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:
@@ -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 "Start Assignment" button above to begin. The timer will start once you confirm.
|
||||
Click the "Start Assignment" button above to begin. Your answers will be saved when you click "Save Answer".
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user