feat(student): 完成 student 模块 v4 剩余修复

- P1-4.2: 新增班级详情页 courses/[classId],展示教师/学校/教室信息与课表

- P2-2.5: 今日课表卡片高亮当前/下一节课(useMemo 实时计算)

- P2-3.9: 作业作答进度网格支持点击跳转题目(scrollIntoView)

- P2-3.10: 作业复习视图显示正确答案(选择/判断/文本题)

- P2-4.4: 课程列表支持按班级名/教师/学校搜索

- P2-5.2: 成绩页新增趋势折线图组件 GradeTrendCard

- P2-9.2/9.3: 诊断报告新增历史记录卡片与弱点练习入口

- P2-10.2: 选课列表支持搜索与选课模式筛选

- P2-11.3: 修复教材阅读页全屏溢出

- P3-1.5: 面包屑保留首个角色段作为根上下文

- P3-7.3: 课表项支持点击跳转至班级详情页(ScheduleList href)
This commit is contained in:
SpecialX
2026-06-22 14:08:34 +08:00
parent c90748124d
commit 30f4983d49
18 changed files with 912 additions and 137 deletions

View File

@@ -1,7 +1,8 @@
"use client"
import { useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { toast } from "sonner"
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
@@ -12,7 +13,18 @@ import { Checkbox } from "@/shared/components/ui/checkbox"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Clock, CheckCircle2, Save, FileText } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert } from "lucide-react"
import { formatDate, cn } from "@/shared/lib/utils"
import type { StudentHomeworkTakeData } from "../types"
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
@@ -64,6 +76,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const [submissionId, setSubmissionId] = useState<string | null>(initialData.submission?.id ?? null)
const [submissionStatus, setSubmissionStatus] = useState<string>(initialData.submission?.status ?? "not_started")
const [isBusy, setIsBusy] = useState(false)
const [showSubmitConfirm, setShowSubmitConfirm] = useState(false)
const initialAnswersByQuestionId = useMemo(() => {
const map = new Map<string, { answer: unknown }>()
@@ -83,6 +96,30 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const canEdit = isStarted && Boolean(submissionId)
const showQuestions = submissionStatus !== "not_started"
// 离开警告:作答中未提交时关闭/刷新页面会丢失答案
useEffect(() => {
if (!canEdit) return
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault()
e.returnValue = ""
}
window.addEventListener("beforeunload", handler)
return () => window.removeEventListener("beforeunload", handler)
}, [canEdit])
// 截止时间与紧急度
const dueAt = initialData.assignment.dueAt
const now = new Date()
const dueDate = dueAt ? new Date(dueAt) : null
const isOverdue = dueDate ? dueDate < now : false
const hoursUntilDue = dueDate ? Math.floor((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60)) : null
const isUrgent = hoursUntilDue !== null && hoursUntilDue >= 0 && hoursUntilDue < 24
// 尝试次数
const maxAttempts = initialData.assignment.maxAttempts
const attemptsUsed = initialData.submission?.attemptNo ?? 0
const attemptsRemaining = Math.max(0, maxAttempts - attemptsUsed)
const handleStart = async () => {
setIsBusy(true)
const fd = new FormData()
@@ -144,11 +181,26 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
setIsBusy(false)
}
// 统计未作答题目数
const unansweredCount = initialData.questions.filter((q) => {
const v = answersByQuestionId[q.questionId]?.answer
if (v === undefined || v === null) return true
if (typeof v === "string" && v.trim() === "") return true
if (Array.isArray(v) && v.length === 0) return true
return false
}).length
return (
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
<div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4 flex items-center justify-between bg-muted/30">
<div className="flex items-center gap-3">
<Button asChild variant="ghost" size="sm" className="mr-1">
<Link href="/student/learning/assignments">
<ChevronLeft className="mr-1 h-4 w-4" />
Back
</Link>
</Button>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
<FileText className="h-4 w-4 text-primary" />
</div>
@@ -169,15 +221,10 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
{isBusy ? "Starting..." : "Start Assignment"}
</Button>
) : (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground hidden sm:inline-block">
Auto-saving enabled
</span>
<Button onClick={handleSubmit} disabled={isBusy} size="sm">
<CheckCircle2 className="mr-2 h-4 w-4" />
{isBusy ? "Submitting..." : "Submit Assignment"}
</Button>
</div>
<Button onClick={() => setShowSubmitConfirm(true)} disabled={isBusy} size="sm">
<CheckCircle2 className="mr-2 h-4 w-4" />
{isBusy ? "Submitting..." : "Submit Assignment"}
</Button>
)}
</div>
@@ -190,7 +237,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
</div>
<h3 className="text-lg font-medium">Ready to start?</h3>
<p className="text-muted-foreground max-w-sm mt-2 mb-6">
Click the &quot;Start Assignment&quot; button above to begin. The timer will start once you confirm.
Click the &quot;Start Assignment&quot; button above to begin. Your answers will be saved when you click &quot;Save Answer&quot;.
</p>
<Button onClick={handleStart} disabled={isBusy}>
Start Now
@@ -204,7 +251,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const value = answersByQuestionId[q.questionId]?.answer
return (
<Card key={q.questionId} className="border-l-4 border-l-primary shadow-sm">
<Card key={q.questionId} id={`question-${q.questionId}`} className="border-l-4 border-l-primary shadow-sm scroll-mt-4">
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
@@ -369,6 +416,40 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
</Badge>
</div>
</div>
{dueAt && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Due Date</Label>
<div className={cn(
"mt-1 flex items-center gap-2 text-sm font-medium",
isOverdue ? "text-destructive" : isUrgent ? "text-orange-500" : "text-foreground"
)}>
{(isOverdue || isUrgent) && <TriangleAlert className="h-4 w-4" />}
<span>{formatDate(dueAt)}</span>
</div>
{isOverdue && (
<p className="mt-1 text-xs text-destructive">Overdue</p>
)}
{isUrgent && !isOverdue && hoursUntilDue !== null && (
<p className="mt-1 text-xs text-orange-500">
{hoursUntilDue === 0 ? "Less than 1 hour left" : `${hoursUntilDue} hour${hoursUntilDue === 1 ? "" : "s"} left`}
</p>
)}
</div>
)}
{maxAttempts > 0 && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Attempts</Label>
<div className="mt-1 text-sm">
<span className="font-medium">{attemptsUsed}</span>
<span className="text-muted-foreground"> / {maxAttempts} used</span>
{attemptsRemaining > 0 && (
<span className="text-muted-foreground"> · {attemptsRemaining} remaining</span>
)}
</div>
</div>
)}
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Description</Label>
@@ -387,15 +468,21 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
(Array.isArray(answersByQuestionId[q.questionId]?.answer) ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 : true)
return (
<div
<button
key={q.questionId}
className={`
h-8 w-8 rounded flex items-center justify-center text-xs font-medium border
${hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"}
`}
type="button"
onClick={() => {
const el = document.getElementById(`question-${q.questionId}`)
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" })
}}
className={cn(
"h-8 w-8 rounded flex items-center justify-center text-xs font-medium border transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"
)}
aria-label={`Jump to question ${i + 1}`}
>
{i + 1}
</div>
</button>
)
})}
</div>
@@ -406,7 +493,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
{canEdit && (
<div className="border-t p-4 bg-muted/20">
<Button className="w-full" onClick={handleSubmit} disabled={isBusy}>
<Button className="w-full" onClick={() => setShowSubmitConfirm(true)} disabled={isBusy}>
{isBusy ? "Submitting..." : "Submit All"}
</Button>
<p className="mt-2 text-xs text-center text-muted-foreground">
@@ -415,6 +502,33 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
</div>
)}
</div>
{/* 提交二次确认对话框 */}
<AlertDialog open={showSubmitConfirm} onOpenChange={setShowSubmitConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Submission</AlertDialogTitle>
<AlertDialogDescription>
{unansweredCount > 0
? `You have ${unansweredCount} unanswered question${unansweredCount === 1 ? "" : "s"}. Submitted answers cannot be changed. Are you sure you want to submit?`
: "All questions have been answered. Submitted answers cannot be changed. Are you sure you want to submit?"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBusy}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isBusy}
onClick={(e) => {
e.preventDefault()
setShowSubmitConfirm(false)
void handleSubmit()
}}
>
{isBusy ? "Submitting..." : "Confirm Submit"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}