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)
185 lines
6.1 KiB
TypeScript
185 lines
6.1 KiB
TypeScript
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]) => {
|
||
// 单次遍历分桶,避免重复 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>
|
||
{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>
|
||
)}
|
||
</>
|
||
)
|
||
}
|