新增数学思维自学板块与考试板块(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>
|
||||
);
|
||||
}
|
||||
+46
-1
@@ -1,4 +1,4 @@
|
||||
import { BookOpen, Flame, Languages } from "lucide-react";
|
||||
import { BookOpen, Calculator, Flame, Languages } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
@@ -12,6 +12,8 @@ import { getSessionUser } from "@/lib/auth";
|
||||
import { streakOf } from "@/lib/streak";
|
||||
import { dayTasks, childCheckStats } from "@/lib/tasks";
|
||||
import { listPoints, passedByPoint } from "@/lib/grammar";
|
||||
import { listMathPoints, mathPassedByPoint } from "@/lib/math";
|
||||
import { recordsByPaper } from "@/lib/exam";
|
||||
import { TaskCard } from "./task-card";
|
||||
|
||||
export default async function KidPage() {
|
||||
@@ -41,6 +43,16 @@ export default async function KidPage() {
|
||||
.where(and(eq(wordProgress.childUserId, user.id), eq(wordProgress.passed, true)));
|
||||
const wDone = wd.n;
|
||||
|
||||
// 数学思维入口:下一课标题 + 已点亮数
|
||||
const mPassed = await mathPassedByPoint(user.id);
|
||||
const mPoints = await listMathPoints();
|
||||
const mGoal = mPoints.find((p) => !mPassed.has(p.pointId));
|
||||
const mDone = mPassed.size;
|
||||
|
||||
// 考试中心入口:已考卷数
|
||||
const examRecords = await recordsByPaper(user.id);
|
||||
const examDone = examRecords.size;
|
||||
|
||||
return (
|
||||
<main className="relative mx-auto min-h-dvh max-w-lg space-y-5 p-4 pb-10">
|
||||
<BackgroundBlobs />
|
||||
@@ -86,7 +98,31 @@ export default async function KidPage() {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/math" className="block">
|
||||
<div className="flex items-center gap-3 rounded-xl bg-gradient-to-r from-orange-500 to-amber-500 p-4 text-white shadow-btn transition-transform duration-200 hover:-translate-y-0.5">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-white/20">
|
||||
<Calculator className="size-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium opacity-90">数学思维 · 已点亮 {mDone} 课</p>
|
||||
<p className="truncate text-base font-bold">
|
||||
{mGoal ? mGoal.title : "全部点亮,真棒!"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-lg">→</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Link href="/exam" className="block">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-slate-100 bg-white p-3 shadow-soft transition-transform duration-200 hover:-translate-y-0.5">
|
||||
<span className="text-xl">📄</span>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-slate-900">考试中心</p>
|
||||
<p className="text-xs text-muted-foreground">真实月考/期中/期末卷{examDone > 0 ? ` · 已考 ${examDone} 卷` : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/weekly" className="block">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-slate-100 bg-white p-3 shadow-soft transition-transform duration-200 hover:-translate-y-0.5">
|
||||
<span className="text-xl">📝</span>
|
||||
@@ -96,6 +132,15 @@ export default async function KidPage() {
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/math/weekly" className="block">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-slate-100 bg-white p-3 shadow-soft transition-transform duration-200 hover:-translate-y-0.5">
|
||||
<span className="text-xl">🧮</span>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-slate-900">数学每周检测</p>
|
||||
<p className="text-xs text-muted-foreground">看看这周数学掌握得怎样</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/wrongbook" className="block">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-slate-100 bg-white p-3 shadow-soft transition-transform duration-200 hover:-translate-y-0.5">
|
||||
<span className="text-xl">📒</span>
|
||||
|
||||
@@ -3,16 +3,45 @@
|
||||
import { Md } from "./md";
|
||||
import { QuizBlock } from "./quiz-block";
|
||||
import { extractQuiz, type QuizQ } from "@/lib/quiz-parse";
|
||||
import type { AnswerValue } from "@/lib/grammar";
|
||||
import type { QuizResult } from "./actions";
|
||||
|
||||
// 课文 = 前段 markdown + ⑤即时练习(quiz)+ 后段 markdown(小结/知识卡)
|
||||
export function LessonBody({ md, pointId }: { md: string; pointId: string }) {
|
||||
// submitter 可选:英语课用默认提交,数学课传入自己的服务端判分函数
|
||||
export function LessonBody({
|
||||
md,
|
||||
pointId,
|
||||
submitter,
|
||||
quizTitle,
|
||||
passText,
|
||||
backHref,
|
||||
backLabel,
|
||||
}: {
|
||||
md: string;
|
||||
pointId: string;
|
||||
submitter?: (a: AnswerValue[]) => Promise<QuizResult>;
|
||||
quizTitle?: string;
|
||||
passText?: string;
|
||||
backHref?: string;
|
||||
backLabel?: string;
|
||||
}) {
|
||||
const parts = md.split(/```quiz\s*\n[\s\S]*?```/);
|
||||
const questions: QuizQ[] = extractQuiz(md);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Md text={parts[0] ?? ""} />
|
||||
{questions.length > 0 && <QuizBlock pointId={pointId} questions={questions} />}
|
||||
{questions.length > 0 && (
|
||||
<QuizBlock
|
||||
pointId={pointId}
|
||||
questions={questions}
|
||||
submitter={submitter}
|
||||
title={quizTitle}
|
||||
passText={passText}
|
||||
backHref={backHref}
|
||||
backLabel={backLabel}
|
||||
/>
|
||||
)}
|
||||
{parts.slice(1).join("\n").trim() !== "" && <Md text={parts.slice(1).join("\n")} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,14 +12,25 @@ import { submitQuiz, type QuizResult } from "./actions";
|
||||
type AV = AnswerValue | null;
|
||||
|
||||
// 结课检验:选择/判断/填空/连词成句/连线,提交后服务端判分,达标点亮并联动打卡
|
||||
// 文案可配置:数学课/考试卷复用此组件时传入各自的标题、打卡文案与返回地址
|
||||
export function QuizBlock({
|
||||
pointId,
|
||||
questions,
|
||||
submitter,
|
||||
title = "⑤ 即时练习",
|
||||
subtitle = "做完提交,马上判分;每题有解说,达标即点亮本课。",
|
||||
passText = "本课点亮 🎉 已计入今日英语打卡",
|
||||
backHref = "/learn",
|
||||
backLabel = "返回课程地图",
|
||||
}: {
|
||||
pointId: string;
|
||||
questions: QuizQ[];
|
||||
submitter?: (a: AnswerValue[]) => Promise<QuizResult>;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
passText?: string;
|
||||
backHref?: string;
|
||||
backLabel?: string;
|
||||
}) {
|
||||
const [answers, setAnswers] = useState<AV[]>(questions.map(() => null));
|
||||
const [result, setResult] = useState<Extract<QuizResult, { score: number }> | null>(null);
|
||||
@@ -53,8 +64,8 @@ export function QuizBlock({
|
||||
|
||||
return (
|
||||
<div className="my-4 rounded-2xl border border-slate-100 bg-white p-5 shadow-soft">
|
||||
<h3 className="text-base font-extrabold text-slate-900">⑤ 即时练习</h3>
|
||||
<p className="mb-3 text-sm text-muted-foreground">做完提交,马上判分;每题有解说,达标即点亮本课。</p>
|
||||
<h3 className="text-base font-extrabold text-slate-900">{title}</h3>
|
||||
<p className="mb-3 text-sm text-muted-foreground">{subtitle}</p>
|
||||
<div className="space-y-4">
|
||||
{questions.map((q, qi) => {
|
||||
const a = answers[qi];
|
||||
@@ -151,7 +162,7 @@ export function QuizBlock({
|
||||
<p className="text-4xl font-extrabold">{result.score} 分</p>
|
||||
<p className="mt-1 text-sm opacity-90">
|
||||
答对 {result.correct} / {result.total}
|
||||
{result.passed ? " · 本课点亮 🎉 已计入今日英语打卡" : " · 看看解说,再试一次"}
|
||||
{result.passed ? ` · ${passText}` : " · 看看解说,再试一次"}
|
||||
</p>
|
||||
<div className="mt-4 flex justify-center gap-3">
|
||||
{!result.passed && (
|
||||
@@ -159,8 +170,8 @@ export function QuizBlock({
|
||||
重做练习
|
||||
</Button>
|
||||
)}
|
||||
<Link href="/learn">
|
||||
<Button variant="secondary" size="lg">{result.passed ? "返回课程地图" : "先看看其它课"}</Button>
|
||||
<Link href={backHref}>
|
||||
<Button variant="secondary" size="lg">{result.passed ? backLabel : "先看看其它课"}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// xuchao: 数学课时页(结构同英语课,判分走数学 action)
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { BackgroundBlobs } from "@/components/background-blobs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { getMathPoint, mathPassedByPoint } from "@/lib/math";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { LessonBody } from "@/app/learn/lesson-body";
|
||||
import { submitMathQuiz } from "../actions";
|
||||
|
||||
export default async function MathLessonPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/join");
|
||||
if (user.role !== "child") redirect("/parent");
|
||||
|
||||
const pointId = (await params).id;
|
||||
const point = await getMathPoint(pointId);
|
||||
if (!point) redirect("/math");
|
||||
|
||||
const passed = await mathPassedByPoint(user.id);
|
||||
const alreadyPassed = passed.has(pointId);
|
||||
|
||||
return (
|
||||
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-12">
|
||||
<BackgroundBlobs />
|
||||
<header className="space-y-4">
|
||||
<Link href="/math" className="text-sm font-medium text-orange-600">
|
||||
← 数学课程地图
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="bg-orange-100 text-orange-700 hover:bg-orange-100">{point.moduleName}</Badge>
|
||||
{point.grade && <Badge variant="secondary">{point.grade}</Badge>}
|
||||
<Badge variant="secondary">难度 {"★".repeat(point.difficulty)}</Badge>
|
||||
{alreadyPassed && (
|
||||
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100">已点亮</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="mt-1 text-2xl font-extrabold leading-snug tracking-tight text-slate-900">
|
||||
{point.title}
|
||||
</h1>
|
||||
{point.summary && <p className="mt-1 text-orange-600">🎯 {point.summary}</p>}
|
||||
</header>
|
||||
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-5 shadow-soft">
|
||||
<LessonBody
|
||||
md={point.md}
|
||||
pointId={point.pointId}
|
||||
submitter={submitMathQuiz.bind(null, pointId)}
|
||||
quizTitle="④ 即时练习"
|
||||
passText="本课点亮 🎉 已计入今日数学打卡"
|
||||
backHref="/math"
|
||||
backLabel="返回数学地图"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use server";
|
||||
|
||||
// xuchao: 数学课即时练习判分(同英语 submitQuiz,打卡联动数学)
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { completeMathPoint, extractQuiz, getMathPoint, gradeAnswers, type AnswerValue } from "@/lib/math";
|
||||
import { recordWrong } from "@/lib/wrongbook";
|
||||
|
||||
export type QuizResult =
|
||||
| { score: number; passed: boolean; correct: number; total: number; detail: boolean[] }
|
||||
| { error: string };
|
||||
|
||||
// 提交数学课练习:服务端判分,达标记进度并联动数学打卡
|
||||
export async function submitMathQuiz(pointId: string, answers: AnswerValue[]): Promise<QuizResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user || user.role !== "child") return { error: "会话无效,请重新激活设备" };
|
||||
|
||||
const point = await getMathPoint(pointId);
|
||||
if (!point) return { error: "课程不存在" };
|
||||
|
||||
const questions = extractQuiz(point.md);
|
||||
if (questions.length === 0) return { error: "本课暂无练习题" };
|
||||
|
||||
const g = gradeAnswers(questions, answers);
|
||||
// 错题进错题本(带详细讲解)
|
||||
questions.forEach((q, i) => {
|
||||
if (!g.detail[i]) void recordWrong(user.id, "mlesson", pointId, point.title, q, answers[i]);
|
||||
});
|
||||
await completeMathPoint(user.id, pointId, point.title, g.score, g.passed);
|
||||
|
||||
revalidatePath("/math");
|
||||
revalidatePath(`/math/${pointId}`);
|
||||
revalidatePath("/kid");
|
||||
revalidatePath("/parent");
|
||||
revalidatePath("/wrongbook");
|
||||
return g;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// xuchao: 数学思维课程地图(结构同英语 /learn,按七大模块分组)
|
||||
import Link from "next/link";
|
||||
import { Calculator, Check } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { BackgroundBlobs } from "@/components/background-blobs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MODULES, listMathPoints, mathPassedByPoint, totalMathPoints } from "@/lib/math";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
|
||||
export default async function MathHome() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/join");
|
||||
if (user.role !== "child") redirect("/parent");
|
||||
|
||||
const points = await listMathPoints();
|
||||
const passed = await mathPassedByPoint(user.id);
|
||||
const total = await totalMathPoints();
|
||||
const done = passed.size;
|
||||
const pct = total ? Math.round((done / total) * 100) : 0;
|
||||
const next = points.find((p) => !passed.has(p.pointId));
|
||||
|
||||
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">
|
||||
已点亮 {done} / {total}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{next ? (
|
||||
<div className="rounded-2xl bg-gradient-to-br from-orange-500 to-amber-500 p-6 text-white shadow-btn">
|
||||
<span className="inline-block rounded-full bg-white/20 px-3 py-1 text-xs font-semibold">
|
||||
推荐下一课 · {next.moduleName}
|
||||
</span>
|
||||
<h1 className="mt-3 text-2xl font-extrabold leading-tight">{next.title}</h1>
|
||||
<p className="mt-1 text-sm opacity-90">每天一课约 40 分钟,学完做即时练习,达标即点亮。</p>
|
||||
<Link href={`/math/${next.pointId}`} className="mt-4 block">
|
||||
<Button variant="secondary" size="lg" className="w-full">
|
||||
{done === 0 ? "开始第一课" : "继续学习"} →
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 p-6 text-center text-white shadow-btn">
|
||||
<p className="text-3xl">🏆</p>
|
||||
<p className="mt-2 text-xl font-extrabold">全部数学课都点亮了!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft">
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span className="font-medium text-slate-700">总进度</span>
|
||||
<span className="font-bold text-orange-600">{pct}%</span>
|
||||
</div>
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-orange-400 to-amber-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{MODULES.map((mod) => {
|
||||
const group = points.filter((p) => p.module === mod.id);
|
||||
if (group.length === 0) return null;
|
||||
const modDone = group.filter((p) => passed.has(p.pointId)).length;
|
||||
return (
|
||||
<section key={mod.id} className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft">
|
||||
<header className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-extrabold text-slate-900">
|
||||
{mod.name}
|
||||
<span className="ml-1 text-amber-500">{"★".repeat(mod.stars)}</span>
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{mod.desc}</p>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-orange-600">
|
||||
{modDone}/{group.length}
|
||||
</span>
|
||||
</header>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{group.map((p) => {
|
||||
const doneIt = passed.has(p.pointId);
|
||||
return (
|
||||
<Link
|
||||
key={p.pointId}
|
||||
href={`/math/${p.pointId}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-all duration-200 hover:-translate-y-0.5",
|
||||
doneIt
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||
: "border-slate-200 bg-white text-slate-700 hover:border-orange-300",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-5 shrink-0 items-center justify-center rounded-full",
|
||||
doneIt ? "bg-emerald-500 text-white" : "border border-slate-300",
|
||||
)}
|
||||
>
|
||||
{doneIt && <Check className="size-3.5" strokeWidth={3} />}
|
||||
</span>
|
||||
<span className="truncate">{p.title}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
<p className="flex items-center justify-center gap-1 text-center text-xs text-muted-foreground">
|
||||
<Calculator className="size-3.5" /> 每课学完做即时练习,≥80 分点亮;每周还有数学周检测
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use server";
|
||||
|
||||
// xuchao: 数学每周检测判分(同英语 takeWeekly,打卡联动数学)
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { gradeAnswers, type AnswerValue } from "@/lib/grammar";
|
||||
import { buildMathWeeklyQuestions, currentMathWeekKey, getMathWeeklyRecord } from "@/lib/math-weekly";
|
||||
import { recordWrong } from "@/lib/wrongbook";
|
||||
import { db } from "@/db";
|
||||
import { taskInstances, mathWeeklyTest } from "@/db/schema";
|
||||
import { todayStr } from "@/lib/dates";
|
||||
|
||||
export type MathWeeklyResult =
|
||||
| { score: number; passed: boolean; correct: number; total: number; detail: boolean[] }
|
||||
| { error: string };
|
||||
|
||||
// 提交数学每周检测:判分、记录、错题入库、达标关联当日数学打卡
|
||||
export async function takeMathWeekly(answers: AnswerValue[]): Promise<MathWeeklyResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user || user.role !== "child") return { error: "会话无效,请重新激活设备" };
|
||||
const wk = currentMathWeekKey();
|
||||
const existing = await getMathWeeklyRecord(user.id, wk);
|
||||
if (existing) return { error: "本周已完成数学检测,下周再来。" };
|
||||
|
||||
const questions = await buildMathWeeklyQuestions(user.id, wk);
|
||||
if (questions.length === 0) return { error: "暂无可组卷的题目,先学几课吧。" };
|
||||
|
||||
const g = gradeAnswers(questions, answers);
|
||||
questions.forEach((q, i) => {
|
||||
if (!g.detail[i]) void recordWrong(user.id, "mweekly", wk, `数学周检测 ${wk}`, q, answers[i]);
|
||||
});
|
||||
|
||||
await db
|
||||
.insert(mathWeeklyTest)
|
||||
.values({
|
||||
childUserId: user.id,
|
||||
weekKey: wk,
|
||||
score: g.score,
|
||||
correct: g.correct,
|
||||
total: g.total,
|
||||
passed: g.passed,
|
||||
questions: JSON.stringify(questions),
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { score: g.score, correct: g.correct, total: g.total, passed: g.passed } });
|
||||
|
||||
// 达标关联当日数学打卡
|
||||
if (g.passed) {
|
||||
const today = todayStr();
|
||||
const title = `数学周检测 · ${wk}`;
|
||||
const [ex] = await db
|
||||
.select({ id: taskInstances.id, status: taskInstances.status })
|
||||
.from(taskInstances)
|
||||
.where(and(eq(taskInstances.childUserId, user.id), eq(taskInstances.date, today), eq(taskInstances.title, title)))
|
||||
.limit(1);
|
||||
if (ex) {
|
||||
if (ex.status !== "done")
|
||||
await db.update(taskInstances).set({ status: "done", doneAt: sql`CURRENT_TIMESTAMP` }).where(eq(taskInstances.id, ex.id));
|
||||
} else {
|
||||
await db.insert(taskInstances).values({
|
||||
childUserId: user.id,
|
||||
date: today,
|
||||
title,
|
||||
subject: "math",
|
||||
status: "done",
|
||||
doneAt: new Date(),
|
||||
sortOrder: 95,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/math/weekly");
|
||||
revalidatePath("/kid");
|
||||
revalidatePath("/parent");
|
||||
revalidatePath("/wrongbook");
|
||||
return g;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// xuchao: 数学每周检测页(结构同英语 /weekly)
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { BackgroundBlobs } from "@/components/background-blobs";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { buildMathWeeklyQuestions, currentMathWeekKey, getMathWeeklyRecord } from "@/lib/math-weekly";
|
||||
import { QuizBlock } from "@/app/learn/quiz-block";
|
||||
import { takeMathWeekly } from "./actions";
|
||||
|
||||
export default async function MathWeeklyPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/join");
|
||||
if (user.role !== "child") redirect("/parent");
|
||||
|
||||
const wk = currentMathWeekKey();
|
||||
const record = await getMathWeeklyRecord(user.id, wk);
|
||||
const questions = record ? [] : await buildMathWeeklyQuestions(user.id, wk);
|
||||
|
||||
return (
|
||||
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-12">
|
||||
<BackgroundBlobs />
|
||||
<header className="flex items-center justify-between">
|
||||
<Link href="/math" className="text-sm font-medium text-orange-600">
|
||||
← 返回数学
|
||||
</Link>
|
||||
<h1 className="text-xl font-extrabold text-slate-900">数学每周检测</h1>
|
||||
<span className="text-xs text-muted-foreground">{wk}</span>
|
||||
</header>
|
||||
<p className="rounded-lg border-l-4 border-orange-400 bg-orange-50 px-3 py-2 text-sm text-orange-800">
|
||||
从你已点亮的数学课里抽 10 题,判断这周的掌握情况;达标自动计入今日数学打卡,错题进错题本。
|
||||
</p>
|
||||
|
||||
{record ? (
|
||||
<div
|
||||
className={
|
||||
record.passed
|
||||
? "rounded-2xl bg-gradient-to-r from-emerald-500 to-emerald-600 p-6 text-center text-white shadow-btn"
|
||||
: "rounded-2xl bg-gradient-to-r from-rose-500 to-rose-600 p-6 text-center text-white shadow-btn"
|
||||
}
|
||||
>
|
||||
<p className="text-4xl font-extrabold">{record.score} 分</p>
|
||||
<p className="mt-1 text-sm opacity-90">
|
||||
答对 {record.correct} / {record.total} · {record.passed ? "本周掌握良好 🎉" : "看看错题本,下周再战"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<QuizBlock
|
||||
pointId=""
|
||||
questions={questions}
|
||||
submitter={takeMathWeekly}
|
||||
title="数学周检测"
|
||||
subtitle="10 题,做完提交;每题有解说,达标计入今日数学打卡。"
|
||||
passText="周检测达标 🎉 已计入今日数学打卡"
|
||||
backHref="/math"
|
||||
backLabel="返回数学地图"
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user