Files
NextEdu/src/app/(dashboard)/student/learning/assignments/page.tsx
SpecialX 978d9a8309
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
feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
主要变更:

- 新增 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)
2026-06-22 01:06:16 +08:00

185 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from "next/link"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils"
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { getCurrentStudentUser } from "@/modules/users/data-access"
import { Inbox, UserX } from "lucide-react"
import type {
StudentHomeworkAssignmentListItem,
StudentHomeworkProgressStatus,
} from "@/modules/homework/types"
export const dynamic = "force-dynamic"
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: 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: StudentHomeworkProgressStatus): string => {
switch (status) {
case "graded":
return "Review"
case "submitted":
return "View"
case "in_progress":
return "Continue"
default:
return "Start"
}
}
const getActionVariant = (
status: StudentHomeworkProgressStatus
): "default" | "secondary" | "outline" => {
return status === "graded" || status === "submitted" ? "outline" : "default"
}
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 (
<EmptyState title="No user found" description="Create a student user to see assignments." icon={UserX} />
)
}
const assignments = await getStudentHomeworkAssignments(student.id)
const hasAssignments = assignments.length > 0
const assignmentsBySubject = assignments.reduce((acc, assignment) => {
const subject = assignment.subjectName?.trim() || "Other"
const existing = acc.get(subject)
if (existing) {
existing.push(assignment)
} else {
acc.set(subject, [assignment])
}
return acc
}, new Map<string, StudentHomeworkAssignmentListItem[]>())
const subjectEntries = Array.from(assignmentsBySubject.entries()).sort((a, b) =>
a[0].localeCompare(b[0])
)
return (
<>
{!hasAssignments ? (
<EmptyState title="No assignments" description="You have no assigned homework right now." icon={Inbox} />
) : (
<div className="space-y-6">
{subjectEntries.map(([subject, items]) => {
// 单次遍历分桶,避免重复 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>
{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>
)}
{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>
)}
</>
)
}