第二期:企业信任风 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
@@ -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