英语语法模块 v2:接入参考语法体系,中文为主,家长可编辑,全部可学

- 内容底座改为你提供的语法体系(入门/基础/进阶 69 知识点,中文讲解 +
  中英对照例句 + 六步教学法),整体更适合小学生
- 内容存库(grammar_point),家长中心新增"编辑英语课程"可改标题/目标/
  课文,保存即时生效
- 取消顺序解锁,所有课程可直接学习,通过测验即点亮
- 测验引擎支持 选择/判断/填空/连词成句/连线 五种题型,达标自动联动
  当日英语打卡

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-03 22:53:10 +08:00
co-authored by Claude Opus 4.8
parent 6ed4d5a61e
commit f98a21672d
36 changed files with 2379 additions and 4002 deletions
+8 -8
View File
@@ -8,8 +8,7 @@ import { todayStr } from "@/lib/dates";
import { getSessionUser } from "@/lib/auth";
import { streakOf } from "@/lib/streak";
import { dayTasks } from "@/lib/tasks";
import { entryBySeq, TOTAL } from "@/content/grammar";
import { nextSeq, passedSet } from "@/lib/grammar";
import { listPoints, passedByPoint } from "@/lib/grammar";
import { TaskCard } from "./task-card";
export default async function KidPage() {
@@ -25,10 +24,11 @@ export default async function KidPage() {
const doneCount = tasks.filter((t) => t.status === "done").length;
const allDone = tasks.length > 0 && doneCount === tasks.length;
// 英语语法入口:下一课标题 + 已完成课数
const gNext = await nextSeq(user.id);
const gGoal = gNext <= TOTAL ? entryBySeq(gNext) : null;
const gDone = (await passedSet(user.id)).size;
// 英语语法入口:下一课标题 + 已点亮数
const gPassed = await passedByPoint(user.id);
const gPoints = await listPoints();
const gGoal = gPoints.find((p) => !gPassed.has(p.pointId));
const gDone = gPassed.size;
return (
<main className="relative mx-auto max-w-lg space-y-5 p-4 pb-10">
@@ -53,9 +53,9 @@ export default async function KidPage() {
<BookOpen className="size-6" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium opacity-90">英语语法 · 已学 {gDone} 课</p>
<p className="text-xs font-medium opacity-90">英语语法 · 已点亮 {gDone} 课</p>
<p className="truncate text-base font-bold">
{gGoal ? gGoal.title : "全部完成,真棒!"}
{gGoal ? gGoal.title : "全部点亮,真棒!"}
</p>
</div>
<span className="shrink-0 text-lg">→</span>
+44
View File
@@ -0,0 +1,44 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { BackgroundBlobs } from "@/components/background-blobs";
import { Badge } from "@/components/ui/badge";
import { getPoint, passedByPoint } from "@/lib/grammar";
import { getSessionUser } from "@/lib/auth";
import { LessonBody } from "../lesson-body";
export default async function LessonPage({ 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 getPoint(pointId);
if (!point) redirect("/learn");
const passed = await passedByPoint(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-2">
<Link href="/learn" className="text-sm font-medium text-indigo-600">
← 课程地图
</Link>
<div className="flex flex-wrap items-center gap-2">
<Badge className="bg-indigo-100 text-indigo-700 hover:bg-indigo-100">{point.levelName}</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="text-2xl font-extrabold tracking-tight text-slate-900">{point.title}</h1>
{point.summary && <p className="text-indigo-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} />
</div>
</main>
);
}
-72
View File
@@ -1,72 +0,0 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { BackgroundBlobs } from "@/components/background-blobs";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { CEFR_STYLE } from "@/lib/cefr";
import { TOTAL, entryBySeq } from "@/content/grammar";
import { isUnlocked, passedSet } from "@/lib/grammar";
import { getSessionUser } from "@/lib/auth";
import { SectionBlock } from "../section-block";
import { Practice } from "../practice";
import { QuizForm } from "../quiz-form";
export default async function LessonPage({ params }: { params: Promise<{ seq: string }> }) {
const user = await getSessionUser();
if (!user) redirect("/join");
if (user.role !== "child") redirect("/parent");
const seq = Number((await params).seq);
const entry = entryBySeq(seq);
if (!entry) redirect("/learn");
const passed = await passedSet(user.id);
if (!isUnlocked(seq, passed)) redirect("/learn");
const alreadyPassed = passed.has(seq);
const nextSeq = seq < TOTAL ? seq + 1 : null;
const cefr = CEFR_STYLE[entry.cefr];
return (
<main className="relative mx-auto max-w-2xl space-y-6 p-4 pb-12">
<BackgroundBlobs />
<header className="space-y-2">
<Link href="/learn" className="text-sm font-medium text-indigo-600">
← 课程地图
</Link>
<div className="flex flex-wrap items-center gap-2">
<span className={cn("rounded-full px-3 py-1 text-xs font-semibold", cefr.cls)}>
{cefr.label}
</span>
<Badge variant="secondary">
{entry.type === "lesson" ? `第 ${seq} 课` : entry.type === "weekly" ? "周测" : "月测"}
</Badge>
{alreadyPassed && (
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100">已通过</Badge>
)}
</div>
<h1 className="text-2xl font-extrabold tracking-tight text-slate-900">{entry.title}</h1>
{entry.type === "lesson" ? (
<p className="text-indigo-600">🎯 {entry.goal}</p>
) : (
<p className="text-slate-600">{entry.intro}</p>
)}
</header>
{entry.type === "lesson" && (
<>
<div className="space-y-8 rounded-2xl border border-slate-100 bg-white p-5 shadow-soft">
{entry.sections.map((s, i) => (
<SectionBlock key={i} s={s} />
))}
</div>
<Practice items={entry.practice} />
</>
)}
<div className="rounded-2xl border border-slate-100 bg-white p-5 shadow-soft">
<QuizForm seq={seq} quiz={entry.quiz} nextSeq={nextSeq} alreadyPassed={alreadyPassed} />
</div>
</main>
);
}
+11 -20
View File
@@ -2,37 +2,28 @@
import { revalidatePath } from "next/cache";
import { getSessionUser } from "@/lib/auth";
import { entryBySeq } from "@/content/grammar";
import { completeSeq, grade, isUnlocked, passedSet } from "@/lib/grammar";
import { completePoint, extractQuiz, getPoint, gradeAnswers, type AnswerValue } from "@/lib/grammar";
export type QuizResult =
| {
score: number;
passed: boolean;
correct: number;
total: number;
detail: boolean[];
}
| { score: number; passed: boolean; correct: number; total: number; detail: boolean[] }
| { error: string };
// 提交测验:服务端按标准答案计分(防篡改),达标即记进度并联动英语打卡
export async function submitQuiz(_prev: QuizResult | null, formData: FormData): Promise<QuizResult> {
// 提交结课练习:服务端按课文 quiz 判分,达标记进度并联动英语打卡
export async function submitQuiz(pointId: string, answers: AnswerValue[]): Promise<QuizResult> {
const user = await getSessionUser();
if (!user || user.role !== "child") return { error: "会话无效,请重新激活设备" };
const seq = Number(formData.get("seq"));
const entry = entryBySeq(seq);
if (!entry) return { error: "课程不存在" };
const point = await getPoint(pointId);
if (!point) return { error: "知识点不存在" };
const passed = await passedSet(user.id);
if (!isUnlocked(seq, passed)) return { error: "请先完成前面的课程" };
const questions = extractQuiz(point.md);
if (questions.length === 0) return { error: "本课暂无练习题" };
const answers = entry.quiz.map((_, i) => Number(formData.get(`a${i}`)));
const g = grade(entry.quiz, answers);
const g = gradeAnswers(questions, answers);
await completePoint(user.id, pointId, point.title, g.score, g.passed);
await completeSeq(user.id, seq, g.score, g.passed);
revalidatePath("/learn");
revalidatePath(`/learn/${seq}`);
revalidatePath(`/learn/${pointId}`);
revalidatePath("/kid");
revalidatePath("/parent");
return g;
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { Md } from "./md";
import { QuizBlock } from "./quiz-block";
import { extractQuiz, type QuizQ } from "@/lib/quiz-parse";
// 课文 = 前段 markdown + ⑤即时练习(quiz)+ 后段 markdown(小结/知识卡)
export function LessonBody({ md, pointId }: { md: string; pointId: 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} />}
{parts.slice(1).join("\n").trim() !== "" && <Md text={parts.slice(1).join("\n")} />}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
// 课文 markdown 的 Tailwind 样式映射(中文为主的企业信任风)
const components: Components = {
h2: ({ children }) => (
<h2 className="mt-6 mb-2 text-xl font-extrabold tracking-tight text-slate-900 first:mt-0">{children}</h2>
),
h3: ({ children }) => <h3 className="mt-4 mb-1 text-lg font-bold text-slate-900">{children}</h3>,
p: ({ children }) => <p className="my-2 leading-relaxed text-slate-700">{children}</p>,
ul: ({ children }) => <ul className="my-2 list-disc space-y-1 pl-5 text-slate-700">{children}</ul>,
ol: ({ children }) => <ol className="my-2 list-decimal space-y-1 pl-5 text-slate-700">{children}</ol>,
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
strong: ({ children }) => <strong className="font-semibold text-slate-900">{children}</strong>,
blockquote: ({ children }) => (
<blockquote className="my-2 rounded-r-lg border-l-4 border-amber-400 bg-amber-50 px-3 py-2 text-amber-800">
{children}
</blockquote>
),
pre: ({ children }) => (
<pre className="my-2 overflow-x-auto rounded-lg bg-slate-900 p-3 text-sm leading-relaxed text-slate-100">
{children}
</pre>
),
code: ({ children }) => <code className="">{children}</code>,
table: ({ children }) => (
<div className="my-2 overflow-hidden rounded-lg border border-slate-200">
<table className="w-full text-sm">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-indigo-50 text-indigo-700">{children}</thead>,
th: ({ children }) => <th className="px-3 py-2 text-left font-semibold">{children}</th>,
td: ({ children }) => <td className="border-t border-slate-100 px-3 py-2 text-slate-700">{children}</td>,
hr: () => <hr className="my-4 border-slate-200" />,
};
// 渲染一段不含 quiz 块的 markdown
export function Md({ text }: { text: string }) {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{text}
</ReactMarkdown>
);
}
+65 -67
View File
@@ -1,12 +1,10 @@
import Link from "next/link";
import { BookOpen, Check, Lock } from "lucide-react";
import { BookOpen, 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 { CEFR_STYLE } from "@/lib/cefr";
import { SYLLABUS, TOTAL, entryBySeq } from "@/content/grammar";
import { isUnlocked, nextSeq, passedSet } from "@/lib/grammar";
import { LEVELS, listPoints, passedByPoint, totalPoints } from "@/lib/grammar";
import { getSessionUser } from "@/lib/auth";
export default async function LearnHome() {
@@ -14,34 +12,33 @@ export default async function LearnHome() {
if (!user) redirect("/join");
if (user.role !== "child") redirect("/parent");
const passed = await passedSet(user.id);
const points = await listPoints();
const passed = await passedByPoint(user.id);
const total = await totalPoints();
const done = passed.size;
const next = await nextSeq(user.id);
const current = next <= TOTAL ? entryBySeq(next) : null;
const pct = Math.round((done / TOTAL) * 100);
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-lg space-y-5 p-4 pb-10">
<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} 课
已点亮 {done} / {total}
</span>
</header>
{current ? (
{next ? (
<div className="rounded-2xl bg-gradient-to-br from-indigo-600 to-violet-600 p-6 text-white shadow-btn">
<span className="inline-block rounded-full bg-white/20 px-3 py-1 text-xs font-semibold">
今日目标 · 第 {current.seq} 课
推荐下一课 · {next.levelName}
</span>
<h1 className="mt-3 text-2xl font-extrabold leading-tight">{current.title}</h1>
<p className="mt-1 text-sm opacity-90">
{current.type === "lesson" ? current.goal : current.intro}
</p>
<Link href={`/learn/${current.seq}`} className="mt-4 block">
<h1 className="mt-3 text-2xl font-extrabold leading-tight">{next.title}</h1>
<p className="mt-1 text-sm opacity-90">也可以直接从下面任意一课开始,全部都能学。</p>
<Link href={`/learn/${next.pointId}`} className="mt-4 block">
<Button variant="secondary" size="lg" className="w-full">
{done === 0 ? "开始第一课" : "继续学习"} →
</Button>
@@ -50,8 +47,7 @@ export default async function LearnHome() {
) : (
<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>
<p className="mt-1 text-sm opacity-90">太厉害了,继续保持每天复习。</p>
<p className="mt-2 text-xl font-extrabold">全部语法点都点亮了!</p>
</div>
)}
@@ -62,60 +58,62 @@ export default async function LearnHome() {
</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-indigo-500 to-violet-500 transition-all"
className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500"
style={{ width: `${pct}%` }}
/>
</div>
</div>
<div className="grid grid-cols-7 gap-2">
{SYLLABUS.map((e) => {
const doneIt = passed.has(e.seq);
const cur = e.seq === next;
const unlocked = isUnlocked(e.seq, passed);
const isMilestone = e.type !== "lesson";
const inner = (
<div
className={cn(
"flex aspect-square items-center justify-center rounded-lg text-xs font-bold transition-colors",
doneIt && "bg-gradient-to-br from-emerald-400 to-emerald-600 text-white shadow-[0_0_12px_rgba(16,185,129,0.4)]",
!doneIt && cur && "bg-gradient-to-br from-indigo-500 to-violet-600 text-white ring-2 ring-indigo-300",
!doneIt && !cur && unlocked && "border border-slate-200 bg-white text-slate-500",
!doneIt && !unlocked && "bg-slate-100 text-slate-300",
)}
>
{doneIt ? (
<Check className="size-4" strokeWidth={3} />
) : !unlocked ? (
<Lock className="size-3.5" />
) : isMilestone ? (
<BookOpen className="size-3.5" />
) : (
e.seq
)}
{LEVELS.map((lvl) => {
const group = points.filter((p) => p.level === lvl.id);
if (group.length === 0) return null;
const lvlDone = group.filter((p) => passed.has(p.pointId)).length;
return (
<section key={lvl.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">
{lvl.name}
<span className="ml-1 text-amber-500">{"★".repeat(lvl.stars)}</span>
</h2>
<p className="text-xs text-muted-foreground">{lvl.desc}</p>
</div>
<span className="text-sm font-medium text-indigo-600">
{lvlDone}/{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={`/learn/${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-indigo-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>
);
const style = e.type === "lesson" ? "" : "ring-1 ring-amber-200";
return unlocked ? (
<Link key={e.seq} href={`/learn/${e.seq}`} className={style} title={e.title}>
{inner}
</Link>
) : (
<div key={e.seq} className={style} title={unlocked ? e.title : "未解锁"}>
{inner}
</div>
);
})}
</div>
<p className="text-center text-xs text-muted-foreground">
方块带书图标的是周测/月测;灰锁需先通过前一课
</p>
<p className="text-center text-xs text-muted-foreground">
级别:{[...new Set(SYLLABUS.map((e) => e.cefr))].map((c) => (
<span key={c} className={cn("mx-0.5 rounded px-1.5 py-0.5", CEFR_STYLE[c].cls)}>
{c}
</span>
))}
</section>
);
})}
<p className="flex items-center justify-center gap-1 text-center text-xs text-muted-foreground">
<BookOpen className="size-3.5" /> 所有课程都可以直接学习,点亮只代表你通过了该课检验
</p>
</main>
);
-70
View File
@@ -1,70 +0,0 @@
"use client";
import { useState } from "react";
import { Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { QuizQ } from "@/content/grammar/types";
// 课内随练:点选后立即显示对错与解析,不计分
export function Practice({ items }: { items: QuizQ[] }) {
const [picked, setPicked] = useState<(number | null)[]>(items.map(() => null));
return (
<div className="space-y-3">
<h3 className="text-base font-bold text-slate-900">练一练</h3>
{items.map((q, qi) => {
const p = picked[qi];
const revealed = p !== null;
return (
<div key={qi} className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft">
<p className="font-medium text-slate-900">
{qi + 1}. {q.q}
</p>
<div className="mt-2 space-y-1.5">
{q.options.map((opt, oi) => {
const isRight = revealed && oi === q.answer;
const isWrong = revealed && oi === p && oi !== q.answer;
return (
<button
key={oi}
type="button"
disabled={revealed}
onClick={() => {
const next = [...picked];
next[qi] = oi;
setPicked(next);
}}
className={cn(
"flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors",
!revealed && "border-slate-200 hover:border-indigo-300 hover:bg-indigo-50/50",
isRight && "border-emerald-300 bg-emerald-50 text-emerald-700",
isWrong && "border-rose-300 bg-rose-50 text-rose-700",
revealed && !isRight && !isWrong && "border-slate-100 text-slate-400",
)}
>
{isRight && <Check className="size-4 shrink-0" />}
{isWrong && <X className="size-4 shrink-0" />}
<span>{opt}</span>
</button>
);
})}
</div>
{revealed && (
<p className="mt-2 text-sm text-muted-foreground">
解析:{q.explain}
</p>
)}
</div>
);
})}
<Button
variant="outline"
size="sm"
onClick={() => setPicked(items.map(() => null))}
>
重做练习
</Button>
</div>
);
}
+258
View File
@@ -0,0 +1,258 @@
"use client";
import { useTransition, useState } from "react";
import Link from "next/link";
import { Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { QuizQ } from "@/lib/quiz-parse";
import type { AnswerValue } from "@/lib/grammar";
import { submitQuiz, type QuizResult } from "./actions";
type AV = AnswerValue | null;
// 结课检验:选择/判断/填空/连词成句/连线,提交后服务端判分,达标点亮并联动打卡
export function QuizBlock({ pointId, questions }: { pointId: string; questions: QuizQ[] }) {
const [answers, setAnswers] = useState<AV[]>(questions.map(() => null));
const [result, setResult] = useState<Extract<QuizResult, { score: number }> | null>(null);
const [error, setError] = useState("");
const [pending, startTransition] = useTransition();
const set = (i: number, v: AV) => {
const next = [...answers];
next[i] = v;
setAnswers(next);
};
const isAnswered = (q: QuizQ, a: AV): boolean => {
if (q.type === "fill") return typeof a === "string" && a.trim() !== "";
if (q.type === "order") return Array.isArray(a) && a.length === (q.words ?? []).length;
if (q.type === "match") return Array.isArray(a) && a.length === (q.pairs ?? []).length && a.every((x) => x !== null && x !== "");
return typeof a === "number";
};
const allAnswered = questions.every((q, i) => isAnswered(q, answers[i]));
const submit = () => {
setError("");
startTransition(async () => {
const r = await submitQuiz(pointId, answers.map((a) => a as AnswerValue));
if ("error" in r) setError(r.error);
else setResult(r);
});
};
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>
<div className="space-y-4">
{questions.map((q, qi) => {
const a = answers[qi];
const ok = result ? result.detail[qi] : null;
return (
<div key={qi} className="rounded-xl border border-slate-100 bg-slate-50/60 p-4">
<p className="font-medium text-slate-900">
{qi + 1}. {q.stem}
</p>
{q.type === "fill" && (
<input
type="text"
value={(a as string) ?? ""}
disabled={!!result}
onChange={(e) => set(qi, e.target.value)}
placeholder="填写答案"
className="mt-2 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
)}
{(q.type === "choice" || q.type === "judge") && (
<div className="mt-2 grid gap-1.5">
{(q.type === "judge" ? ["对", "错"] : q.options ?? []).map((opt, oi) => {
const sel = a === oi;
const isRight = result && oi === (q.answer as number);
const isWrong = result && sel && oi !== (q.answer as number);
return (
<button
key={oi}
type="button"
disabled={!!result}
onClick={() => set(qi, oi)}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors",
!result && sel && "border-indigo-400 bg-indigo-50 ring-1 ring-indigo-300",
!result && !sel && "border-slate-200 bg-white hover:border-indigo-300",
isRight && "border-emerald-300 bg-emerald-50 text-emerald-700",
isWrong && "border-rose-300 bg-rose-50 text-rose-700",
result && !isRight && !isWrong && "border-slate-100 bg-white text-slate-400",
)}
>
{isRight && <Check className="size-4 shrink-0" />}
{isWrong && <X className="size-4 shrink-0" />}
<span>{opt}</span>
</button>
);
})}
</div>
)}
{q.type === "order" && <OrderInput q={q} a={a} disabled={!!result} onChange={(v) => set(qi, v)} />}
{q.type === "match" && <MatchInput q={q} a={a} disabled={!!result} onChange={(v) => set(qi, v)} />}
{result && (
<p className="mt-2 text-sm text-muted-foreground">
{q.type === "fill" && (
<span className="mr-2 font-medium text-emerald-700">参考:{(q.answers ?? []).join(" / ")}</span>
)}
{q.type === "order" && (
<span className="mr-2 font-medium text-emerald-700">参考:{(q.answer as string[]).join(" ")}</span>
)}
{q.type === "match" && (
<span className="mr-2 font-medium text-emerald-700">
参考:{(q.pairs ?? []).map((p) => `${p[0]}→${p[1]}`).join("、")}
</span>
)}
解说:{q.explain}
</p>
)}
{result && ok === false && <p className="mt-1 text-xs text-rose-600">这题答错了,看看上面的参考与解说。</p>}
</div>
);
})}
</div>
{!result && (
<>
<Button size="lg" className="mt-4 w-full" disabled={pending || !allAnswered} onClick={submit}>
{pending ? "判分中…" : "提交答案"}
</Button>
{error && <p className="mt-2 text-sm font-medium text-destructive">{error}</p>}
</>
)}
{result && (
<div
className={cn(
"mt-4 rounded-xl p-5 text-center text-white shadow-btn",
result.passed ? "bg-gradient-to-r from-emerald-500 to-emerald-600" : "bg-gradient-to-r from-rose-500 to-rose-600",
)}
>
<p className="text-4xl font-extrabold">{result.score} 分</p>
<p className="mt-1 text-sm opacity-90">
答对 {result.correct} / {result.total}
{result.passed ? " · 本课点亮 🎉 已计入今日英语打卡" : " · 看看解说,再试一次"}
</p>
<div className="mt-4 flex justify-center gap-3">
{!result.passed && (
<Button variant="secondary" onClick={() => window.location.reload()}>
重做练习
</Button>
)}
<Link href="/learn">
<Button variant="secondary" size="lg">{result.passed ? "返回课程地图" : "先看看其它课"}</Button>
</Link>
</div>
</div>
)}
</div>
);
}
// 连词成句:点词入槽,再点槽里的词可移回
function OrderInput({
q,
a,
disabled,
onChange,
}: {
q: QuizQ;
a: AV;
disabled: boolean;
onChange: (v: string[]) => void;
}) {
const seq = Array.isArray(a) ? (a as string[]) : [];
const remaining = (() => {
const pool = [...(q.words ?? [])];
for (const w of seq) {
const idx = pool.indexOf(w);
if (idx >= 0) pool.splice(idx, 1);
}
return pool;
})();
return (
<div className="mt-2 space-y-2">
<div className="flex min-h-10 flex-wrap items-center gap-1.5 rounded-lg border border-dashed border-slate-300 bg-white p-2">
{seq.length === 0 && <span className="text-sm text-muted-foreground">按顺序点下面的词,组成句子</span>}
{seq.map((w, i) => (
<button
key={`${w}-${i}`}
type="button"
disabled={disabled}
onClick={() => onChange(seq.filter((_, j) => j !== i))}
className="rounded-md bg-indigo-600 px-2 py-1 text-sm text-white"
>
{w}
</button>
))}
</div>
<div className="flex flex-wrap gap-1.5">
{remaining.map((w, i) => (
<button
key={`${w}-${i}`}
type="button"
disabled={disabled}
onClick={() => onChange([...seq, w])}
className="rounded-md border border-slate-200 bg-white px-2 py-1 text-sm hover:border-indigo-300"
>
{w}
</button>
))}
</div>
</div>
);
}
// 连线:为左边每项选择右边的类型
function MatchInput({
q,
a,
disabled,
onChange,
}: {
q: QuizQ;
a: AV;
disabled: boolean;
onChange: (v: string[]) => void;
}) {
const pairs = q.pairs ?? [];
const rights = [...new Set(pairs.map((p) => p[1]))];
const sel = Array.isArray(a) ? ([...(a as string[])] as string[]) : pairs.map(() => "");
return (
<div className="mt-2 space-y-1.5">
{pairs.map((p, pi) => (
<div key={pi} className="flex items-center gap-2">
<span className="min-w-28 rounded-md bg-white px-2 py-1 text-sm ring-1 ring-slate-200">{p[0]}</span>
<span className="text-muted-foreground">→</span>
<select
disabled={disabled}
value={sel[pi] ?? ""}
onChange={(e) => {
const next = [...sel];
next[pi] = e.target.value;
onChange(next);
}}
className="rounded-md border border-slate-200 bg-white px-2 py-1 text-sm focus:border-indigo-500"
>
<option value="">选择</option>
{rights.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</div>
))}
</div>
);
}
-149
View File
@@ -1,149 +0,0 @@
"use client";
import { useActionState, useState } from "react";
import Link from "next/link";
import { CheckCircle2, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { QuizQ } from "@/content/grammar/types";
import { submitQuiz, type QuizResult } from "./actions";
// 计分测验:选中后提交,服务端判分;达标才算通过并解锁下一课
export function QuizForm({
seq,
quiz,
nextSeq,
alreadyPassed,
}: {
seq: number;
quiz: QuizQ[];
nextSeq: number | null;
alreadyPassed: boolean;
}) {
const [state, formAction, pending] = useActionState<QuizResult | null, FormData>(submitQuiz, null);
const [picked, setPicked] = useState<(number | null)[]>(quiz.map(() => null));
const result = state && "score" in state ? state : null;
const error = state && "error" in state ? state.error : null;
const allAnswered = picked.every((p) => p !== null);
return (
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900">
{alreadyPassed ? "再测一次" : "课后测验"}
<span className="ml-2 text-sm font-normal text-muted-foreground">
满分通过线 80%,达标即完成本课并自动打卡
</span>
</h3>
<form action={formAction} className="space-y-3">
<input type="hidden" name="seq" value={seq} />
{quiz.map((q, qi) => (
<div key={qi} className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft">
<p className="font-medium text-slate-900">
{qi + 1}. {q.q}
</p>
<div className="mt-2 space-y-1.5">
{q.options.map((opt, oi) => {
const sel = picked[qi] === oi;
const isRight = result && oi === q.answer;
const isWrong = result && sel && oi !== q.answer;
return (
<label
key={oi}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors",
!result && sel && "border-indigo-400 bg-indigo-50 ring-1 ring-indigo-300",
!result && !sel && "border-slate-200 hover:border-indigo-300",
isRight && "border-emerald-300 bg-emerald-50 text-emerald-700",
isWrong && "border-rose-300 bg-rose-50 text-rose-700",
result && !isRight && !isWrong && "border-slate-100 text-slate-400",
)}
>
<input
type="radio"
name={`a${qi}`}
value={oi}
checked={sel}
disabled={!!result}
onChange={() => {
const next = [...picked];
next[qi] = oi;
setPicked(next);
}}
className="sr-only"
/>
<span
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-full border",
sel && !result && "border-indigo-500 bg-indigo-500",
!sel && !result && "border-slate-300",
)}
>
{sel && !result && <span className="size-1.5 rounded-full bg-white" />}
</span>
<span>{opt}</span>
</label>
);
})}
</div>
{result && (
<p className="mt-2 text-sm text-muted-foreground">解析:{q.explain}</p>
)}
</div>
))}
{!result && (
<Button type="submit" size="lg" className="w-full" disabled={pending || !allAnswered}>
{pending ? "判分中…" : "提交测验"}
</Button>
)}
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
</form>
{result && (
<div
className={cn(
"rounded-xl p-5 text-center text-white shadow-btn",
result.passed
? "bg-gradient-to-r from-emerald-500 to-emerald-600"
: "bg-gradient-to-r from-rose-500 to-rose-600",
)}
>
<p className="text-4xl font-extrabold">{result.score} 分</p>
<p className="mt-1 text-sm opacity-90">
答对 {result.correct} / {result.total}
{result.passed ? " · 通过 🎉" : " · 再复习一遍吧"}
</p>
<div className="mt-4 flex justify-center gap-3">
{!result.passed && (
<Button
variant="secondary"
onClick={() => {
setPicked(quiz.map(() => null));
// 触发重新挂载以清空结果:通过刷新页面最稳妥
window.location.reload();
}}
>
重做测验
</Button>
)}
{result.passed && nextSeq && (
<Link href={`/learn/${nextSeq}`}>
<Button variant="secondary" size="lg">
下一课 →
</Button>
</Link>
)}
{result.passed && !nextSeq && (
<Link href="/learn">
<Button variant="secondary" size="lg">
全部完成 🏆
</Button>
</Link>
)}
</div>
</div>
)}
</div>
);
}
-69
View File
@@ -1,69 +0,0 @@
import { renderBold } from "@/lib/md";
import type { Section } from "@/content/grammar/types";
// 渲染一个讲解小节:英文讲解 + 中文要点 + 例句 + 结构表 + 易错提示
export function SectionBlock({ s }: { s: Section }) {
return (
<section className="space-y-3">
<div>
<h3 className="text-lg font-bold text-slate-900">{s.heading}</h3>
<p className="text-sm font-medium text-indigo-600">{s.zh}</p>
</div>
<p className="leading-relaxed text-slate-700">
{renderBold(s.body).map((part, i) =>
typeof part === "string" ? (
<span key={i}>{part}</span>
) : (
<strong key={i} className="font-semibold text-slate-900">
{part.b}
</strong>
),
)}
</p>
{s.structure && (
<div className="overflow-hidden rounded-lg border border-slate-200">
<table className="w-full text-sm">
<thead className="bg-indigo-50 text-indigo-700">
<tr>
{s.structure.cols.map((c) => (
<th key={c} className="px-3 py-2 text-left font-semibold">
{c}
</th>
))}
</tr>
</thead>
<tbody>
{s.structure.rows.map((row, ri) => (
<tr key={ri} className="border-t border-slate-100">
{row.map((cell, ci) => (
<td key={ci} className="px-3 py-2 text-slate-700">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{s.examples && (
<ul className="space-y-1 rounded-lg bg-slate-50 p-3">
{s.examples.map((ex, i) => (
<li key={i} className="flex gap-2 text-slate-700">
<span className="font-bold text-indigo-400">›</span>
<span>{ex}</span>
</li>
))}
</ul>
)}
{s.note && (
<p className="rounded-lg border-l-4 border-amber-400 bg-amber-50 px-3 py-2 text-sm text-amber-800">
💡 {s.note}
</p>
)}
</section>
);
}
+13 -5
View File
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { logout } from "@/app/login/actions";
import { BackgroundBlobs } from "@/components/background-blobs";
import { Button } from "@/components/ui/button";
@@ -60,11 +61,18 @@ export function ParentDashboard({
<h1 className="text-2xl font-extrabold tracking-tight">
家长<span className="text-gradient">中心</span>
</h1>
<form action={logout}>
<Button type="submit" variant="outline" size="sm">
退出登录
</Button>
</form>
<div className="flex items-center gap-2">
<Link href="/parent/grammar">
<Button variant="outline" size="sm">
编辑英语课程
</Button>
</Link>
<form action={logout}>
<Button type="submit" variant="outline" size="sm">
退出登录
</Button>
</form>
</div>
</header>
<Tabs defaultValue="overview">
+41
View File
@@ -0,0 +1,41 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { BackgroundBlobs } from "@/components/background-blobs";
import { Badge } from "@/components/ui/badge";
import { getPoint } from "@/lib/grammar";
import { getSessionUser } from "@/lib/auth";
import { EditorForm } from "../editor-form";
export default async function GrammarEditPage({ params }: { params: Promise<{ pointId: string }> }) {
const parent = await getSessionUser();
if (!parent) redirect("/login");
if (parent.role !== "parent") redirect("/kid");
const pointId = (await params).pointId;
const point = await getPoint(pointId);
if (!point) redirect("/parent/grammar");
return (
<main className="relative mx-auto max-w-3xl space-y-5 p-4 pb-10">
<BackgroundBlobs />
<header className="flex flex-wrap items-center justify-between gap-2">
<Link href="/parent/grammar" className="text-sm font-medium text-indigo-600">
← 课程列表
</Link>
<div className="flex items-center gap-2">
<Badge className="bg-indigo-100 text-indigo-700 hover:bg-indigo-100">{point.levelName}</Badge>
<h1 className="text-lg font-extrabold text-slate-900">{point.title}</h1>
</div>
</header>
<div className="rounded-2xl border border-slate-100 bg-white p-5 shadow-soft">
<EditorForm
pointId={point.pointId}
initialTitle={point.title}
initialSummary={point.summary}
initialMd={point.md}
/>
</div>
</main>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use server";
import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { grammarPoints } from "@/db/schema";
import { getSessionUser } from "@/lib/auth";
// 家长编辑语法知识点(标题/摘要/课文 markdown)
export async function updatePoint(
pointId: string,
data: { title: string; summary: string; md: string },
): Promise<{ ok?: true; error?: string }> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
if (!data.title.trim()) return { error: "标题不能为空" };
if (!data.md.trim()) return { error: "课文内容不能为空" };
await db
.update(grammarPoints)
.set({
title: data.title.trim(),
summary: data.summary,
md: data.md,
updatedAt: new Date(),
})
.where(eq(grammarPoints.pointId, pointId));
revalidatePath("/learn");
revalidatePath(`/learn/${pointId}`);
revalidatePath("/parent/grammar");
revalidatePath(`/parent/grammar/${pointId}`);
return { ok: true };
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
import { useState, useTransition } from "react";
import { Eye, EyeOff, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Md } from "@/app/learn/md";
import { extractQuiz } from "@/lib/quiz-parse";
import { updatePoint } from "./actions";
// 家长课程编辑器:改标题/摘要/课文 markdown,可实时预览,保存即生效
export function EditorForm({
pointId,
initialTitle,
initialSummary,
initialMd,
}: {
pointId: string;
initialTitle: string;
initialSummary: string | null;
initialMd: string;
}) {
const [title, setTitle] = useState(initialTitle);
const [summary, setSummary] = useState(initialSummary ?? "");
const [md, setMd] = useState(initialMd);
const [preview, setPreview] = useState(false);
const [msg, setMsg] = useState<{ ok?: boolean; text: string } | null>(null);
const [pending, startTransition] = useTransition();
const quizCount = extractQuiz(md).length;
const save = () => {
setMsg(null);
startTransition(async () => {
const r = await updatePoint(pointId, { title, summary, md });
if (r.error) setMsg({ text: r.error });
else setMsg({ ok: true, text: "已保存,孩子端立即生效" });
});
};
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label>标题</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="space-y-1">
<Label>学习目标(一句话)</Label>
<Input value={summary} onChange={(e) => setSummary(e.target.value)} />
</div>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label>课文内容(Markdown,含 ```quiz 练习块)</Label>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>检测到练习题 {quizCount} 道</span>
<Button variant="outline" size="sm" type="button" onClick={() => setPreview((p) => !p)}>
{preview ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
{preview ? "编辑" : "预览"}
</Button>
</div>
</div>
{preview ? (
<div className="max-h-[480px] overflow-y-auto rounded-xl border border-slate-100 bg-white p-4">
<Md text={md} />
</div>
) : (
<textarea
value={md}
onChange={(e) => setMd(e.target.value)}
rows={18}
className="w-full rounded-lg border border-slate-200 bg-white p-3 font-mono text-xs leading-relaxed focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
)}
</div>
<div className="flex items-center gap-3">
<Button size="lg" disabled={pending} onClick={save}>
<Save className="size-4" /> 保存
</Button>
{msg && (
<p className={msg.ok ? "text-sm font-medium text-emerald-600" : "text-sm font-medium text-destructive"}>
{msg.text}
</p>
)}
</div>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import Link from "next/link";
import { Pencil } from "lucide-react";
import { redirect } from "next/navigation";
import { BackgroundBlobs } from "@/components/background-blobs";
import { LEVELS, listPoints } from "@/lib/grammar";
import { getSessionUser } from "@/lib/auth";
export default async function GrammarAdmin() {
const parent = await getSessionUser();
if (!parent) redirect("/login");
if (parent.role !== "parent") redirect("/kid");
const points = await listPoints();
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="/parent" className="text-sm font-medium text-indigo-600">
← 返回家长中心
</Link>
<h1 className="text-xl font-extrabold text-slate-900">英语课程编辑</h1>
</header>
<p className="rounded-lg border-l-4 border-indigo-400 bg-indigo-50 px-3 py-2 text-sm text-indigo-800">
点击任意知识点即可修改标题、目标与课文内容,保存后孩子端立即生效。
</p>
{LEVELS.map((lvl) => {
const group = points.filter((p) => p.level === lvl.id);
if (group.length === 0) return null;
return (
<section key={lvl.id} className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft">
<h2 className="mb-3 text-lg font-extrabold text-slate-900">
{lvl.name}
<span className="ml-1 text-amber-500">{"★".repeat(lvl.stars)}</span>
</h2>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{group.map((p) => (
<Link
key={p.pointId}
href={`/parent/grammar/${p.pointId}`}
className="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 transition-all duration-200 hover:-translate-y-0.5 hover:border-indigo-300"
>
<Pencil className="size-3.5 shrink-0 text-indigo-400" />
<span className="truncate">{p.title}</span>
</Link>
))}
</div>
</section>
);
})}
</main>
);
}
+4 -4
View File
@@ -8,8 +8,7 @@ import { SESSION_COOKIE, getSessionUser } from "@/lib/auth";
import { daysAgo, todayStr } from "@/lib/dates";
import { ensureDay } from "@/lib/tasks";
import { streakOf } from "@/lib/streak";
import { passedSet } from "@/lib/grammar";
import { TOTAL } from "@/content/grammar";
import { passedCount, totalPoints } from "@/lib/grammar";
import { ParentDashboard, type DevicesData, type OverviewData, type TemplatesData } from "./dashboard";
const SHANGHAI = { timeZone: "Asia/Shanghai" } as const;
@@ -29,6 +28,7 @@ export default async function ParentPage() {
const today = todayStr();
const weekDates = Array.from({ length: 7 }, (_, i) => daysAgo(i)); // 今天在前
const gTotal = await totalPoints();
// ===== 今日概览 =====
const overview: OverviewData = { kids: [] };
@@ -36,7 +36,7 @@ export default async function ParentPage() {
await ensureDay(kid.id, today); // 家长先看时也要生成今天的快照
const [streak, gDone, rows] = await Promise.all([
streakOf(kid.id),
passedSet(kid.id).then((s) => s.size),
passedCount(kid.id),
db
.select({
id: taskInstances.id,
@@ -67,7 +67,7 @@ export default async function ParentPage() {
total: todayTasks.length,
done: todayTasks.filter((t) => t.status === "done").length,
},
grammar: { done: gDone, total: TOTAL },
grammar: { done: gDone, total: gTotal },
week,
});
}