新增数学思维自学板块与考试板块(9模块154课+初一上5套真实试卷客观题)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-06 08:00:46 +08:00
co-authored by Claude Opus 4.8
parent 3473ecbf2b
commit bc53e2b2a5
38 changed files with 3393 additions and 10 deletions
+75
View File
@@ -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>
);
}
+37
View File
@@ -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;
}
+115
View File
@@ -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>
);
}