英语语法模块 v2:接入参考语法体系,中文为主,家长可编辑,全部可学
- 内容底座改为你提供的语法体系(入门/基础/进阶 69 知识点,中文讲解 + 中英对照例句 + 六步教学法),整体更适合小学生 - 内容存库(grammar_point),家长中心新增"编辑英语课程"可改标题/目标/ 课文,保存即时生效 - 取消顺序解锁,所有课程可直接学习,通过测验即点亮 - 测验引擎支持 选择/判断/填空/连词成句/连线 五种题型,达标自动联动 当日英语打卡 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6ed4d5a61e
commit
f98a21672d
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user