第二期:企业信任风 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:
co-authored by
Claude Opus 4.8
parent
b08f6649be
commit
6ed4d5a61e
+50
-20
@@ -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
@@ -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
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
})),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user