家长中心待掌握错题查看:概览卡片可点击,进入孩子错题页并可标记已掌握

概览页「待掌握错题」卡片改 Link 进入 /parent/wrongbook?kid=xx(仅未掌握),
WrongRow 注入 markAction 由家长 action 标记,家庭归属防越权,孩子端零改动。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-19 11:23:59 +08:00
co-authored by Claude Haiku 4.5
parent 7a11a89031
commit f6d5aa8680
4 changed files with 113 additions and 3 deletions
+6 -2
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Link from "next/link";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { addDays, dowOf, todayStr } from "@/lib/dates"; import { addDays, dowOf, todayStr } from "@/lib/dates";
import { subjectMeta } from "@/lib/subjects"; import { subjectMeta } from "@/lib/subjects";
@@ -44,10 +45,13 @@ export function OverviewTab({ data }: { data: OverviewData }) {
{kid.grammar.done}/{kid.grammar.total} {kid.grammar.done}/{kid.grammar.total}
</p> </p>
</div> </div>
<div className="rounded-xl border border-slate-100 bg-white p-3 text-center shadow-soft"> <Link
href={`/parent/wrongbook?kid=${kid.id}`}
className="rounded-xl border border-slate-100 bg-white p-3 text-center shadow-soft transition-all duration-200 hover:-translate-y-0.5 hover:border-rose-200"
>
<p className="text-xs text-muted-foreground">待掌握错题</p> <p className="text-xs text-muted-foreground">待掌握错题</p>
<p className="text-lg font-extrabold text-rose-600">{kid.wrongOpen}</p> <p className="text-lg font-extrabold text-rose-600">{kid.wrongOpen}</p>
</div> </Link>
<div className="rounded-xl border border-slate-100 bg-white p-3 text-center shadow-soft"> <div className="rounded-xl border border-slate-100 bg-white p-3 text-center shadow-soft">
<p className="text-xs text-muted-foreground">最近周测</p> <p className="text-xs text-muted-foreground">最近周测</p>
<p className="text-lg font-extrabold text-violet-600"> <p className="text-lg font-extrabold text-violet-600">
+23
View File
@@ -0,0 +1,23 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import { users, wrongBook } from "@/db/schema";
import { getSessionUser } from "@/lib/auth";
import { setMastered } from "@/lib/wrongbook";
// xuchao: 家长标记某错题已掌握/未掌握(仅限本家庭孩子,防越权)
export async function markMastered(id: number, mastered: boolean): Promise<void> {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") return;
const [row] = await db.select().from(wrongBook).where(eq(wrongBook.id, id)).limit(1);
if (!row) return;
const [child] = await db.select().from(users).where(eq(users.id, row.childUserId)).limit(1);
if (!child || child.role !== "child" || child.familyId !== parent.familyId) return;
await setMastered(id, row.childUserId, mastered);
revalidatePath("/parent");
revalidatePath("/parent/wrongbook");
}
+80
View File
@@ -0,0 +1,80 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { and, eq } from "drizzle-orm";
import { BackgroundBlobs } from "@/components/background-blobs";
import { db } from "@/db";
import { users } from "@/db/schema";
import { getSessionUser } from "@/lib/auth";
import { listWrong } from "@/lib/wrongbook";
import { photoViewUrl } from "@/lib/oss";
import type { QuizQ } from "@/lib/quiz-parse";
import { WrongRow } from "@/app/wrongbook/wrong-row";
import { markMastered } from "./actions";
// xuchao: 家长查看某孩子待掌握错题(?kid=),家庭归属校验防越权
export default async function ParentWrongBookPage({
searchParams,
}: {
searchParams: Promise<{ kid?: string }>;
}) {
const parent = await getSessionUser();
if (!parent) redirect("/login");
if (parent.role !== "parent") redirect("/kid");
const kidId = Number((await searchParams).kid);
if (!Number.isInteger(kidId)) redirect("/parent");
const [kid] = await db
.select({ id: users.id, name: users.displayName })
.from(users)
.where(and(eq(users.id, kidId), eq(users.role, "child"), eq(users.familyId, parent.familyId)))
.limit(1);
if (!kid) redirect("/parent");
const rows = (await listWrong(kid.id)).filter((r) => !r.mastered);
return (
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-12">
<BackgroundBlobs />
<header className="flex items-center justify-between">
<Link href="/parent" className="text-sm font-medium text-indigo-600">
← 返回家长中心
</Link>
<h1 className="text-xl font-extrabold text-slate-900">待掌握错题</h1>
<span className="text-xs text-muted-foreground">{kid.name} · {rows.length} 题</span>
</header>
{rows.length === 0 ? (
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-soft">
<p className="text-3xl">🎉</p>
<p className="mt-2 font-bold text-slate-900">没有待掌握错题!</p>
</div>
) : (
<div className="space-y-3">
{rows.map((r) => {
let q: QuizQ;
try {
q = JSON.parse(r.question) as QuizQ;
} catch {
return null;
}
// xuchao: 解答题展示当时的作答照片(OSS 签名地址;开发环境为本地回退)
const photoUrl = q.type === "solve" && q.solveSnap?.ossKey ? photoViewUrl(q.solveSnap.ossKey) : "";
return (
<WrongRow
key={r.id}
id={r.id}
sourceTitle={r.sourceTitle}
q={q}
userAnswer={r.userAnswer}
correctAnswer={r.correctAnswer}
mastered={r.mastered}
photoUrl={photoUrl}
markAction={markMastered}
/>
);
})}
</div>
)}
</main>
);
}
+4 -1
View File
@@ -7,6 +7,7 @@ import { cn } from "@/lib/utils";
import type { QuizQ } from "@/lib/quiz-parse"; import type { QuizQ } from "@/lib/quiz-parse";
import { toggleMastered } from "./actions"; import { toggleMastered } from "./actions";
// xuchao: markAction 供家长版错题页注入专属标记动作,缺省走孩子端 toggleMastered
export function WrongRow({ export function WrongRow({
id, id,
sourceTitle, sourceTitle,
@@ -15,6 +16,7 @@ export function WrongRow({
correctAnswer, correctAnswer,
mastered, mastered,
photoUrl = "", photoUrl = "",
markAction,
}: { }: {
id: number; id: number;
sourceTitle: string | null; sourceTitle: string | null;
@@ -23,6 +25,7 @@ export function WrongRow({
correctAnswer: string | null; correctAnswer: string | null;
mastered: boolean; mastered: boolean;
photoUrl?: string; photoUrl?: string;
markAction?: (id: number, mastered: boolean) => Promise<void>;
}) { }) {
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const isSolve = q.type === "solve"; const isSolve = q.type === "solve";
@@ -81,7 +84,7 @@ export function WrongRow({
variant={mastered ? "outline" : "secondary"} variant={mastered ? "outline" : "secondary"}
size="sm" size="sm"
disabled={pending} disabled={pending}
onClick={() => startTransition(() => toggleMastered(id, !mastered))} onClick={() => startTransition(() => (markAction ?? toggleMastered)(id, !mastered))}
> >
{mastered ? ( {mastered ? (
<> <>