英语语法模块 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
@@ -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