新增解答题拍照判分:百炼 Qwen-VL 按评分点逐点判分,无 API Key 时演示判分回退;混合卷按真实分值加权计分;判分照片与评分点进错题本;补齐历史迁移缺口(0005 模板生效方式/0006 五张缺失表+grammar_progress point_id)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-17 16:24:23 +08:00
co-authored by Claude Opus 4.8
parent 4033abc21d
commit f80a931ef0
21 changed files with 1377 additions and 45 deletions
+49
View File
@@ -0,0 +1,49 @@
// xuchao: 本地开发照片回退——OSS 未配置时,解答题照片直传/查看走本地磁盘
// 生产环境配置了 OSS 或 NODE_ENV=production 时,本路由一律 404
import { NextRequest, NextResponse } from "next/server";
import fs from "node:fs";
import path from "node:path";
import { ossConfigured } from "@/lib/oss";
const BASE = path.join(process.cwd(), ".dev-uploads");
function disabled() {
return ossConfigured() || process.env.NODE_ENV === "production";
}
// xuchao: 键名白名单校验 + 防路径穿越
function safePath(key: string): string | null {
if (!/^exam-solve\/\d+\/[\w-]+\/q\d+-[0-9a-f]{16}\.jpg$/.test(key)) return null;
const p = path.normalize(path.join(BASE, key));
return p.startsWith(BASE + path.sep) ? p : null;
}
// xuchao: 直传入口:multipart file + key
export async function POST(req: NextRequest) {
if (disabled()) return NextResponse.json({ error: "not available" }, { status: 404 });
const form = await req.formData();
const file = form.get("file");
const key = form.get("key");
if (!(file instanceof File) || typeof key !== "string") {
return NextResponse.json({ error: "缺少文件或键名" }, { status: 400 });
}
const target = safePath(key);
if (!target) return NextResponse.json({ error: "键名不合法" }, { status: 400 });
fs.mkdirSync(path.dirname(target), { recursive: true });
const buf = Buffer.from(await file.arrayBuffer());
if (buf.byteLength > 8 * 1024 * 1024) {
return NextResponse.json({ error: "照片过大" }, { status: 400 });
}
fs.writeFileSync(target, buf);
return NextResponse.json({ ok: true, key });
}
// xuchao: 查看入口:?key=xxx
export async function GET(req: NextRequest) {
if (disabled()) return NextResponse.json({ error: "not available" }, { status: 404 });
const key = req.nextUrl.searchParams.get("key") ?? "";
const target = safePath(key);
if (!target || !fs.existsSync(target)) return NextResponse.json({ error: "不存在" }, { status: 404 });
const buf = fs.readFileSync(target);
return new NextResponse(buf, { headers: { "Content-Type": "image/jpeg" } });
}
+156 -12
View File
@@ -1,18 +1,97 @@
"use server";
// xuchao: 考试判分——按卷面题判分,错题进错题本,达标联动数学打卡
// xuchao: 考试判分——客观题即时判分;解答题拍照后 AI 按评分点判分,成绩以服务端日志为准
import { revalidatePath } from "next/cache";
import { getSessionUser } from "@/lib/auth";
import { gradeAnswers, type AnswerValue } from "@/lib/grammar";
import { getPaper, paperQuestions, recordExam } from "@/lib/exam";
import { gradeOne, gradeAnswers, type AnswerValue } from "@/lib/grammar";
import { getPaper, gradeCountToday, latestSolveLogs, paperQuestions, recordExam } from "@/lib/exam";
import { recordWrong } from "@/lib/wrongbook";
import { gradeSolvePhoto, type GradeVerdict } from "@/lib/ai-grade";
import { ossConfigured, photoViewUrl, solveKey, uploadUrl } from "@/lib/oss";
import { db } from "@/db";
import { aiGradeLog } from "@/db/schema";
// xuchao: 每人每天最多判分次数(防滥用)
const GRADE_DAILY_LIMIT = 30;
export type SolveDetail = {
qIndex: number;
ossKey: string;
status: string;
earned: number;
points: number;
hits: GradeVerdict["hits"];
comment: string;
};
export type ExamResult =
| { score: number; passed: boolean; correct: number; total: number; detail: boolean[] }
| { score: number; passed: boolean; correct: number; total: number; detail: boolean[]; solves?: SolveDetail[] }
| { error: string };
// 提交试卷:服务端判分、记录成绩、错题入库;可重考,取最近一次成绩
export async function takeExam(paperId: string, answers: AnswerValue[]): Promise<ExamResult> {
// xuchao: 签发解答题照片直传地址(或本地开发回退)
export async function getSolveUploadUrl(
paperId: string,
questionIndex: number,
): Promise<{ mode: "oss"; url: string; key: string } | { mode: "dev"; key: string } | { error: string }> {
const user = await getSessionUser();
if (!user || user.role !== "child") return { error: "会话无效,请重新激活设备" };
const key = solveKey(user.id, paperId, questionIndex);
if (!ossConfigured()) return { mode: "dev", key };
return { mode: "oss", url: uploadUrl(key, "image/jpeg"), key };
}
// xuchao: 单题判分——调 AI、写审计日志、返回逐评分点结果;限频 30 次/天
export async function gradeSolve(
paperId: string,
questionIndex: number,
ossKey: string,
): Promise<{ verdict: GradeVerdict; photoUrl: string } | { error: string }> {
const user = await getSessionUser();
if (!user || user.role !== "child") return { error: "会话无效,请重新激活设备" };
const paper = await getPaper(paperId);
if (!paper) return { error: "试卷不存在" };
const questions = paperQuestions(paper);
const q = questions[questionIndex];
if (!q || q.type !== "solve") return { error: "题目不存在或不是解答题" };
// xuchao: 键名必须属于本人本卷,防止拿别人的 key 判分
const prefix = `exam-solve/${user.id}/${paperId}/`;
if (!ossKey.startsWith(prefix) || !ossKey.endsWith(".jpg")) return { error: "照片键名不合法" };
const used = await gradeCountToday(user.id);
if (used >= GRADE_DAILY_LIMIT) return { error: `今日 AI 判分次数已用完(${GRADE_DAILY_LIMIT} 次),明天再来吧` };
const points = q.points ?? 0;
// xuchao: 未配 OSS 时照片在本地磁盘,AI 模型读不到,强制演示判分
const verdict = await gradeSolvePhoto({
stem: q.stem,
points,
rubric: q.rubric ?? [],
reference: q.reference ?? "",
photoUrl: photoViewUrl(ossKey),
figureUrl: q.figure ? `/exam/${paper.paperId}/${q.figure}` : undefined,
});
await db.insert(aiGradeLog).values({
childUserId: user.id,
paperId,
questionIndex,
ossKey,
status: verdict.status,
earned: verdict.earned,
points,
verdict: JSON.stringify({ hits: verdict.hits, comment: verdict.comment }),
mock: verdict.mock,
model: verdict.model,
ms: verdict.ms,
});
return { verdict, photoUrl: photoViewUrl(ossKey) };
}
// xuchao: 提交试卷——纯客观卷走原逻辑;含解答题的卷按分值加权,解答题得分取服务端判分日志
export async function takeExam(paperId: string, answers: (AnswerValue | null)[]): Promise<ExamResult> {
const user = await getSessionUser();
if (!user || user.role !== "child") return { error: "会话无效,请重新激活设备" };
@@ -22,16 +101,81 @@ export async function takeExam(paperId: string, answers: AnswerValue[]): Promise
const questions = paperQuestions(paper);
if (questions.length === 0) return { error: "试卷暂无题目" };
const g = gradeAnswers(questions, answers);
questions.forEach((q, i) => {
if (!g.detail[i]) void recordWrong(user.id, "exam", paperId, paper.title, q, answers[i]);
});
await recordExam(user.id, paperId, paper.title, g.score, g.correct, g.total, g.passed, answers);
const hasSolve = questions.some((q) => q.type === "solve");
if (!hasSolve) {
// 原有客观卷判分路径,保持行为不变
const g = gradeAnswers(questions, answers.map((a) => a as AnswerValue));
questions.forEach((q, i) => {
if (!g.detail[i]) void recordWrong(user.id, "exam", paperId, paper.title, q, answers[i]);
});
await recordExam(user.id, paperId, paper.title, g.score, g.correct, g.total, g.passed, answers);
revalidateAll(paperId);
return g;
}
// xuchao: 混合卷加权判分——解答题只信服务端最近一次判定,不信任客户端上报
const logs = await latestSolveLogs(user.id, paperId);
let earnedSum = 0;
let pointSum = 0;
let correct = 0;
const detail: boolean[] = [];
const solves: SolveDetail[] = [];
const storedAnswers: unknown[] = [];
questions.forEach((q, i) => {
const pts = q.points ?? 1;
pointSum += pts;
if (q.type === "solve") {
const log = logs.get(i);
const okLog = log && log.status === "ok" ? log : null;
const earned = okLog ? Math.min(okLog.earned, pts) : 0;
earnedSum += earned;
const full = earned >= pts;
if (full) correct++;
detail.push(full);
const hits = okLog ? (JSON.parse(okLog.verdict ?? "{}") as { hits?: GradeVerdict["hits"] }).hits ?? [] : [];
solves.push({
qIndex: i,
ossKey: log?.ossKey ?? "",
status: log?.status ?? "missing",
earned,
points: pts,
hits,
comment: okLog ? (JSON.parse(okLog.verdict ?? "{}") as { comment?: string }).comment ?? "" : "未完成判分",
});
storedAnswers.push({ type: "solve", qIndex: i, ossKey: log?.ossKey ?? "", earned, points: pts });
if (!full) {
void recordWrong(user.id, "exam", paperId, paper.title, q, null, {
ossKey: log?.ossKey ?? "",
earned,
hits,
});
}
} else {
const ok = gradeOne(q, answers[i]);
if (ok) {
correct++;
earnedSum += pts;
} else {
void recordWrong(user.id, "exam", paperId, paper.title, q, answers[i]);
}
detail.push(ok);
storedAnswers.push(answers[i] ?? null);
}
});
const score = pointSum ? Math.round((earnedSum / pointSum) * 100) : 0;
const passed = score >= 80;
await recordExam(user.id, paperId, paper.title, score, correct, questions.length, passed, storedAnswers);
revalidateAll(paperId);
return { score, passed, correct, total: questions.length, detail, solves };
}
function revalidateAll(paperId: string) {
revalidatePath("/exam");
revalidatePath(`/exam/${paperId}`);
revalidatePath("/kid");
revalidatePath("/parent");
revalidatePath("/wrongbook");
return g;
}
+3 -2
View File
@@ -8,15 +8,16 @@ import { cn } from "@/lib/utils";
import { EXAM_TYPE_LABEL, listPapers, recordsByPaper } from "@/lib/exam";
import { getSessionUser } from "@/lib/auth";
// xuchao: 月份分组展示顺序与标签(初一上:9月~次年1月)
// xuchao: 月份分组展示顺序与标签(入学分班考 + 初一上:9月~次年1月)
const MONTH_LABEL: Record<number, string> = {
0: "入学 · 分班考",
9: "9 月 · 有理数",
10: "10 月 · 整式的加减",
11: "11 月 · 一元一次方程",
12: "12 月 · 几何图形初步",
1: "1 月 · 期末全册",
};
const MONTH_ORDER = [9, 10, 11, 12, 1];
const MONTH_ORDER = [0, 9, 10, 11, 12, 1];
export default async function ExamHome() {
const user = await getSessionUser();
+269 -12
View File
@@ -1,17 +1,28 @@
"use client";
import { useTransition, useState } from "react";
import { useTransition, useRef, useState } from "react";
import Link from "next/link";
import { Check, X } from "lucide-react";
import { Camera, Check, Loader2, RotateCcw, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { QuizQ } from "@/lib/quiz-parse";
import type { AnswerValue } from "@/lib/grammar";
import type { GradeVerdict } from "@/lib/ai-grade";
import { getSolveUploadUrl, gradeSolve } from "@/app/exam/actions";
import { submitQuiz, type QuizResult } from "./actions";
type AV = AnswerValue | null;
// 结课检验:选择/判断/填空/连词成句/连线,提交后服务端判分,达标点亮并联动打卡
// xuchao: 解答题拍照状态机
type SolveState =
| { phase: "idle" }
| { phase: "preview"; blob: Blob; dataUrl: string }
| { phase: "grading"; dataUrl: string }
| { phase: "graded"; dataUrl: string; photoUrl: string; verdict: GradeVerdict };
type CellValue = AV | SolveState;
// 结课检验:选择/判断/填空/连词成句/连线/解答题(拍照),提交后服务端判分,达标点亮并联动打卡
// 文案可配置:数学课/考试卷复用此组件时传入各自的标题、打卡文案与返回地址
export function QuizBlock({
pointId,
@@ -32,18 +43,21 @@ export function QuizBlock({
backHref?: string;
backLabel?: string;
}) {
const [answers, setAnswers] = useState<AV[]>(questions.map(() => null));
const [answers, setAnswers] = useState<CellValue[]>(questions.map(() => null));
const [result, setResult] = useState<Extract<QuizResult, { score: number }> | null>(null);
const [error, setError] = useState("");
const [pending, startTransition] = useTransition();
const set = (i: number, v: AV) => {
const set = (i: number, v: CellValue) => {
const next = [...answers];
next[i] = v;
setAnswers(next);
};
const isAnswered = (q: QuizQ, a: AV): boolean => {
const isAnswered = (q: QuizQ, a: CellValue): boolean => {
if (q.type === "solve") {
return !!a && typeof a === "object" && "phase" in a && a.phase === "graded" && a.verdict.status === "ok";
}
if (q.type === "fill") return typeof a === "string" && a.trim() !== "";
if (q.type === "order") return Array.isArray(a) && a.length === (q.words ?? []).length;
if (q.type === "match") return Array.isArray(a) && a.length === (q.pairs ?? []).length && a.every((x) => x !== null && x !== "");
@@ -54,9 +68,11 @@ export function QuizBlock({
const submit = () => {
setError("");
startTransition(async () => {
// xuchao: 解答题得分以服务端判分日志为准,客户端只传 null 占位
const payload = answers.map((a, i) => (questions[i].type === "solve" ? null : (a as AnswerValue)));
const r = submitter
? await submitter(answers.map((a) => a as AnswerValue))
: await submitQuiz(pointId, answers.map((a) => a as AnswerValue));
? await submitter(payload as AnswerValue[])
: await submitQuiz(pointId, payload as AnswerValue[]);
if ("error" in r) setError(r.error);
else setResult(r);
});
@@ -74,6 +90,7 @@ export function QuizBlock({
<div key={qi} className="rounded-xl border border-slate-100 bg-slate-50/60 p-4">
<p className="font-medium text-slate-900">
{qi + 1}. {q.stem}
{q.type === "solve" && <span className="ml-2 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-semibold text-amber-700">{q.points} 分 · 拍照作答</span>}
</p>
{q.type === "fill" && (
@@ -117,11 +134,21 @@ export function QuizBlock({
</div>
)}
{q.type === "order" && <OrderInput q={q} a={a} disabled={!!result} onChange={(v) => set(qi, v)} />}
{q.type === "order" && <OrderInput q={q} a={a as AV} disabled={!!result} onChange={(v) => set(qi, v)} />}
{q.type === "match" && <MatchInput q={q} a={a} disabled={!!result} onChange={(v) => set(qi, v)} />}
{q.type === "match" && <MatchInput q={q} a={a as AV} disabled={!!result} onChange={(v) => set(qi, v)} />}
{result && (
{q.type === "solve" && (
<SolveInput
paperId={pointId}
qIndex={qi}
value={a as SolveState | null}
disabled={!!result}
onChange={(v) => set(qi, v)}
/>
)}
{result && q.type !== "solve" && (
<p className="mt-2 text-sm text-muted-foreground">
{q.type === "fill" && (
<span className="mr-2 font-medium text-emerald-700">参考:{(q.answers ?? []).join(" / ")}</span>
@@ -137,7 +164,14 @@ export function QuizBlock({
解说:{q.explain}
</p>
)}
{result && ok === false && <p className="mt-1 text-xs text-rose-600">这题答错了,看看上面的参考与解说。</p>}
{result && q.type === "solve" && (
<div className="mt-2 space-y-1 text-sm">
<p className="font-medium text-emerald-700">参考解答:</p>
<p className="whitespace-pre-line rounded-lg bg-white px-3 py-2 text-slate-700 ring-1 ring-slate-200">{q.reference}</p>
<p className="text-muted-foreground">解说:{q.explain}</p>
</div>
)}
{result && ok === false && <p className="mt-1 text-xs text-rose-600">这题没拿满分,看看上面的参考与解说。</p>}
</div>
);
})}
@@ -180,6 +214,229 @@ export function QuizBlock({
);
}
// xuchao: 解答题拍照输入——纸上写→拍照→压缩→直传→AI 逐评分点判分,可重拍
function SolveInput({
paperId,
qIndex,
value,
disabled,
onChange,
}: {
paperId: string;
qIndex: number;
value: SolveState | null;
disabled: boolean;
onChange: (v: SolveState) => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState("");
const [err, setErr] = useState("");
const st = value ?? { phase: "idle" as const };
// xuchao: 压缩到 ≤2MB(先缩到 2000px 内,再降质量)
const compress = (file: File) =>
new Promise<{ blob: Blob; dataUrl: string }>((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
const scale = Math.min(1, 2000 / Math.max(img.width, img.height));
const w = Math.max(1, Math.round(img.width * scale));
const h = Math.max(1, Math.round(img.height * scale));
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) return reject(new Error("浏览器不支持图片处理"));
ctx.drawImage(img, 0, 0, w, h);
let quality = 0.85;
const attempt = () =>
canvas.toBlob(
(b) => {
if (!b) return reject(new Error("图片压缩失败"));
if (b.size > 2 * 1024 * 1024 && quality > 0.4) {
quality -= 0.15;
return attempt();
}
const r = new FileReader();
r.onload = () => resolve({ blob: b, dataUrl: String(r.result) });
r.onerror = () => reject(new Error("读取图片失败"));
r.readAsDataURL(b);
},
"image/jpeg",
quality,
);
attempt();
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("图片加载失败,请换一张"));
};
img.src = url;
});
const onPick = async (file: File | undefined) => {
if (!file || disabled) return;
setErr("");
try {
const { blob, dataUrl } = await compress(file);
onChange({ phase: "preview", blob, dataUrl });
} catch (e) {
setErr(e instanceof Error ? e.message : "处理照片失败");
}
};
const doGrade = async (blob: Blob, dataUrl: string) => {
setErr("");
setBusy("上传中…");
onChange({ phase: "grading", dataUrl });
try {
const up = await getSolveUploadUrl(paperId, qIndex);
if ("error" in up) throw new Error(up.error);
if (up.mode === "oss") {
const resp = await fetch(up.url, { method: "PUT", headers: { "Content-Type": "image/jpeg" }, body: blob });
if (!resp.ok) throw new Error("照片上传失败,请重试");
} else {
const fd = new FormData();
fd.append("file", blob, "solve.jpg");
fd.append("key", up.key);
const resp = await fetch("/api/dev-photo", { method: "POST", body: fd });
if (!resp.ok) throw new Error("照片上传失败,请重试");
}
setBusy("AI 判分中…");
let r = await gradeSolve(paperId, qIndex, up.key);
// xuchao: 调用失败自动重试一次
if ("error" in r && /失败|超时/.test(r.error)) r = await gradeSolve(paperId, qIndex, up.key);
if ("error" in r) throw new Error(r.error);
onChange({ phase: "graded", dataUrl, photoUrl: r.photoUrl, verdict: r.verdict });
} catch (e) {
setErr(e instanceof Error ? e.message : "判分失败,请重试");
onChange({ phase: "preview", blob, dataUrl });
} finally {
setBusy("");
}
};
if (st.phase === "idle") {
return (
<div className="mt-2">
<input
ref={fileRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
void onPick(e.target.files?.[0]);
e.target.value = "";
}}
/>
<button
type="button"
disabled={disabled}
onClick={() => fileRef.current?.click()}
className="flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-indigo-300 bg-indigo-50/50 px-4 py-6 text-sm font-semibold text-indigo-700 hover:bg-indigo-50"
>
<Camera className="size-5" /> 在纸上写好,拍照上传
</button>
<p className="mt-1 text-xs text-muted-foreground">写清每一步过程,按评分点给分;请横拍、光线充足、字迹清楚。</p>
</div>
);
}
const dataUrl = st.phase === "preview" || st.phase === "grading" || st.phase === "graded" ? st.dataUrl : "";
return (
<div className="mt-2 space-y-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={dataUrl} alt="作答照片" className="max-h-64 w-full rounded-lg border border-slate-200 object-contain" />
{st.phase === "preview" && !disabled && (
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={() => void doGrade(st.blob, st.dataUrl)}>
<Check className="size-4" /> 就这张,开始判分
</Button>
<Button size="sm" variant="outline" onClick={() => fileRef.current?.click()}>
<RotateCcw className="size-4" /> 重拍
</Button>
<input
ref={fileRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
void onPick(e.target.files?.[0]);
e.target.value = "";
}}
/>
</div>
)}
{st.phase === "grading" && (
<p className="flex items-center gap-2 text-sm font-medium text-indigo-600">
<Loader2 className="size-4 animate-spin" /> {busy || "AI 判分中…"}
</p>
)}
{st.phase === "graded" && (
<>
<SolveVerdict verdict={st.verdict} />
{st.verdict.status !== "ok" && !disabled && (
<Button size="sm" variant="outline" onClick={() => onChange({ phase: "idle" })}>
<RotateCcw className="size-4" /> 重新拍照
</Button>
)}
</>
)}
{err && <p className="text-sm font-medium text-destructive">{err}</p>}
</div>
);
}
// xuchao: 判分结果展示——逐评分点命中情况 + 得分 + AI 点评
function SolveVerdict({ verdict }: { verdict: GradeVerdict }) {
if (verdict.status !== "ok") {
const tip: Record<string, string> = {
blank: "照片里没有作答内容,请写完再拍。",
blurry: "字迹太模糊啦,重新拍一张清楚点的。",
printed: "只能批改手写解答,请把过程写在纸上再拍。",
unrelated: "照片内容和这道题无关,请拍这道题的解答。",
error: "AI 判分出了点问题,请重试。",
};
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
{tip[verdict.status] ?? "请重新拍照"} {verdict.comment && <span className="text-amber-700">({verdict.comment})</span>}
</div>
);
}
const total = verdict.hits.reduce((s, h) => s + h.score, 0);
return (
<div className="space-y-1.5 rounded-lg border border-indigo-100 bg-white px-3 py-2">
<p className="text-sm font-bold text-slate-900">
AI 判分:{verdict.earned} / {total} 分
</p>
<ul className="space-y-1">
{verdict.hits.map((h, i) => (
<li key={i} className="flex items-start gap-1.5 text-xs">
{h.hit ? (
<Check className="mt-0.5 size-3.5 shrink-0 text-emerald-600" />
) : (
<X className="mt-0.5 size-3.5 shrink-0 text-rose-500" />
)}
<span className={h.hit ? "text-slate-600" : "text-rose-600"}>
({h.score}分){h.point}
{h.reason && <span className="text-muted-foreground"> —— {h.reason}</span>}
</span>
</li>
))}
</ul>
{verdict.comment && <p className="text-xs text-indigo-600">💬 {verdict.comment}</p>}
{verdict.mock && <p className="text-[10px] text-muted-foreground">演示判分(未配置 AI Key)</p>}
</div>
);
}
// 连词成句:点词入槽,再点槽里的词可移回
function OrderInput({
q,
+4
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { BackgroundBlobs } from "@/components/background-blobs";
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 "./wrong-row";
@@ -39,6 +40,8 @@ export default async function WrongBookPage() {
} catch {
return null;
}
// xuchao: 解答题展示当时的作答照片(OSS 签名地址;开发环境为本地回退)
const photoUrl = q.type === "solve" && q.solveSnap?.ossKey ? photoViewUrl(q.solveSnap.ossKey) : "";
return (
<WrongRow
key={r.id}
@@ -48,6 +51,7 @@ export default async function WrongBookPage() {
userAnswer={r.userAnswer}
correctAnswer={r.correctAnswer}
mastered={r.mastered}
photoUrl={photoUrl}
/>
);
})}
+38 -3
View File
@@ -1,7 +1,7 @@
"use client";
import { useTransition } from "react";
import { CheckCircle2, RotateCcw } from "lucide-react";
import { Check, CheckCircle2, RotateCcw, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { QuizQ } from "@/lib/quiz-parse";
@@ -14,6 +14,7 @@ export function WrongRow({
userAnswer,
correctAnswer,
mastered,
photoUrl = "",
}: {
id: number;
sourceTitle: string | null;
@@ -21,8 +22,10 @@ export function WrongRow({
userAnswer: string | null;
correctAnswer: string | null;
mastered: boolean;
photoUrl?: string;
}) {
const [pending, startTransition] = useTransition();
const isSolve = q.type === "solve";
return (
<div
className={cn(
@@ -31,14 +34,46 @@ export function WrongRow({
)}
>
<div className="flex items-start justify-between gap-2">
<p className="font-medium text-slate-900">{q.stem}</p>
<p className="font-medium text-slate-900">
{q.stem}
{isSolve && <span className="ml-2 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-semibold text-amber-700">解答题</span>}
</p>
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-xs text-muted-foreground">
{sourceTitle ?? "检测"}
</span>
</div>
{isSolve && photoUrl && (
// xuchao: 当时的作答照片(签名地址 5 分钟有效,页面刷新即重签)
<div className="mt-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photoUrl} alt="作答照片" className="max-h-56 rounded-lg border border-slate-200 object-contain" />
</div>
)}
{isSolve && (q.solveSnap?.hits?.length ?? 0) > 0 && (
<ul className="mt-2 space-y-1">
{q.solveSnap!.hits.map((h, i) => (
<li key={i} className="flex items-start gap-1.5 text-xs">
{h.hit ? (
<Check className="mt-0.5 size-3.5 shrink-0 text-emerald-600" />
) : (
<X className="mt-0.5 size-3.5 shrink-0 text-rose-500" />
)}
<span className={h.hit ? "text-slate-600" : "text-rose-600"}>
({h.score}分){h.point}
</span>
</li>
))}
</ul>
)}
<div className="mt-2 space-y-1 text-sm">
<p className="text-rose-600">你的答案:{userAnswer ?? "未作答"}</p>
<p className="text-emerald-700">正确答案:{correctAnswer ?? ""}</p>
<p className={cn("text-emerald-700", isSolve && "whitespace-pre-line")}>
{isSolve ? "参考解答:" : "正确答案:"}
{correctAnswer ?? ""}
</p>
</div>
<p className="mt-2 rounded-lg bg-indigo-50 px-3 py-2 text-sm text-indigo-800">💡 {q.explain}</p>
<div className="mt-3">