第二期:企业信任风 UI + 打卡科目分类 + 英语语法学习模块

- 全站按 Corporate Trust 设计规范重做:靛蓝/紫罗兰渐变、Plus Jakarta Sans
  自托管字体、带色阴影、卡片悬浮、氛围光斑
- 打卡任务增加科目分类(语文/数学/英语/运动/其他),孩子端按科目分组
- 英语语法自学模块:线性解锁课程 + 课后/周/月测验引擎,测验达标
  自动联动当日英语打卡;家长概览显示语法进度
- 课程内容:A1→B1 完整大纲,9 周 54 课 + 9 周测 + 2 月测,讲解/例句/
  练习/测验均原创,仅参考两份语法书的主题编排

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-03 01:20:04 +08:00
co-authored by Claude Opus 4.8
parent b08f6649be
commit 6ed4d5a61e
45 changed files with 5824 additions and 92 deletions
+50 -20
View File
@@ -1,4 +1,5 @@
@import "tailwindcss";
@import "@fontsource-variable/plus-jakarta-sans";
@custom-variant dark (&:is(.dark *));
@@ -25,28 +26,41 @@
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
/* 品牌字体:拉丁用 Plus Jakarta Sans,中文回落系统字体 */
--font-sans: "Plus Jakarta Sans Variable", "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", system-ui, sans-serif;
/* 带色阴影(企业信任风的核心视觉签名) */
--shadow-soft: 0 4px 20px -2px rgb(79 70 229 / 0.1);
--shadow-lift: 0 10px 25px -5px rgb(79 70 229 / 0.15),
0 8px 10px -6px rgb(79 70 229 / 0.1);
--shadow-btn: 0 4px 14px 0 rgb(79 70 229 / 0.3);
}
/* Corporate Trust 色板:靛蓝主色 + 紫罗兰辅色 + 冷灰中性色 */
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.75rem;
--background: #f8fafc; /* slate-50 */
--foreground: #0f172a; /* slate-900 */
--card: #ffffff;
--card-foreground: #0f172a;
--popover: #ffffff;
--popover-foreground: #0f172a;
--primary: #4f46e5; /* indigo-600 */
--primary-foreground: #ffffff;
--secondary: #f1f5f9; /* slate-100 */
--secondary-foreground: #334155; /* slate-700 */
--muted: #f1f5f9;
--muted-foreground: #64748b; /* slate-500 */
--accent: #eef2ff; /* indigo-50 */
--accent-foreground: #4f46e5;
--destructive: #ef4444;
--border: #e2e8f0; /* slate-200 */
--input: #e2e8f0;
--ring: #6366f1; /* indigo-500 */
--violet: #7c3aed; /* violet-600,渐变辅色 */
--emerald: #10b981; /* 成功色 */
}
@layer base {
@@ -54,6 +68,22 @@
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground font-sans antialiased;
}
}
/* 渐变文字:靛蓝 → 紫罗兰,用于重点标题 */
@utility text-gradient {
background-image: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* 背景氛围光斑:大号模糊渐变圆,营造纵深 */
@utility bg-blob {
position: absolute;
border-radius: 9999px;
filter: blur(64px);
pointer-events: none;
}
+14 -8
View File
@@ -2,9 +2,11 @@
import { useActionState } from "react";
import Link from "next/link";
import { KeyRound } from "lucide-react";
import { BackgroundBlobs } from "@/components/background-blobs";
import { activate } from "./actions";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -12,12 +14,16 @@ export default function JoinPage() {
const [state, formAction, pending] = useActionState(activate, null);
return (
<main className="flex min-h-dvh items-center justify-center bg-muted/40 p-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">激活这台设备</CardTitle>
<main className="relative flex min-h-dvh items-center justify-center p-4">
<BackgroundBlobs />
<Card className="w-full max-w-sm border-slate-100 shadow-soft transition-shadow duration-200 hover:shadow-lift">
<CardHeader className="items-center gap-3 text-center">
<div className="flex size-14 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-600 to-violet-600 shadow-btn">
<KeyRound className="size-7 text-white" />
</div>
<h1 className="text-2xl font-extrabold tracking-tight">激活这台设备</h1>
<p className="text-sm text-muted-foreground">
请让家长在家长端生成激活码,然后输入下面的框里
请让家长在家长端生成激活码,输入下面的框里
</p>
</CardHeader>
<CardContent>
@@ -32,7 +38,7 @@ export default function JoinPage() {
pattern="\d{6}"
maxLength={6}
autoComplete="one-time-code"
className="text-center text-3xl tracking-[0.5em] font-bold"
className="text-center text-3xl font-bold tracking-[0.5em] text-indigo-600"
placeholder="······"
required
/>
@@ -47,7 +53,7 @@ export default function JoinPage() {
我是家长,{" "}
<Link
href="/login"
className="font-medium text-primary underline-offset-4 hover:underline"
className="font-semibold text-indigo-600 underline-offset-4 hover:underline"
>
去登录
</Link>
+68 -18
View File
@@ -1,9 +1,15 @@
import { Flame } from "lucide-react";
import { BookOpen, Flame } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { BackgroundBlobs } from "@/components/background-blobs";
import { cn } from "@/lib/utils";
import { SUBJECT_ORDER, subjectMeta } from "@/lib/subjects";
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 { TaskCard } from "./task-card";
export default async function KidPage() {
@@ -19,40 +25,84 @@ 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;
return (
<main className="mx-auto max-w-lg space-y-5 p-4 pb-10">
<main className="relative mx-auto max-w-lg space-y-5 p-4 pb-10">
<BackgroundBlobs />
<header className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">你好,{user.displayName}</h1>
<h1 className="text-2xl font-extrabold tracking-tight">
你好,<span className="text-gradient">{user.displayName}</span>
</h1>
<p className="text-sm text-muted-foreground">{today}</p>
</div>
<div className="flex items-center gap-1 rounded-full bg-orange-100 px-4 py-2 text-orange-600">
<Flame className="size-6" fill="currentColor" />
<span className="text-xl font-bold">{streak}</span>
<span className="text-sm">天</span>
<div className="flex items-center gap-1.5 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 px-4 py-2 text-white shadow-[0_4px_14px_0_rgba(249,115,22,0.35)]">
<Flame className="size-5" fill="currentColor" />
<span className="text-xl font-extrabold">{streak}</span>
<span className="text-xs font-medium">天</span>
</div>
</header>
<Link href="/learn" className="block">
<div className="flex items-center gap-3 rounded-xl bg-gradient-to-r from-indigo-600 to-violet-600 p-4 text-white shadow-btn transition-transform duration-200 hover:-translate-y-0.5">
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-white/20">
<BookOpen className="size-6" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium opacity-90">英语语法 · 已学 {gDone} 课</p>
<p className="truncate text-base font-bold">
{gGoal ? gGoal.title : "全部完成,真棒!"}
</p>
</div>
<span className="shrink-0 text-lg">→</span>
</div>
</Link>
{tasks.length === 0 ? (
<div className="rounded-2xl bg-muted p-8 text-center">
<p className="text-2xl">🎉</p>
<p className="mt-2 text-lg font-medium">今天没有任务</p>
<div className="rounded-xl border border-slate-100 bg-white p-8 text-center shadow-soft">
<p className="text-3xl">🎉</p>
<p className="mt-2 text-lg font-bold">今天没有任务</p>
<p className="mt-1 text-sm text-muted-foreground">连击自动保持,去玩吧!</p>
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
已完成 {doneCount} / {tasks.length} 项
<p className="text-sm font-medium text-muted-foreground">
已完成{" "}
<span className="text-base font-extrabold text-indigo-600">{doneCount}</span> /{" "}
{tasks.length} 项
</p>
<div className="space-y-3">
{tasks.map((t) => (
<TaskCard key={t.id} task={t} />
))}
<div className="space-y-4">
{SUBJECT_ORDER.map((key) => {
const group = tasks.filter((t) => t.subject === key);
if (group.length === 0) return null;
const meta = subjectMeta(key);
const gDone = group.filter((t) => t.status === "done").length;
return (
<section key={key} className="space-y-2">
<header className="flex items-center gap-2 px-1">
<span className={cn("size-2.5 rounded-full", meta.dot)} />
<span className={cn("text-sm font-bold", meta.text)}>{meta.label}</span>
<span className="text-xs text-muted-foreground">
{gDone}/{group.length}
</span>
</header>
<div className="space-y-3">
{group.map((t) => (
<TaskCard key={t.id} task={t} />
))}
</div>
</section>
);
})}
</div>
{allDone && (
<div className="rounded-2xl bg-green-100 p-6 text-center">
<div className="rounded-xl bg-gradient-to-r from-indigo-600 to-violet-600 p-6 text-center text-white shadow-btn">
<p className="text-2xl">💪</p>
<p className="mt-2 text-lg font-bold text-green-700">今天全部完成,太棒了!</p>
<p className="mt-2 text-lg font-extrabold">今天全部完成,太棒了!</p>
</div>
)}
</>
+18 -8
View File
@@ -77,8 +77,8 @@ export function TaskCard({
return (
<div
className={`rounded-2xl border-2 p-4 transition-colors ${
done ? "border-green-500 bg-green-50" : "border-border bg-card"
className={`rounded-xl border bg-white p-4 shadow-soft transition-all duration-200 hover:shadow-lift ${
done ? "border-emerald-200 bg-emerald-50/60" : "border-slate-100"
}`}
>
<div className="flex items-center gap-4">
@@ -87,26 +87,36 @@ export function TaskCard({
onClick={onToggle}
disabled={busy}
aria-label={done ? "取消完成" : "标记完成"}
className={`flex size-12 shrink-0 items-center justify-center rounded-full border-2 transition-colors ${
done ? "border-green-500 bg-green-500 text-white" : "border-muted-foreground/40"
className={`flex size-12 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-200 ${
done
? "border-transparent bg-gradient-to-br from-emerald-400 to-emerald-600 text-white shadow-[0_0_16px_rgba(16,185,129,0.4)]"
: "border-slate-300 hover:border-indigo-400"
}`}
>
{done && <Check className="size-7" strokeWidth={3} />}
</button>
<div className="min-w-0 flex-1">
<p className={`text-lg font-medium ${done ? "text-green-700 line-through" : ""}`}>
<p
className={`text-lg font-semibold ${
done ? "text-emerald-700 line-through" : "text-slate-900"
}`}
>
{task.title}
</p>
<div className="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
{task.requiresPhoto && <Camera className="size-4" />}
{task.requiresPhoto && <span>需拍照凭证</span>}
{task.isMakeup && <span className="text-orange-500">补卡</span>}
{task.isMakeup && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-600">
补卡
</span>
)}
</div>
</div>
{task.requiresPhoto && task.photoUrls.length < 3 && (
<Button
type="button"
variant="outline"
variant="secondary"
size="sm"
disabled={busy}
onClick={() => fileRef.current?.click()}
@@ -132,7 +142,7 @@ export function TaskCard({
key={url}
src={url}
alt="凭证照片"
className="size-16 rounded-lg border object-cover"
className="size-16 rounded-lg border border-slate-100 object-cover shadow-xs"
/>
))}
</div>
+1 -2
View File
@@ -17,9 +17,8 @@ export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
// 系统字体栈,不加载任何网络字体(避免构建与访问时请求境外服务)
<html lang="zh-CN">
<body className="min-h-dvh antialiased">{children}</body>
<body className="min-h-dvh">{children}</body>
</html>
);
}
+72
View File
@@ -0,0 +1,72 @@
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>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use server";
import { revalidatePath } from "next/cache";
import { getSessionUser } from "@/lib/auth";
import { entryBySeq } from "@/content/grammar";
import { completeSeq, grade, isUnlocked, passedSet } from "@/lib/grammar";
export type QuizResult =
| {
score: number;
passed: boolean;
correct: number;
total: number;
detail: boolean[];
}
| { error: string };
// 提交测验:服务端按标准答案计分(防篡改),达标即记进度并联动英语打卡
export async function submitQuiz(_prev: QuizResult | null, formData: FormData): 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 passed = await passedSet(user.id);
if (!isUnlocked(seq, passed)) return { error: "请先完成前面的课程" };
const answers = entry.quiz.map((_, i) => Number(formData.get(`a${i}`)));
const g = grade(entry.quiz, answers);
await completeSeq(user.id, seq, g.score, g.passed);
revalidatePath("/learn");
revalidatePath(`/learn/${seq}`);
revalidatePath("/kid");
revalidatePath("/parent");
return g;
}
+122
View File
@@ -0,0 +1,122 @@
import Link from "next/link";
import { BookOpen, Check, Lock } 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 { getSessionUser } from "@/lib/auth";
export default async function LearnHome() {
const user = await getSessionUser();
if (!user) redirect("/join");
if (user.role !== "child") redirect("/parent");
const passed = await passedSet(user.id);
const done = passed.size;
const next = await nextSeq(user.id);
const current = next <= TOTAL ? entryBySeq(next) : null;
const pct = Math.round((done / TOTAL) * 100);
return (
<main className="relative mx-auto max-w-lg space-y-5 p-4 pb-10">
<BackgroundBlobs />
<header className="flex items-center justify-between">
<Link href="/kid" className="text-sm font-medium text-indigo-600">
← 返回打卡
</Link>
<span className="text-sm text-muted-foreground">
{done} / {TOTAL} 课
</span>
</header>
{current ? (
<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} 课
</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">
<Button variant="secondary" size="lg" className="w-full">
{done === 0 ? "开始第一课" : "继续学习"} →
</Button>
</Link>
</div>
) : (
<div className="rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 p-6 text-center text-white shadow-btn">
<p className="text-3xl">🏆</p>
<p className="mt-2 text-xl font-extrabold">语法课程全部完成!</p>
<p className="mt-1 text-sm opacity-90">太厉害了,继续保持每天复习。</p>
</div>
)}
<div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft">
<div className="mb-1 flex justify-between text-sm">
<span className="font-medium text-slate-700">总进度</span>
<span className="font-bold text-indigo-600">{pct}%</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500 transition-all"
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
)}
</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>
))}
</p>
</main>
);
}
+70
View File
@@ -0,0 +1,70 @@
"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>
);
}
+149
View File
@@ -0,0 +1,149 @@
"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
@@ -0,0 +1,69 @@
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>
);
}
+14 -6
View File
@@ -2,9 +2,11 @@
import { useActionState } from "react";
import Link from "next/link";
import { GraduationCap } from "lucide-react";
import { BackgroundBlobs } from "@/components/background-blobs";
import { login } from "./actions";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -12,10 +14,16 @@ export default function LoginPage() {
const [state, formAction, pending] = useActionState(login, null);
return (
<main className="flex min-h-dvh items-center justify-center bg-muted/40 p-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-3xl">SuperJump</CardTitle>
<main className="relative flex min-h-dvh items-center justify-center p-4">
<BackgroundBlobs />
<Card className="w-full max-w-sm border-slate-100 shadow-soft transition-shadow duration-200 hover:shadow-lift">
<CardHeader className="items-center gap-3 text-center">
<div className="flex size-14 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-600 to-violet-600 shadow-btn">
<GraduationCap className="size-8 text-white" />
</div>
<h1 className="text-3xl font-extrabold tracking-tight">
Super<span className="text-gradient">Jump</span>
</h1>
<p className="text-sm text-muted-foreground">家长登录</p>
</CardHeader>
<CardContent>
@@ -52,7 +60,7 @@ export default function LoginPage() {
孩子的设备第一次使用?{" "}
<Link
href="/join"
className="font-medium text-primary underline-offset-4 hover:underline"
className="font-semibold text-indigo-600 underline-offset-4 hover:underline"
>
去输入激活码
</Link>
+4
View File
@@ -6,6 +6,7 @@ import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/db";
import { activationCodes, sessions, taskInstances, taskTemplates, users } from "@/db/schema";
import { SUBJECT_VALUES } from "@/db/schema";
import { todayStr } from "@/lib/dates";
import { getSessionUser } from "@/lib/auth";
@@ -76,6 +77,7 @@ export async function makeupTask(instanceId: number, done: boolean) {
const templateSchema = z.object({
childId: z.coerce.number().int().positive(),
dow: z.coerce.number().int().min(0).max(6),
subject: z.enum(SUBJECT_VALUES).catch("other"),
title: z.string().trim().min(1, "任务内容不能为空").max(100, "最多 100 字"),
requiresPhoto: z.boolean(),
});
@@ -97,6 +99,7 @@ export async function addTemplate(formData: FormData) {
const parsed = templateSchema.safeParse({
childId: formData.get("childId"),
dow: formData.get("dow"),
subject: formData.get("subject"),
title: formData.get("title"),
requiresPhoto: formData.get("requiresPhoto") === "on",
});
@@ -116,6 +119,7 @@ export async function addTemplate(formData: FormData) {
await db.insert(taskTemplates).values({
childUserId: parsed.data.childId,
dow: parsed.data.dow,
subject: parsed.data.subject,
title: parsed.data.title,
requiresPhoto: parsed.data.requiresPhoto,
sortOrder: maxRow.maxSort + 1,
+28 -8
View File
@@ -1,6 +1,7 @@
"use client";
import { logout } from "@/app/login/actions";
import { BackgroundBlobs } from "@/components/background-blobs";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DevicesTab } from "./devices-tab";
@@ -13,10 +14,11 @@ export type OverviewData = {
name: string;
streak: number;
today: { done: number; total: number };
grammar: { done: number; total: number };
week: {
date: string;
isToday: boolean;
tasks: { id: number; title: string; status: "pending" | "done"; isMakeup: boolean }[];
tasks: { id: number; title: string; subject: string; status: "pending" | "done"; isMakeup: boolean }[];
}[];
}[];
};
@@ -25,7 +27,7 @@ export type TemplatesData = {
kids: {
id: number;
name: string;
items: { id: number; dow: number; title: string; requiresPhoto: boolean }[];
items: { id: number; dow: number; title: string; subject: string; requiresPhoto: boolean }[];
}[];
};
@@ -52,9 +54,12 @@ export function ParentDashboard({
const kids = overview.kids.map((k) => ({ id: k.id, name: k.name }));
return (
<main className="mx-auto max-w-2xl 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">
<h1 className="text-2xl font-bold">家长端</h1>
<h1 className="text-2xl font-extrabold tracking-tight">
家长<span className="text-gradient">中心</span>
</h1>
<form action={logout}>
<Button type="submit" variant="outline" size="sm">
退出登录
@@ -63,10 +68,25 @@ export function ParentDashboard({
</header>
<Tabs defaultValue="overview">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">今日概览</TabsTrigger>
<TabsTrigger value="templates">任务模板</TabsTrigger>
<TabsTrigger value="devices">设备管理</TabsTrigger>
<TabsList className="grid w-full grid-cols-3 bg-slate-100/80">
<TabsTrigger
value="overview"
className="data-[state=active]:bg-white data-[state=active]:text-indigo-600 data-[state=active]:shadow-soft"
>
今日概览
</TabsTrigger>
<TabsTrigger
value="templates"
className="data-[state=active]:bg-white data-[state=active]:text-indigo-600 data-[state=active]:shadow-soft"
>
任务模板
</TabsTrigger>
<TabsTrigger
value="devices"
className="data-[state=active]:bg-white data-[state=active]:text-indigo-600 data-[state=active]:shadow-soft"
>
设备管理
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<OverviewTab data={overview} />
+17
View File
@@ -5,6 +5,8 @@ import { Flame } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { subjectMeta } from "@/lib/subjects";
import { makeupTask } from "./actions";
import type { OverviewData } from "./dashboard";
@@ -35,6 +37,20 @@ export function OverviewTab({ data }: { data: OverviewData }) {
<p className="text-sm text-muted-foreground">
今日完成 {kid.today.done} / {kid.today.total} 项
</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="shrink-0">英语语法</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500"
style={{
width: `${kid.grammar.total ? (kid.grammar.done / kid.grammar.total) * 100 : 0}%`,
}}
/>
</div>
<span className="shrink-0 font-medium text-indigo-600">
{kid.grammar.done}/{kid.grammar.total}
</span>
</div>
<div className="space-y-2">
{kid.week.map((day) => (
<div key={day.date} className="rounded-lg border p-2">
@@ -49,6 +65,7 @@ export function OverviewTab({ data }: { data: OverviewData }) {
{day.tasks.map((t) => (
<span key={t.id} className="inline-flex items-center gap-1">
<Badge variant={t.status === "done" ? "default" : "secondary"}>
<span className={cn("size-1.5 rounded-full", subjectMeta(t.subject).dot)} />
{t.title}
{t.isMakeup && " · 补"}
</Badge>
+7 -1
View File
@@ -8,6 +8,8 @@ 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 { ParentDashboard, type DevicesData, type OverviewData, type TemplatesData } from "./dashboard";
const SHANGHAI = { timeZone: "Asia/Shanghai" } as const;
@@ -32,13 +34,15 @@ export default async function ParentPage() {
const overview: OverviewData = { kids: [] };
for (const kid of kids) {
await ensureDay(kid.id, today); // 家长先看时也要生成今天的快照
const [streak, rows] = await Promise.all([
const [streak, gDone, rows] = await Promise.all([
streakOf(kid.id),
passedSet(kid.id).then((s) => s.size),
db
.select({
id: taskInstances.id,
date: taskInstances.date,
title: taskInstances.title,
subject: taskInstances.subject,
status: taskInstances.status,
isMakeup: taskInstances.isMakeup,
sortOrder: taskInstances.sortOrder,
@@ -63,6 +67,7 @@ export default async function ParentPage() {
total: todayTasks.length,
done: todayTasks.filter((t) => t.status === "done").length,
},
grammar: { done: gDone, total: TOTAL },
week,
});
}
@@ -85,6 +90,7 @@ export default async function ParentPage() {
id: t.id,
dow: t.dow,
title: t.title,
subject: t.subject,
requiresPhoto: t.requiresPhoto,
})),
})),
+43 -16
View File
@@ -7,6 +7,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { SUBJECTS, type Subject, subjectMeta } from "@/lib/subjects";
import { addTemplate, deleteTemplate } from "./actions";
import type { TemplatesData } from "./dashboard";
@@ -32,6 +34,7 @@ function KidTemplateCard({
kid: TemplatesData["kids"][number];
}) {
const [dow, setDow] = useState(1);
const [subject, setSubject] = useState<Subject>("other");
const [title, setTitle] = useState("");
const [requiresPhoto, setRequiresPhoto] = useState(false);
const [error, setError] = useState("");
@@ -43,6 +46,7 @@ function KidTemplateCard({
const fd = new FormData();
fd.set("childId", String(kid.id));
fd.set("dow", String(dow));
fd.set("subject", subject);
fd.set("title", title);
if (requiresPhoto) fd.set("requiresPhoto", "on");
startTransition(async () => {
@@ -74,23 +78,32 @@ function KidTemplateCard({
{items.length === 0 && (
<span className="text-sm text-muted-foreground">休息日(自动保持连击)</span>
)}
{items.map((item) => (
<span
key={item.id}
className="inline-flex items-center gap-1 rounded-full bg-muted px-3 py-1 text-sm"
>
{item.title}
{item.requiresPhoto && " · 拍照"}
<button
type="button"
aria-label={`删除 ${item.title}`}
className="ml-1 text-muted-foreground hover:text-destructive"
onClick={() => startTransition(() => deleteTemplate(item.id))}
{items.map((item) => {
const sm = subjectMeta(item.subject);
return (
<span
key={item.id}
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm ring-1",
sm.soft,
sm.ring,
)}
>
<Trash2 className="size-3.5" />
</button>
</span>
))}
<span className={cn("size-1.5 rounded-full", sm.dot)} />
<span className="font-medium">{sm.label}</span>
<span className="text-slate-600">{item.title}</span>
{item.requiresPhoto && <span className="text-muted-foreground">· 拍照</span>}
<button
type="button"
aria-label={`删除 ${item.title}`}
className="ml-1 text-muted-foreground hover:text-destructive"
onClick={() => startTransition(() => deleteTemplate(item.id))}
>
<Trash2 className="size-3.5" />
</button>
</span>
);
})}
</div>
</div>
);
@@ -98,6 +111,20 @@ function KidTemplateCard({
</div>
<form onSubmit={onAdd} className="flex flex-wrap items-end gap-3 border-t pt-3">
<div className="space-y-1">
<Label>科目</Label>
<select
value={subject}
onChange={(e) => setSubject(e.target.value as Subject)}
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
>
{SUBJECTS.map((s) => (
<option key={s.key} value={s.key}>
{s.label}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label>星期</Label>
<select