家长端考试记录管理页:/parent/exam-records 编辑分数达标+删除(卷已删兼容)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-19 16:55:16 +08:00
co-authored by Claude Haiku 4.5
parent abcdd5bed8
commit 1b5e95725e
3 changed files with 188 additions and 0 deletions
+6
View File
@@ -163,6 +163,12 @@ export function ParentDashboard({
<p className="mt-1 text-xs text-muted-foreground">改分数/达标,删除误生成记录</p>
</div>
</Link>
<Link href="/parent/exam-records" className="block">
<div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft transition-transform duration-200 hover:-translate-y-0.5">
<p className="font-bold text-slate-900">📄 考试记录管理</p>
<p className="mt-1 text-xs text-muted-foreground">改考试成绩/达标,删除误生成记录</p>
</div>
</Link>
</div>
</Section>
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Check, Pencil, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { deleteExamRecord, updateExamRecord } from "../actions";
export type ExamRowItem = {
id: number;
childName: string;
paperTitle: string;
score: number;
passed: boolean;
takenAt: string;
};
// xuchao: 考试记录行内编辑(分数+达标开关)、删除;保存后刷新服务端列表
export function ExamList({ rows }: { rows: ExamRowItem[] }) {
const router = useRouter();
const [editingId, setEditingId] = useState<number | null>(null);
const [score, setScore] = useState("");
const [passed, setPassed] = useState(false);
const [msg, setMsg] = useState("");
const [pending, startTransition] = useTransition();
const startEdit = (r: ExamRowItem) => {
setEditingId(r.id);
setScore(String(r.score));
setPassed(r.passed);
setMsg("");
};
const save = () => {
if (editingId == null) return;
const s = Number(score);
startTransition(async () => {
const r = await updateExamRecord(editingId, s, passed);
if (r) setMsg(r);
else {
setEditingId(null);
router.refresh();
}
});
};
const remove = (id: number, label: string) => {
if (!confirm(`确定删除「${label}」这条考试记录?孩子可重新考试。`)) return;
startTransition(() => {
deleteExamRecord(id).then(() => router.refresh());
});
};
return (
<div className="space-y-2.5">
{rows.length === 0 && (
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-soft">
<p className="text-sm text-muted-foreground">暂无考试记录</p>
</div>
)}
{rows.map((r) => (
<div key={r.id} className="rounded-xl border border-slate-100 bg-white p-3 shadow-soft">
{editingId === r.id ? (
<div className="space-y-2.5">
<p className="text-sm font-medium text-slate-800">
{r.childName} · {r.paperTitle}
</p>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 text-sm text-slate-600">
分数
<Input
className="h-8 w-20"
type="number"
min={0}
max={1000}
value={score}
onChange={(e) => setScore(e.target.value)}
/>
</div>
<label className="flex items-center gap-1.5 text-sm text-slate-600">
<Checkbox checked={passed} onCheckedChange={(v) => setPassed(v === true)} />
达标
</label>
{msg && <span className="text-xs font-medium text-destructive">{msg}</span>}
</div>
<div className="flex gap-2">
<Button size="sm" disabled={pending} onClick={save}>
<Check className="size-4" /> 保存
</Button>
<Button size="sm" variant="outline" disabled={pending} onClick={() => setEditingId(null)}>
<X className="size-4" /> 取消
</Button>
</div>
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-800">{r.childName}</span>
<span className="max-w-[45%] truncate rounded-full bg-slate-100 px-2 py-0.5 text-xs text-muted-foreground">
{r.paperTitle}
</span>
<span className={cn("text-sm font-bold", r.passed ? "text-emerald-600" : "text-rose-600")}>
{r.score} 分 {r.passed ? "✅" : "❌"}
</span>
<span className="ml-auto text-xs text-muted-foreground">{r.takenAt}</span>
<button type="button" aria-label="编辑" className="text-muted-foreground hover:text-indigo-600" onClick={() => startEdit(r)}>
<Pencil className="size-4" />
</button>
<button
type="button"
aria-label="删除"
className="text-muted-foreground hover:text-destructive"
onClick={() => remove(r.id, `${r.childName} ${r.paperTitle}`)}
>
<Trash2 className="size-4" />
</button>
</div>
)}
</div>
))}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { desc, eq } from "drizzle-orm";
import { BackgroundBlobs } from "@/components/background-blobs";
import { db } from "@/db";
import { examPapers, examRecords, users } from "@/db/schema";
import { getSessionUser } from "@/lib/auth";
import { ExamList } from "./exam-list";
// xuchao: 考试成绩记录管理——编辑分数/达标、删除记录(卷已删时标题显示占位)
export default async function ExamRecordsPage() {
const parent = await getSessionUser();
if (!parent) redirect("/login");
if (parent.role !== "parent") redirect("/kid");
const rows = await db
.select({
id: examRecords.id,
childName: users.displayName,
paperTitle: examPapers.title,
score: examRecords.score,
passed: examRecords.passed,
takenAt: examRecords.takenAt,
})
.from(examRecords)
.innerJoin(users, eq(users.id, examRecords.childUserId))
.leftJoin(examPapers, eq(examPapers.paperId, examRecords.paperId))
.where(eq(users.familyId, parent.familyId))
.orderBy(desc(examRecords.takenAt))
.limit(100);
return (
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-10">
<BackgroundBlobs />
<header className="flex items-center justify-between">
<Link href="/parent" className="text-sm font-medium text-indigo-600">
← 返回家长中心
</Link>
<h1 className="text-xl font-extrabold text-slate-900">考试记录管理</h1>
<span className="text-xs text-muted-foreground">最近 {rows.length} 条</span>
</header>
<p className="rounded-lg border-l-4 border-indigo-400 bg-indigo-50 px-3 py-2 text-sm text-indigo-800">
修改分数/达标状态或删除记录不会影响已发的积分与当日打卡;删除后孩子可重新考试。
</p>
<ExamList
rows={rows.map((r) => ({
id: r.id,
childName: r.childName,
paperTitle: r.paperTitle ?? "已删试卷",
score: r.score ?? 0,
passed: r.passed,
takenAt: r.takenAt.toLocaleDateString("zh-CN"),
}))}
/>
</main>
);
}