错题本乱序和顺序刷题

This commit is contained in:
2025-12-18 01:59:30 +08:00
parent 3d47e568f6
commit 3e4157f021
11 changed files with 157 additions and 308 deletions

View File

@@ -3,6 +3,7 @@ import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-d
import { Toaster } from 'react-hot-toast'
import { AuthProvider } from './context/AuthContext'
import { ProtectedRoute } from './components/ProtectedRoute'
import Layout from './components/Layout'
// Auth Pages
import Login from './pages/Login'
@@ -56,69 +57,15 @@ function App() {
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
{/* Protected Routes */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/exams"
element={
<ProtectedRoute>
<ExamList />
</ProtectedRoute>
}
/>
<Route
path="/exams/:examId"
element={
<ProtectedRoute>
<ExamDetail />
</ProtectedRoute>
}
/>
<Route
path="/quiz/:examId"
element={
<ProtectedRoute>
<QuizPlayer />
</ProtectedRoute>
}
/>
<Route
path="/mistakes"
element={
<ProtectedRoute>
<MistakeList />
</ProtectedRoute>
}
/>
<Route
path="/mistake-quiz"
element={
<ProtectedRoute>
<MistakePlayer />
</ProtectedRoute>
}
/>
<Route
path="/questions"
element={
<ProtectedRoute>
<QuestionBank />
</ProtectedRoute>
}
/>
{/* Protected Routes with Layout */}
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/exams" element={<ExamList />} />
<Route path="/exams/:examId" element={<ExamDetail />} />
<Route path="/quiz/:examId" element={<QuizPlayer />} />
<Route path="/mistakes" element={<MistakeList />} />
<Route path="/mistake-quiz" element={<MistakePlayer />} />
<Route path="/questions" element={<QuestionBank />} />
{/* Admin Only Routes */}
<Route
@@ -129,7 +76,6 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/admin/settings"
element={
@@ -138,6 +84,7 @@ function App() {
</ProtectedRoute>
}
/>
</Route>
{/* Default Route */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />

View File

@@ -2,7 +2,7 @@
* Main Layout Component with Navigation
*/
import React, { useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import {
BookOpen,
@@ -12,10 +12,11 @@ import {
Settings,
LogOut,
Menu,
X
X,
Shield
} from 'lucide-react'
export const Layout = ({ children }) => {
export const Layout = () => {
const { user, logout, isAdmin } = useAuth()
const navigate = useNavigate()
const location = useLocation()
@@ -33,6 +34,7 @@ export const Layout = ({ children }) => {
]
if (isAdmin) {
navigation.push({ name: '管理面板', href: '/admin', icon: Shield })
navigation.push({ name: '系统设置', href: '/admin/settings', icon: Settings })
}
@@ -63,8 +65,7 @@ export const Layout = ({ children }) => {
key={item.name}
to={item.href}
onClick={() => setMobileMenuOpen(false)}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
isActive(item.href)
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive(item.href)
? 'bg-primary-50 text-primary-600'
: 'text-gray-700 hover:bg-gray-100'
}`}
@@ -105,8 +106,7 @@ export const Layout = ({ children }) => {
<Link
key={item.name}
to={item.href}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
isActive(item.href)
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive(item.href)
? 'bg-primary-50 text-primary-600'
: 'text-gray-700 hover:bg-gray-100'
}`}
@@ -132,7 +132,7 @@ export const Layout = ({ children }) => {
{/* Main Content */}
<div className="flex-1 lg:pl-64">
{children}
<Outlet />
</div>
</div>
</div>

View File

@@ -106,39 +106,18 @@ export const AdminPanel = () => {
}
return (
<div className="min-h-screen bg-gray-100">
{/* Header */}
<div className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={() => navigate(-1)} className="p-2 hover:bg-gray-100 rounded-lg">
<ArrowLeft className="h-6 w-6 text-gray-600" />
</button>
<Shield className="h-8 w-8 text-primary-600" />
<div>
<h1 className="text-2xl font-bold text-gray-900">管理员面板</h1>
<p className="text-gray-600">{user?.username}</p>
</div>
</div>
<button
onClick={() => navigate('/admin/settings')}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
>
<Settings className="h-5 w-5" />
系统设置
</button>
</div>
</div>
<div className="p-4 md:p-8">
<div className="mb-6">
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">管理员面板</h1>
<p className="text-gray-600 mt-1">系统统计与用户管理</p>
</div>
{/* Tabs */}
<div className="max-w-7xl mx-auto px-4 py-6">
<div className="mb-6">
<div className="flex gap-4 border-b border-gray-200 mb-6">
<button
onClick={() => setActiveTab('stats')}
className={`pb-3 px-4 font-medium border-b-2 transition-colors ${
activeTab === 'stats'
className={`pb-3 px-4 font-medium border-b-2 transition-colors ${activeTab === 'stats'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}
@@ -150,8 +129,7 @@ export const AdminPanel = () => {
</button>
<button
onClick={() => setActiveTab('users')}
className={`pb-3 px-4 font-medium border-b-2 transition-colors ${
activeTab === 'users'
className={`pb-3 px-4 font-medium border-b-2 transition-colors ${activeTab === 'users'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}
@@ -265,13 +243,14 @@ export const AdminPanel = () => {
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
>
<Plus className="h-5 w-5" />
创建用户
<span className="hidden md:inline">创建用户</span>
<span className="md:hidden">新建</span>
</button>
</div>
</div>
{/* Users Table */}
<div className="bg-white rounded-xl shadow overflow-hidden">
<div className="bg-white rounded-xl shadow overflow-hidden overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>

View File

@@ -117,31 +117,14 @@ export const AdminSettings = () => {
}
return (
<div className="min-h-screen bg-gray-100">
{/* Header */}
<div className="bg-white shadow">
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => navigate(-1)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="返回"
>
<ArrowLeft className="h-6 w-6 text-gray-600" />
</button>
<Settings className="h-8 w-8 text-primary-600" />
<div>
<h1 className="text-2xl font-bold text-gray-900">系统设置</h1>
<p className="text-gray-600">管理员{user?.username}</p>
</div>
</div>
</div>
</div>
<div className="p-4 md:p-8">
<div className="mb-6">
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">系统设置</h1>
<p className="text-gray-600 mt-1">配置系统参数与 AI 接口</p>
</div>
{/* Content */}
<div className="max-w-5xl mx-auto px-4 py-8 space-y-6">
<div className="space-y-6">
{/* Basic Settings */}
<div className="bg-white rounded-xl shadow-md p-6 space-y-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">基础设置</h2>

View File

@@ -5,7 +5,7 @@ import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { examAPI, mistakeAPI } from '../api/client'
import { useAuth } from '../context/AuthContext'
import Layout from '../components/Layout'
import {
FolderOpen, XCircle, TrendingUp, BookOpen, ArrowRight, Settings, Shield
} from 'lucide-react'
@@ -58,7 +58,7 @@ export const Dashboard = () => {
}
return (
<Layout>
<>
<div className="p-4 md:p-8">
{/* Welcome */}
<div className="mb-8">
@@ -179,35 +179,8 @@ export const Dashboard = () => {
)}
</div>
{/* Admin Quick Access */}
{isAdmin && (
<div className="mt-6 bg-gradient-to-r from-primary-500 to-primary-600 rounded-xl shadow-sm p-6 text-white">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold mb-1">管理员功能</h3>
<p className="text-sm text-primary-100">用户管理系统统计配置设置</p>
</div>
<div className="flex gap-2">
<button
onClick={() => navigate('/admin')}
className="bg-white text-primary-600 px-4 py-2 rounded-lg font-medium hover:bg-primary-50 transition-colors flex items-center gap-2"
>
<Shield className="h-5 w-5" />
管理面板
</button>
<button
onClick={() => navigate('/admin/settings')}
className="bg-white/90 text-primary-600 px-4 py-2 rounded-lg font-medium hover:bg-white transition-colors flex items-center gap-2"
>
<Settings className="h-5 w-5" />
系统设置
</button>
</div>
</div>
</div>
)}
</div>
</Layout>
</>
)
}

View File

@@ -4,7 +4,6 @@
import React, { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { examAPI, questionAPI } from '../api/client'
import Layout from '../components/Layout'
import ParsingProgress from '../components/ParsingProgress'
import {
ArrowLeft, Upload, Play, Loader, FileText, AlertCircle, RefreshCw, ArrowRight
@@ -155,22 +154,18 @@ export const ExamDetail = () => {
if (loading) {
return (
<Layout>
<div className="flex items-center justify-center h-screen">
<Loader className="h-8 w-8 animate-spin text-primary-600" />
</div>
</Layout>
)
}
if (!exam) {
return (
<Layout>
<div className="flex flex-col items-center justify-center h-screen">
<AlertCircle className="h-16 w-16 text-gray-300 mb-4" />
<p className="text-gray-600">题库不存在</p>
</div>
</Layout>
)
}
@@ -180,7 +175,7 @@ export const ExamDetail = () => {
const quizProgress = calculateProgress(exam.current_index, exam.total_questions)
return (
<Layout>
<>
<div className="p-4 md:p-8">
{/* Back Button */}
<button
@@ -374,7 +369,7 @@ export const ExamDetail = () => {
</div>
</div>
)}
</Layout>
</>
)
}

View File

@@ -4,7 +4,6 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { examAPI } from '../api/client'
import Layout from '../components/Layout'
import {
Plus, FolderOpen, Loader, AlertCircle, Trash2, Upload
} from 'lucide-react'
@@ -131,16 +130,14 @@ export const ExamList = () => {
if (loading) {
return (
<Layout>
<div className="flex items-center justify-center h-screen">
<Loader className="h-8 w-8 animate-spin text-primary-600" />
</div>
</Layout>
)
}
return (
<Layout>
<>
<div className="p-4 md:p-8">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
@@ -150,10 +147,11 @@ export const ExamList = () => {
</div>
<button
onClick={() => setShowCreateModal(true)}
className="mt-4 md:mt-0 bg-primary-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors flex items-center gap-2 justify-center"
className="mt-4 md:mt-0 bg-primary-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors flex items-center gap-2 justify-center text-sm md:text-base"
>
<Plus className="h-5 w-5" />
创建题库
<Plus className="h-4 w-4 md:h-5 md:w-5" />
<span className="hidden md:inline">创建题库</span>
<span className="md:hidden">新建</span>
</button>
</div>
@@ -337,7 +335,7 @@ export const ExamList = () => {
</div>
</div>
)}
</Layout>
</>
)
}

View File

@@ -4,9 +4,8 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { mistakeAPI } from '../api/client'
import Layout from '../components/Layout'
import Pagination from '../components/Pagination'
import { XCircle, Loader, Trash2, BookOpen, Play } from 'lucide-react'
import { XCircle, Loader, Trash2, BookOpen, Play, ChevronRight } from 'lucide-react'
import toast from 'react-hot-toast'
import { getQuestionTypeText, formatRelativeTime } from '../utils/helpers'
@@ -62,16 +61,14 @@ export const MistakeList = () => {
if (loading) {
return (
<Layout>
<div className="flex items-center justify-center h-screen">
<Loader className="h-8 w-8 animate-spin text-primary-600" />
</div>
</Layout>
)
}
return (
<Layout>
<>
<div className="p-4 md:p-8">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
@@ -83,10 +80,11 @@ export const MistakeList = () => {
{mistakes.length > 0 && (
<button
onClick={() => setShowModeModal(true)}
className="bg-primary-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors flex items-center justify-center gap-2"
className="bg-primary-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors flex items-center justify-center gap-2 text-sm md:text-base"
>
<Play className="h-5 w-5" />
开始刷错题
<Play className="h-4 w-4 md:h-5 md:w-5" />
<span className="hidden md:inline">开始刷错题</span>
<span className="md:hidden">刷题</span>
</button>
)}
</div>
@@ -237,7 +235,7 @@ export const MistakeList = () => {
</div>
</div>
)}
</Layout>
</>
)
}

View File

@@ -4,7 +4,6 @@
import React, { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { mistakeAPI, questionAPI } from '../api/client'
import Layout from '../components/Layout'
import {
ArrowLeft, ArrowRight, Check, X, Loader, Trash2, AlertCircle
} from 'lucide-react'
@@ -13,16 +12,17 @@ import { getQuestionTypeText } from '../utils/helpers'
export const MistakePlayer = () => {
const navigate = useNavigate()
const location = useLocation()
const searchParams = new URLSearchParams(location.search)
const mode = searchParams.get('mode') || 'sequential'
console.log('MistakePlayer mounted, mode:', mode)
const [loading, setLoading] = useState(true)
const [mistake, setMistake] = useState(null)
const [currentIndex, setCurrentIndex] = useState(0)
const [total, setTotal] = useState(0)
const [randomIds, setRandomIds] = useState([]) // For random mode
const [randomMistakes, setRandomMistakes] = useState([]) // Store full mistake objects
const [submitting, setSubmitting] = useState(false)
const [userAnswer, setUserAnswer] = useState('')
@@ -31,7 +31,7 @@ export const MistakePlayer = () => {
useEffect(() => {
loadMistake()
}, [currentIndex])
}, [currentIndex, mode])
const loadMistake = async () => {
try {
@@ -41,49 +41,26 @@ export const MistakePlayer = () => {
if (mode === 'random') {
// Random Mode Logic
if (randomIds.length === 0) {
// First load: fetch all mistakes to get IDs
// Note: fetching up to 1000 for now. For larger datasets, we need a specific API to get just IDs.
if (randomMistakes.length === 0) {
// First load: fetch all mistakes
const response = await mistakeAPI.getList(0, 1000)
const allMistakes = response.data.mistakes
setTotal(response.data.total)
if (allMistakes.length > 0) {
// Shuffle IDs
const ids = allMistakes.map(m => m.id)
for (let i = ids.length - 1; i > 0; i--) {
// Shuffle mistakes
const shuffled = [...allMistakes]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[ids[i], ids[j]] = [ids[j], ids[i]];
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
setRandomIds(ids)
// Get first mistake from shuffled list
// We need to find the full mistake object from our initial fetch
// (Since we fetched all, we have it)
const firstId = ids[0]
currentMistake = allMistakes.find(m => m.id === firstId)
setRandomMistakes(shuffled)
currentMistake = shuffled[0]
}
} else {
// Subsequent loads: use stored random IDs
// We need to fetch the specific mistake details if we don't have them cached
// But wait, mistakeAPI.getList is pagination based.
// We can't easily "get mistake by ID" using getList without knowing its index.
// However, we have `mistakeAPI.remove` but not `getById`.
// Actually, the `mistake` object contains the `question`.
// If we only have the ID, we might need an API to get mistake by ID.
// But since we fetched ALL mistakes initially (up to 1000), we can just store the whole objects in randomIds?
// No, that's too much memory if many.
// Let's assume for now we fetched all and stored them in a "cache" or just store the list of objects if < 1000.
// For simplicity and performance on small datasets:
// If randomIds contains objects, use them.
// REVISION: Let's just store the full shuffled list of mistakes in state if < 1000.
// If > 1000, this approach needs backend support for "random fetch".
// Given the user requirements, let's assume < 1000 for now.
if (currentIndex < randomIds.length) {
currentMistake = randomIds[currentIndex]
// Subsequent loads: use stored mistakes
if (currentIndex < randomMistakes.length) {
currentMistake = randomMistakes[currentIndex]
}
}
} else {
@@ -101,15 +78,10 @@ export const MistakePlayer = () => {
currentMistake.question.options = ['A. 正确', 'B. 错误']
}
setMistake(currentMistake)
console.log('Mistake loaded:', currentMistake)
setResult(null)
setUserAnswer('')
setMultipleAnswers([])
// If we just initialized random mode, update state
if (mode === 'random' && randomIds.length === 0) {
// This part is tricky because state updates are async.
// We handled the initialization above.
}
} else {
setMistake(null)
}
@@ -118,6 +90,7 @@ export const MistakePlayer = () => {
toast.error('加载错题失败')
} finally {
setLoading(false)
console.log('Loading finished')
}
}
@@ -176,8 +149,8 @@ export const MistakePlayer = () => {
// If we remove the last item, we need to go back one step or show empty
if (mode === 'random') {
// Remove from random list
const newRandomList = randomIds.filter(m => m.id !== mistake.id)
setRandomIds(newRandomList)
const newRandomList = randomMistakes.filter(m => m.id !== mistake.id)
setRandomMistakes(newRandomList)
setTotal(newRandomList.length)
if (currentIndex >= newRandomList.length && newRandomList.length > 0) {
@@ -219,18 +192,15 @@ export const MistakePlayer = () => {
if (loading && !mistake) {
return (
<Layout>
<div className="flex items-center justify-center h-screen">
<div className="flex items-center justify-center min-h-[50vh]">
<Loader className="h-8 w-8 animate-spin text-primary-600" />
</div>
</Layout>
)
}
if (!mistake) {
return (
<Layout>
<div className="flex flex-col items-center justify-center h-screen">
<div className="flex flex-col items-center justify-center min-h-[50vh]">
<AlertCircle className="h-16 w-16 text-gray-300 mb-4" />
<p className="text-gray-600">错题本为空</p>
<button
@@ -240,14 +210,28 @@ export const MistakePlayer = () => {
返回错题列表
</button>
</div>
</Layout>
)
}
const question = mistake.question
if (!question) {
return (
<Layout>
<div className="flex flex-col items-center justify-center min-h-[50vh]">
<AlertCircle className="h-16 w-16 text-red-300 mb-4" />
<p className="text-gray-600">题目数据缺失</p>
<button
onClick={() => navigate('/mistakes')}
className="mt-4 text-primary-600 hover:text-primary-700 font-medium"
>
返回错题列表
</button>
</div>
)
}
return (
<>
<div className="max-w-4xl mx-auto p-4 md:p-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
@@ -421,7 +405,7 @@ export const MistakePlayer = () => {
</div>
)}
</div>
</Layout>
</>
)
}

View File

@@ -3,7 +3,6 @@
*/
import React, { useState, useEffect } from 'react'
import { questionAPI } from '../api/client'
import Layout from '../components/Layout'
import Pagination from '../components/Pagination'
import { FileText, Loader, Search } from 'lucide-react'
import toast from 'react-hot-toast'
@@ -44,16 +43,14 @@ export const QuestionBank = () => {
if (loading && questions.length === 0) {
return (
<Layout>
<div className="flex items-center justify-center h-screen">
<Loader className="h-8 w-8 animate-spin text-primary-600" />
</div>
</Layout>
)
}
return (
<Layout>
<>
<div className="p-4 md:p-8">
{/* Header */}
<div className="mb-6">
@@ -152,7 +149,7 @@ export const QuestionBank = () => {
}}
/>
</div>
</Layout>
</>
)
}

View File

@@ -4,7 +4,6 @@
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { examAPI, questionAPI, mistakeAPI } from '../api/client'
import Layout from '../components/Layout'
import {
ArrowLeft, ArrowRight, Check, X, Loader, BookmarkPlus, BookmarkX, AlertCircle
} from 'lucide-react'
@@ -159,27 +158,23 @@ export const QuizPlayer = () => {
if (loading) {
return (
<Layout>
<div className="flex items-center justify-center h-screen">
<Loader className="h-8 w-8 animate-spin text-primary-600" />
</div>
</Layout>
)
}
if (!question) {
return (
<Layout>
<div className="flex flex-col items-center justify-center h-screen">
<AlertCircle className="h-16 w-16 text-gray-300 mb-4" />
<p className="text-gray-600">没有更多题目了</p>
</div>
</Layout>
)
}
return (
<Layout>
<>
<div className="max-w-4xl mx-auto p-4 md:p-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
@@ -367,7 +362,7 @@ export const QuizPlayer = () => {
</div>
)}
</div>
</Layout>
</>
)
}