feat: 复习页面支持科目筛选、题目列表选择、上下题切换
This commit is contained in:
parent
a3b4687326
commit
f2fabed2ee
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue