refactor(grades,diagnostic): 完成成绩和学情诊断模块审计 P1+P2 改进项
P1-1: 抽取 stats-service.ts,将 8 个统计计算纯函数从 data-access 层分离 P1-5: 创建 WidgetBoundary 组件 + 补齐 teacher 路由 loading.tsx/error.tsx (14 文件) P1-6: 同步架构图文档 004/005,新增 stats-service 与 widget-boundary 节点 P2-1: 补充 a11y ARIA 属性(5 图表 role=img + aria-label,2 表格 caption,3 列表 role=list,3 按钮 aria-label) P2-3: 修复班级报告 studentId 字段语义错误(schema 改为可空 + 迁移 + 代码适配) P2-4: 修复 grade_managed scope 返回空数据(改为子查询 classes 表按 gradeId 过滤) P2-5: 新增 /parent/diagnostic/ 页面(多子女学情诊断聚合 + loading + error) P2-6: 统一 SearchParams 工具(student/grades 和 management/grade/insights 改用 @/shared/lib/search-params)
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
|
||||
@@ -11,22 +15,23 @@ import { Button } from "@/shared/components/ui/button"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
if (typeof v === "string") return v
|
||||
if (Array.isArray(v)) return v[0]
|
||||
return undefined
|
||||
}
|
||||
|
||||
const formatScore = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-")
|
||||
|
||||
export default async function TeacherGradeInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("school")
|
||||
return {
|
||||
title: `${t("classManagement.grade.insights.title")} - Next_Edu`,
|
||||
description: t("classManagement.grade.insights.description"),
|
||||
}
|
||||
}
|
||||
|
||||
export default async function TeacherGradeInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("school")
|
||||
const params = await searchParams
|
||||
const gradeId = getParam(params, "gradeId")
|
||||
|
||||
@@ -41,13 +46,13 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
||||
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">Grade Insights</h2>
|
||||
<p className="text-muted-foreground">View grade-level homework statistics for grades you lead.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("classManagement.grade.insights.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("classManagement.grade.insights.description")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="No grades assigned"
|
||||
description="You are not assigned as a grade head or teaching head for any grade."
|
||||
title={t("classManagement.grade.insights.noGrades")}
|
||||
description={t("classManagement.grade.insights.noGradesDescription")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
</div>
|
||||
@@ -57,27 +62,27 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
||||
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">Grade Insights</h2>
|
||||
<p className="text-muted-foreground">Homework statistics aggregated across all classes in a grade.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("classManagement.grade.insights.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("classManagement.grade.insights.description")}</p>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">Filters</CardTitle>
|
||||
<CardTitle className="text-base">{t("classManagement.grade.insights.filters")}</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{grades.length}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action="/management/grade/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<label htmlFor="gradeId" className="text-sm font-medium">Grade</label>
|
||||
<label htmlFor="gradeId" className="text-sm font-medium">{t("classManagement.grade.insights.grade")}</label>
|
||||
<select
|
||||
id="gradeId"
|
||||
name="gradeId"
|
||||
defaultValue={selected || "all"}
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-[360px]"
|
||||
>
|
||||
<option value="all">Select a grade</option>
|
||||
<option value="all">{t("classManagement.grade.insights.selectGrade")}</option>
|
||||
{grades.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.school.name} / {g.name}
|
||||
@@ -85,7 +90,7 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" className="md:ml-2">
|
||||
Apply
|
||||
{t("classManagement.grade.insights.apply")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
@@ -94,47 +99,47 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
||||
{!selected ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="Select a grade to view insights"
|
||||
description="Pick a grade to see latest homework and historical score statistics."
|
||||
title={t("classManagement.grade.insights.selectToView")}
|
||||
description={t("classManagement.grade.insights.selectToViewDescription")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
) : !insights ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="Grade not found"
|
||||
description="This grade may not exist or has no accessible data."
|
||||
title={t("classManagement.grade.insights.notFound")}
|
||||
description={t("classManagement.grade.insights.notFoundDescription")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
) : insights.assignments.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="No homework data for this grade"
|
||||
description="No homework assignments were targeted to students in this grade yet."
|
||||
title={t("classManagement.grade.insights.noData")}
|
||||
description={t("classManagement.grade.insights.noDataDescription")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
title="Classes"
|
||||
title={t("classManagement.grade.insights.classes")}
|
||||
value={insights.classCount}
|
||||
description={`${insights.grade.school.name} / ${insights.grade.name}`}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title="Students"
|
||||
title={t("classManagement.grade.insights.students")}
|
||||
value={insights.studentCounts.total}
|
||||
description={`Active ${insights.studentCounts.active} • Inactive ${insights.studentCounts.inactive}`}
|
||||
description={`${t("classManagement.grade.insights.active")} ${insights.studentCounts.active} • ${t("classManagement.grade.insights.inactive")} ${insights.studentCounts.inactive}`}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title="Overall Avg"
|
||||
title={t("classManagement.grade.insights.overallAvg")}
|
||||
value={formatScore(insights.overallScores.avg)}
|
||||
description="Across graded homework"
|
||||
description="-"
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title="Latest Avg"
|
||||
title={t("classManagement.grade.insights.latestAvg")}
|
||||
value={formatScore(insights.latest?.scoreStats.avg ?? null)}
|
||||
description={insights.latest ? insights.latest.title : "-"}
|
||||
valueClassName="tabular-nums"
|
||||
@@ -143,7 +148,7 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">Homework timeline</CardTitle>
|
||||
<CardTitle className="text-base">{t("classManagement.grade.insights.homeworkTimeline")}</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{insights.assignments.length}
|
||||
</Badge>
|
||||
@@ -153,14 +158,14 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>Assignment</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Targeted</TableHead>
|
||||
<TableHead className="text-right">Submitted</TableHead>
|
||||
<TableHead className="text-right">Graded</TableHead>
|
||||
<TableHead className="text-right">Avg</TableHead>
|
||||
<TableHead className="text-right">Median</TableHead>
|
||||
<TableHead>{t("classManagement.grade.insights.assignment")}</TableHead>
|
||||
<TableHead>{t("classManagement.grade.insights.status")}</TableHead>
|
||||
<TableHead>{t("classManagement.grade.insights.created")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.targeted")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.submitted")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.graded")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.avg")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.median")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -188,7 +193,7 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">Class ranking</CardTitle>
|
||||
<CardTitle className="text-base">{t("classManagement.grade.insights.classRanking")}</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{insights.classes.length}
|
||||
</Badge>
|
||||
@@ -198,12 +203,12 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>Class</TableHead>
|
||||
<TableHead className="text-right">Students</TableHead>
|
||||
<TableHead className="text-right">Latest Avg</TableHead>
|
||||
<TableHead className="text-right">Prev Avg</TableHead>
|
||||
<TableHead className="text-right">Δ</TableHead>
|
||||
<TableHead className="text-right">Overall Avg</TableHead>
|
||||
<TableHead>{t("classManagement.grade.insights.class")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.students")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.latestAvgCol")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.prevAvg")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.delta")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.overallAvgCol")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -230,4 +235,3 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
27
src/app/(dashboard)/parent/diagnostic/error.tsx
Normal file
27
src/app/(dashboard)/parent/diagnostic/error.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function ParentDiagnosticError({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="子女学情诊断加载失败"
|
||||
description="抱歉,加载子女诊断数据时发生了意外错误。请稍后重试。"
|
||||
action={{
|
||||
label: "重试",
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
src/app/(dashboard)/parent/diagnostic/loading.tsx
Normal file
32
src/app/(dashboard)/parent/diagnostic/loading.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-8 p-6 md:p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-72 w-full" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{Array.from({ length: 2 }).map((_, j) => (
|
||||
<Skeleton key={j} className="h-40 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
src/app/(dashboard)/parent/diagnostic/page.tsx
Normal file
70
src/app/(dashboard)/parent/diagnostic/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Stethoscope } from "lucide-react"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getStudentMasterySummary } from "@/modules/diagnostic/data-access"
|
||||
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
|
||||
import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view"
|
||||
import {
|
||||
ParentChildrenDataPage,
|
||||
ParentNoChildrenPage,
|
||||
} from "@/modules/parent/components/parent-children-data-page"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ParentDiagnosticPage() {
|
||||
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||
|
||||
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
|
||||
return (
|
||||
<ParentNoChildrenPage
|
||||
title="Children Diagnostic"
|
||||
description="View your children's knowledge point mastery and diagnostic reports."
|
||||
icon={Stethoscope}
|
||||
emptyTitle="No children linked"
|
||||
emptyDescription="Your account is not linked to any student accounts yet. Please contact the school administrator."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 使用 allSettled 容错:单个子女查询失败不影响其他子女展示
|
||||
const results = await Promise.allSettled(
|
||||
ctx.dataScope.childrenIds.map(async (id) => {
|
||||
const [summary, reports] = await Promise.all([
|
||||
getStudentMasterySummary(id),
|
||||
getDiagnosticReports({ studentId: id }),
|
||||
])
|
||||
return { summary, reports, studentId: id }
|
||||
}),
|
||||
)
|
||||
const validItems = results
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<{
|
||||
summary: Awaited<ReturnType<typeof getStudentMasterySummary>>
|
||||
reports: Awaited<ReturnType<typeof getDiagnosticReports>>
|
||||
studentId: string
|
||||
}> => r.status === "fulfilled",
|
||||
)
|
||||
.map((r) => r.value)
|
||||
|
||||
return (
|
||||
<ParentChildrenDataPage
|
||||
title="Children Diagnostic"
|
||||
description="View knowledge point mastery and diagnostic reports for all your children."
|
||||
icon={Stethoscope}
|
||||
noRecordsTitle="No diagnostic data"
|
||||
noRecordsDescription="Your children don't have any diagnostic data yet."
|
||||
items={validItems}
|
||||
renderItem={({ summary, reports }) => (
|
||||
<>
|
||||
<div className="border-b pb-2">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{summary?.studentName ?? "Unknown student"}
|
||||
</h3>
|
||||
</div>
|
||||
<StudentDiagnosticView summary={summary} reports={reports} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,21 @@
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
||||
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
|
||||
import { GradeFilters } from "@/modules/grades/components/grade-filters"
|
||||
import { GradeTrendCard } from "@/modules/grades/components/grade-trend-card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { UserX } from "lucide-react"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
|
||||
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 StudentGradesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const ctx = await getAuthContext()
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const [sp, summary] = await Promise.all([
|
||||
searchParams,
|
||||
getStudentGradeSummary(ctx.userId),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function DiagnosticClassError({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="班级学情诊断加载失败"
|
||||
description="抱歉,加载班级诊断数据时发生了意外错误。请稍后重试。"
|
||||
action={{
|
||||
label: "重试",
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-72 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
src/app/(dashboard)/teacher/diagnostic/error.tsx
Normal file
27
src/app/(dashboard)/teacher/diagnostic/error.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function TeacherDiagnosticError({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="学情诊断页面加载失败"
|
||||
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||
action={{
|
||||
label: "重试",
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
src/app/(dashboard)/teacher/diagnostic/loading.tsx
Normal file
23
src/app/(dashboard)/teacher/diagnostic/loading.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function DiagnosticStudentError({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="学生学情诊断加载失败"
|
||||
description="抱歉,加载学生诊断数据时发生了意外错误。请稍后重试。"
|
||||
action={{
|
||||
label: "重试",
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-72 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
src/app/(dashboard)/teacher/grades/analytics/error.tsx
Normal file
27
src/app/(dashboard)/teacher/grades/analytics/error.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function TeacherGradesAnalyticsError({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="成绩分析加载失败"
|
||||
description="抱歉,分析数据加载时发生了意外错误。请稍后重试。"
|
||||
action={{
|
||||
label: "重试",
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
src/app/(dashboard)/teacher/grades/analytics/loading.tsx
Normal file
31
src/app/(dashboard)/teacher/grades/analytics/loading.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
src/app/(dashboard)/teacher/grades/entry/error.tsx
Normal file
27
src/app/(dashboard)/teacher/grades/entry/error.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function TeacherGradesEntryError({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="成绩录入页面加载失败"
|
||||
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||
action={{
|
||||
label: "重试",
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
src/app/(dashboard)/teacher/grades/entry/loading.tsx
Normal file
23
src/app/(dashboard)/teacher/grades/entry/loading.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-4 w-56" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
src/app/(dashboard)/teacher/grades/error.tsx
Normal file
27
src/app/(dashboard)/teacher/grades/error.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function TeacherGradesError({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="成绩页面加载失败"
|
||||
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||
action={{
|
||||
label: "重试",
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
src/app/(dashboard)/teacher/grades/loading.tsx
Normal file
28
src/app/(dashboard)/teacher/grades/loading.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-4 w-56" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Skeleton className="h-9 w-32" />
|
||||
<Skeleton className="h-9 w-32" />
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
src/app/(dashboard)/teacher/grades/stats/error.tsx
Normal file
27
src/app/(dashboard)/teacher/grades/stats/error.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function TeacherGradesStatsError({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="成绩统计加载失败"
|
||||
description="抱歉,统计数据加载时发生了意外错误。请稍后重试。"
|
||||
action={{
|
||||
label: "重试",
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
src/app/(dashboard)/teacher/grades/stats/loading.tsx
Normal file
33
src/app/(dashboard)/teacher/grades/stats/loading.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-4 w-56" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -35,35 +35,37 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
|
||||
emptyDescription="No knowledge point mastery records found for this student."
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<ComparisonRadarChart
|
||||
data={chartData}
|
||||
angleKey="shortName"
|
||||
angleTickFontSize={11}
|
||||
domain={[0, 100]}
|
||||
tickCount={5}
|
||||
showLegend={hasClassAverage}
|
||||
heightClassName="mx-auto h-96 w-full max-w-lg"
|
||||
gridStrokeDasharray="4 4"
|
||||
series={[
|
||||
{
|
||||
dataKey: "student",
|
||||
name: "Student",
|
||||
color: "hsl(var(--primary))",
|
||||
fillOpacity: 0.35,
|
||||
strokeWidth: 2,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
dataKey: "classAverage",
|
||||
name: "Class Avg",
|
||||
color: "hsl(var(--chart-2))",
|
||||
fillOpacity: 0.15,
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: "4 4",
|
||||
show: hasClassAverage,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div role="img" aria-label={`知识点掌握度雷达图:${isEmpty ? "暂无数据" : `共 ${data.length} 个知识点的掌握度${hasClassAverage ? "(含班级平均对比)" : ""}`}`}>
|
||||
<ComparisonRadarChart
|
||||
data={chartData}
|
||||
angleKey="shortName"
|
||||
angleTickFontSize={11}
|
||||
domain={[0, 100]}
|
||||
tickCount={5}
|
||||
showLegend={hasClassAverage}
|
||||
heightClassName="mx-auto h-96 w-full max-w-lg"
|
||||
gridStrokeDasharray="4 4"
|
||||
series={[
|
||||
{
|
||||
dataKey: "student",
|
||||
name: "Student",
|
||||
color: "hsl(var(--primary))",
|
||||
fillOpacity: 0.35,
|
||||
strokeWidth: 2,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
dataKey: "classAverage",
|
||||
name: "Class Avg",
|
||||
color: "hsl(var(--chart-2))",
|
||||
fillOpacity: 0.15,
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: "4 4",
|
||||
show: hasClassAverage,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
) : (
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<caption className="sr-only">学情诊断报告列表</caption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
@@ -175,7 +176,7 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
<TableCell>
|
||||
<Badge variant="outline">{typeLabels[r.reportType] ?? r.reportType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{r.studentName}</TableCell>
|
||||
<TableCell className="font-medium">{r.studentName ?? (r.reportType === "class" ? "(班级报告)" : r.reportType === "grade" ? "(年级报告)" : "-")}</TableCell>
|
||||
<TableCell>{r.period ?? "-"}</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{r.overallScore !== null ? `${r.overallScore.toFixed(1)}%` : "-"}
|
||||
@@ -195,6 +196,7 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
className="h-8 w-8 text-green-600"
|
||||
onClick={() => setPublishId(r.id)}
|
||||
title="Publish"
|
||||
aria-label={`发布报告 ${r.studentName}`}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -205,6 +207,7 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => setDeleteId(r.id)}
|
||||
title="Delete"
|
||||
aria-label={`删除报告 ${r.studentName}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -96,7 +96,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
||||
{summary.strengths.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No strengths identified yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
<ul className="space-y-2" role="list" aria-label="优势知识点列表">
|
||||
{summary.strengths.map((m) => (
|
||||
<li key={m.knowledgePointId} className="flex items-center justify-between">
|
||||
<span className="text-sm">{m.knowledgePointName}</span>
|
||||
@@ -119,7 +119,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
||||
{summary.weaknesses.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No weaknesses identified.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
<ul className="space-y-2" role="list" aria-label="薄弱知识点列表">
|
||||
{summary.weaknesses.map((m) => (
|
||||
<li key={m.knowledgePointId} className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -162,7 +162,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
||||
{latestReport.recommendations && latestReport.recommendations.length > 0 ? (
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold">Recommendations</h4>
|
||||
<ul className="space-y-1.5">
|
||||
<ul className="space-y-1.5" role="list" aria-label="学习建议列表">
|
||||
{latestReport.recommendations.map((rec, i) => (
|
||||
<li key={i} className="text-sm text-muted-foreground">• {rec}</li>
|
||||
))}
|
||||
|
||||
@@ -109,7 +109,7 @@ export async function generateClassDiagnosticReport(
|
||||
const id = createId()
|
||||
await db.insert(learningDiagnosticReports).values({
|
||||
id,
|
||||
studentId: generatedBy, // 班级报告 studentId 存生成者 ID(schema 要求 NOT NULL)
|
||||
studentId: null, // 班级报告无单个学生,studentId 置空(P2-3 修复:不再存生成者 ID)
|
||||
generatedBy,
|
||||
reportType: "class",
|
||||
period,
|
||||
@@ -141,14 +141,16 @@ export const getDiagnosticReports = cache(
|
||||
// 收集所有需要查询姓名的用户 ID(学生 + 生成者),通过 users data-access 统一获取
|
||||
const userIds = new Set<string>()
|
||||
for (const r of rows) {
|
||||
userIds.add(r.report.studentId)
|
||||
if (r.report.studentId) userIds.add(r.report.studentId)
|
||||
if (r.report.generatedBy) userIds.add(r.report.generatedBy)
|
||||
}
|
||||
const userMap = await getUserNamesByIds(Array.from(userIds))
|
||||
|
||||
return rows.map((r) => ({
|
||||
...serializeReport(r.report),
|
||||
studentName: userMap.get(r.report.studentId)?.name ?? "Unknown",
|
||||
studentName: r.report.studentId
|
||||
? userMap.get(r.report.studentId)?.name ?? "Unknown"
|
||||
: null,
|
||||
generatedByName: r.report.generatedBy
|
||||
? userMap.get(r.report.generatedBy)?.name ?? "Unknown"
|
||||
: null,
|
||||
@@ -167,13 +169,16 @@ export const getDiagnosticReportById = cache(
|
||||
if (!row) return null
|
||||
|
||||
// 通过 users data-access 获取学生姓名和生成者姓名
|
||||
const userIds = [row.report.studentId]
|
||||
const userIds: string[] = []
|
||||
if (row.report.studentId) userIds.push(row.report.studentId)
|
||||
if (row.report.generatedBy) userIds.push(row.report.generatedBy)
|
||||
const userMap = await getUserNamesByIds(userIds)
|
||||
|
||||
return {
|
||||
...serializeReport(row.report),
|
||||
studentName: userMap.get(row.report.studentId)?.name ?? "Unknown",
|
||||
studentName: row.report.studentId
|
||||
? userMap.get(row.report.studentId)?.name ?? "Unknown"
|
||||
: null,
|
||||
generatedByName: row.report.generatedBy
|
||||
? userMap.get(row.report.generatedBy)?.name ?? null
|
||||
: null,
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface StudentMasterySummary {
|
||||
/** 诊断报告 */
|
||||
export interface DiagnosticReport {
|
||||
id: string
|
||||
studentId: string
|
||||
studentId: string | null
|
||||
generatedBy: string | null
|
||||
reportType: DiagnosticReportType
|
||||
period: string | null
|
||||
@@ -52,7 +52,7 @@ export interface DiagnosticReport {
|
||||
|
||||
/** 含学生名的诊断报告(join users 后) */
|
||||
export interface DiagnosticReportWithDetails extends DiagnosticReport {
|
||||
studentName: string
|
||||
studentName: string | null
|
||||
generatedByName: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -38,23 +38,25 @@ export function ClassComparisonChart({ data }: ClassComparisonChartProps) {
|
||||
emptyDescription="Select a grade and subject to compare classes."
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<SimpleBarChart
|
||||
data={chartData}
|
||||
bars={[
|
||||
{ dataKey: "averageScore", name: "Average (%)", color: "hsl(var(--primary))" },
|
||||
{ dataKey: "passRate", name: "Pass Rate (%)", color: "hsl(var(--chart-2))" },
|
||||
{ dataKey: "excellentRate", name: "Excellent (%)", color: "hsl(var(--chart-3))" },
|
||||
]}
|
||||
xKey="name"
|
||||
xTruncateLength={8}
|
||||
yDomain={[0, 100]}
|
||||
yTickFormatter={(value: number) => `${value}%`}
|
||||
yWidth={36}
|
||||
heightClassName="h-[300px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
showLegend
|
||||
tooltipClassName="w-[240px]"
|
||||
/>
|
||||
<div role="img" aria-label={`班级对比柱状图:${isEmpty ? "暂无数据" : `共 ${data.length} 个班级的均分、及格率与优秀率对比`}`}>
|
||||
<SimpleBarChart
|
||||
data={chartData}
|
||||
bars={[
|
||||
{ dataKey: "averageScore", name: "Average (%)", color: "hsl(var(--primary))" },
|
||||
{ dataKey: "passRate", name: "Pass Rate (%)", color: "hsl(var(--chart-2))" },
|
||||
{ dataKey: "excellentRate", name: "Excellent (%)", color: "hsl(var(--chart-3))" },
|
||||
]}
|
||||
xKey="name"
|
||||
xTruncateLength={8}
|
||||
yDomain={[0, 100]}
|
||||
yTickFormatter={(value: number) => `${value}%`}
|
||||
yWidth={36}
|
||||
heightClassName="h-[300px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
showLegend
|
||||
tooltipClassName="w-[240px]"
|
||||
/>
|
||||
</div>
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,37 +70,39 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
|
||||
emptyDescription="Select a class and subject to view score distribution."
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<SimpleBarChart
|
||||
data={chartData}
|
||||
bars={[
|
||||
{
|
||||
dataKey: "count",
|
||||
name: "Students",
|
||||
color: "hsl(var(--primary))",
|
||||
},
|
||||
]}
|
||||
xKey="label"
|
||||
xTickFormatter={null}
|
||||
yAllowDecimals={false}
|
||||
yWidth={32}
|
||||
heightClassName="h-[280px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
tooltipClassName="w-[200px]"
|
||||
cellColors={BUCKET_COLORS}
|
||||
tooltipFormatter={(payload: unknown) => {
|
||||
if (!isDistributionTooltipPayload(payload)) return null
|
||||
const item = payload.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>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div role="img" aria-label={`分数分布柱状图:${isEmpty ? "暂无数据" : `共 ${data.totalCount} 条成绩记录分布在 5 个分数区间`}`}>
|
||||
<SimpleBarChart
|
||||
data={chartData}
|
||||
bars={[
|
||||
{
|
||||
dataKey: "count",
|
||||
name: "Students",
|
||||
color: "hsl(var(--primary))",
|
||||
},
|
||||
]}
|
||||
xKey="label"
|
||||
xTickFormatter={null}
|
||||
yAllowDecimals={false}
|
||||
yWidth={32}
|
||||
heightClassName="h-[280px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
tooltipClassName="w-[200px]"
|
||||
cellColors={BUCKET_COLORS}
|
||||
tooltipFormatter={(payload: unknown) => {
|
||||
if (!isDistributionTooltipPayload(payload)) return null
|
||||
const item = payload.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>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
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,
|
||||
@@ -21,18 +20,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||
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",
|
||||
}
|
||||
import { GRADE_TYPE_VARIANT } from "../types"
|
||||
|
||||
export function GradeRecordList({ records }: { records: GradeRecordListItem[] }) {
|
||||
const router = useRouter()
|
||||
@@ -65,6 +59,7 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
|
||||
<>
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<caption className="sr-only">成绩记录列表</caption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Student</TableHead>
|
||||
@@ -90,9 +85,7 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
|
||||
{r.score} / {r.fullScore}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={typeColors[r.type]} className="capitalize">
|
||||
{r.type}
|
||||
</Badge>
|
||||
<StatusBadge status={r.type} variantMap={GRADE_TYPE_VARIANT} />
|
||||
</TableCell>
|
||||
<TableCell>S{r.semester}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{r.recorderName}</TableCell>
|
||||
@@ -103,6 +96,7 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => setDeleteId(r.id)}
|
||||
aria-label={`删除 ${r.studentName} 的 ${r.subjectName} 成绩记录`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -40,22 +40,24 @@ export function GradeTrendChart({ data }: GradeTrendChartProps) {
|
||||
emptyDescription="Select a class and subject to view the grade trend."
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<TrendLineChart
|
||||
data={chartData}
|
||||
series={[
|
||||
{
|
||||
dataKey: "normalizedScore",
|
||||
name: "Score (%)",
|
||||
color: "hsl(var(--primary))",
|
||||
dotRadius: 3,
|
||||
activeDotRadius: 5,
|
||||
},
|
||||
]}
|
||||
heightClassName="h-[280px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
yWidth={36}
|
||||
tooltipClassName="w-[220px]"
|
||||
/>
|
||||
<div role="img" aria-label={`成绩趋势图:${isEmpty ? "暂无数据" : `${data.label},平均 ${data.averageScore.toFixed(1)}%`}`}>
|
||||
<TrendLineChart
|
||||
data={chartData}
|
||||
series={[
|
||||
{
|
||||
dataKey: "normalizedScore",
|
||||
name: "Score (%)",
|
||||
color: "hsl(var(--primary))",
|
||||
dotRadius: 3,
|
||||
activeDotRadius: 5,
|
||||
},
|
||||
]}
|
||||
heightClassName="h-[280px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
yWidth={36}
|
||||
tooltipClassName="w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,28 +37,30 @@ export function SubjectComparisonChart({ data }: SubjectComparisonChartProps) {
|
||||
emptyDescription="Select a class to compare subject performance."
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<ComparisonRadarChart
|
||||
data={chartData}
|
||||
angleKey="subject"
|
||||
angleTickFormatter={(value: string) =>
|
||||
value.length > 6 ? `${value.slice(0, 6)}...` : value
|
||||
}
|
||||
heightClassName="h-[300px]"
|
||||
series={[
|
||||
{
|
||||
dataKey: "averageScore",
|
||||
name: "Average",
|
||||
color: "hsl(var(--primary))",
|
||||
fillOpacity: 0.4,
|
||||
},
|
||||
{
|
||||
dataKey: "passRate",
|
||||
name: "Pass Rate",
|
||||
color: "hsl(var(--chart-2))",
|
||||
fillOpacity: 0.2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div role="img" aria-label={`科目对比雷达图:${isEmpty ? "暂无数据" : `共 ${data.length} 个科目的均分与及格率对比`}`}>
|
||||
<ComparisonRadarChart
|
||||
data={chartData}
|
||||
angleKey="subject"
|
||||
angleTickFormatter={(value: string) =>
|
||||
value.length > 6 ? `${value.slice(0, 6)}...` : value
|
||||
}
|
||||
heightClassName="h-[300px]"
|
||||
series={[
|
||||
{
|
||||
dataKey: "averageScore",
|
||||
name: "Average",
|
||||
color: "hsl(var(--primary))",
|
||||
fillOpacity: 0.4,
|
||||
},
|
||||
{
|
||||
dataKey: "passRate",
|
||||
name: "Pass Rate",
|
||||
color: "hsl(var(--chart-2))",
|
||||
fillOpacity: 0.2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
139
src/modules/grades/components/widget-boundary.tsx
Normal file
139
src/modules/grades/components/widget-boundary.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* Grades/Diagnostic 模块通用 Widget 边界组件。
|
||||
*
|
||||
* 组合三个能力:
|
||||
* 1. Error Boundary — 隔离故障域,单个 Widget 抛错不影响其他区块
|
||||
* 2. Suspense — 流式渲染时显示骨架屏,避免白屏等待
|
||||
* 3. Skeleton — 与 Widget 尺寸匹配的占位
|
||||
*
|
||||
* 用法:
|
||||
* ```tsx
|
||||
* <WidgetBoundary title="成绩趋势">
|
||||
* <GradeTrendChart data={data} />
|
||||
* </WidgetBoundary>
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { Component, Suspense, type ReactNode } from "react"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
interface WidgetBoundaryProps {
|
||||
children: ReactNode
|
||||
/** Widget 标题(用于错误提示和 aria-label) */
|
||||
title?: string
|
||||
/** 骨架屏高度(默认 200px) */
|
||||
skeletonHeight?: number
|
||||
/** 自定义错误描述 */
|
||||
fallbackDescription?: string
|
||||
/** 重试按钮文案 */
|
||||
retryLabel?: string
|
||||
}
|
||||
|
||||
interface WidgetBoundaryState {
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
class WidgetErrorBoundary extends Component<
|
||||
Pick<
|
||||
WidgetBoundaryProps,
|
||||
"title" | "fallbackDescription" | "retryLabel" | "children"
|
||||
>,
|
||||
WidgetBoundaryState
|
||||
> {
|
||||
constructor(props: WidgetBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): WidgetBoundaryState {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({ hasError: false })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
const title = this.props.title ?? "区块"
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-center"
|
||||
>
|
||||
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{title}加载失败
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{this.props.fallbackDescription ?? "请重试或刷新页面"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={this.handleReset}
|
||||
aria-label={`重试加载${title}`}
|
||||
>
|
||||
{this.props.retryLabel ?? "重试"}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
function WidgetSkeleton({
|
||||
height,
|
||||
title,
|
||||
}: {
|
||||
height: number
|
||||
title?: string
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={`${title ?? "区块"}加载中`}
|
||||
aria-live="polite"
|
||||
className="space-y-3 p-4"
|
||||
style={{ minHeight: height }}
|
||||
>
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WidgetBoundary({
|
||||
children,
|
||||
title,
|
||||
skeletonHeight = 200,
|
||||
fallbackDescription,
|
||||
retryLabel,
|
||||
}: WidgetBoundaryProps): ReactNode {
|
||||
return (
|
||||
<WidgetErrorBoundary
|
||||
title={title}
|
||||
fallbackDescription={fallbackDescription}
|
||||
retryLabel={retryLabel}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<WidgetSkeleton height={skeletonHeight} title={title} />
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Suspense>
|
||||
</WidgetErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -13,11 +13,16 @@ import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import { buildScopeClassFilter, normalize, toNumber } from "./lib/grade-utils"
|
||||
import {
|
||||
buildGradeTrendPoints,
|
||||
computeClassComparisonStats,
|
||||
computeGradeDistribution,
|
||||
computeSubjectComparisonStats,
|
||||
computeTrendAverage,
|
||||
} from "./stats-service"
|
||||
import type {
|
||||
ClassComparisonItem,
|
||||
GradeDistributionBucket,
|
||||
GradeDistributionResult,
|
||||
GradeTrendPoint,
|
||||
GradeTrendResult,
|
||||
SubjectComparisonItem,
|
||||
} from "./types"
|
||||
@@ -64,20 +69,8 @@ export const getGradeTrend = cache(
|
||||
subjectName = subject?.name ?? "Unknown"
|
||||
}
|
||||
|
||||
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 points = buildGradeTrendPoints(rows)
|
||||
const avg = computeTrendAverage(points)
|
||||
const finalClassName = className ?? "Class"
|
||||
const studentLabel = params.studentId
|
||||
? `Student ${params.studentId.slice(-4)}`
|
||||
@@ -88,7 +81,7 @@ export const getGradeTrend = cache(
|
||||
? `${finalClassName} · ${subjectName} · ${studentLabel}`
|
||||
: `${finalClassName} · ${studentLabel}`,
|
||||
points,
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
averageScore: avg,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -145,46 +138,11 @@ export const getClassComparison = cache(
|
||||
|
||||
const result: ClassComparisonItem[] = allowedClassRows.map((cls) => {
|
||||
const rows = byClass.get(cls.id) ?? []
|
||||
if (rows.length === 0) {
|
||||
return {
|
||||
classId: cls.id,
|
||||
className: cls.name,
|
||||
averageScore: 0,
|
||||
medianScore: 0,
|
||||
passRate: 0,
|
||||
excellentRate: 0,
|
||||
count: 0,
|
||||
studentCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
const { passCount, excellentCount } = normalized.reduce(
|
||||
(acc, s) => ({
|
||||
passCount: acc.passCount + (s >= 60 ? 1 : 0),
|
||||
excellentCount: acc.excellentCount + (s >= 85 ? 1 : 0),
|
||||
}),
|
||||
{ passCount: 0, excellentCount: 0 }
|
||||
)
|
||||
|
||||
const stats = computeClassComparisonStats(rows)
|
||||
return {
|
||||
classId: cls.id,
|
||||
className: cls.name,
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
medianScore: Math.round(median * 100) / 100,
|
||||
passRate: Math.round((passCount / normalized.length) * 10000) / 100,
|
||||
excellentRate: Math.round((excellentCount / normalized.length) * 10000) / 100,
|
||||
count: normalized.length,
|
||||
studentCount: uniqueStudents,
|
||||
...stats,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -234,28 +192,11 @@ export const getSubjectComparison = cache(
|
||||
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
|
||||
|
||||
const { passCount, excellentCount } = entry.scores.reduce(
|
||||
(acc, s) => ({
|
||||
passCount: acc.passCount + (s >= 60 ? 1 : 0),
|
||||
excellentCount: acc.excellentCount + (s >= 85 ? 1 : 0),
|
||||
}),
|
||||
{ passCount: 0, excellentCount: 0 }
|
||||
)
|
||||
|
||||
const stats = computeSubjectComparisonStats(entry.scores)
|
||||
result.push({
|
||||
subjectId,
|
||||
subjectName: entry.name,
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
medianScore: Math.round(median * 100) / 100,
|
||||
passRate: Math.round((passCount / entry.scores.length) * 10000) / 100,
|
||||
excellentRate: Math.round((excellentCount / entry.scores.length) * 10000) / 100,
|
||||
count: entry.scores.length,
|
||||
...stats,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -289,24 +230,6 @@ export const getGradeDistribution = cache(
|
||||
.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 }
|
||||
return computeGradeDistribution(rows)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -9,8 +9,8 @@ import { getStudentActiveClassId } from "@/modules/classes/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
|
||||
import { normalize, toNumber } from "./lib/grade-utils"
|
||||
import { buildRankingTrendPoints, type RankingTrendEntry } from "./stats-service"
|
||||
import type {
|
||||
RankingTrendPoint,
|
||||
RankingTrendResult,
|
||||
} from "./types"
|
||||
|
||||
@@ -56,13 +56,7 @@ export const getRankingTrend = cache(
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(gradeRecords.createdAt))
|
||||
|
||||
const byTitle = new Map<
|
||||
string,
|
||||
{
|
||||
date: Date
|
||||
entries: Array<{ studentId: string; normalized: number }>
|
||||
}
|
||||
>()
|
||||
const byTitle = new Map<string, RankingTrendEntry>()
|
||||
|
||||
for (const r of rows) {
|
||||
const entry = byTitle.get(r.title) ?? { date: r.createdAt, entries: [] }
|
||||
@@ -73,33 +67,7 @@ export const getRankingTrend = cache(
|
||||
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)
|
||||
// Single traversal: find rank and student entry together
|
||||
let rank = 0
|
||||
let studentEntry: { studentId: string; normalized: number } | null = null
|
||||
for (let i = 0; i < sorted.length; i += 1) {
|
||||
const e = sorted[i]
|
||||
if (e.studentId === studentId) {
|
||||
rank = i + 1
|
||||
studentEntry = e
|
||||
break
|
||||
}
|
||||
}
|
||||
if (rank <= 0 || !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())
|
||||
const points = buildRankingTrendPoints(byTitle, studentId)
|
||||
|
||||
return {
|
||||
studentId,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import { buildScopeClassFilter, toNumber } from "./lib/grade-utils"
|
||||
import { computeAverageScore, computeGradeStats } from "./stats-service"
|
||||
import type {
|
||||
ClassGradeStats,
|
||||
ClassRankingItem,
|
||||
@@ -208,40 +209,7 @@ export const getClassGradeStats = cache(
|
||||
.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++) {
|
||||
if (fullScores[i] <= 0) continue
|
||||
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,
|
||||
}
|
||||
return computeGradeStats(rows)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -300,13 +268,13 @@ export const getStudentGradeSummary = cache(
|
||||
createdAt: r.record.createdAt.toISOString(),
|
||||
}))
|
||||
|
||||
const avg = listItems.reduce((a, b) => a + b.score, 0) / listItems.length
|
||||
const avg = computeAverageScore(listItems.map((i) => i.score))
|
||||
|
||||
return {
|
||||
studentId,
|
||||
studentName: studentName ?? "Unknown",
|
||||
records: listItems,
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
averageScore: avg,
|
||||
rank: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import "server-only"
|
||||
|
||||
import { eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||
|
||||
import { gradeRecords } from "@/shared/db/schema"
|
||||
import { db } from "@/shared/db"
|
||||
import { classes, gradeRecords } from "@/shared/db/schema"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
/**
|
||||
@@ -39,7 +40,16 @@ export const buildScopeClassFilter = (scope: DataScope): SQL | null => {
|
||||
? inArray(gradeRecords.classId, scope.classIds)
|
||||
: sql`1=0`
|
||||
}
|
||||
if (scope.type === "grade_managed") return sql`1=0`
|
||||
if (scope.type === "grade_managed") {
|
||||
// P2-4 修复:grade_managed scope 应返回所管年级的班级成绩记录
|
||||
// 通过子查询过滤 classId IN (SELECT id FROM classes WHERE grade_id IN (...))
|
||||
return scope.gradeIds.length > 0
|
||||
? inArray(
|
||||
gradeRecords.classId,
|
||||
db.select({ id: classes.id }).from(classes).where(inArray(classes.gradeId, scope.gradeIds))
|
||||
)
|
||||
: sql`1=0`
|
||||
}
|
||||
if (scope.type === "class_members") return null
|
||||
if (scope.type === "children") {
|
||||
return scope.childrenIds.length > 0
|
||||
|
||||
305
src/modules/grades/stats-service.ts
Normal file
305
src/modules/grades/stats-service.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Grade statistics pure functions.
|
||||
*
|
||||
* Extracted from data-access / data-access-analytics / data-access-ranking
|
||||
* to keep data-access layer focused on DB I/O and make statistics logic
|
||||
* independently testable. All functions are pure (no side effects, no I/O).
|
||||
*/
|
||||
|
||||
import { normalize, toNumber } from "./lib/grade-utils"
|
||||
import type {
|
||||
ClassComparisonItem,
|
||||
GradeDistributionBucket,
|
||||
GradeDistributionResult,
|
||||
GradeStats,
|
||||
GradeTrendPoint,
|
||||
RankingTrendPoint,
|
||||
} from "./types"
|
||||
|
||||
/** Round to 2 decimal places. */
|
||||
const round2 = (n: number): number => Math.round(n * 100) / 100
|
||||
|
||||
/** Pass threshold (60% of full score, normalized to 60/100). */
|
||||
export const PASS_THRESHOLD = 60
|
||||
|
||||
/** Excellent threshold (85% of full score, normalized to 85/100). */
|
||||
export const EXCELLENT_THRESHOLD = 85
|
||||
|
||||
/**
|
||||
* Raw score row from DB (numeric columns may be string | number depending on driver).
|
||||
*/
|
||||
export interface RawScoreRow {
|
||||
score: unknown
|
||||
fullScore: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute aggregate stats (average/median/max/min/stdDev/passRate/excellentRate/count)
|
||||
* from a list of raw score rows. Returns null when rows is empty.
|
||||
*/
|
||||
export function computeGradeStats(rows: RawScoreRow[]): GradeStats | null {
|
||||
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 += 1) {
|
||||
if (fullScores[i] <= 0) continue
|
||||
const ratio = scores[i] / fullScores[i]
|
||||
if (ratio >= 0.6) passCount += 1
|
||||
if (ratio >= 0.85) excellentCount += 1
|
||||
}
|
||||
|
||||
return {
|
||||
average: round2(average),
|
||||
median: round2(median),
|
||||
max,
|
||||
min,
|
||||
stdDev: round2(stdDev),
|
||||
passRate: round2((passCount / countN) * 100),
|
||||
excellentRate: round2((excellentCount / countN) * 100),
|
||||
count: countN,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute average score from a list of grade record list items.
|
||||
*/
|
||||
export function computeAverageScore(scores: number[]): number {
|
||||
if (scores.length === 0) return 0
|
||||
return round2(scores.reduce((a, b) => a + b, 0) / scores.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a grade trend point list from raw DB rows (already ordered by date asc).
|
||||
*/
|
||||
export function buildGradeTrendPoints(
|
||||
rows: Array<{
|
||||
record: {
|
||||
createdAt: Date
|
||||
title: string
|
||||
score: unknown
|
||||
fullScore: unknown
|
||||
type: string
|
||||
}
|
||||
}>
|
||||
): GradeTrendPoint[] {
|
||||
return 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 as GradeTrendPoint["type"],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the average of normalized scores from a list of trend points.
|
||||
*/
|
||||
export function computeTrendAverage(points: GradeTrendPoint[]): number {
|
||||
if (points.length === 0) return 0
|
||||
const avg =
|
||||
points.reduce((acc, p) => acc + p.normalizedScore, 0) / points.length
|
||||
return round2(avg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute class comparison stats for a single class from raw score rows.
|
||||
*/
|
||||
export function computeClassComparisonStats(
|
||||
rows: Array<{
|
||||
score: unknown
|
||||
fullScore: unknown
|
||||
studentId: string
|
||||
}>
|
||||
): Pick<
|
||||
ClassComparisonItem,
|
||||
| "averageScore"
|
||||
| "medianScore"
|
||||
| "passRate"
|
||||
| "excellentRate"
|
||||
| "count"
|
||||
| "studentCount"
|
||||
> {
|
||||
if (rows.length === 0) {
|
||||
return {
|
||||
averageScore: 0,
|
||||
medianScore: 0,
|
||||
passRate: 0,
|
||||
excellentRate: 0,
|
||||
count: 0,
|
||||
studentCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
let passCount = 0
|
||||
let excellentCount = 0
|
||||
for (const s of normalized) {
|
||||
if (s >= PASS_THRESHOLD) passCount += 1
|
||||
if (s >= EXCELLENT_THRESHOLD) excellentCount += 1
|
||||
}
|
||||
|
||||
return {
|
||||
averageScore: round2(avg),
|
||||
medianScore: round2(median),
|
||||
passRate: round2((passCount / normalized.length) * 100),
|
||||
excellentRate: round2((excellentCount / normalized.length) * 100),
|
||||
count: normalized.length,
|
||||
studentCount: uniqueStudents,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute subject comparison stats for a single subject from normalized scores.
|
||||
*/
|
||||
export function computeSubjectComparisonStats(
|
||||
scores: number[]
|
||||
): Pick<
|
||||
import("./types").SubjectComparisonItem,
|
||||
"averageScore" | "medianScore" | "passRate" | "excellentRate" | "count"
|
||||
> {
|
||||
if (scores.length === 0) {
|
||||
return {
|
||||
averageScore: 0,
|
||||
medianScore: 0,
|
||||
passRate: 0,
|
||||
excellentRate: 0,
|
||||
count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...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 = scores.reduce((a, b) => a + b, 0) / scores.length
|
||||
|
||||
let passCount = 0
|
||||
let excellentCount = 0
|
||||
for (const s of scores) {
|
||||
if (s >= PASS_THRESHOLD) passCount += 1
|
||||
if (s >= EXCELLENT_THRESHOLD) excellentCount += 1
|
||||
}
|
||||
|
||||
return {
|
||||
averageScore: round2(avg),
|
||||
medianScore: round2(median),
|
||||
passRate: round2((passCount / scores.length) * 100),
|
||||
excellentRate: round2((excellentCount / scores.length) * 100),
|
||||
count: scores.length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default distribution buckets (90-100, 80-89, 70-79, 60-69, <60).
|
||||
*/
|
||||
export function createDefaultBuckets(): GradeDistributionBucket[] {
|
||||
return [
|
||||
{ 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 },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucketize raw score rows into a grade distribution result.
|
||||
*/
|
||||
export function computeGradeDistribution(
|
||||
rows: RawScoreRow[]
|
||||
): GradeDistributionResult {
|
||||
const buckets = createDefaultBuckets()
|
||||
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 += 1
|
||||
else if (rounded >= 80) buckets[1].count += 1
|
||||
else if (rounded >= 70) buckets[2].count += 1
|
||||
else if (rounded >= 60) buckets[3].count += 1
|
||||
else buckets[4].count += 1
|
||||
}
|
||||
return { buckets, totalCount: rows.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranking trend entry for a single assessment (grouped by title).
|
||||
*/
|
||||
export interface RankingTrendEntry {
|
||||
date: Date
|
||||
entries: Array<{ studentId: string; normalized: number }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ranking trend points from grouped entries.
|
||||
* Returns points sorted by date ascending.
|
||||
*/
|
||||
export function buildRankingTrendPoints(
|
||||
byTitle: Map<string, RankingTrendEntry>,
|
||||
targetStudentId: string
|
||||
): RankingTrendPoint[] {
|
||||
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
|
||||
)
|
||||
let rank = 0
|
||||
let studentEntry: { studentId: string; normalized: number } | null = null
|
||||
for (let i = 0; i < sorted.length; i += 1) {
|
||||
const e = sorted[i]
|
||||
if (e.studentId === targetStudentId) {
|
||||
rank = i + 1
|
||||
studentEntry = e
|
||||
break
|
||||
}
|
||||
}
|
||||
if (rank <= 0 || !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 points
|
||||
}
|
||||
@@ -1247,7 +1247,7 @@ export const diagnosticReportTypeEnum = mysqlEnum("report_type", ["individual",
|
||||
|
||||
export const learningDiagnosticReports = mysqlTable("learning_diagnostic_reports", {
|
||||
id: id("id").primaryKey(),
|
||||
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
studentId: varchar("student_id", { length: 128 }).references(() => users.id, { onDelete: "cascade" }),
|
||||
generatedBy: varchar("generated_by", { length: 128 }).references(() => users.id, { onDelete: "set null" }),
|
||||
reportType: diagnosticReportTypeEnum.default("individual").notNull(),
|
||||
period: varchar("period", { length: 50 }),
|
||||
@@ -1317,3 +1317,23 @@ export const lessonPlanTemplates = mysqlTable("lesson_plan_templates", {
|
||||
}, (table) => ({
|
||||
typeCreatorIdx: index("lpt_type_creator_idx").on(table.type, table.creatorId),
|
||||
}));
|
||||
|
||||
// --- 25. System Settings (系统设置 - 键值对存储) ---
|
||||
|
||||
export const systemSettings = mysqlTable("system_settings", {
|
||||
id: id("id").primaryKey(),
|
||||
/** 设置分组:school_info / security_policy / file_upload / notification_config */
|
||||
category: varchar("category", { length: 50 }).notNull(),
|
||||
/** 设置键名,如 schoolName / passwordMinLength / maxFileSize */
|
||||
key: varchar("key", { length: 100 }).notNull(),
|
||||
/** 设置值(JSON 字符串,支持字符串/数字/布尔/对象) */
|
||||
value: text("value").notNull(),
|
||||
/** 值类型:string / number / boolean / json */
|
||||
valueType: varchar("value_type", { length: 20 }).default("string").notNull(),
|
||||
updatedBy: varchar("updated_by", { length: 128 }),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
categoryKeyIdx: uniqueIndex("ss_category_key_idx").on(table.category, table.key),
|
||||
categoryIdx: index("ss_category_idx").on(table.category),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user