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,133 @@
"use server"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import {
getClassComparison,
getGradeDistribution,
getGradeTrend,
getSubjectComparison,
type ClassComparisonParams,
type GradeDistributionParams,
type GradeTrendParams,
type SubjectComparisonParams,
} from "./data-access-analytics"
import { getRankingTrend } from "./data-access-ranking"
import type {
ClassComparisonItem,
GradeDistributionResult,
GradeTrendResult,
RankingTrendResult,
SubjectComparisonItem,
} from "./types"
export async function getGradeTrendAction(
params: Omit<GradeTrendParams, "scope" | "currentUserId">
): Promise<ActionState<GradeTrendResult | null>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const result = await getGradeTrend({
...params,
scope: ctx.dataScope,
currentUserId: ctx.userId,
})
return { success: true, data: result }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function getClassComparisonAction(
params: Omit<ClassComparisonParams, "scope">
): Promise<ActionState<ClassComparisonItem[]>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const result = await getClassComparison({
...params,
scope: ctx.dataScope,
})
return { success: true, data: result }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function getSubjectComparisonAction(
params: Omit<SubjectComparisonParams, "scope">
): Promise<ActionState<SubjectComparisonItem[]>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const result = await getSubjectComparison({
...params,
scope: ctx.dataScope,
})
return { success: true, data: result }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function getGradeDistributionAction(
params: Omit<GradeDistributionParams, "scope" | "currentUserId">
): Promise<ActionState<GradeDistributionResult>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const result = await getGradeDistribution({
...params,
scope: ctx.dataScope,
currentUserId: ctx.userId,
})
return { success: true, data: result }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function getRankingTrendAction(
studentId: string,
subjectId?: string,
semester?: "1" | "2"
): Promise<ActionState<RankingTrendResult | null>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
// Students can only view their own ranking trend
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
return { success: false, message: "Can only view your own ranking trend" }
}
// Parents can only view their children's ranking trend
if (
ctx.dataScope.type === "children" &&
!ctx.dataScope.childrenIds.includes(studentId)
) {
return { success: false, message: "Can only view your children's ranking trend" }
}
const result = await getRankingTrend(studentId, subjectId, semester)
return { success: true, data: result }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}

View File

@@ -0,0 +1,312 @@
"use server"
import { revalidatePath } from "next/cache"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import {
CreateGradeRecordSchema,
BatchCreateGradeRecordSchema,
UpdateGradeRecordSchema,
} from "./schema"
import {
createGradeRecord,
batchCreateGradeRecords,
updateGradeRecord,
deleteGradeRecord,
getGradeRecords,
getGradeRecordById,
getClassGradeStatsWithMeta,
getStudentGradeSummary,
getClassRanking,
} from "./data-access"
import {
exportGradeRecordsToExcel,
exportClassGradeReportToExcel,
formatDateForFile,
} from "./export"
import type { GradeQueryParams, GradeRecordListItem, GradeStats } from "./types"
export async function createGradeRecordAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const parsed = CreateGradeRecordSchema.safeParse({
studentId: formData.get("studentId"),
classId: formData.get("classId"),
subjectId: formData.get("subjectId"),
examId: formData.get("examId") || undefined,
academicYearId: formData.get("academicYearId") || undefined,
title: formData.get("title"),
score: formData.get("score"),
fullScore: formData.get("fullScore") || undefined,
type: formData.get("type") || undefined,
semester: formData.get("semester") || undefined,
remark: formData.get("remark") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const id = await createGradeRecord(parsed.data, ctx.userId)
revalidatePath("/teacher/grades")
return { success: true, message: "Grade record created", data: id }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function batchCreateGradeRecordsAction(
prevState: ActionState<number> | null,
formData: FormData
): Promise<ActionState<number>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const recordsJson = formData.get("recordsJson")
if (typeof recordsJson !== "string" || recordsJson.length === 0) {
return { success: false, message: "Missing records data" }
}
const parsed = BatchCreateGradeRecordSchema.safeParse({
classId: formData.get("classId"),
subjectId: formData.get("subjectId"),
examId: formData.get("examId") || undefined,
academicYearId: formData.get("academicYearId") || undefined,
title: formData.get("title"),
fullScore: formData.get("fullScore") || undefined,
type: formData.get("type") || undefined,
semester: formData.get("semester") || undefined,
records: JSON.parse(recordsJson),
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const count = await batchCreateGradeRecords(parsed.data, ctx.userId)
revalidatePath("/teacher/grades")
return { success: true, message: `Created ${count} grade records`, data: count }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function updateGradeRecordAction(
id: string,
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const parsed = UpdateGradeRecordSchema.safeParse({
title: formData.get("title") || undefined,
score: formData.get("score") || undefined,
fullScore: formData.get("fullScore") || undefined,
type: formData.get("type") || undefined,
semester: formData.get("semester") || undefined,
remark: formData.get("remark") || undefined,
examId: formData.get("examId") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
await updateGradeRecord(id, parsed.data)
revalidatePath("/teacher/grades")
return { success: true, message: "Grade record updated" }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function deleteGradeRecordAction(
id: string
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.GRADE_RECORD_MANAGE)
await deleteGradeRecord(id)
revalidatePath("/teacher/grades")
return { success: true, message: "Grade record deleted" }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function getGradeRecordsAction(
params: GradeQueryParams
): Promise<ActionState<GradeRecordListItem[]>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const records = await getGradeRecords({
...params,
scope: ctx.dataScope,
currentUserId: ctx.userId,
})
return { success: true, data: records }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function getClassGradeStatsAction(
classId: string,
subjectId?: string,
examId?: string
): Promise<ActionState<GradeStats | null>> {
try {
await requirePermission(Permissions.GRADE_RECORD_READ)
const result = await getClassGradeStatsWithMeta(classId, subjectId, examId)
return { success: true, data: result?.stats ?? null }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function getStudentGradeSummaryAction(
studentId: string
): Promise<ActionState<Awaited<ReturnType<typeof getStudentGradeSummary>>>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
return { success: false, message: "Can only view your own grades" }
}
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
return { success: false, message: "Can only view your children's grades" }
}
const summary = await getStudentGradeSummary(studentId)
return { success: true, data: summary }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function getClassRankingAction(
classId: string,
subjectId?: string,
examId?: string
): Promise<ActionState<Awaited<ReturnType<typeof getClassRanking>>>> {
try {
await requirePermission(Permissions.GRADE_RECORD_READ)
const ranking = await getClassRanking(classId, subjectId, examId)
return { success: true, data: ranking }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function getGradeRecordByIdAction(
id: string
): Promise<ActionState<Awaited<ReturnType<typeof getGradeRecordById>>>> {
try {
await requirePermission(Permissions.GRADE_RECORD_READ)
const record = await getGradeRecordById(id)
return { success: true, data: record }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
/**
* 导出成绩单(返回 base64 编码的 Excel
*/
export async function exportGradesAction(params: {
classId: string
subjectId?: string
examId?: string
reportType?: "detail" | "class"
}): Promise<ActionState<{ buffer: string; filename: string }>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
let buffer: Buffer
let filename: string
if (params.reportType === "class") {
buffer = await exportClassGradeReportToExcel({
classId: params.classId,
scope: ctx.dataScope,
})
filename = `班级成绩总表_${formatDateForFile()}.xlsx`
} else {
buffer = await exportGradeRecordsToExcel({
classId: params.classId,
subjectId: params.subjectId,
examId: params.examId,
scope: ctx.dataScope,
})
filename = `成绩单_${formatDateForFile()}.xlsx`
}
return {
success: true,
data: {
buffer: buffer.toString("base64"),
filename,
},
}
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "导出失败" }
}
}

View File

@@ -0,0 +1,219 @@
"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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { batchCreateGradeRecordsAction } from "../actions"
type Option = { id: string; name: string }
type Student = { id: string; name: string; email: string }
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save All Grades"}
</Button>
)
}
export function BatchGradeEntry({
classes,
subjects,
students,
defaultClassId,
defaultSubjectId,
}: {
classes: Option[]
subjects: Option[]
students: Student[]
defaultClassId?: string
defaultSubjectId?: string
}) {
const router = useRouter()
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
const [subjectId, setSubjectId] = useState(defaultSubjectId ?? subjects[0]?.id ?? "")
const [type, setType] = useState<"exam" | "quiz" | "homework" | "other">("exam")
const [semester, setSemester] = useState<"1" | "2">("1")
const [scores, setScores] = useState<Record<string, string>>({})
const handleScoreChange = (studentId: string, value: string) => {
setScores((prev) => ({ ...prev, [studentId]: value }))
}
const handleSubmit = async (formData: FormData) => {
if (!classId || !subjectId) {
toast.error("Please select class and subject")
return
}
const records = students
.map((s) => ({
studentId: s.id,
score: Number(scores[s.id] ?? 0),
remark: undefined as string | undefined,
}))
.filter((r) => r.score > 0 || scores[r.studentId] !== undefined)
if (records.length === 0) {
toast.error("Please enter at least one score")
return
}
formData.set("classId", classId)
formData.set("subjectId", subjectId)
formData.set("type", type)
formData.set("semester", semester)
formData.set("recordsJson", JSON.stringify(records))
const result = await batchCreateGradeRecordsAction(null, formData)
if (result.success) {
toast.success(result.message)
router.push("/teacher/grades")
router.refresh()
} else {
toast.error(result.message || "Failed to save")
}
}
return (
<Card>
<CardHeader>
<CardTitle>Batch Grade Entry</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>Subject</Label>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger>
<SelectValue placeholder="Select a subject" />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="title">Exam / Quiz Title</Label>
<Input id="title" name="title" placeholder="e.g. Mid-term Exam" required />
</div>
<div className="grid gap-2">
<Label htmlFor="fullScore">Full Score</Label>
<Input id="fullScore" name="fullScore" type="number" step="0.01" min="1" defaultValue="100" />
</div>
<div className="grid gap-2">
<Label>Type</Label>
<Select value={type} onValueChange={(v) => setType(v as typeof type)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exam">Exam</SelectItem>
<SelectItem value="quiz">Quiz</SelectItem>
<SelectItem value="homework">Homework</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Semester</Label>
<Select value={semester} onValueChange={(v) => setSemester(v as "1" | "2")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Semester 1</SelectItem>
<SelectItem value="2">Semester 2</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{students.length === 0 ? (
<p className="text-sm text-muted-foreground">No students in this class.</p>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Email</TableHead>
<TableHead className="w-32">Score</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>
<Input
type="number"
step="0.01"
min="0"
placeholder="0"
value={scores[s.id] ?? ""}
onChange={(e) => handleScoreChange(s.id, e.target.value)}
className="h-8"
/>
</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,132 @@
"use client"
import { BarChart3 } from "lucide-react"
import {
Bar,
BarChart,
CartesianGrid,
Legend,
XAxis,
YAxis,
} from "recharts"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/shared/components/ui/chart"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { ClassComparisonItem } from "@/modules/grades/types"
const chartConfig = {
averageScore: { label: "Average (%)", color: "hsl(var(--primary))" },
passRate: { label: "Pass Rate (%)", color: "hsl(var(--chart-2))" },
excellentRate: { label: "Excellent (%)", color: "hsl(var(--chart-3))" },
}
interface ClassComparisonChartProps {
data: ClassComparisonItem[]
}
export function ClassComparisonChart({ data }: ClassComparisonChartProps) {
if (!data || data.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Class Comparison
</CardTitle>
<CardDescription>
Compare average, pass rate, and excellent rate across classes.
</CardDescription>
</CardHeader>
<CardContent>
<EmptyState
icon={BarChart3}
title="No comparison data"
description="Select a grade and subject to compare classes."
className="border-none h-60"
/>
</CardContent>
</Card>
)
}
const chartData = data.map((d) => ({
name: d.className,
averageScore: d.averageScore,
passRate: d.passRate,
excellentRate: d.excellentRate,
count: d.count,
studentCount: d.studentCount,
}))
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Class Comparison
</CardTitle>
<CardDescription>
Average score, pass rate (60%), and excellent rate (85%) per class.
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[300px] w-full">
<BarChart
data={chartData}
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
>
<CartesianGrid
vertical={false}
strokeDasharray="4 4"
strokeOpacity={0.4}
/>
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: string) =>
value.length > 8 ? `${value.slice(0, 8)}...` : value
}
/>
<YAxis
domain={[0, 100]}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `${value}%`}
width={36}
/>
<ChartTooltip content={<ChartTooltipContent className="w-[240px]" />} />
<Legend />
<Bar
dataKey="averageScore"
fill="var(--color-averageScore)"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="passRate"
fill="var(--color-passRate)"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="excellentRate"
fill="var(--color-excellentRate)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,92 @@
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 { Trophy } from "lucide-react"
import { GradeStatsCard } from "./grade-stats-card"
import type { ClassGradeStats, ClassRankingItem } from "../types"
interface ClassGradeReportProps {
stats: ClassGradeStats | null
ranking: ClassRankingItem[]
}
export function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) {
return (
<div className="space-y-6">
{stats ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">{stats.className}</h3>
<p className="text-sm text-muted-foreground">
{stats.studentCount} students · {stats.stats.count} grade records
</p>
</div>
</div>
<GradeStatsCard stats={stats.stats} />
</div>
) : (
<EmptyState
title="No data"
description="No grade records found for this class."
icon={Trophy}
className="border-none shadow-none"
/>
)}
{ranking.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Class Ranking</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Rank</TableHead>
<TableHead>Student</TableHead>
<TableHead className="text-right">Average Score</TableHead>
<TableHead className="text-right">Records</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ranking.map((r) => (
<TableRow key={r.studentId}>
<TableCell>
<Badge
variant={
r.rank === 1 ? "default" : r.rank <= 3 ? "secondary" : "outline"
}
className="font-mono"
>
#{r.rank}
</Badge>
</TableCell>
<TableCell className="font-medium">{r.studentName}</TableCell>
<TableCell className="text-right font-mono">
{r.averageScore.toFixed(2)}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{r.recordCount}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
) : null}
</div>
)
}

View File

@@ -0,0 +1,101 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import { Download, Loader2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { exportGradesAction } from "../actions"
function downloadBase64File(base64: string, filename: string) {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
const blob = new Blob([bytes], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
})
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
type ExportButtonProps = {
classId: string
subjectId?: string
examId?: string
variant?: "default" | "outline" | "secondary" | "ghost"
size?: "default" | "sm" | "lg" | "icon"
label?: string
}
export function ExportButton({
classId,
subjectId,
examId,
variant = "outline",
size = "default",
label = "导出",
}: ExportButtonProps) {
const [isExporting, setIsExporting] = useState(false)
const handleExport = async (reportType: "detail" | "class") => {
if (!classId) {
toast.error("请先选择班级")
return
}
setIsExporting(true)
const result = await exportGradesAction({
classId,
subjectId,
examId,
reportType,
})
setIsExporting(false)
if (result.success && result.data) {
downloadBase64File(result.data.buffer, result.data.filename)
toast.success("导出成功")
} else {
toast.error(result.message ?? "导出失败")
}
}
if (isExporting) {
return (
<Button variant={variant} size={size} disabled>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</Button>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={variant} size={size}>
<Download className="mr-2 h-4 w-4" />
{label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleExport("detail")}>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("class")}>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,138 @@
"use client"
import { PieChart as PieChartIcon } from "lucide-react"
import { Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis } from "recharts"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/shared/components/ui/chart"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { GradeDistributionResult } from "@/modules/grades/types"
const BUCKET_COLORS: Record<string, string> = {
"90-100": "hsl(142, 71%, 45%)",
"80-89": "hsl(217, 91%, 60%)",
"70-79": "hsl(43, 96%, 56%)",
"60-69": "hsl(25, 95%, 53%)",
"<60": "hsl(0, 84%, 60%)",
}
const chartConfig = {
count: { label: "Students", color: "hsl(var(--primary))" },
}
interface GradeDistributionChartProps {
data: GradeDistributionResult | null
}
export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
if (!data || data.totalCount === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PieChartIcon className="h-4 w-4" />
Score Distribution
</CardTitle>
<CardDescription>
Number of students in each score range (normalized to 0-100).
</CardDescription>
</CardHeader>
<CardContent>
<EmptyState
icon={PieChartIcon}
title="No distribution data"
description="Select a class and subject to view score distribution."
className="border-none h-60"
/>
</CardContent>
</Card>
)
}
const chartData = data.buckets.map((b) => ({
label: b.label,
count: b.count,
percentage:
data.totalCount > 0
? Math.round((b.count / data.totalCount) * 1000) / 10
: 0,
}))
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PieChartIcon className="h-4 w-4" />
Score Distribution
</CardTitle>
<CardDescription>
{data.totalCount} grade record{data.totalCount === 1 ? "" : "s"} across
score ranges.
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full">
<BarChart
data={chartData}
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
>
<CartesianGrid
vertical={false}
strokeDasharray="4 4"
strokeOpacity={0.4}
/>
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<YAxis
allowDecimals={false}
tickLine={false}
axisLine={false}
width={32}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[200px]"
formatter={(payload: unknown) => {
const item = (payload as { payload?: (typeof chartData)[number] })?.payload
if (!item) return null
return (
<div className="flex w-full flex-col gap-0.5">
<span className="text-sm font-medium">
{item.label}: {item.count} student
{item.count === 1 ? "" : "s"}
</span>
<span className="text-xs text-muted-foreground">
{item.percentage}% of total
</span>
</div>
)
}}
/>
}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{chartData.map((entry) => (
<Cell key={entry.label} fill={BUCKET_COLORS[entry.label]} />
))}
</Bar>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,104 @@
"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"
type Option = { id: string; name: string }
interface GradeQueryFiltersProps {
classes: Option[]
subjects: Option[]
}
export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps) {
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 subjectId = searchParams.get("subjectId") ?? "all"
const type = searchParams.get("type") ?? "all"
const semester = searchParams.get("semester") ?? "all"
return (
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-4">
<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">Subject</Label>
<Select value={subjectId} onValueChange={(v) => updateParam("subjectId", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All subjects" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All subjects</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-xs">Type</Label>
<Select value={type} onValueChange={(v) => updateParam("type", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="exam">Exam</SelectItem>
<SelectItem value="quiz">Quiz</SelectItem>
<SelectItem value="homework">Homework</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-xs">Semester</Label>
<Select value={semester} onValueChange={(v) => updateParam("semester", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All semesters" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All semesters</SelectItem>
<SelectItem value="1">Semester 1</SelectItem>
<SelectItem value="2">Semester 2</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@@ -0,0 +1,184 @@
"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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Textarea } from "@/shared/components/ui/textarea"
import { createGradeRecordAction } from "../actions"
type Option = { id: string; name: string }
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save Record"}
</Button>
)
}
export function GradeRecordForm({
classes,
subjects,
students,
defaultClassId,
defaultSubjectId,
}: {
classes: Option[]
subjects: Option[]
students: Option[]
defaultClassId?: string
defaultSubjectId?: string
}) {
const router = useRouter()
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
const [subjectId, setSubjectId] = useState(defaultSubjectId ?? subjects[0]?.id ?? "")
const [studentId, setStudentId] = useState(students[0]?.id ?? "")
const [type, setType] = useState<"exam" | "quiz" | "homework" | "other">("exam")
const [semester, setSemester] = useState<"1" | "2">("1")
const handleSubmit = async (formData: FormData) => {
if (!classId || !subjectId || !studentId) {
toast.error("Please select class, subject and student")
return
}
formData.set("classId", classId)
formData.set("subjectId", subjectId)
formData.set("studentId", studentId)
formData.set("type", type)
formData.set("semester", semester)
const result = await createGradeRecordAction(null, formData)
if (result.success) {
toast.success(result.message)
router.push("/teacher/grades")
router.refresh()
} else {
toast.error(result.message || "Failed to create")
}
}
return (
<Card>
<CardHeader>
<CardTitle>Record Grade</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>Subject</Label>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger>
<SelectValue placeholder="Select a subject" />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2 md:col-span-2">
<Label>Student</Label>
<Select value={studentId} onValueChange={setStudentId}>
<SelectTrigger>
<SelectValue placeholder="Select a student" />
</SelectTrigger>
<SelectContent>
{students.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="title">Title</Label>
<Input id="title" name="title" placeholder="e.g. Mid-term Exam" required />
</div>
<div className="grid gap-2">
<Label htmlFor="score">Score</Label>
<Input id="score" name="score" type="number" step="0.01" min="0" required />
</div>
<div className="grid gap-2">
<Label htmlFor="fullScore">Full Score</Label>
<Input id="fullScore" name="fullScore" type="number" step="0.01" min="1" defaultValue="100" />
</div>
<div className="grid gap-2">
<Label>Type</Label>
<Select value={type} onValueChange={(v) => setType(v as typeof type)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exam">Exam</SelectItem>
<SelectItem value="quiz">Quiz</SelectItem>
<SelectItem value="homework">Homework</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Semester</Label>
<Select value={semester} onValueChange={(v) => setSemester(v as "1" | "2")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Semester 1</SelectItem>
<SelectItem value="2">Semester 2</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="remark">Remark (optional)</Label>
<Textarea id="remark" name="remark" placeholder="Notes about this grade..." />
</div>
</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,136 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
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 { Trash2 } from "lucide-react"
import { deleteGradeRecordAction } from "../actions"
import type { GradeRecordListItem } from "../types"
const typeColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
exam: "default",
quiz: "secondary",
homework: "outline",
other: "outline",
}
export function GradeRecordList({ records }: { records: GradeRecordListItem[] }) {
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 deleteGradeRecordAction(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 grade records found.
</div>
)
}
return (
<>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Class</TableHead>
<TableHead>Subject</TableHead>
<TableHead>Title</TableHead>
<TableHead className="text-right">Score</TableHead>
<TableHead>Type</TableHead>
<TableHead>Semester</TableHead>
<TableHead>Recorded By</TableHead>
<TableHead>Date</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.subjectName}</TableCell>
<TableCell>{r.title}</TableCell>
<TableCell className="text-right font-mono">
{r.score} / {r.fullScore}
</TableCell>
<TableCell>
<Badge variant={typeColors[r.type]} className="capitalize">
{r.type}
</Badge>
</TableCell>
<TableCell>S{r.semester}</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 Grade Record</DialogTitle>
<DialogDescription>
Are you sure you want to delete this grade 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,93 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { TrendingUp, TrendingDown, BarChart3, Target, Award, CheckCircle2 } from "lucide-react"
import type { GradeStats } 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 GradeStatsCard({ stats }: { stats: GradeStats | null }) {
if (!stats || stats.count === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Statistics</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">No data available for statistics.</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Statistics</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatItem
label="Average"
value={stats.average.toFixed(2)}
icon={<BarChart3 className="h-4 w-4" />}
/>
<StatItem
label="Median"
value={stats.median.toFixed(2)}
icon={<BarChart3 className="h-4 w-4" />}
/>
<StatItem
label="Max"
value={stats.max.toFixed(2)}
icon={<TrendingUp className="h-4 w-4" />}
/>
<StatItem
label="Min"
value={stats.min.toFixed(2)}
icon={<TrendingDown className="h-4 w-4" />}
/>
<StatItem
label="Std Dev"
value={stats.stdDev.toFixed(2)}
icon={<Target className="h-4 w-4" />}
hint="Standard deviation"
/>
<StatItem
label="Pass Rate"
value={`${stats.passRate.toFixed(1)}%`}
icon={<CheckCircle2 className="h-4 w-4" />}
hint="Score >= 60% of full"
/>
<StatItem
label="Excellent Rate"
value={`${stats.excellentRate.toFixed(1)}%`}
icon={<Award className="h-4 w-4" />}
hint="Score >= 85% of full"
/>
<StatItem
label="Count"
value={stats.count}
icon={<BarChart3 className="h-4 w-4" />}
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,137 @@
"use client"
import { BarChart3 } from "lucide-react"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/shared/components/ui/chart"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { formatDate } from "@/shared/lib/utils"
import type { GradeTrendResult } from "@/modules/grades/types"
const chartConfig = {
normalizedScore: {
label: "Score (%)",
color: "hsl(var(--primary))",
},
}
interface GradeTrendChartProps {
data: GradeTrendResult | null
}
export function GradeTrendChart({ data }: GradeTrendChartProps) {
if (!data || data.points.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Grade Trend
</CardTitle>
<CardDescription>
Score progression over time (normalized to 0-100).
</CardDescription>
</CardHeader>
<CardContent>
<EmptyState
icon={BarChart3}
title="No trend data"
description="Select a class and subject to view the grade trend."
className="border-none h-60"
/>
</CardContent>
</Card>
)
}
const chartData = data.points.map((p) => ({
title: p.title,
normalizedScore: p.normalizedScore,
fullTitle: p.title,
date: formatDate(p.date),
rawScore: p.score,
fullScore: p.fullScore,
type: p.type,
}))
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Grade Trend
</CardTitle>
<CardDescription>
{data.label} · avg {data.averageScore.toFixed(1)}%
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full">
<LineChart
data={chartData}
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
>
<CartesianGrid
vertical={false}
strokeDasharray="4 4"
strokeOpacity={0.4}
/>
<XAxis
dataKey="title"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: string) =>
value.length > 10 ? `${value.slice(0, 10)}...` : value
}
/>
<YAxis
domain={[0, 100]}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `${value}%`}
width={36}
/>
<ChartTooltip
cursor={{
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
strokeDasharray: "4 4",
}}
content={
<ChartTooltipContent
indicator="line"
labelKey="fullTitle"
className="w-[220px]"
/>
}
/>
<Line
dataKey="normalizedScore"
type="monotone"
stroke="var(--color-normalizedScore)"
strokeWidth={2}
dot={{
fill: "var(--color-normalizedScore)",
r: 3,
strokeWidth: 2,
}}
activeDot={{ r: 5, strokeWidth: 0 }}
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,117 @@
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 { formatDate } from "@/shared/lib/utils"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { GraduationCap } from "lucide-react"
import type { StudentGradeSummary } from "../types"
const typeColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
exam: "default",
quiz: "secondary",
homework: "outline",
other: "outline",
}
export function StudentGradeSummary({ summary }: { summary: StudentGradeSummary | null }) {
if (!summary) {
return (
<EmptyState
title="No data"
description="Student grade summary is not available."
icon={GraduationCap}
className="border-none shadow-none"
/>
)
}
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<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">Average Score</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.averageScore.toFixed(2)}</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.records.length}</p>
</CardContent>
</Card>
</div>
{summary.records.length === 0 ? (
<EmptyState
title="No grades yet"
description="There are no grade records for this student."
icon={GraduationCap}
className="border-none shadow-none"
/>
) : (
<Card>
<CardHeader>
<CardTitle>Grade History</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Class</TableHead>
<TableHead>Subject</TableHead>
<TableHead className="text-right">Score</TableHead>
<TableHead>Type</TableHead>
<TableHead>Semester</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summary.records.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.title}</TableCell>
<TableCell>{r.className}</TableCell>
<TableCell>{r.subjectName}</TableCell>
<TableCell className="text-right font-mono">
{r.score} / {r.fullScore}
</TableCell>
<TableCell>
<Badge variant={typeColors[r.type]} className="capitalize">
{r.type}
</Badge>
</TableCell>
<TableCell>S{r.semester}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,116 @@
"use client"
import { Radar } from "lucide-react"
import {
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar as RechartsRadar,
RadarChart,
} from "recharts"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/shared/components/ui/chart"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { SubjectComparisonItem } from "@/modules/grades/types"
const chartConfig = {
averageScore: { label: "Average (%)", color: "hsl(var(--primary))" },
passRate: { label: "Pass Rate (%)", color: "hsl(var(--chart-2))" },
}
interface SubjectComparisonChartProps {
data: SubjectComparisonItem[]
}
export function SubjectComparisonChart({ data }: SubjectComparisonChartProps) {
if (!data || data.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Radar className="h-4 w-4" />
Subject Comparison
</CardTitle>
<CardDescription>
Compare performance across subjects for the selected class.
</CardDescription>
</CardHeader>
<CardContent>
<EmptyState
icon={Radar}
title="No comparison data"
description="Select a class to compare subject performance."
className="border-none h-60"
/>
</CardContent>
</Card>
)
}
const chartData = data.map((d) => ({
subject: d.subjectName,
averageScore: d.averageScore,
passRate: d.passRate,
excellentRate: d.excellentRate,
count: d.count,
}))
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Radar className="h-4 w-4" />
Subject Comparison
</CardTitle>
<CardDescription>
Average score and pass rate per subject (normalized to 0-100).
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[300px] w-full">
<RadarChart data={chartData} outerRadius="75%">
<PolarGrid strokeOpacity={0.4} />
<PolarAngleAxis
dataKey="subject"
tick={{ fontSize: 12 }}
tickFormatter={(value: string) =>
value.length > 6 ? `${value.slice(0, 6)}...` : value
}
/>
<PolarRadiusAxis
domain={[0, 100]}
tickFormatter={(value: number) => `${value}%`}
tick={{ fontSize: 10 }}
/>
<ChartTooltip content={<ChartTooltipContent className="w-[220px]" />} />
<RechartsRadar
name="Average"
dataKey="averageScore"
stroke="var(--color-averageScore)"
fill="var(--color-averageScore)"
fillOpacity={0.4}
/>
<RechartsRadar
name="Pass Rate"
dataKey="passRate"
stroke="var(--color-passRate)"
fill="var(--color-passRate)"
fillOpacity={0.2}
/>
</RadarChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,293 @@
import "server-only"
import { and, asc, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
gradeRecords,
subjects,
} from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions"
import type {
ClassComparisonItem,
GradeDistributionBucket,
GradeDistributionResult,
GradeTrendPoint,
GradeTrendResult,
SubjectComparisonItem,
} from "./types"
const toNumber = (v: unknown): number => {
const n = typeof v === "number" ? v : Number(v)
return Number.isFinite(n) ? n : 0
}
const normalize = (score: number, fullScore: number): number => {
if (fullScore <= 0) return 0
return Math.round((score / fullScore) * 10000) / 100
}
const buildScopeClassFilter = (scope: DataScope) => {
if (scope.type === "all") return null
if (scope.type === "class_taught") {
return scope.classIds.length > 0 ? inArray(gradeRecords.classId, scope.classIds) : sql`1=0`
}
if (scope.type === "grade_managed") return sql`1=0`
if (scope.type === "class_members") return null
if (scope.type === "children") {
return scope.childrenIds.length > 0
? inArray(gradeRecords.studentId, scope.childrenIds)
: sql`1=0`
}
if (scope.type === "owned") return eq(gradeRecords.studentId, scope.userId)
return sql`1=0`
}
export interface GradeTrendParams {
classId: string
subjectId?: string
studentId?: string
semester?: "1" | "2"
scope: DataScope
currentUserId?: string
}
export async function getGradeTrend(
params: GradeTrendParams
): Promise<GradeTrendResult | null> {
const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
if (params.scope.type === "class_members" && params.currentUserId) {
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
}
const scopeFilter = buildScopeClassFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
const rows = await db
.select({
record: gradeRecords,
className: classes.name,
subjectName: subjects.name,
})
.from(gradeRecords)
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
.where(and(...conditions))
.orderBy(asc(gradeRecords.createdAt))
if (rows.length === 0) return null
const points: GradeTrendPoint[] = rows.map((r) => {
const score = toNumber(r.record.score)
const fullScore = toNumber(r.record.fullScore)
return {
date: r.record.createdAt.toISOString(),
title: r.record.title,
score,
fullScore,
normalizedScore: normalize(score, fullScore),
type: r.record.type,
}
})
const avg = points.reduce((acc, p) => acc + p.normalizedScore, 0) / points.length
const className = rows[0].className ?? "Class"
const subjectName = rows[0].subjectName ?? "All Subjects"
const studentLabel = params.studentId
? `Student ${params.studentId.slice(-4)}`
: "Class Average"
return {
label: params.subjectId
? `${className} · ${subjectName} · ${studentLabel}`
: `${className} · ${studentLabel}`,
points,
averageScore: Math.round(avg * 100) / 100,
}
}
export interface ClassComparisonParams {
gradeId: string
subjectId: string
examId?: string
scope: DataScope
}
export async function getClassComparison(
params: ClassComparisonParams
): Promise<ClassComparisonItem[]> {
const classRows = await db
.select({ id: classes.id, name: classes.name })
.from(classes)
.where(eq(classes.gradeId, params.gradeId))
if (classRows.length === 0) return []
const scope = params.scope
const allowedClassIds =
scope.type === "class_taught"
? classRows.filter((c) => scope.classIds.includes(c.id)).map((c) => c.id)
: classRows.map((c) => c.id)
if (allowedClassIds.length === 0) return []
const result: ClassComparisonItem[] = []
for (const cls of classRows) {
if (!allowedClassIds.includes(cls.id)) continue
const conditions = [
eq(gradeRecords.classId, cls.id),
eq(gradeRecords.subjectId, params.subjectId),
]
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
const rows = await db
.select({
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
studentId: gradeRecords.studentId,
})
.from(gradeRecords)
.where(and(...conditions))
if (rows.length === 0) {
result.push({
classId: cls.id, className: cls.name, averageScore: 0, medianScore: 0,
passRate: 0, excellentRate: 0, count: 0, studentCount: 0,
})
continue
}
const normalized = rows.map((r) => normalize(toNumber(r.score), toNumber(r.fullScore)))
const sorted = [...normalized].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
const avg = normalized.reduce((a, b) => a + b, 0) / normalized.length
const uniqueStudents = new Set(rows.map((r) => r.studentId)).size
result.push({
classId: cls.id,
className: cls.name,
averageScore: Math.round(avg * 100) / 100,
medianScore: Math.round(median * 100) / 100,
passRate: Math.round((normalized.filter((s) => s >= 60).length / normalized.length) * 10000) / 100,
excellentRate: Math.round((normalized.filter((s) => s >= 85).length / normalized.length) * 10000) / 100,
count: normalized.length,
studentCount: uniqueStudents,
})
}
return result
}
export interface SubjectComparisonParams {
classId: string
examId?: string
scope: DataScope
}
export async function getSubjectComparison(
params: SubjectComparisonParams
): Promise<SubjectComparisonItem[]> {
const scopeFilter = buildScopeClassFilter(params.scope)
const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
if (scopeFilter) conditions.push(scopeFilter)
const rows = await db
.select({
subjectId: gradeRecords.subjectId,
subjectName: subjects.name,
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
})
.from(gradeRecords)
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
.where(and(...conditions))
const bySubject = new Map<string, { name: string; scores: number[] }>()
for (const r of rows) {
const sid = r.subjectId
if (!sid) continue
const entry = bySubject.get(sid) ?? { name: r.subjectName ?? "Unknown", scores: [] }
entry.scores.push(normalize(toNumber(r.score), toNumber(r.fullScore)))
bySubject.set(sid, entry)
}
const result: SubjectComparisonItem[] = []
for (const [subjectId, entry] of bySubject.entries()) {
if (entry.scores.length === 0) continue
const sorted = [...entry.scores].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
const avg = entry.scores.reduce((a, b) => a + b, 0) / entry.scores.length
result.push({
subjectId,
subjectName: entry.name,
averageScore: Math.round(avg * 100) / 100,
medianScore: Math.round(median * 100) / 100,
passRate: Math.round((entry.scores.filter((s) => s >= 60).length / entry.scores.length) * 10000) / 100,
excellentRate: Math.round((entry.scores.filter((s) => s >= 85).length / entry.scores.length) * 10000) / 100,
count: entry.scores.length,
})
}
return result.sort((a, b) => b.averageScore - a.averageScore)
}
export interface GradeDistributionParams {
classId: string
subjectId?: string
examId?: string
scope: DataScope
currentUserId?: string
}
export async function getGradeDistribution(
params: GradeDistributionParams
): Promise<GradeDistributionResult> {
const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
if (params.scope.type === "class_members" && params.currentUserId) {
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
}
const scopeFilter = buildScopeClassFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
const rows = await db
.select({ score: gradeRecords.score, fullScore: gradeRecords.fullScore })
.from(gradeRecords)
.where(and(...conditions))
const buckets: GradeDistributionBucket[] = [
{ label: "90-100", min: 90, max: 100, count: 0 },
{ label: "80-89", min: 80, max: 89, count: 0 },
{ label: "70-79", min: 70, max: 79, count: 0 },
{ label: "60-69", min: 60, max: 69, count: 0 },
{ label: "<60", min: 0, max: 59, count: 0 },
]
for (const r of rows) {
const normalized = normalize(toNumber(r.score), toNumber(r.fullScore))
const rounded = Math.round(normalized)
if (rounded >= 90) buckets[0].count++
else if (rounded >= 80) buckets[1].count++
else if (rounded >= 70) buckets[2].count++
else if (rounded >= 60) buckets[3].count++
else buckets[4].count++
}
return { buckets, totalCount: rows.length }
}

View File

@@ -0,0 +1,121 @@
import "server-only"
import { and, asc, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classEnrollments,
gradeRecords,
users,
} from "@/shared/db/schema"
import type {
RankingTrendPoint,
RankingTrendResult,
} from "./types"
const toNumber = (v: unknown): number => {
const n = typeof v === "number" ? v : Number(v)
return Number.isFinite(n) ? n : 0
}
const normalize = (score: number, fullScore: number): number => {
if (fullScore <= 0) return 0
return Math.round((score / fullScore) * 10000) / 100
}
/**
* Get a student's ranking trend across assessments within their class.
* Each point represents one assessment (grouped by title), with the
* student's normalized score, rank, and total participants.
*/
export async function getRankingTrend(
studentId: string,
subjectId?: string,
semester?: "1" | "2"
): Promise<RankingTrendResult | null> {
const [student] = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(eq(users.id, studentId))
.limit(1)
if (!student) return null
const [enrollment] = await db
.select({ classId: classEnrollments.classId })
.from(classEnrollments)
.where(
and(
eq(classEnrollments.studentId, studentId),
eq(classEnrollments.status, "active")
)
)
.limit(1)
if (!enrollment) {
return {
studentId,
studentName: student.name ?? "Unknown",
points: [],
}
}
const conditions = [eq(gradeRecords.classId, enrollment.classId)]
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
if (semester) conditions.push(eq(gradeRecords.semester, semester))
const rows = await db
.select({
title: gradeRecords.title,
createdAt: gradeRecords.createdAt,
studentId: gradeRecords.studentId,
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
})
.from(gradeRecords)
.where(and(...conditions))
.orderBy(asc(gradeRecords.createdAt))
const byTitle = new Map<
string,
{
date: Date
entries: Array<{ studentId: string; normalized: number }>
}
>()
for (const r of rows) {
const entry = byTitle.get(r.title) ?? { date: r.createdAt, entries: [] }
entry.entries.push({
studentId: r.studentId,
normalized: normalize(toNumber(r.score), toNumber(r.fullScore)),
})
byTitle.set(r.title, entry)
}
const points: RankingTrendPoint[] = []
for (const [title, entry] of byTitle.entries()) {
if (entry.entries.length === 0) continue
const sorted = [...entry.entries].sort((a, b) => b.normalized - a.normalized)
const rank = sorted.findIndex((e) => e.studentId === studentId) + 1
if (rank <= 0) continue
const studentEntry = sorted.find((e) => e.studentId === studentId)
if (!studentEntry) continue
points.push({
title,
date: entry.date.toISOString(),
score: studentEntry.normalized,
rank,
totalStudents: sorted.length,
})
}
points.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
return {
studentId,
studentName: student.name ?? "Unknown",
points,
}
}

View File

@@ -0,0 +1,419 @@
import "server-only"
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
gradeRecords,
subjects,
users,
} from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions"
import type {
ClassGradeStats,
ClassRankingItem,
GradeQueryParams,
GradeRecord,
GradeRecordListItem,
GradeStats,
StudentGradeSummary,
} from "./types"
import type {
BatchCreateGradeRecordInput,
CreateGradeRecordInput,
UpdateGradeRecordInput,
} from "./schema"
const toNumber = (v: unknown): number => {
const n = typeof v === "number" ? v : Number(v)
return Number.isFinite(n) ? n : 0
}
const serializeRecord = (r: typeof gradeRecords.$inferSelect): GradeRecord => ({
id: r.id,
studentId: r.studentId,
classId: r.classId,
subjectId: r.subjectId,
examId: r.examId ?? null,
academicYearId: r.academicYearId ?? null,
title: r.title,
score: String(r.score),
fullScore: String(r.fullScore),
type: r.type,
semester: r.semester,
recordedBy: r.recordedBy,
remark: r.remark ?? null,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})
const buildScopeClassFilter = (scope: DataScope) => {
if (scope.type === "all") return null
if (scope.type === "class_taught") {
return scope.classIds.length > 0 ? inArray(gradeRecords.classId, scope.classIds) : sql`1=0`
}
if (scope.type === "grade_managed") {
return sql`1=0`
}
if (scope.type === "class_members") {
return null
}
if (scope.type === "children") {
return scope.childrenIds.length > 0 ? inArray(gradeRecords.studentId, scope.childrenIds) : sql`1=0`
}
if (scope.type === "owned") {
return eq(gradeRecords.studentId, scope.userId)
}
return sql`1=0`
}
export async function getGradeRecords(
params: GradeQueryParams & { scope: DataScope; currentUserId?: string }
): Promise<GradeRecordListItem[]> {
const conditions = []
const scopeFilter = buildScopeClassFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
if (params.scope.type === "class_members" && params.currentUserId) {
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
}
if (params.classId) conditions.push(eq(gradeRecords.classId, params.classId))
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
if (params.type) conditions.push(eq(gradeRecords.type, params.type))
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
const rows = await db
.select({
record: gradeRecords,
studentName: users.name,
className: classes.name,
subjectName: subjects.name,
})
.from(gradeRecords)
.leftJoin(users, eq(users.id, gradeRecords.studentId))
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(gradeRecords.createdAt))
const recorderIds = Array.from(new Set(rows.map((r) => r.record.recordedBy)))
const recorderMap = new Map<string, string>()
if (recorderIds.length > 0) {
const recorders = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, recorderIds))
for (const r of recorders) {
recorderMap.set(r.id, r.name ?? "Unknown")
}
}
return rows.map((r) => ({
id: r.record.id,
studentId: r.record.studentId,
studentName: r.studentName ?? "Unknown",
classId: r.record.classId,
className: r.className ?? "Unknown",
subjectId: r.record.subjectId,
subjectName: r.subjectName ?? "Unknown",
examId: r.record.examId ?? null,
title: r.record.title,
score: toNumber(r.record.score),
fullScore: toNumber(r.record.fullScore),
type: r.record.type,
semester: r.record.semester,
recordedBy: r.record.recordedBy,
recorderName: recorderMap.get(r.record.recordedBy) ?? "Unknown",
remark: r.record.remark ?? null,
createdAt: r.record.createdAt.toISOString(),
}))
}
export async function getGradeRecordById(id: string): Promise<GradeRecord | null> {
const [row] = await db.select().from(gradeRecords).where(eq(gradeRecords.id, id)).limit(1)
return row ? serializeRecord(row) : null
}
export async function createGradeRecord(
data: CreateGradeRecordInput,
recordedBy: string
): Promise<string> {
const { createId } = await import("@paralleldrive/cuid2")
const id = createId()
await db.insert(gradeRecords).values({
id,
studentId: data.studentId,
classId: data.classId,
subjectId: data.subjectId,
examId: data.examId ?? null,
academicYearId: data.academicYearId ?? null,
title: data.title,
score: String(data.score),
fullScore: String(data.fullScore ?? 100),
type: data.type ?? "exam",
semester: data.semester ?? "1",
recordedBy,
remark: data.remark ?? null,
})
return id
}
export async function batchCreateGradeRecords(
data: BatchCreateGradeRecordInput,
recordedBy: string
): Promise<number> {
const { createId } = await import("@paralleldrive/cuid2")
const rows = data.records.map((r) => ({
id: createId(),
studentId: r.studentId,
classId: data.classId,
subjectId: data.subjectId,
examId: data.examId ?? null,
academicYearId: data.academicYearId ?? null,
title: data.title,
score: String(r.score),
fullScore: String(data.fullScore ?? 100),
type: data.type ?? "exam",
semester: data.semester ?? "1",
recordedBy,
remark: r.remark ?? null,
}))
if (rows.length === 0) return 0
await db.insert(gradeRecords).values(rows)
return rows.length
}
export async function updateGradeRecord(
id: string,
data: UpdateGradeRecordInput
): Promise<void> {
const update: Record<string, unknown> = { updatedAt: new Date() }
if (data.title !== undefined) update.title = data.title
if (data.score !== undefined) update.score = String(data.score)
if (data.fullScore !== undefined) update.fullScore = String(data.fullScore)
if (data.type !== undefined) update.type = data.type
if (data.semester !== undefined) update.semester = data.semester
if (data.remark !== undefined) update.remark = data.remark
if (data.examId !== undefined) update.examId = data.examId
await db.update(gradeRecords).set(update).where(eq(gradeRecords.id, id))
}
export async function deleteGradeRecord(id: string): Promise<void> {
await db.delete(gradeRecords).where(eq(gradeRecords.id, id))
}
export async function getClassGradeStats(
classId: string,
subjectId?: string,
examId?: string
): Promise<GradeStats | null> {
const conditions = [eq(gradeRecords.classId, classId)]
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
if (examId) conditions.push(eq(gradeRecords.examId, examId))
const rows = await db
.select({
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
})
.from(gradeRecords)
.where(and(...conditions))
if (rows.length === 0) return null
const scores = rows.map((r) => toNumber(r.score))
const fullScores = rows.map((r) => toNumber(r.fullScore))
const countN = scores.length
const sum = scores.reduce((a, b) => a + b, 0)
const average = sum / countN
const sorted = [...scores].sort((a, b) => a - b)
const mid = Math.floor(countN / 2)
const median = countN % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
const max = sorted[countN - 1]
const min = sorted[0]
const variance = scores.reduce((acc, s) => acc + Math.pow(s - average, 2), 0) / countN
const stdDev = Math.sqrt(variance)
let passCount = 0
let excellentCount = 0
for (let i = 0; i < countN; i++) {
const ratio = scores[i] / fullScores[i]
if (ratio >= 0.6) passCount++
if (ratio >= 0.85) excellentCount++
}
return {
average: Math.round(average * 100) / 100,
median: Math.round(median * 100) / 100,
max,
min,
stdDev: Math.round(stdDev * 100) / 100,
passRate: Math.round((passCount / countN) * 10000) / 100,
excellentRate: Math.round((excellentCount / countN) * 10000) / 100,
count: countN,
}
}
export async function getStudentGradeSummary(
studentId: string
): Promise<StudentGradeSummary | null> {
const [student] = await db.select({ name: users.name }).from(users).where(eq(users.id, studentId)).limit(1)
if (!student) return null
const records = await db
.select({
record: gradeRecords,
className: classes.name,
subjectName: subjects.name,
})
.from(gradeRecords)
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
.where(eq(gradeRecords.studentId, studentId))
.orderBy(desc(gradeRecords.createdAt))
if (records.length === 0) {
return {
studentId,
studentName: student.name ?? "Unknown",
records: [],
averageScore: 0,
rank: 0,
}
}
const listItems: GradeRecordListItem[] = records.map((r) => ({
id: r.record.id,
studentId: r.record.studentId,
studentName: student.name ?? "Unknown",
classId: r.record.classId,
className: r.className ?? "Unknown",
subjectId: r.record.subjectId,
subjectName: r.subjectName ?? "Unknown",
examId: r.record.examId ?? null,
title: r.record.title,
score: toNumber(r.record.score),
fullScore: toNumber(r.record.fullScore),
type: r.record.type,
semester: r.record.semester,
recordedBy: r.record.recordedBy,
recorderName: "Unknown",
remark: r.record.remark ?? null,
createdAt: r.record.createdAt.toISOString(),
}))
const avg = listItems.reduce((a, b) => a + b.score, 0) / listItems.length
return {
studentId,
studentName: student.name ?? "Unknown",
records: listItems,
averageScore: Math.round(avg * 100) / 100,
rank: 0,
}
}
export async function getClassRanking(
classId: string,
subjectId?: string,
examId?: string
): Promise<ClassRankingItem[]> {
const conditions = [eq(gradeRecords.classId, classId)]
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
if (examId) conditions.push(eq(gradeRecords.examId, examId))
const rows = await db
.select({
studentId: gradeRecords.studentId,
studentName: users.name,
avgScore: sql<number>`AVG(${gradeRecords.score})`,
recordCount: count(gradeRecords.id),
})
.from(gradeRecords)
.leftJoin(users, eq(users.id, gradeRecords.studentId))
.where(and(...conditions))
.groupBy(gradeRecords.studentId, users.name)
.orderBy(desc(sql`AVG(${gradeRecords.score})`))
return rows.map((r, idx) => ({
studentId: r.studentId,
studentName: r.studentName ?? "Unknown",
averageScore: Math.round(toNumber(r.avgScore) * 100) / 100,
rank: idx + 1,
recordCount: toNumber(r.recordCount),
}))
}
export async function getClassStudentsForEntry(classId: string): Promise<
Array<{ id: string; name: string; email: string }>
> {
const rows = await db
.select({
id: users.id,
name: users.name,
email: users.email,
})
.from(classEnrollments)
.innerJoin(users, eq(users.id, classEnrollments.studentId))
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
.orderBy(asc(users.name))
return rows.map((r) => ({
id: r.id,
name: r.name ?? "Unknown",
email: r.email,
}))
}
export async function getClassGradeStatsWithMeta(
classId: string,
subjectId?: string,
examId?: string
): Promise<ClassGradeStats | 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 stats = await getClassGradeStats(classId, subjectId, examId)
if (!stats) {
return {
classId,
className: classRow.name,
stats: {
average: 0,
median: 0,
max: 0,
min: 0,
stdDev: 0,
passRate: 0,
excellentRate: 0,
count: 0,
},
studentCount: 0,
}
}
const [studentCountRow] = await db
.select({ c: count() })
.from(classEnrollments)
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
return {
classId,
className: classRow.name,
stats,
studentCount: toNumber(studentCountRow?.c ?? 0),
}
}

View File

@@ -0,0 +1,214 @@
import "server-only"
import { eq } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
gradeRecords,
subjects,
users,
} from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions"
import { exportToExcel } from "@/shared/lib/excel"
import { getClassGradeStats, getGradeRecords } from "./data-access"
import type { GradeRecordType } from "./types"
const TYPE_LABELS: Record<GradeRecordType, string> = {
exam: "考试",
quiz: "测验",
homework: "作业",
other: "其他",
}
const formatDateForFile = (d = new Date()) => {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, "0")
const day = String(d.getDate()).padStart(2, "0")
return `${y}-${m}-${day}`
}
/**
* 导出成绩单
* Sheet 1: 成绩明细
* Sheet 2: 统计汇总
*/
export async function exportGradeRecordsToExcel(params: {
classId: string
subjectId?: string
examId?: string
scope: DataScope
}): Promise<Buffer> {
const records = await getGradeRecords({
scope: params.scope,
classId: params.classId,
subjectId: params.subjectId,
examId: params.examId,
})
const detailRows = records.map((r) => ({
studentName: r.studentName,
className: r.className,
subjectName: r.subjectName,
title: r.title,
score: r.score,
fullScore: r.fullScore,
type: TYPE_LABELS[r.type] ?? r.type,
semester: r.semester === "1" ? "第一学期" : "第二学期",
recorderName: r.recorderName,
remark: r.remark ?? "",
createdAt: r.createdAt.split("T")[0],
}))
const stats = await getClassGradeStats(params.classId, params.subjectId, params.examId)
const statsRows = stats
? [
{ metric: "均分", value: stats.average },
{ metric: "中位数", value: stats.median },
{ metric: "最高分", value: stats.max },
{ metric: "最低分", value: stats.min },
{ metric: "标准差", value: stats.stdDev },
{ metric: "及格率(%)", value: stats.passRate },
{ metric: "优秀率(%)", value: stats.excellentRate },
{ metric: "参考人数", value: stats.count },
]
: [{ metric: "无数据", value: "" }]
return exportToExcel({
sheets: [
{
name: "成绩明细",
columns: [
{ header: "学生姓名", key: "studentName", width: 16 },
{ header: "班级", key: "className", width: 20 },
{ header: "科目", key: "subjectName", width: 14 },
{ header: "标题", key: "title", width: 24 },
{ header: "分数", key: "score", width: 10 },
{ header: "满分", key: "fullScore", width: 10 },
{ header: "类型", key: "type", width: 10 },
{ header: "学期", key: "semester", width: 12 },
{ header: "录入人", key: "recorderName", width: 14 },
{ header: "备注", key: "remark", width: 24 },
{ header: "录入日期", key: "createdAt", width: 14 },
],
rows: detailRows,
},
{
name: "统计汇总",
columns: [
{ header: "指标", key: "metric", width: 20 },
{ header: "数值", key: "value", width: 16 },
],
rows: statsRows,
},
],
})
}
/**
* 导出班级成绩总表(多科目横向对比)
*/
export async function exportClassGradeReportToExcel(params: {
classId: string
scope: DataScope
}): Promise<Buffer> {
const [classRow] = await db
.select({ id: classes.id, name: classes.name })
.from(classes)
.where(eq(classes.id, params.classId))
.limit(1)
const className = classRow?.name ?? "Unknown"
// Get all subjects that have grade records for this class
const subjectRows = await db
.select({
id: subjects.id,
name: subjects.name,
})
.from(subjects)
.innerJoin(gradeRecords, eq(gradeRecords.subjectId, subjects.id))
.where(eq(gradeRecords.classId, params.classId))
.groupBy(subjects.id, subjects.name)
// Get all students with grades in this class
const studentRows = await db
.select({
id: users.id,
name: users.name,
})
.from(users)
.innerJoin(gradeRecords, eq(gradeRecords.studentId, users.id))
.where(eq(gradeRecords.classId, params.classId))
.groupBy(users.id, users.name)
.orderBy(users.name)
// Build a map: studentId -> subjectId -> average score
const allRecords = await getGradeRecords({
scope: params.scope,
classId: params.classId,
})
const scoreMap = new Map<string, Map<string, number[]>>()
for (const r of allRecords) {
if (!scoreMap.has(r.studentId)) scoreMap.set(r.studentId, new Map())
const subjMap = scoreMap.get(r.studentId)!
const arr = subjMap.get(r.subjectId) ?? []
arr.push(r.score)
subjMap.set(r.subjectId, arr)
}
const avg = (arr: number[]) =>
arr.length > 0 ? Math.round((arr.reduce((a, b) => a + b, 0) / arr.length) * 100) / 100 : 0
const columns = [
{ header: "学生姓名", key: "studentName", width: 16 },
...subjectRows.map((s) => ({
header: s.name,
key: s.id,
width: 14,
})),
{ header: "总分", key: "_total", width: 12 },
{ header: "平均分", key: "_average", width: 12 },
{ header: "排名", key: "_rank", width: 10 },
]
const rowsData = studentRows.map((student) => {
const subjMap = scoreMap.get(student.id) ?? new Map<string, number[]>()
const row: Record<string, unknown> = {
studentName: student.name ?? "Unknown",
}
let total = 0
let count = 0
for (const subj of subjectRows) {
const scores = subjMap.get(subj.id) ?? []
const score = avg(scores)
row[subj.id] = scores.length > 0 ? score : "-"
if (scores.length > 0) {
total += score
count++
}
}
row["_total"] = total
row["_average"] = count > 0 ? Math.round((total / count) * 100) / 100 : 0
return { row, total }
})
// Rank by total descending
rowsData.sort((a, b) => b.total - a.total)
const rows = rowsData.map((d, idx) => ({
...d.row,
_rank: idx + 1,
}))
return exportToExcel({
sheets: [
{
name: `${className}_成绩总表`,
columns,
rows,
},
],
})
}
export { formatDateForFile }

View File

@@ -0,0 +1,52 @@
import { z } from "zod"
export const GradeRecordTypeEnum = z.enum(["exam", "quiz", "homework", "other"])
export const GradeRecordSemesterEnum = z.enum(["1", "2"])
export const CreateGradeRecordSchema = z.object({
studentId: z.string().min(1),
classId: z.string().min(1),
subjectId: z.string().min(1),
examId: z.string().optional(),
academicYearId: z.string().optional(),
title: z.string().min(1).max(255),
score: z.coerce.number().min(0),
fullScore: z.coerce.number().min(1).optional(),
type: GradeRecordTypeEnum.optional(),
semester: GradeRecordSemesterEnum.optional(),
remark: z.string().optional(),
})
export type CreateGradeRecordInput = z.infer<typeof CreateGradeRecordSchema>
export const BatchGradeRecordItemSchema = z.object({
studentId: z.string().min(1),
score: z.coerce.number().min(0),
remark: z.string().optional(),
})
export const BatchCreateGradeRecordSchema = z.object({
classId: z.string().min(1),
subjectId: z.string().min(1),
examId: z.string().optional(),
academicYearId: z.string().optional(),
title: z.string().min(1).max(255),
fullScore: z.coerce.number().min(1).optional(),
type: GradeRecordTypeEnum.optional(),
semester: GradeRecordSemesterEnum.optional(),
records: z.array(BatchGradeRecordItemSchema),
})
export type BatchCreateGradeRecordInput = z.infer<typeof BatchCreateGradeRecordSchema>
export const UpdateGradeRecordSchema = z.object({
title: z.string().min(1).max(255).optional(),
score: z.coerce.number().min(0).optional(),
fullScore: z.coerce.number().min(1).optional(),
type: GradeRecordTypeEnum.optional(),
semester: GradeRecordSemesterEnum.optional(),
remark: z.string().optional(),
examId: z.string().optional(),
})
export type UpdateGradeRecordInput = z.infer<typeof UpdateGradeRecordSchema>

176
src/modules/grades/types.ts Normal file
View File

@@ -0,0 +1,176 @@
export type GradeRecordType = "exam" | "quiz" | "homework" | "other"
export type GradeRecordSemester = "1" | "2"
export interface GradeRecord {
id: string
studentId: string
classId: string
subjectId: string
examId: string | null
academicYearId: string | null
title: string
score: string
fullScore: string
type: GradeRecordType
semester: GradeRecordSemester
recordedBy: string
remark: string | null
createdAt: string
updatedAt: string
}
export interface GradeRecordListItem {
id: string
studentId: string
studentName: string
classId: string
className: string
subjectId: string
subjectName: string
examId: string | null
title: string
score: number
fullScore: number
type: GradeRecordType
semester: GradeRecordSemester
recordedBy: string
recorderName: string
remark: string | null
createdAt: string
}
export interface GradeStats {
average: number
median: number
max: number
min: number
stdDev: number
passRate: number
excellentRate: number
count: number
}
export interface ClassGradeStats {
classId: string
className: string
stats: GradeStats
studentCount: number
}
export interface StudentGradeSummary {
studentId: string
studentName: string
records: GradeRecordListItem[]
averageScore: number
rank: number
}
export interface ClassRankingItem {
studentId: string
studentName: string
averageScore: number
rank: number
recordCount: number
}
export interface GradeQueryParams {
classId?: string
subjectId?: string
studentId?: string
type?: GradeRecordType
semester?: GradeRecordSemester
examId?: string
}
// --- Analytics Types ---
export interface GradeTrendPoint {
/** ISO date string of the grade record creation */
date: string
/** Title of the exam/assessment */
title: string
/** Raw score */
score: number
/** Full score for this record */
fullScore: number
/** Score normalized to 0-100 scale for cross-record comparison */
normalizedScore: number
/** Type of grade record */
type: GradeRecordType
}
export interface GradeTrendResult {
/** Label for the trend series (e.g. class name + subject) */
label: string
/** Sorted ascending by date */
points: GradeTrendPoint[]
/** Average of normalized scores */
averageScore: number
}
export interface ClassComparisonItem {
classId: string
className: string
/** Average score (normalized to 0-100) */
averageScore: number
/** Median score */
medianScore: number
/** Pass rate (score/fullScore >= 0.6) */
passRate: number
/** Excellent rate (score/fullScore >= 0.85) */
excellentRate: number
/** Number of grade records */
count: number
/** Number of unique students */
studentCount: number
}
export interface SubjectComparisonItem {
subjectId: string
subjectName: string
/** Average normalized score (0-100) */
averageScore: number
/** Median normalized score */
medianScore: number
/** Pass rate */
passRate: number
/** Excellent rate */
excellentRate: number
/** Number of records */
count: number
}
export interface GradeDistributionBucket {
/** Bucket label e.g. "90-100" */
label: string
/** Lower bound (inclusive) */
min: number
/** Upper bound (inclusive) */
max: number
/** Number of students in this bucket */
count: number
}
export interface GradeDistributionResult {
buckets: GradeDistributionBucket[]
totalCount: number
}
export interface RankingTrendPoint {
/** Title of the exam/assessment */
title: string
/** ISO date string */
date: string
/** Student's average score (normalized) */
score: number
/** Rank in class for this assessment (1-based) */
rank: number
/** Total students participating */
totalStudents: number
}
export interface RankingTrendResult {
studentId: string
studentName: string
points: RankingTrendPoint[]
}