feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled

主要变更:

- 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布

- 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item)

- 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验

- 新增 teacher/lesson-plans 页面 (列表/新建/编辑)

- 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot

- 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts

- 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false)

- 重构多模块 data-access/actions/组件, 修复权限校验与类型规范

- 同步架构文档 004/005 反映新增模块、导出、依赖关系

- 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
This commit is contained in:
SpecialX
2026-06-22 01:06:16 +08:00
parent d8962aba96
commit 978d9a8309
327 changed files with 34070 additions and 5642 deletions

View File

@@ -2,12 +2,10 @@
import Link from "next/link"
import { BarChart3 } from "lucide-react"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
import { formatDate } from "@/shared/lib/utils"
import type { StudentDashboardGradeProps } from "@/modules/homework/types"
@@ -24,140 +22,84 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
maxScore: item.maxScore,
}))
const chartConfig = {
score: {
label: "Score (%)",
color: "hsl(var(--primary))",
},
}
const latestGrade = grades.trend[grades.trend.length - 1]
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-muted-foreground" />
Recent Grades
</CardTitle>
</CardHeader>
<CardContent>
{!hasGradeTrend ? (
<EmptyState
icon={BarChart3}
title="No graded work yet"
description="Finish and submit assignments to see your score trend."
className="border-none h-72"
<ChartCardShell
title="Recent Grades"
icon={BarChart3}
iconClassName="text-muted-foreground"
isEmpty={!hasGradeTrend}
emptyTitle="No graded work yet"
emptyDescription="Finish and submit assignments to see your score trend."
emptyClassName="h-72"
>
<div className="space-y-4">
<div className="rounded-md border bg-card p-4">
<TrendLineChart
data={chartData}
series={[
{
dataKey: "score",
name: "Score (%)",
color: "hsl(var(--primary))",
dotRadius: 4,
activeDotRadius: 6,
},
]}
heightClassName="h-[200px]"
margin={{ left: 12, right: 12, top: 12, bottom: 12 }}
yWidth={30}
tooltipClassName="w-[200px]"
/>
) : (
<div className="space-y-4">
<div className="rounded-md border bg-card p-4">
<ChartContainer config={chartConfig} className="h-[200px] w-full">
<LineChart
data={chartData}
margin={{
left: 12,
right: 12,
top: 12,
bottom: 12,
}}
>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis
dataKey="title"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 10) + (value.length > 10 ? "..." : "")}
/>
<YAxis
domain={[0, 100]}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}%`}
width={30}
/>
<ChartTooltip
cursor={{
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
strokeDasharray: "4 4",
}}
content={
<ChartTooltipContent
indicator="line"
labelKey="fullTitle"
className="w-[200px]"
/>
}
/>
<Line
dataKey="score"
type="monotone"
stroke="var(--color-score)"
strokeWidth={2}
dot={{
fill: "var(--color-score)",
r: 4,
strokeWidth: 2,
}}
activeDot={{
r: 6,
strokeWidth: 0,
}}
/>
</LineChart>
</ChartContainer>
{latestGrade && (
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground">
<div>
Latest:{" "}
<span className="font-medium text-foreground tabular-nums">
{Math.round(latestGrade.percentage)}%
</span>
</div>
<div>
Points:{" "}
<span className="font-medium text-foreground tabular-nums">
{latestGrade.score}/{latestGrade.maxScore}
</span>
</div>
</div>
)}
</div>
{!hasRecentGrades ? null : (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Assignment</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">When</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{grades.recent.map((r) => (
<TableRow key={r.assignmentId} className="h-12">
<TableCell className="font-medium">
<Link href={`/student/learning/assignments/${r.assignmentId}`} className="hover:underline">
{r.assignmentTitle}
</Link>
</TableCell>
<TableCell className="tabular-nums">
{r.score}/{r.maxScore} <span className="text-muted-foreground">({Math.round(r.percentage)}%)</span>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.submittedAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{latestGrade ? (
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground">
<div>
Latest:{" "}
<span className="font-medium text-foreground tabular-nums">
{Math.round(latestGrade.percentage)}%
</span>
</div>
)}
<div>
Points:{" "}
<span className="font-medium text-foreground tabular-nums">
{latestGrade.score}/{latestGrade.maxScore}
</span>
</div>
</div>
) : null}
</div>
{!hasRecentGrades ? null : (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Assignment</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">When</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{grades.recent.map((r) => (
<TableRow key={r.assignmentId} className="h-12">
<TableCell className="font-medium">
<Link href={`/student/learning/assignments/${r.assignmentId}`} className="hover:underline">
{r.assignmentTitle}
</Link>
</TableCell>
<TableCell className="tabular-nums">
{r.score}/{r.maxScore} <span className="text-muted-foreground">({Math.round(r.percentage)}%)</span>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.submittedAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
</ChartCardShell>
)
}

View File

@@ -1,19 +1,8 @@
import Link from "next/link"
import { BookOpen, PenTool, TriangleAlert, Trophy, TrendingUp } from "lucide-react"
import { PenTool, TriangleAlert, Trophy, TrendingUp } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
import { StatCard } from "@/shared/components/ui/stat-card"
import type { StudentRanking } from "@/modules/homework/types"
type Stat = {
title: string
value: string
description: string
icon: typeof BookOpen
href: string
color?: string
}
export function StudentStatsGrid({
dueSoonCount,
overdueCount,
@@ -25,57 +14,44 @@ export function StudentStatsGrid({
gradedCount: number
ranking: StudentRanking | null
}) {
const stats: Stat[] = [
{
title: "Average Score",
value: ranking ? `${Math.round(ranking.percentage)}%` : "-",
description: ranking ? "Overall performance" : "No grades yet",
icon: TrendingUp,
href: "/student/learning/assignments",
color: "text-blue-500",
},
{
title: "Class Rank",
value: ranking ? `${ranking.rank}/${ranking.classSize}` : "-",
description: ranking ? "Current position" : "No ranking yet",
icon: Trophy,
href: "/student/learning/assignments",
color: "text-purple-500",
},
{
title: "Due Soon",
value: String(dueSoonCount),
description: "Next 7 days",
icon: PenTool,
href: "/student/learning/assignments",
color: dueSoonCount > 0 ? "text-orange-500" : undefined,
},
{
title: "Overdue",
value: String(overdueCount),
description: "Needs attention",
icon: TriangleAlert,
href: "/student/learning/assignments",
color: overdueCount > 0 ? "text-red-500" : undefined,
},
]
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
<Link key={stat.title} href={stat.href}>
<Card className="hover:bg-muted/50 transition-colors cursor-pointer h-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
<stat.icon className={cn("h-4 w-4 text-muted-foreground", stat.color)} />
</CardHeader>
<CardContent>
<div className={cn("text-2xl font-bold tabular-nums", stat.color)}>{stat.value}</div>
<div className="text-xs text-muted-foreground">{stat.description}</div>
</CardContent>
</Card>
</Link>
))}
<StatCard
title="Average Score"
value={ranking ? `${Math.round(ranking.percentage)}%` : "-"}
description={ranking ? "Overall performance" : "No grades yet"}
icon={TrendingUp}
href="/student/learning/assignments"
color="text-blue-500"
valueClassName={ranking ? "text-blue-500 tabular-nums" : "tabular-nums"}
/>
<StatCard
title="Class Rank"
value={ranking ? `${ranking.rank}/${ranking.classSize}` : "-"}
description={ranking ? "Current position" : "No ranking yet"}
icon={Trophy}
href="/student/learning/assignments"
color="text-purple-500"
valueClassName={ranking ? "text-purple-500 tabular-nums" : "tabular-nums"}
/>
<StatCard
title="Due Soon"
value={String(dueSoonCount)}
description="Next 7 days"
icon={PenTool}
href="/student/learning/assignments"
color={dueSoonCount > 0 ? "text-orange-500" : undefined}
valueClassName={dueSoonCount > 0 ? "text-orange-500 tabular-nums" : "tabular-nums"}
/>
<StatCard
title="Overdue"
value={String(overdueCount)}
description="Needs attention"
icon={TriangleAlert}
href="/student/learning/assignments"
color={overdueCount > 0 ? "text-red-500" : undefined}
valueClassName={overdueCount > 0 ? "text-red-500 tabular-nums" : "tabular-nums"}
/>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { CalendarDays, CalendarX, Clock, MapPin } from "lucide-react"
import { CalendarDays, CalendarX } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { ScheduleList } from "@/shared/components/schedule/schedule-list"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { StudentTodayScheduleItem } from "@/modules/dashboard/types"
@@ -25,32 +25,11 @@ export function StudentTodayScheduleCard({ items }: { items: StudentTodaySchedul
className="border-none h-72"
/>
) : (
<div className="space-y-4">
{items.map((item) => (
<div key={item.id} className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0">
<div className="space-y-1 min-w-0">
<div className="font-medium leading-none truncate">{item.course}</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
<div className="flex items-center">
<Clock className="mr-1 h-3 w-3" />
<span>
{item.startTime}{item.endTime}
</span>
</div>
{item.location ? (
<div className="flex items-center">
<MapPin className="mr-1 h-3 w-3" />
<span className="truncate">{item.location}</span>
</div>
) : null}
</div>
</div>
<Badge variant="secondary" className="shrink-0">
{item.className}
</Badge>
</div>
))}
</div>
<ScheduleList
items={items}
variant="separator"
spacingClassName="space-y-4"
/>
)}
</CardContent>
</Card>