feat: 复习页面支持科目筛选、题目列表选择、上下题切换

This commit is contained in:
chen guanjun 2026-06-22 10:24:35 +08:00
parent a3b4687326
commit f2fabed2ee
1 changed files with 187 additions and 158 deletions

View File

@ -1,13 +1,13 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import api from "@/lib/api";
import { useAuthStore } from "@/lib/store";
import { useAuth } from "@/hooks/useAuth";
import type { ReviewCardResponse, ReviewTodayResponse } from "@/types";
import { REVIEW_QUALITY_LABELS } from "@/types";
import { REVIEW_QUALITY_LABELS, SUBJECTS, SUBJECT_COLORS } from "@/types";
export default function ReviewPage() {
const router = useRouter();
@ -20,12 +20,29 @@ export default function ReviewPage() {
const [submitting, setSubmitting] = useState(false);
const [completed, setCompleted] = useState(false);
const [analyzing, setAnalyzing] = useState(false);
const [subjectFilter, setSubjectFilter] = useState<string>("");
const [view, setView] = useState<"list" | "review">("list");
useEffect(() => {
if (!ready) return;
loadToday();
}, [ready]);
const filteredCards = useMemo(() => {
if (!data) return [];
if (!subjectFilter) return data.cards;
return data.cards.filter((c) => c.mistake.subject === subjectFilter);
}, [data, subjectFilter]);
const subjectCounts = useMemo(() => {
if (!data) return {};
const counts: Record<string, number> = {};
data.cards.forEach((c) => {
counts[c.mistake.subject] = (counts[c.mistake.subject] || 0) + 1;
});
return counts;
}, [data]);
const loadToday = async () => {
try {
const res = await api.get<ReviewTodayResponse>("/review/today");
@ -40,9 +57,15 @@ export default function ReviewPage() {
}
};
const startReview = (index: number) => {
setCurrentIndex(index);
setIsFlipped(false);
setView("review");
};
const handleRate = async (quality: number) => {
if (!data || submitting) return;
const card = data.cards[currentIndex];
const card = filteredCards[currentIndex];
setSubmitting(true);
try {
@ -51,11 +74,24 @@ export default function ReviewPage() {
duration_seconds: null,
});
if (currentIndex + 1 >= data.cards.length) {
setCompleted(true);
toast.success("今天的复习全部完成!");
// Remove from data
setData((prev) => {
if (!prev) return prev;
return {
...prev,
cards: prev.cards.filter((c) => c.review_card_id !== card.review_card_id),
due_count: prev.due_count - 1,
};
});
toast.success(quality >= 3 ? "答对了!" : "没关系,下次会更好!");
if (currentIndex + 1 >= filteredCards.length) {
setView("list");
if (filteredCards.length <= 1) {
setCompleted(true);
}
} else {
setCurrentIndex(currentIndex + 1);
setIsFlipped(false);
}
} catch (err: any) {
@ -67,15 +103,16 @@ export default function ReviewPage() {
const handleAnalyze = async () => {
if (!data || analyzing) return;
const card = data.cards[currentIndex];
const card = filteredCards[currentIndex];
setAnalyzing(true);
try {
const res = await api.post(`/mistakes/${card.mistake.id}/analyze`);
const updatedMistake = { ...card.mistake, ai_analysis: res.data.ai_analysis, ai_analysis_at: new Date().toISOString() };
setData((prev) => {
if (!prev) return prev;
const newCards = [...prev.cards];
newCards[currentIndex] = { ...newCards[currentIndex], mistake: updatedMistake };
const newCards = prev.cards.map((c) =>
c.review_card_id === card.review_card_id ? { ...c, mistake: updatedMistake } : c
);
return { ...prev, cards: newCards };
});
toast.success("AI解析完成");
@ -121,163 +158,155 @@ export default function ReviewPage() {
);
}
const card = data.cards[currentIndex];
const mistake = card.mistake;
const progress = ((currentIndex) / data.cards.length) * 100;
// === Review Mode ===
if (view === "review") {
const card = filteredCards[currentIndex];
if (!card) {
setView("list");
return null;
}
const mistake = card.mistake;
const progress = ((currentIndex) / filteredCards.length) * 100;
return (
<div className="max-w-lg mx-auto px-4 pt-6">
{/* Header */}
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<button onClick={() => setView("list")} className="text-sm text-blue-500 font-medium">
</button>
<span className="text-sm text-slate-500">
{currentIndex + 1} / {filteredCards.length}
</span>
</div>
<div className="bg-slate-100 rounded-full h-2">
<div className="bg-blue-500 rounded-full h-2 transition-all" style={{ width: `${progress}%` }} />
</div>
</div>
{/* Flip Card */}
<div className="perspective-1000 mb-4">
<div className={`flip-card-inner relative ${isFlipped ? "flipped" : ""}`} style={{ minHeight: "280px" }}>
{/* Front */}
<div className={`absolute inset-0 backface-hidden bg-white rounded-2xl shadow-lg border border-slate-100 p-6 flex flex-col ${isFlipped ? "invisible" : ""}`}>
<div className="flex items-center gap-2 mb-4">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${mistake.subject === "数学" ? "bg-blue-100 text-blue-700" : mistake.subject === "语文" ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"}`}>
{mistake.subject}
</span>
{mistake.question_type && <span className="px-3 py-1 rounded-full text-sm bg-slate-100 text-slate-600">{mistake.question_type}</span>}
{card.is_new && <span className="px-3 py-1 rounded-full text-sm bg-yellow-100 text-yellow-700"></span>}
</div>
<div className="flex-1 flex items-center justify-center">
<p className="text-xl text-slate-800 leading-relaxed text-center">{mistake.question_text}</p>
</div>
{mistake.image_url && (
<img src={mistake.image_url} alt="" className="w-full max-h-32 object-contain rounded-xl mt-3 bg-slate-50" />
)}
<button onClick={() => setIsFlipped(true)} className="mt-4 w-full py-3.5 bg-blue-500 text-white rounded-xl font-medium text-lg active:scale-95 transition-transform">
</button>
</div>
{/* Back */}
<div className={`absolute inset-0 backface-hidden rotate-y-180 bg-white rounded-2xl shadow-lg border border-slate-100 p-6 flex flex-col ${!isFlipped ? "invisible" : ""}`}>
<h3 className="font-bold text-slate-700 mb-3 text-lg"></h3>
<div className="flex-1 space-y-3 overflow-y-auto">
<div>
<span className="text-sm text-slate-400"></span>
<p className="text-slate-700">{mistake.question_text}</p>
</div>
{mistake.student_answer && (
<div><span className="text-sm text-red-400"></span><p className="text-red-600">{mistake.student_answer}</p></div>
)}
{mistake.correct_answer && (
<div><span className="text-sm text-green-500"></span><p className="text-green-700 font-medium">{mistake.correct_answer}</p></div>
)}
{mistake.error_analysis && (
<div><span className="text-sm text-orange-500"></span><p className="text-orange-700">{mistake.error_analysis}</p></div>
)}
{mistake.ai_analysis ? (
<div className="mt-2 p-3 bg-purple-50 rounded-xl space-y-2">
<span className="text-xs text-purple-500 font-bold">AI深度解析</span>
{mistake.ai_analysis.solution_steps && <p className="text-sm text-slate-700 whitespace-pre-wrap">{mistake.ai_analysis.solution_steps}</p>}
{mistake.ai_analysis.knowledge_explanation && <div><span className="text-xs text-purple-400"></span><p className="text-sm text-slate-700">{mistake.ai_analysis.knowledge_explanation}</p></div>}
</div>
) : (
<button onClick={handleAnalyze} disabled={analyzing} className="w-full py-2 bg-purple-50 text-purple-600 rounded-xl text-sm font-medium disabled:opacity-50">
{analyzing ? "AI解析中..." : "查看AI深度解析"}
</button>
)}
</div>
{/* Rating */}
<div className="mt-3 pt-3 border-t border-slate-100">
<p className="text-sm text-slate-500 mb-2 text-center"></p>
<div className="grid grid-cols-5 gap-2">
{REVIEW_QUALITY_LABELS.map((q) => (
<button key={q.value} onClick={() => handleRate(q.value)} disabled={submitting} className={`flex flex-col items-center py-2 rounded-xl text-white font-medium active:scale-90 transition-all disabled:opacity-50 ${q.color}`}>
<span className="text-lg">{q.emoji}</span>
<span className="text-xs mt-0.5">{q.label}</span>
</button>
))}
</div>
</div>
</div>
</div>
</div>
{/* Quick Nav */}
<div className="flex gap-2">
<button onClick={() => { if (currentIndex > 0) { setCurrentIndex(currentIndex - 1); setIsFlipped(false); } }} disabled={currentIndex === 0} className="flex-1 py-2.5 bg-slate-100 text-slate-600 rounded-xl font-medium disabled:opacity-30"></button>
<button onClick={() => { if (currentIndex < filteredCards.length - 1) { setCurrentIndex(currentIndex + 1); setIsFlipped(false); } }} disabled={currentIndex >= filteredCards.length - 1} className="flex-1 py-2.5 bg-slate-100 text-slate-600 rounded-xl font-medium disabled:opacity-30"></button>
</div>
</div>
);
}
// === List Mode ===
return (
<div className="max-w-lg mx-auto px-4 pt-6">
{/* Progress */}
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<h1 className="text-xl font-bold text-slate-800"></h1>
<span className="text-sm text-slate-500">
{currentIndex + 1} / {data.cards.length}
</span>
</div>
<div className="bg-slate-100 rounded-full h-2">
<div
className="bg-blue-500 rounded-full h-2 transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<h1 className="text-2xl font-bold text-slate-800 mb-4"></h1>
{/* Subject Filter */}
<div className="flex gap-2 mb-4 overflow-x-auto pb-1">
<button onClick={() => setSubjectFilter("")} className={`px-4 py-2 rounded-xl font-medium text-sm whitespace-nowrap transition-all ${!subjectFilter ? "bg-slate-800 text-white" : "bg-slate-100 text-slate-500"}`}>
({data.cards.length})
</button>
{SUBJECTS.filter((s) => subjectCounts[s]).map((s) => (
<button key={s} onClick={() => setSubjectFilter(s === subjectFilter ? "" : s)} className={`px-4 py-2 rounded-xl font-medium text-sm whitespace-nowrap transition-all ${subjectFilter === s ? (s === "数学" ? "bg-blue-500 text-white" : s === "语文" ? "bg-red-500 text-white" : "bg-green-500 text-white") : "bg-slate-100 text-slate-500"}`}>
{s} ({subjectCounts[s]})
</button>
))}
</div>
{/* Flip Card */}
<div className="perspective-1000 mb-6">
<div
className={`flip-card-inner relative ${isFlipped ? "flipped" : ""}`}
style={{ minHeight: "300px" }}
>
{/* Front - Question */}
<div
className={`absolute inset-0 backface-hidden bg-white rounded-2xl shadow-lg border border-slate-100 p-6 flex flex-col ${
isFlipped ? "invisible" : ""
}`}
>
<div className="flex items-center gap-2 mb-4">
<span
className={`px-3 py-1 rounded-full text-sm font-medium ${
mistake.subject === "数学"
? "bg-blue-100 text-blue-700"
: mistake.subject === "语文"
? "bg-red-100 text-red-700"
: "bg-green-100 text-green-700"
}`}
>
{mistake.subject}
</span>
{mistake.question_type && (
<span className="px-3 py-1 rounded-full text-sm bg-slate-100 text-slate-600">
{mistake.question_type}
</span>
)}
{card.is_new && (
<span className="px-3 py-1 rounded-full text-sm bg-yellow-100 text-yellow-700">
</span>
)}
</div>
<div className="flex-1 flex items-center justify-center">
<p className="text-xl text-slate-800 leading-relaxed text-center">
{mistake.question_text}
</p>
</div>
{mistake.image_url && (
<img
src={mistake.image_url}
alt="原题图片"
className="w-full max-h-32 object-contain rounded-xl mt-3 bg-slate-50"
/>
)}
<button
onClick={() => setIsFlipped(true)}
className="mt-4 w-full py-3.5 bg-blue-500 text-white rounded-xl font-medium text-lg active:scale-95 transition-transform"
>
</button>
</div>
{/* Back - Answer */}
<div
className={`absolute inset-0 backface-hidden rotate-y-180 bg-white rounded-2xl shadow-lg border border-slate-100 p-6 flex flex-col ${
!isFlipped ? "invisible" : ""
}`}
>
<h3 className="font-bold text-slate-700 mb-4 text-lg"></h3>
<div className="flex-1 space-y-3">
<div>
<span className="text-sm text-slate-400"></span>
<p className="text-slate-700">{mistake.question_text}</p>
</div>
{mistake.student_answer && (
<div>
<span className="text-sm text-red-400"></span>
<p className="text-red-600">{mistake.student_answer}</p>
{/* Question List */}
<div className="space-y-2 mb-6">
{filteredCards.map((card, idx) => {
const m = card.mistake;
const colors = SUBJECT_COLORS[m.subject] || { bg: "bg-slate-50", text: "text-slate-700", border: "border-slate-300" };
return (
<div key={card.review_card_id} onClick={() => startReview(idx)} className={`bg-white rounded-xl shadow-sm border ${colors.border} p-4 cursor-pointer active:scale-[0.98] transition-transform`}>
<div className="flex items-center justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${colors.bg} ${colors.text}`}>{m.subject}</span>
{m.question_type && <span className="text-xs text-slate-400">{m.question_type}</span>}
{card.is_new && <span className="text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full"></span>}
</div>
<p className="text-slate-700 font-medium text-sm line-clamp-2">{m.question_text}</p>
</div>
)}
{mistake.correct_answer && (
<div>
<span className="text-sm text-green-500"></span>
<p className="text-green-700 font-medium">{mistake.correct_answer}</p>
</div>
)}
{mistake.error_analysis && (
<div>
<span className="text-sm text-orange-500"></span>
<p className="text-orange-700">{mistake.error_analysis}</p>
</div>
)}
{/* AI Analysis */}
{mistake.ai_analysis ? (
<div className="mt-3 p-3 bg-purple-50 rounded-xl space-y-2">
<span className="text-xs text-purple-500 font-bold">AI深度解析</span>
{mistake.ai_analysis.solution_steps && (
<p className="text-sm text-slate-700 whitespace-pre-wrap">{mistake.ai_analysis.solution_steps}</p>
)}
{mistake.ai_analysis.knowledge_explanation && (
<div>
<span className="text-xs text-purple-400"></span>
<p className="text-sm text-slate-700">{mistake.ai_analysis.knowledge_explanation}</p>
</div>
)}
</div>
) : (
<button
onClick={handleAnalyze}
disabled={analyzing}
className="mt-2 w-full py-2 bg-purple-50 text-purple-600 rounded-xl text-sm font-medium disabled:opacity-50"
>
{analyzing ? "AI解析中..." : "查看AI深度解析"}
</button>
)}
</div>
{/* Rating Buttons */}
<div className="mt-4">
<p className="text-sm text-slate-500 mb-2 text-center"></p>
<div className="grid grid-cols-5 gap-2">
{REVIEW_QUALITY_LABELS.map((q) => (
<button
key={q.value}
onClick={() => handleRate(q.value)}
disabled={submitting}
className={`flex flex-col items-center py-2.5 rounded-xl text-white font-medium active:scale-90 transition-all disabled:opacity-50 ${q.color}`}
>
<span className="text-xl">{q.emoji}</span>
<span className="text-xs mt-0.5">{q.label}</span>
</button>
))}
<div className="text-2xl text-slate-300"></div>
</div>
</div>
</div>
</div>
);
})}
</div>
{/* Start All Button */}
<button onClick={() => startReview(0)} className="w-full py-3.5 bg-blue-500 text-white rounded-xl font-medium text-lg hover:bg-blue-600 active:scale-95 transition-all">
({filteredCards.length} )
</button>
</div>
);
}