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

@@ -7,46 +7,109 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui
import { formatDate } from "@/shared/lib/utils"
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { getCurrentStudentUser } from "@/modules/users/data-access"
import { Inbox } from "lucide-react"
import { Inbox, UserX } from "lucide-react"
import type {
StudentHomeworkAssignmentListItem,
StudentHomeworkProgressStatus,
} from "@/modules/homework/types"
export const dynamic = "force-dynamic"
const getStatusVariant = (status: string): "default" | "secondary" | "outline" => {
if (status === "graded") return "default"
if (status === "submitted") return "secondary"
if (status === "in_progress") return "secondary"
return "outline"
const getStatusVariant = (
status: StudentHomeworkProgressStatus
): "default" | "secondary" | "outline" => {
switch (status) {
case "graded":
return "default"
case "submitted":
return "secondary"
case "in_progress":
return "outline"
default:
return "outline"
}
}
const getStatusLabel = (status: string) => {
if (status === "graded") return "Graded"
if (status === "submitted") return "Submitted"
if (status === "in_progress") return "In progress"
return "Not started"
const getStatusLabel = (status: StudentHomeworkProgressStatus): string => {
switch (status) {
case "graded":
return "Graded"
case "submitted":
return "Submitted"
case "in_progress":
return "In progress"
default:
return "Not started"
}
}
const getActionLabel = (status: string) => {
if (status === "graded") return "Review"
if (status === "submitted") return "View"
if (status === "in_progress") return "Continue"
return "Start"
const getActionLabel = (status: StudentHomeworkProgressStatus): string => {
switch (status) {
case "graded":
return "Review"
case "submitted":
return "View"
case "in_progress":
return "Continue"
default:
return "Start"
}
}
const getActionVariant = (status: string): "default" | "secondary" | "outline" => {
if (status === "graded" || status === "submitted") return "outline"
return "default"
const getActionVariant = (
status: StudentHomeworkProgressStatus
): "default" | "secondary" | "outline" => {
return status === "graded" || status === "submitted" ? "outline" : "default"
}
const isAnswered = (status: string) => status === "submitted" || status === "graded"
const isAnswered = (status: StudentHomeworkProgressStatus): boolean =>
status === "submitted" || status === "graded"
function AssignmentCard({ assignment: a }: { assignment: StudentHomeworkAssignmentListItem }) {
return (
<Card className="flex h-full flex-col overflow-hidden transition-all hover:shadow-md">
<CardHeader className="gap-2 pb-3">
<div className="flex items-start justify-between gap-3">
<CardTitle className="text-base">
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
{a.title}
</Link>
</CardTitle>
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
{getStatusLabel(a.progressStatus)}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
<span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
<span className="px-2" aria-hidden="true">
</span>
<span>
Attempts {a.attemptsUsed}/{a.maxAttempts}
</span>
</div>
</CardHeader>
<CardContent className="mt-auto flex items-center justify-between">
<div className="text-sm">
<div className="text-muted-foreground">Score</div>
<div className="font-medium tabular-nums">{a.latestScore ?? "-"}</div>
</div>
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
<Link href={`/student/learning/assignments/${a.id}`}>
{getActionLabel(a.progressStatus)}
</Link>
</Button>
</CardContent>
</Card>
)
}
export default async function StudentAssignmentsPage() {
const student = await getCurrentStudentUser()
if (!student) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<EmptyState title="No user found" description="Create a student user to see assignments." icon={Inbox} />
</div>
<EmptyState title="No user found" description="Create a student user to see assignments." icon={UserX} />
)
}
@@ -61,108 +124,61 @@ export default async function StudentAssignmentsPage() {
acc.set(subject, [assignment])
}
return acc
}, new Map<string, typeof assignments>())
const subjectEntries = Array.from(assignmentsBySubject.entries()).sort((a, b) => a[0].localeCompare(b[0]))
}, new Map<string, StudentHomeworkAssignmentListItem[]>())
const subjectEntries = Array.from(assignmentsBySubject.entries()).sort((a, b) =>
a[0].localeCompare(b[0])
)
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<>
{!hasAssignments ? (
<EmptyState title="No assignments" description="You have no assigned homework right now." icon={Inbox} />
) : (
<div className="space-y-6">
{subjectEntries.map(([subject, items]) => {
const answeredItems = items.filter((a) => isAnswered(a.progressStatus))
const unansweredItems = items.filter((a) => !isAnswered(a.progressStatus))
// 单次遍历分桶,避免重复 filterPERF-05
const answered: StudentHomeworkAssignmentListItem[] = []
const unanswered: StudentHomeworkAssignmentListItem[] = []
for (const a of items) {
if (isAnswered(a.progressStatus)) {
answered.push(a)
} else {
unanswered.push(a)
}
}
return (
<div key={subject} className="space-y-3">
<div className="text-sm font-semibold text-muted-foreground">{subject}</div>
{unansweredItems.length > 0 && (
<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{unansweredItems.map((a) => (
<Card key={a.id} className="flex h-full flex-col overflow-hidden transition-all hover:shadow-md">
<CardHeader className="gap-2 pb-3">
<div className="flex items-start justify-between gap-3">
<CardTitle className="text-base">
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
{a.title}
</Link>
</CardTitle>
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
{getStatusLabel(a.progressStatus)}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
<span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
<span className="px-2"></span>
<span>
Attempts {a.attemptsUsed}/{a.maxAttempts}
</span>
</div>
</CardHeader>
<CardContent className="mt-auto flex items-center justify-between">
<div className="text-sm">
<div className="text-muted-foreground">Score</div>
<div className="font-medium tabular-nums">{a.latestScore ?? "-"}</div>
</div>
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
<Link href={`/student/learning/assignments/${a.id}`}>
{getActionLabel(a.progressStatus)}
</Link>
</Button>
</CardContent>
</Card>
))}
<div className="text-sm font-semibold text-muted-foreground">{subject}</div>
{unanswered.length > 0 && (
<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Pending
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{unanswered.map((a) => (
<AssignmentCard key={a.id} assignment={a} />
))}
</div>
</div>
</div>
)}
{answeredItems.length > 0 && (
<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{answeredItems.map((a) => (
<Card key={a.id} className="flex h-full flex-col overflow-hidden transition-all hover:shadow-md">
<CardHeader className="gap-2 pb-3">
<div className="flex items-start justify-between gap-3">
<CardTitle className="text-base">
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
{a.title}
</Link>
</CardTitle>
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
{getStatusLabel(a.progressStatus)}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
<span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
<span className="px-2"></span>
<span>
Attempts {a.attemptsUsed}/{a.maxAttempts}
</span>
</div>
</CardHeader>
<CardContent className="mt-auto flex items-center justify-between">
<div className="text-sm">
<div className="text-muted-foreground">Score</div>
<div className="font-medium tabular-nums">{a.latestScore ?? "-"}</div>
</div>
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
<Link href={`/student/learning/assignments/${a.id}`}>
{getActionLabel(a.progressStatus)}
</Link>
</Button>
</CardContent>
</Card>
))}
)}
{answered.length > 0 && (
<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Completed
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{answered.map((a) => (
<AssignmentCard key={a.id} assignment={a} />
))}
</div>
</div>
</div>
)}
</div>
)})}
)}
</div>
)
})}
</div>
)}
</div>
</>
)
}