diff --git a/src/modules/adaptive-practice/actions.ts b/src/modules/adaptive-practice/actions.ts new file mode 100644 index 0000000..c3c5124 --- /dev/null +++ b/src/modules/adaptive-practice/actions.ts @@ -0,0 +1,267 @@ +"use server" + +import { revalidatePath } from "next/cache" + +import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import type { ActionState } from "@/shared/types/action-state" +import { handleActionError } from "@/shared/lib/action-utils" + +import { + CreatePracticeSessionSchema, + SubmitPracticeAnswerSchema, + CompletePracticeSessionSchema, + AbandonPracticeSessionSchema, +} from "./schema" +import { + createPracticeSession, + submitPracticeAnswer, + completePracticeSession, + abandonPracticeSession, + getPracticeSessionById, + getPracticeSessions, + getPracticeStats, +} from "./data-access" +import type { PracticeSessionDetail, PracticeSessionSummary, PracticeStats, PracticeSourceMeta } from "./types" + +// --------------------------------------------------------------------------- +// 查询 Actions +// --------------------------------------------------------------------------- + +export async function getPracticeSessionsAction( + studentId?: string, +): Promise> { + try { + const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ) + + let targetStudentId = ctx.userId + if (studentId && studentId !== ctx.userId) { + if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) { + throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ) + } + targetStudentId = studentId + } else if (ctx.dataScope.type === "children") { + targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId + } + + const result = await getPracticeSessions(targetStudentId) + return { success: true, data: result } + } catch (e) { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + const message = e instanceof Error ? e.message : "获取练习列表失败" + return { success: false, message } + } +} + +export async function getPracticeSessionDetailAction( + sessionId: string, + studentId?: string, +): Promise> { + try { + const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ) + + let targetStudentId = ctx.userId + if (studentId && studentId !== ctx.userId) { + if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) { + throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ) + } + targetStudentId = studentId + } else if (ctx.dataScope.type === "children") { + targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId + } + + const data = await getPracticeSessionById(sessionId, targetStudentId) + if (!data) { + return { success: false, message: "练习会话不存在或无权访问" } + } + return { success: true, data } + } catch (e) { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + const message = e instanceof Error ? e.message : "获取练习详情失败" + return { success: false, message } + } +} + +export async function getPracticeStatsAction( + studentId?: string, +): Promise> { + try { + const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ) + + let targetStudentId = ctx.userId + if (studentId && studentId !== ctx.userId) { + if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) { + throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ) + } + targetStudentId = studentId + } else if (ctx.dataScope.type === "children") { + targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId + } + + const data = await getPracticeStats(targetStudentId) + return { success: true, data } + } catch (e) { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + const message = e instanceof Error ? e.message : "获取练习统计失败" + return { success: false, message } + } +} + +// --------------------------------------------------------------------------- +// 写入 Actions +// --------------------------------------------------------------------------- + +export async function createPracticeSessionAction( + prevState: ActionState<{ sessionId: string; selectedCount: number }> | undefined, + formData: FormData, +): Promise> { + try { + const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_MANAGE) + + const jsonString = formData.get("json") + if (typeof jsonString !== "string") { + return { success: false, message: "提交格式错误,需要 JSON 字段" } + } + + const parsed = CreatePracticeSessionSchema.safeParse(JSON.parse(jsonString)) + if (!parsed.success) { + return { + success: false, + message: "输入验证失败", + errors: parsed.error.flatten().fieldErrors, + } + } + + // 从 JSON 解析的 sourceMeta 需要经过 unknown 中间转换 + // 因为 Zod 的 z.record(z.string(), z.unknown()) 返回 Record + // 而实际运行时结构由前端按练习类型构建,此处做类型收窄 + const sourceMeta = parsed.data.sourceMeta as unknown as PracticeSourceMeta + + const result = await createPracticeSession(ctx.userId, { + practiceType: parsed.data.practiceType, + subjectId: parsed.data.subjectId, + sourceMeta, + questionCount: parsed.data.questionCount, + }) + + if (result.selectedCount === 0) { + return { success: false, message: "未找到符合条件的题目,请尝试其他筛选条件" } + } + + revalidatePath("/student/practice") + + return { + success: true, + message: `已创建练习会话,共 ${result.selectedCount} 道题目`, + data: result, + } + } catch (e) { + return handleActionError(e) + } +} + +export async function submitPracticeAnswerAction( + prevState: ActionState<{ isCorrect: boolean | null; score: number | null }> | undefined, + formData: FormData, +): Promise> { + try { + const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_MANAGE) + + const jsonString = formData.get("json") + if (typeof jsonString !== "string") { + return { success: false, message: "提交格式错误" } + } + + const parsed = SubmitPracticeAnswerSchema.safeParse(JSON.parse(jsonString)) + if (!parsed.success) { + return { + success: false, + message: "输入验证失败", + errors: parsed.error.flatten().fieldErrors, + } + } + + const { sessionId, answerId, answer, skip } = parsed.data + + const result = await submitPracticeAnswer( + sessionId, + ctx.userId, + answerId, + answer, + skip ?? false, + ) + + revalidatePath(`/student/practice/${sessionId}`) + + return { + success: true, + message: skip ? "已跳过此题" : (result.isCorrect === true ? "回答正确" : result.isCorrect === false ? "回答错误" : "答案已提交"), + data: result, + } + } catch (e) { + return handleActionError(e) + } +} + +export async function completePracticeSessionAction( + prevState: ActionState | undefined, + formData: FormData, +): Promise> { + try { + const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_MANAGE) + + const parsed = CompletePracticeSessionSchema.safeParse({ + sessionId: formData.get("sessionId"), + }) + if (!parsed.success) { + return { + success: false, + message: "输入验证失败", + errors: parsed.error.flatten().fieldErrors, + } + } + + await completePracticeSession(parsed.data.sessionId, ctx.userId) + + revalidatePath("/student/practice") + revalidatePath(`/student/practice/${parsed.data.sessionId}`) + + return { success: true, message: "练习已完成" } + } catch (e) { + return handleActionError(e) + } +} + +export async function abandonPracticeSessionAction( + prevState: ActionState | undefined, + formData: FormData, +): Promise> { + try { + const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_MANAGE) + + const parsed = AbandonPracticeSessionSchema.safeParse({ + sessionId: formData.get("sessionId"), + }) + if (!parsed.success) { + return { + success: false, + message: "输入验证失败", + errors: parsed.error.flatten().fieldErrors, + } + } + + await abandonPracticeSession(parsed.data.sessionId, ctx.userId) + + revalidatePath("/student/practice") + + return { success: true, message: "练习已放弃" } + } catch (e) { + return handleActionError(e) + } +} diff --git a/src/modules/adaptive-practice/components/class-knowledge-point-weakness-chart.tsx b/src/modules/adaptive-practice/components/class-knowledge-point-weakness-chart.tsx new file mode 100644 index 0000000..1f36cb1 --- /dev/null +++ b/src/modules/adaptive-practice/components/class-knowledge-point-weakness-chart.tsx @@ -0,0 +1,144 @@ +"use client" + +import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts" +import { useTranslations } from "next-intl" +import { BarChart3 } from "lucide-react" + +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/shared/components/ui/chart" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { cn } from "@/shared/lib/utils" + +import type { ClassKnowledgePointWeakness } from "@/modules/adaptive-practice/data-access-analytics" + +interface ClassKnowledgePointWeaknessChartProps { + data: ClassKnowledgePointWeakness[] + className?: string +} + +const CHART_COLORS = [ + "var(--color-chart-1)", + "var(--color-chart-2)", + "var(--color-chart-3)", + "var(--color-chart-4)", + "var(--color-chart-5)", +] + +/** + * 班级知识点薄弱度柱状图(教师视图) + * + * 横轴:知识点名称,纵轴:错误率 + * tooltip 显示答题数、错题数、错误率 + */ +export function ClassKnowledgePointWeaknessChart({ + data, + className, +}: ClassKnowledgePointWeaknessChartProps) { + const t = useTranslations("practice") + + if (data.length === 0) { + return ( + + + {t("teacher.knowledgePointWeakness.title")} + {t("teacher.knowledgePointWeakness.description")} + + + + + + ) + } + + const chartData = data.map((d) => ({ + name: d.knowledgePointName, + errorRate: Number((d.errorRate * 100).toFixed(0)), + totalAnswers: d.totalAnswers, + wrongAnswers: d.wrongAnswers, + })) + + const chartConfig: ChartConfig = { + errorRate: { + label: t("teacher.knowledgePointWeakness.errorRate"), + color: "var(--color-chart-1)", + }, + } + + return ( + + + {t("teacher.knowledgePointWeakness.title")} + {t("teacher.knowledgePointWeakness.description")} + + + + + + + value.length > 8 ? `${value.slice(0, 8)}...` : value + } + /> + `${value}%`} + /> + { + const p = payload as unknown as { + name: string + errorRate: number + totalAnswers: number + wrongAnswers: number + } + return ( +
+
{p.name}
+
+ {t("teacher.knowledgePointWeakness.totalAnswers")}: + {p.totalAnswers} +
+
+ {t("teacher.knowledgePointWeakness.wrongAnswers")}: + {p.wrongAnswers} +
+
+ {t("teacher.knowledgePointWeakness.errorRate")}: + {p.errorRate}% +
+
+ ) + }} + /> + } + /> + + {chartData.map((_, idx) => ( + + ))} + +
+
+
+
+ ) +} diff --git a/src/modules/adaptive-practice/components/class-practice-comparison-table.tsx b/src/modules/adaptive-practice/components/class-practice-comparison-table.tsx new file mode 100644 index 0000000..ccafdd1 --- /dev/null +++ b/src/modules/adaptive-practice/components/class-practice-comparison-table.tsx @@ -0,0 +1,88 @@ +import { useTranslations } from "next-intl" + +import { Badge } from "@/shared/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Progress } from "@/shared/components/ui/progress" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" +import { cn } from "@/shared/lib/utils" + +import type { TeacherClassPracticeOverview } from "@/modules/adaptive-practice/data-access-analytics" + +interface ClassPracticeComparisonTableProps { + data: TeacherClassPracticeOverview[] | import("@/modules/adaptive-practice/data-access-analytics").GradeClassPracticeComparison[] + className?: string +} + +/** + * 班级练习对比表格(教师/年级视图) + * + * 显示每个班级的参与率、练习数、完成数、答题数、正确率。 + * 支持教师视图(TeacherClassPracticeOverview)和年级视图(GradeClassPracticeComparison)。 + */ +export function ClassPracticeComparisonTable({ + data, + className, +}: ClassPracticeComparisonTableProps) { + const t = useTranslations("practice") + + if (data.length === 0) return null + + return ( + + + {t("teacher.classComparison.title")} + + {data.length} + + + +
+ {/* 移动端表格水平滚动 */} +
+ + + + {t("teacher.classComparison.className")} + {t("teacher.classComparison.totalStudents")} + {t("teacher.classComparison.activeStudents")} + {t("teacher.classComparison.participationRate")} + {t("teacher.classComparison.totalSessions")} + {t("teacher.classComparison.completedSessions")} + {t("teacher.classComparison.totalAnswered")} + {t("teacher.classComparison.averageAccuracy")} + + + + {data.map((row) => { + const participationPct = Math.round(row.participationRate * 100) + const accuracyPct = Math.round(row.averageAccuracy * 100) + return ( + + {row.className} + {row.totalStudents} + {row.activeStudents} + +
+ + {participationPct}% +
+
+ {row.totalSessions} + {row.completedSessions} + {row.totalQuestionsAnswered} + + = 60 ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400")}> + {accuracyPct}% + + +
+ ) + })} +
+
+
+
+
+
+ ) +} diff --git a/src/modules/adaptive-practice/components/inactive-students-alert.tsx b/src/modules/adaptive-practice/components/inactive-students-alert.tsx new file mode 100644 index 0000000..9809a93 --- /dev/null +++ b/src/modules/adaptive-practice/components/inactive-students-alert.tsx @@ -0,0 +1,64 @@ +import { useTranslations } from "next-intl" +import { AlertTriangle, CheckCircle2 } from "lucide-react" + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Badge } from "@/shared/components/ui/badge" +import { cn } from "@/shared/lib/utils" + +interface InactiveStudentsAlertProps { + inactiveStudentIds: string[] + studentNames: Map + className?: string +} + +/** + * 未参与练习学生提醒卡片(教师视图) + * + * 当班级中有学生未参与任何专项练习时,显示提醒列表, + * 帮助教师识别需要主动引导的学生。 + */ +export function InactiveStudentsAlert({ + inactiveStudentIds, + studentNames, + className, +}: InactiveStudentsAlertProps) { + const t = useTranslations("practice") + + const isEmpty = inactiveStudentIds.length === 0 + + return ( + + +
+ + {isEmpty ? ( + + ) : ( + + )} + {t("teacher.inactiveStudents.title")} + + {t("teacher.inactiveStudents.description")} +
+ + {inactiveStudentIds.length} + +
+ + {isEmpty ? ( +
+ {t("teacher.inactiveStudents.empty")} +
+ ) : ( +
+ {inactiveStudentIds.map((studentId) => ( + + {studentNames.get(studentId) ?? "Unknown"} + + ))} +
+ )} +
+
+ ) +} diff --git a/src/modules/adaptive-practice/components/practice-history.tsx b/src/modules/adaptive-practice/components/practice-history.tsx new file mode 100644 index 0000000..e5fa21d --- /dev/null +++ b/src/modules/adaptive-practice/components/practice-history.tsx @@ -0,0 +1,95 @@ +"use client" + +import { useTranslations } from "next-intl" +import Link from "next/link" +import { Target, Clock, CheckCircle2, XCircle } from "lucide-react" + +import { Card, CardContent } from "@/shared/components/ui/card" +import { Badge } from "@/shared/components/ui/badge" +import { Progress } from "@/shared/components/ui/progress" +import { formatDate } from "@/shared/lib/utils" + +import type { PracticeSessionSummary, PracticeStatus } from "../types" + +interface PracticeHistoryProps { + sessions: PracticeSessionSummary[] +} + +const STATUS_VARIANTS: Record = { + in_progress: "default", + completed: "secondary", + abandoned: "outline", +} + +/** + * 专项练习历史列表 + */ +export function PracticeHistory({ sessions }: PracticeHistoryProps): React.ReactNode { + const t = useTranslations("practice") + + if (sessions.length === 0) { + return ( + + + + {t("history.empty")} + + + ) + } + + return ( +
+ {sessions.map((session) => { + const accuracy = session.answeredQuestions > 0 + ? Math.round((session.correctCount / session.answeredQuestions) * 100) + : 0 + const progress = session.totalQuestions > 0 + ? (session.answeredQuestions / session.totalQuestions) * 100 + : 0 + + return ( + + + +
+
+
+ + {t(`status.${session.status}`)} + + + {t(`types.${session.practiceType}`)} + +
+
+ + + {formatDate(session.startedAt)} + + + + {session.correctCount} + + + + {session.answeredQuestions - session.correctCount} + +
+
+
+
{accuracy}%
+
+ {session.answeredQuestions}/{session.totalQuestions} +
+
+
+ +
+
+ + ) + })} +
+ ) +} diff --git a/src/modules/adaptive-practice/components/practice-overview-stats-cards.tsx b/src/modules/adaptive-practice/components/practice-overview-stats-cards.tsx new file mode 100644 index 0000000..58eb4fc --- /dev/null +++ b/src/modules/adaptive-practice/components/practice-overview-stats-cards.tsx @@ -0,0 +1,99 @@ +import { Activity, BookOpen, CheckCircle2, Target, Users } from "lucide-react" +import { useTranslations } from "next-intl" + +import { Card, CardContent } from "@/shared/components/ui/card" +import { cn } from "@/shared/lib/utils" + +interface PracticeOverviewStatsCardsProps { + totalClasses: number + totalSessions: number + totalAnswered: number + averageAccuracy: number + participationRate: number + className?: string +} + +/** + * 专项练习概览统计卡片(教师视图) + * + * 5 个卡片:覆盖班级 / 练习总数 / 答题总数 / 平均正确率 / 参与率 + */ +export function PracticeOverviewStatsCards({ + totalClasses, + totalSessions, + totalAnswered, + averageAccuracy, + participationRate, + className, +}: PracticeOverviewStatsCardsProps) { + const t = useTranslations("practice") + + const cards = [ + { + label: t("teacher.overview.totalClasses"), + value: totalClasses, + sub: "", + icon: BookOpen, + color: "text-blue-600 dark:text-blue-400", + bg: "bg-blue-50 dark:bg-blue-950/30", + }, + { + label: t("teacher.overview.totalSessions"), + value: totalSessions, + sub: "", + icon: Activity, + color: "text-purple-600 dark:text-purple-400", + bg: "bg-purple-50 dark:bg-purple-950/30", + }, + { + label: t("teacher.overview.totalAnswered"), + value: totalAnswered, + sub: "", + icon: Target, + color: "text-amber-600 dark:text-amber-400", + bg: "bg-amber-50 dark:bg-amber-950/30", + }, + { + label: t("teacher.overview.averageAccuracy"), + value: `${Math.round(averageAccuracy * 100)}%`, + sub: averageAccuracy >= 0.6 ? "" : "", + icon: CheckCircle2, + color: "text-emerald-600 dark:text-emerald-400", + bg: "bg-emerald-50 dark:bg-emerald-950/30", + }, + { + label: t("teacher.overview.participationRate"), + value: `${Math.round(participationRate * 100)}%`, + sub: participationRate >= 0.5 ? "" : "", + icon: Users, + color: "text-rose-600 dark:text-rose-400", + bg: "bg-rose-50 dark:bg-rose-950/30", + }, + ] + + return ( +
+ {cards.map((card) => { + const Icon = card.icon + return ( + + +
+
+
{card.label}
+
+ {card.value} +
+ {card.sub ?
{card.sub}
: null} +
+
+ +
+
+
+
+ ) + })} +
+ ) +} diff --git a/src/modules/adaptive-practice/components/practice-session-view.tsx b/src/modules/adaptive-practice/components/practice-session-view.tsx new file mode 100644 index 0000000..c78f7af --- /dev/null +++ b/src/modules/adaptive-practice/components/practice-session-view.tsx @@ -0,0 +1,550 @@ +"use client" + +import { useState, useTransition, useMemo } from "react" +import { useRouter } from "next/navigation" +import { useTranslations } from "next-intl" +import { CheckCircle2, XCircle, ChevronLeft, ChevronRight, Flag, Trophy } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Progress } from "@/shared/components/ui/progress" +import { Badge } from "@/shared/components/ui/badge" +import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group" +import { Label } from "@/shared/components/ui/label" +import { Checkbox } from "@/shared/components/ui/checkbox" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/shared/components/ui/alert-dialog" + +import { submitPracticeAnswerAction, completePracticeSessionAction, abandonPracticeSessionAction } from "../actions" +import type { PracticeSessionDetail, PracticeAnswerRecord } from "../types" + +interface PracticeSessionViewProps { + session: PracticeSessionDetail +} + +/** + * 专项练习答题界面 + * + * 功能: + * 1. 逐题作答,支持上一题/下一题导航 + * 2. 自动判分(选择题/判断题) + * 3. 跳过题目 + * 4. 完成练习后展示结果摘要 + */ +export function PracticeSessionView({ session }: PracticeSessionViewProps): React.ReactNode { + const t = useTranslations("practice") + const router = useRouter() + const [isPending, startTransition] = useTransition() + const [currentIndex, setCurrentIndex] = useState(0) + const [answers, setAnswers] = useState>({}) + const [results, setResults] = useState>({}) + + const answersList = session.answers + const total = answersList.length + const current = answersList[currentIndex] + const progress = total > 0 ? ((currentIndex + 1) / total) * 100 : 0 + + // 已答题数和正确数 + const answeredCount = useMemo( + () => answersList.filter((a) => a.status === "answered" || a.status === "skipped").length, + [answersList], + ) + const correctCount = useMemo( + () => answersList.filter((a) => a.isCorrect === true).length, + [answersList], + ) + + if (session.status !== "in_progress") { + return + } + + if (total === 0) { + return ( + + + {t("session.empty")} + + + ) + } + + function handleSubmit(answer: unknown, skip: boolean = false): void { + if (!current) return + + startTransition(async () => { + const formData = new FormData() + formData.append( + "json", + JSON.stringify({ + sessionId: session.id, + answerId: current.id, + answer, + skip, + }), + ) + const res = await submitPracticeAnswerAction(undefined, formData) + if (res.success && res.data) { + setResults((prev) => ({ ...prev, [current.id]: res.data! })) + toast.success(res.message ?? t("toasts.submitted")) + // 自动跳到下一题 + if (currentIndex < total - 1) { + setCurrentIndex(currentIndex + 1) + } + } else { + toast.error(res.message ?? t("toasts.submitFailed")) + } + }) + } + + function handleComplete(): void { + startTransition(async () => { + const formData = new FormData() + formData.append("sessionId", session.id) + const res = await completePracticeSessionAction(undefined, formData) + if (res.success) { + toast.success(res.message ?? t("toasts.completed")) + router.refresh() + } else { + toast.error(res.message ?? t("toasts.completeFailed")) + } + }) + } + + function handleAbandon(): void { + startTransition(async () => { + const formData = new FormData() + formData.append("sessionId", session.id) + const res = await abandonPracticeSessionAction(undefined, formData) + if (res.success) { + toast.success(res.message ?? t("toasts.abandoned")) + router.push("/student/practice") + } else { + toast.error(res.message ?? t("toasts.abandonFailed")) + } + }) + } + + const currentResult = current ? results[current.id] : undefined + const isAnswered = current?.status === "answered" || current?.status === "skipped" + + return ( +
+ {/* 顶部进度条 */} + + +
+ + {t("session.progress")}: {currentIndex + 1} / {total} + +
+ + + {correctCount} + + {answeredCount}/{total} +
+
+ +
+
+ + {/* 题目内容 */} + {current ? ( + setAnswers((prev) => ({ ...prev, [current.id]: ans }))} + onSubmit={(ans) => handleSubmit(ans)} + onSkip={() => handleSubmit(null, true)} + isAnswered={isAnswered} + result={currentResult} + isPending={isPending} + /> + ) : null} + + {/* 底部导航 */} +
+ + +
+ + + + + + + {t("session.abandonConfirm")} + {t("session.abandonDescription")} + + + {t("session.cancel")} + + {t("session.confirmAbandon")} + + + + + + {currentIndex < total - 1 ? ( + + ) : ( + + )} +
+
+
+ ) +} + +// --------------------------------------------------------------------------- +// 题目卡片 +// --------------------------------------------------------------------------- + +interface QuestionCardProps { + answer: PracticeAnswerRecord + index: number + total: number + userAnswer: unknown + onAnswerChange: (answer: unknown) => void + onSubmit: (answer: unknown) => void + onSkip: () => void + isAnswered: boolean + result?: { isCorrect: boolean | null; score: number | null } + isPending: boolean +} + +function QuestionCard({ + answer, + index, + total, + userAnswer, + onAnswerChange, + onSubmit, + onSkip, + isAnswered, + result, + isPending, +}: QuestionCardProps): React.ReactNode { + const t = useTranslations("practice") + + const question = answer.question + const content = answer.variantContent ?? question?.content + const questionType = question?.type ?? "unknown" + + return ( + + +
+ + {t("session.question")} {index + 1}/{total} + +
+ {questionType} + {question?.difficulty ? ( + + {t("session.difficulty")}: {question.difficulty} + + ) : null} + {answer.isVariant ? ( + {t("session.variant")} + ) : null} +
+
+
+ + {/* 题目内容 */} +
+
+            {typeof content === "string"
+              ? content
+              : JSON.stringify(content, null, 2)}
+          
+
+ + {/* 作答区域 */} + {!isAnswered ? ( + + ) : ( + + )} + + {/* 操作按钮 */} + {!isAnswered ? ( +
+ + +
+ ) : null} +
+
+ ) +} + +// --------------------------------------------------------------------------- +// 答题输入组件 +// --------------------------------------------------------------------------- + +interface AnswerInputProps { + questionType: string + content: unknown + userAnswer: unknown + onAnswerChange: (answer: unknown) => void +} + +function AnswerInput({ questionType, content, userAnswer, onAnswerChange }: AnswerInputProps): React.ReactNode { + const t = useTranslations("practice") + + if (questionType === "single_choice") { + const options = extractOptions(content) + const selectedId = typeof userAnswer === "string" ? userAnswer : "" + + return ( + +
+ {options.map((opt) => ( +
+ + +
+ ))} +
+
+ ) + } + + if (questionType === "multiple_choice") { + const options = extractOptions(content) + const selectedIds = Array.isArray(userAnswer) ? userAnswer as string[] : [] + + function toggle(id: string): void { + const newIds = selectedIds.includes(id) + ? selectedIds.filter((v) => v !== id) + : [...selectedIds, id] + onAnswerChange(newIds) + } + + return ( +
+ {options.map((opt) => ( +
+ toggle(opt.id)} + id={opt.id} + /> + +
+ ))} +
+ ) + } + + if (questionType === "judgment") { + const value = typeof userAnswer === "string" ? userAnswer : "" + + return ( + +
+
+ + +
+
+ + +
+
+
+ ) + } + + // text 题型 + return ( +