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
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:
@@ -0,0 +1,13 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export default async function StudentAssignmentTakePage({
|
||||
const status = data.submission?.status
|
||||
if (status === "graded" || status === "submitted") {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-6">
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
@@ -43,7 +43,7 @@ export default async function StudentAssignmentTakePage({
|
||||
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span>Due: {data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-"}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span className="mx-2" aria-hidden="true">•</span>
|
||||
<span>Max Attempts: {data.assignment.maxAttempts}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
33
src/app/(dashboard)/student/learning/assignments/loading.tsx
Normal file
33
src/app/(dashboard)/student/learning/assignments/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-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="gap-2 pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-9 w-20" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
// 单次遍历分桶,避免重复 filter(PERF-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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inbox } from "lucide-react"
|
||||
import { UserX } from "lucide-react"
|
||||
|
||||
import { getStudentClasses } from "@/modules/classes/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
@@ -11,7 +11,7 @@ export default async function StudentCoursesPage() {
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Courses</h2>
|
||||
<p className="text-muted-foreground">Your enrolled classes.</p>
|
||||
@@ -19,7 +19,7 @@ export default async function StudentCoursesPage() {
|
||||
<EmptyState
|
||||
title="No user found"
|
||||
description="Create a student user to see courses."
|
||||
icon={Inbox}
|
||||
icon={UserX}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b py-3 px-6 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 p-6">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { BookOpen, Inbox } from "lucide-react"
|
||||
import { BookOpen } from "lucide-react"
|
||||
|
||||
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"
|
||||
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
|
||||
@@ -16,19 +16,7 @@ export default async function StudentTextbookDetailPage({
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Textbook</h2>
|
||||
<p className="text-muted-foreground">Read chapters and review content.</p>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState title="No user found" description="Create a student user to read textbooks." icon={Inbox} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!student) return notFound()
|
||||
|
||||
const { id } = await params
|
||||
|
||||
@@ -46,7 +34,7 @@ export default async function StudentTextbookDetailPage({
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<h1 className="text-lg font-bold tracking-tight truncate">{textbook.title}</h1>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="hidden sm:inline-block w-px h-4 bg-border" />
|
||||
<span className="hidden sm:inline-block w-px h-4 bg-border" aria-hidden="true" />
|
||||
<Badge variant="outline" className="font-normal text-xs">{textbook.subject}</Badge>
|
||||
{textbook.grade && (
|
||||
<Badge variant="secondary" className="font-normal text-xs">{textbook.grade}</Badge>
|
||||
|
||||
18
src/app/(dashboard)/student/learning/textbooks/loading.tsx
Normal file
18
src/app/(dashboard)/student/learning/textbooks/loading.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-4 w-56" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full max-w-md" />
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-48 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookOpen, Inbox } from "lucide-react"
|
||||
import { BookOpen, UserX } from "lucide-react"
|
||||
|
||||
import { getTextbooks } from "@/modules/textbooks/data-access"
|
||||
import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
|
||||
@@ -25,7 +25,7 @@ export default async function StudentTextbooksPage({
|
||||
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 textbooks." icon={Inbox} />
|
||||
<EmptyState title="No user found" description="Create a student user to see textbooks." icon={UserX} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -39,15 +39,10 @@ export default async function StudentTextbooksPage({
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
{/* <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
|
||||
<p className="text-muted-foreground">Browse your course textbooks.</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/student/dashboard">Back</Link>
|
||||
</Button>
|
||||
</div> */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
|
||||
<p className="text-muted-foreground">Browse your course textbooks.</p>
|
||||
</div>
|
||||
|
||||
<TextbookFilters />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user