新增数学思维自学板块与考试板块(9模块154课+初一上5套真实试卷客观题)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3473ecbf2b
commit
bc53e2b2a5
@@ -0,0 +1,75 @@
|
||||
// xuchao: 考试答题页——复用 QuizBlock,判分走考试 action,可重考取最近成绩
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { BackgroundBlobs } from "@/components/background-blobs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EXAM_TYPE_LABEL, getExamRecord, getPaper, paperQuestions } from "@/lib/exam";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { QuizBlock } from "@/app/learn/quiz-block";
|
||||
import { takeExam } from "../actions";
|
||||
|
||||
export default async function ExamTakePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/join");
|
||||
if (user.role !== "child") redirect("/parent");
|
||||
|
||||
const paperId = (await params).id;
|
||||
const paper = await getPaper(paperId);
|
||||
if (!paper) redirect("/exam");
|
||||
|
||||
const questions = paperQuestions(paper);
|
||||
const record = await getExamRecord(user.id, paperId);
|
||||
|
||||
return (
|
||||
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-12">
|
||||
<BackgroundBlobs />
|
||||
<header className="space-y-4">
|
||||
<Link href="/exam" className="text-sm font-medium text-indigo-600">
|
||||
← 考试中心
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="bg-sky-100 text-sky-700 hover:bg-sky-100">
|
||||
{EXAM_TYPE_LABEL[paper.examType] ?? paper.examType}
|
||||
</Badge>
|
||||
{paper.duration && <Badge variant="secondary">{paper.duration} 分钟</Badge>}
|
||||
{paper.fullScore && <Badge variant="secondary">满分 {paper.fullScore}</Badge>}
|
||||
{record && (
|
||||
<Badge variant="secondary" className={record.passed ? "text-emerald-700" : "text-rose-600"}>
|
||||
上次 {record.score} 分
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="mt-1 text-2xl font-extrabold leading-snug tracking-tight text-slate-900">{paper.title}</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[paper.year, paper.region].filter(Boolean).join(" · ")}
|
||||
{paper.sourceUrl && (
|
||||
<>
|
||||
{" · "}
|
||||
<a href={paper.sourceUrl} target="_blank" rel="noreferrer" className="underline">
|
||||
来源
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{paper.summary && <p className="mt-1 text-indigo-600">📋 {paper.summary}</p>}
|
||||
</header>
|
||||
|
||||
{questions.length === 0 ? (
|
||||
<div className="rounded-xl border border-slate-100 bg-white p-8 text-center shadow-soft">
|
||||
<p className="text-lg font-bold">试卷暂无题目</p>
|
||||
</div>
|
||||
) : (
|
||||
<QuizBlock
|
||||
pointId={paperId}
|
||||
questions={questions}
|
||||
submitter={takeExam.bind(null, paperId)}
|
||||
title="开始答题"
|
||||
subtitle={`共 ${questions.length} 题,做完提交判分;≥80 分达标,可重考刷新成绩。`}
|
||||
passText="考试达标 🎉 已计入今日数学打卡"
|
||||
backHref="/exam"
|
||||
backLabel="返回考试中心"
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use server";
|
||||
|
||||
// xuchao: 考试判分——按卷面题判分,错题进错题本,达标联动数学打卡
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { gradeAnswers, type AnswerValue } from "@/lib/grammar";
|
||||
import { getPaper, paperQuestions, recordExam } from "@/lib/exam";
|
||||
import { recordWrong } from "@/lib/wrongbook";
|
||||
|
||||
export type ExamResult =
|
||||
| { score: number; passed: boolean; correct: number; total: number; detail: boolean[] }
|
||||
| { error: string };
|
||||
|
||||
// 提交试卷:服务端判分、记录成绩、错题入库;可重考,取最近一次成绩
|
||||
export async function takeExam(paperId: string, answers: AnswerValue[]): Promise<ExamResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user || user.role !== "child") return { error: "会话无效,请重新激活设备" };
|
||||
|
||||
const paper = await getPaper(paperId);
|
||||
if (!paper) return { error: "试卷不存在" };
|
||||
|
||||
const questions = paperQuestions(paper);
|
||||
if (questions.length === 0) return { error: "试卷暂无题目" };
|
||||
|
||||
const g = gradeAnswers(questions, answers);
|
||||
questions.forEach((q, i) => {
|
||||
if (!g.detail[i]) void recordWrong(user.id, "exam", paperId, paper.title, q, answers[i]);
|
||||
});
|
||||
await recordExam(user.id, paperId, paper.title, g.score, g.correct, g.total, g.passed, answers);
|
||||
|
||||
revalidatePath("/exam");
|
||||
revalidatePath(`/exam/${paperId}`);
|
||||
revalidatePath("/kid");
|
||||
revalidatePath("/parent");
|
||||
revalidatePath("/wrongbook");
|
||||
return g;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// xuchao: 考试中心——按月度分组的试卷列表,首期初一上学期数学
|
||||
import Link from "next/link";
|
||||
import { ScrollText } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { BackgroundBlobs } from "@/components/background-blobs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EXAM_TYPE_LABEL, listPapers, recordsByPaper } from "@/lib/exam";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
|
||||
// xuchao: 月份分组展示顺序与标签(初一上:9月~次年1月)
|
||||
const MONTH_LABEL: Record<number, string> = {
|
||||
9: "9 月 · 有理数",
|
||||
10: "10 月 · 整式的加减",
|
||||
11: "11 月 · 一元一次方程",
|
||||
12: "12 月 · 几何图形初步",
|
||||
1: "1 月 · 期末全册",
|
||||
};
|
||||
const MONTH_ORDER = [9, 10, 11, 12, 1];
|
||||
|
||||
export default async function ExamHome() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/join");
|
||||
if (user.role !== "child") redirect("/parent");
|
||||
|
||||
const papers = await listPapers("g7", "s1");
|
||||
const records = await recordsByPaper(user.id);
|
||||
|
||||
return (
|
||||
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-10">
|
||||
<BackgroundBlobs />
|
||||
<header className="flex items-center justify-between">
|
||||
<Link href="/kid" className="text-sm font-medium text-indigo-600">
|
||||
← 返回打卡
|
||||
</Link>
|
||||
<span className="text-sm text-muted-foreground">初一上学期 · 数学</span>
|
||||
</header>
|
||||
|
||||
<div className="rounded-2xl bg-gradient-to-br from-sky-600 to-indigo-600 p-6 text-white shadow-btn">
|
||||
<span className="inline-block rounded-full bg-white/20 px-3 py-1 text-xs font-semibold">考试中心</span>
|
||||
<h1 className="mt-3 text-2xl font-extrabold leading-tight">真实月考 / 期中 / 期末卷</h1>
|
||||
<p className="mt-1 text-sm opacity-90">按月度分类,与真实试卷格式一致;≥80 分达标,错题自动进错题本。</p>
|
||||
</div>
|
||||
|
||||
{papers.length === 0 ? (
|
||||
<div className="rounded-xl border border-slate-100 bg-white p-8 text-center shadow-soft">
|
||||
<p className="text-3xl">📄</p>
|
||||
<p className="mt-2 text-lg font-bold">试卷还在整理中</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">稍后再来看看吧</p>
|
||||
</div>
|
||||
) : (
|
||||
MONTH_ORDER.map((m) => {
|
||||
const group = papers.filter((p) => p.month === m);
|
||||
if (group.length === 0) return null;
|
||||
return (
|
||||
<section key={m} className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft">
|
||||
<header className="mb-3">
|
||||
<h2 className="text-lg font-extrabold text-slate-900">{MONTH_LABEL[m] ?? `${m} 月`}</h2>
|
||||
<p className="text-xs text-muted-foreground">{group.length} 套试卷</p>
|
||||
</header>
|
||||
<div className="space-y-3">
|
||||
{group.map((p) => {
|
||||
const rec = records.get(p.paperId);
|
||||
return (
|
||||
<Link
|
||||
key={p.paperId}
|
||||
href={`/exam/${p.paperId}`}
|
||||
className="block rounded-xl border border-slate-200 bg-white p-4 transition-all duration-200 hover:-translate-y-0.5 hover:border-indigo-300"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge className="bg-sky-100 text-sky-700 hover:bg-sky-100">
|
||||
{EXAM_TYPE_LABEL[p.examType] ?? p.examType}
|
||||
</Badge>
|
||||
{p.duration && <Badge variant="secondary">{p.duration} 分钟</Badge>}
|
||||
{p.fullScore && <Badge variant="secondary">满分 {p.fullScore}</Badge>}
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-sm font-bold text-slate-900">{p.title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{[p.year, p.region].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
{rec ? (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"block text-xl font-extrabold",
|
||||
rec.passed ? "text-emerald-600" : "text-rose-500",
|
||||
)}
|
||||
>
|
||||
{rec.score}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{rec.passed ? "已达标" : "未达标"}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ScrollText className="ml-auto size-6 text-slate-300" />
|
||||
<span className="mt-1 block text-xs text-muted-foreground">未作答</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user