mirror of
https://github.com/handsomezhuzhu/QQuiz.git
synced 2026-02-20 20:10:14 +00:00
🎉 Initial commit: QQuiz - 智能刷题与题库管理平台
## 功能特性 ✅ **核心功能** - 多文件上传与智能去重(基于 content_hash) - 异步文档解析(支持 TXT/PDF/DOCX/XLSX) - AI 智能题目提取与评分(OpenAI/Anthropic/Qwen) - 断点续做与进度管理 - 自动错题本收集 ✅ **技术栈** - Backend: FastAPI + SQLAlchemy 2.0 + PostgreSQL - Frontend: React 18 + Vite + Tailwind CSS - Deployment: Docker Compose ✅ **项目结构** - 53 个文件 - 完整的前后端分离架构 - Docker/源码双模部署支持 🚀 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
9
frontend/.dockerignore
Normal file
9
frontend/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
build
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
.env
|
||||
.env.local
|
||||
4
frontend/.env.example
Normal file
4
frontend/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# Frontend Environment Variables
|
||||
|
||||
# API URL
|
||||
VITE_API_URL=http://localhost:8000
|
||||
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start development server
|
||||
CMD ["npm", "start"]
|
||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="QQuiz - 智能刷题与题库管理平台" />
|
||||
<title>QQuiz - 智能刷题平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
44
frontend/package.json
Normal file
44
frontend/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "qquiz-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "QQuiz Frontend - React Application",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"axios": "^1.6.5",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"lucide-react": "^0.309.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.0.11"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
124
frontend/src/App.jsx
Normal file
124
frontend/src/App.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React from 'react'
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||
|
||||
// Auth Pages
|
||||
import Login from './pages/Login'
|
||||
import Register from './pages/Register'
|
||||
|
||||
// Main Pages
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import ExamList from './pages/ExamList'
|
||||
import ExamDetail from './pages/ExamDetail'
|
||||
import QuizPlayer from './pages/QuizPlayer'
|
||||
import MistakeList from './pages/MistakeList'
|
||||
|
||||
// Admin Pages
|
||||
import AdminSettings from './pages/AdminSettings'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<AuthProvider>
|
||||
<div className="App">
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 3000,
|
||||
style: {
|
||||
background: '#363636',
|
||||
color: '#fff',
|
||||
},
|
||||
success: {
|
||||
duration: 3000,
|
||||
iconTheme: {
|
||||
primary: '#10b981',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
duration: 4000,
|
||||
iconTheme: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Admin Only Routes */}
|
||||
<Route
|
||||
path="/admin/settings"
|
||||
element={
|
||||
<ProtectedRoute adminOnly>
|
||||
<AdminSettings />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Default Route */}
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
{/* 404 */}
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
166
frontend/src/api/client.js
Normal file
166
frontend/src/api/client.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* API Client for QQuiz Backend
|
||||
*/
|
||||
import axios from 'axios'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
// Create axios instance
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// Request interceptor - Add auth token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor - Handle errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const message = error.response?.data?.detail || 'An error occurred'
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
// Unauthorized - Clear token and redirect to login
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('user')
|
||||
window.location.href = '/login'
|
||||
toast.error('Session expired. Please login again.')
|
||||
} else if (error.response?.status === 403) {
|
||||
toast.error('Permission denied')
|
||||
} else if (error.response?.status === 429) {
|
||||
toast.error(message)
|
||||
} else if (error.response?.status >= 500) {
|
||||
toast.error('Server error. Please try again later.')
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// ============ Auth APIs ============
|
||||
export const authAPI = {
|
||||
register: (username, password) =>
|
||||
api.post('/api/auth/register', { username, password }),
|
||||
|
||||
login: (username, password) =>
|
||||
api.post('/api/auth/login', { username, password }),
|
||||
|
||||
getCurrentUser: () =>
|
||||
api.get('/api/auth/me'),
|
||||
|
||||
changePassword: (oldPassword, newPassword) =>
|
||||
api.post('/api/auth/change-password', null, {
|
||||
params: { old_password: oldPassword, new_password: newPassword }
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Exam APIs ============
|
||||
export const examAPI = {
|
||||
// Create exam with first document
|
||||
create: (title, file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('title', title)
|
||||
formData.append('file', file)
|
||||
return api.post('/api/exams/create', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
|
||||
// Append document to existing exam
|
||||
appendDocument: (examId, file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return api.post(`/api/exams/${examId}/append`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
|
||||
// Get user's exam list
|
||||
getList: (skip = 0, limit = 20) =>
|
||||
api.get('/api/exams/', { params: { skip, limit } }),
|
||||
|
||||
// Get exam detail
|
||||
getDetail: (examId) =>
|
||||
api.get(`/api/exams/${examId}`),
|
||||
|
||||
// Delete exam
|
||||
delete: (examId) =>
|
||||
api.delete(`/api/exams/${examId}`),
|
||||
|
||||
// Update quiz progress
|
||||
updateProgress: (examId, currentIndex) =>
|
||||
api.put(`/api/exams/${examId}/progress`, { current_index: currentIndex })
|
||||
}
|
||||
|
||||
// ============ Question APIs ============
|
||||
export const questionAPI = {
|
||||
// Get all questions for an exam
|
||||
getExamQuestions: (examId, skip = 0, limit = 50) =>
|
||||
api.get(`/api/questions/exam/${examId}/questions`, { params: { skip, limit } }),
|
||||
|
||||
// Get current question (based on exam's current_index)
|
||||
getCurrentQuestion: (examId) =>
|
||||
api.get(`/api/questions/exam/${examId}/current`),
|
||||
|
||||
// Get question by ID
|
||||
getById: (questionId) =>
|
||||
api.get(`/api/questions/${questionId}`),
|
||||
|
||||
// Check answer
|
||||
checkAnswer: (questionId, userAnswer) =>
|
||||
api.post('/api/questions/check', {
|
||||
question_id: questionId,
|
||||
user_answer: userAnswer
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Mistake APIs ============
|
||||
export const mistakeAPI = {
|
||||
// Get user's mistake book
|
||||
getList: (skip = 0, limit = 50, examId = null) => {
|
||||
const params = { skip, limit }
|
||||
if (examId) params.exam_id = examId
|
||||
return api.get('/api/mistakes/', { params })
|
||||
},
|
||||
|
||||
// Add to mistake book
|
||||
add: (questionId) =>
|
||||
api.post('/api/mistakes/add', { question_id: questionId }),
|
||||
|
||||
// Remove from mistake book by mistake ID
|
||||
remove: (mistakeId) =>
|
||||
api.delete(`/api/mistakes/${mistakeId}`),
|
||||
|
||||
// Remove from mistake book by question ID
|
||||
removeByQuestionId: (questionId) =>
|
||||
api.delete(`/api/mistakes/question/${questionId}`)
|
||||
}
|
||||
|
||||
// ============ Admin APIs ============
|
||||
export const adminAPI = {
|
||||
// Get system config
|
||||
getConfig: () =>
|
||||
api.get('/api/admin/config'),
|
||||
|
||||
// Update system config
|
||||
updateConfig: (config) =>
|
||||
api.put('/api/admin/config', config)
|
||||
}
|
||||
|
||||
export default api
|
||||
142
frontend/src/components/Layout.jsx
Normal file
142
frontend/src/components/Layout.jsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Main Layout Component with Navigation
|
||||
*/
|
||||
import React, { useState } from 'react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import {
|
||||
BookOpen,
|
||||
LayoutDashboard,
|
||||
FolderOpen,
|
||||
XCircle,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
|
||||
export const Layout = ({ children }) => {
|
||||
const { user, logout, isAdmin } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{ name: '首页', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ name: '题库管理', href: '/exams', icon: FolderOpen },
|
||||
{ name: '错题本', href: '/mistakes', icon: XCircle },
|
||||
]
|
||||
|
||||
if (isAdmin) {
|
||||
navigation.push({ name: '系统设置', href: '/admin/settings', icon: Settings })
|
||||
}
|
||||
|
||||
const isActive = (href) => location.pathname === href
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* Mobile Header */}
|
||||
<div className="lg:hidden bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="h-6 w-6 text-primary-600" />
|
||||
<span className="font-bold text-lg">QQuiz</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
{mobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="border-t border-gray-200 px-4 py-3 space-y-2">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
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)
|
||||
? 'bg-primary-50 text-primary-600'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
{/* Desktop Sidebar */}
|
||||
<div className="hidden lg:flex lg:flex-col lg:w-64 lg:fixed lg:inset-y-0">
|
||||
<div className="flex flex-col flex-1 bg-white border-r border-gray-200">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 px-6 py-6 border-b border-gray-200">
|
||||
<div className="bg-primary-600 p-2 rounded-lg">
|
||||
<BookOpen className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-bold text-lg">QQuiz</h1>
|
||||
<p className="text-xs text-gray-500">{user?.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 py-6 space-y-2">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={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'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="px-4 py-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 lg:pl-64">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
28
frontend/src/components/ProtectedRoute.jsx
Normal file
28
frontend/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Protected Route Component
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export const ProtectedRoute = ({ children, adminOnly = false }) => {
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
if (adminOnly && !user.is_admin) {
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
95
frontend/src/context/AuthContext.jsx
Normal file
95
frontend/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Authentication Context
|
||||
*/
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { authAPI } from '../api/client'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Load user from localStorage on mount
|
||||
useEffect(() => {
|
||||
const loadUser = async () => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
try {
|
||||
const response = await authAPI.getCurrentUser()
|
||||
setUser(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load user:', error)
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
loadUser()
|
||||
}, [])
|
||||
|
||||
const login = async (username, password) => {
|
||||
try {
|
||||
const response = await authAPI.login(username, password)
|
||||
const { access_token } = response.data
|
||||
|
||||
// Save token
|
||||
localStorage.setItem('access_token', access_token)
|
||||
|
||||
// Get user info
|
||||
const userResponse = await authAPI.getCurrentUser()
|
||||
setUser(userResponse.data)
|
||||
|
||||
toast.success('Login successful!')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const register = async (username, password) => {
|
||||
try {
|
||||
await authAPI.register(username, password)
|
||||
toast.success('Registration successful! Please login.')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Registration failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('user')
|
||||
setUser(null)
|
||||
toast.success('Logged out successfully')
|
||||
}
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
isAuthenticated: !!user,
|
||||
isAdmin: user?.is_admin || false
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
22
frontend/src/index.css
Normal file
22
frontend/src/index.css
Normal file
@@ -0,0 +1,22 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
10
frontend/src/index.jsx
Normal file
10
frontend/src/index.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
177
frontend/src/pages/AdminSettings.jsx
Normal file
177
frontend/src/pages/AdminSettings.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Admin Settings Page
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { adminAPI } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { Settings, Save, Loader } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export const AdminSettings = () => {
|
||||
const { user } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [config, setConfig] = useState({
|
||||
allow_registration: true,
|
||||
max_upload_size_mb: 10,
|
||||
max_daily_uploads: 20,
|
||||
ai_provider: 'openai'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
}, [])
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const response = await adminAPI.getConfig()
|
||||
setConfig(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load config:', error)
|
||||
toast.error('加载配置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await adminAPI.updateConfig(config)
|
||||
toast.success('配置保存成功!')
|
||||
} catch (error) {
|
||||
console.error('Failed to save config:', error)
|
||||
toast.error('保存配置失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
setConfig({
|
||||
...config,
|
||||
[key]: value
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<Loader className="h-8 w-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* Header */}
|
||||
<div className="bg-white shadow">
|
||||
<div className="max-w-4xl mx-auto px-4 py-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="bg-white rounded-xl shadow-md p-6 space-y-6">
|
||||
{/* Allow Registration */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">允许用户注册</h3>
|
||||
<p className="text-sm text-gray-500">关闭后新用户无法注册</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.allow_registration}
|
||||
onChange={(e) => handleChange('allow_registration', e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Max Upload Size */}
|
||||
<div>
|
||||
<label className="block font-medium text-gray-900 mb-2">
|
||||
最大上传文件大小 (MB)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={config.max_upload_size_mb}
|
||||
onChange={(e) => handleChange('max_upload_size_mb', parseInt(e.target.value))}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">建议:5-20 MB</p>
|
||||
</div>
|
||||
|
||||
{/* Max Daily Uploads */}
|
||||
<div>
|
||||
<label className="block font-medium text-gray-900 mb-2">
|
||||
每日上传次数限制
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={config.max_daily_uploads}
|
||||
onChange={(e) => handleChange('max_daily_uploads', parseInt(e.target.value))}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">建议:10-50 次</p>
|
||||
</div>
|
||||
|
||||
{/* AI Provider */}
|
||||
<div>
|
||||
<label className="block font-medium text-gray-900 mb-2">
|
||||
AI 提供商
|
||||
</label>
|
||||
<select
|
||||
value={config.ai_provider}
|
||||
onChange={(e) => handleChange('ai_provider', e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="openai">OpenAI (GPT)</option>
|
||||
<option value="anthropic">Anthropic (Claude)</option>
|
||||
<option value="qwen">Qwen (通义千问)</option>
|
||||
</select>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
需在 .env 文件中配置对应的 API Key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader className="h-5 w-5 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-5 w-5" />
|
||||
保存设置
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminSettings
|
||||
197
frontend/src/pages/Dashboard.jsx
Normal file
197
frontend/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Dashboard Page
|
||||
*/
|
||||
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
|
||||
} from 'lucide-react'
|
||||
import { getStatusColor, getStatusText, formatRelativeTime, calculateProgress } from '../utils/helpers'
|
||||
|
||||
export const Dashboard = () => {
|
||||
const { user, isAdmin } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [stats, setStats] = useState({
|
||||
totalExams: 0,
|
||||
totalQuestions: 0,
|
||||
completedQuestions: 0,
|
||||
mistakeCount: 0
|
||||
})
|
||||
|
||||
const [recentExams, setRecentExams] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData()
|
||||
}, [])
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
const [examsRes, mistakesRes] = await Promise.all([
|
||||
examAPI.getList(0, 5),
|
||||
mistakeAPI.getList(0, 1)
|
||||
])
|
||||
|
||||
const exams = examsRes.data.exams
|
||||
|
||||
// Calculate stats
|
||||
const totalQuestions = exams.reduce((sum, e) => sum + e.total_questions, 0)
|
||||
const completedQuestions = exams.reduce((sum, e) => sum + e.current_index, 0)
|
||||
|
||||
setStats({
|
||||
totalExams: exams.length,
|
||||
totalQuestions,
|
||||
completedQuestions,
|
||||
mistakeCount: mistakesRes.data.total
|
||||
})
|
||||
|
||||
setRecentExams(exams)
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="p-4 md:p-8">
|
||||
{/* Welcome */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||
欢迎回来,{user?.username}!
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">继续你的学习之旅</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-sm p-6 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => navigate('/exams')}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-primary-100 p-2 rounded-lg">
|
||||
<FolderOpen className="h-5 w-5 text-primary-600" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-gray-900">{stats.totalExams}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">题库总数</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-blue-100 p-2 rounded-lg">
|
||||
<BookOpen className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-gray-900">{stats.totalQuestions}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">题目总数</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-green-100 p-2 rounded-lg">
|
||||
<TrendingUp className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-gray-900">{stats.completedQuestions}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">已完成</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-sm p-6 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => navigate('/mistakes')}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-red-100 p-2 rounded-lg">
|
||||
<XCircle className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-gray-900">{stats.mistakeCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">错题数量</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Exams */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900">最近的题库</h2>
|
||||
<button
|
||||
onClick={() => navigate('/exams')}
|
||||
className="text-primary-600 hover:text-primary-700 flex items-center gap-1 text-sm font-medium"
|
||||
>
|
||||
查看全部
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{recentExams.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<FolderOpen className="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500">还没有题库,快去创建一个吧!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentExams.map((exam) => (
|
||||
<div
|
||||
key={exam.id}
|
||||
onClick={() => navigate(`/exams/${exam.id}`)}
|
||||
className="border border-gray-200 rounded-lg p-4 hover:border-primary-300 hover:bg-primary-50 transition-all cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="font-semibold text-gray-900">{exam.title}</h3>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(exam.status)}`}>
|
||||
{getStatusText(exam.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-600">
|
||||
<span>
|
||||
{exam.current_index} / {exam.total_questions} 题
|
||||
</span>
|
||||
<span>{formatRelativeTime(exam.updated_at)}</span>
|
||||
</div>
|
||||
|
||||
{exam.total_questions > 0 && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-3">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${calculateProgress(exam.current_index, exam.total_questions)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
<button
|
||||
onClick={() => navigate('/admin/settings')}
|
||||
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"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
系统设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
361
frontend/src/pages/ExamDetail.jsx
Normal file
361
frontend/src/pages/ExamDetail.jsx
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Exam Detail Page - with append upload and status polling
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { examAPI, questionAPI } from '../api/client'
|
||||
import Layout from '../components/Layout'
|
||||
import {
|
||||
ArrowLeft, Upload, Play, Loader, FileText, AlertCircle, RefreshCw
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
getStatusColor,
|
||||
getStatusText,
|
||||
formatDate,
|
||||
calculateProgress,
|
||||
isValidFileType,
|
||||
getQuestionTypeText
|
||||
} from '../utils/helpers'
|
||||
|
||||
export const ExamDetail = () => {
|
||||
const { examId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [exam, setExam] = useState(null)
|
||||
const [questions, setQuestions] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [showUploadModal, setShowUploadModal] = useState(false)
|
||||
const [uploadFile, setUploadFile] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadExamDetail()
|
||||
|
||||
// Start polling if status is processing
|
||||
const interval = setInterval(() => {
|
||||
pollExamStatus()
|
||||
}, 3000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [examId])
|
||||
|
||||
const loadExamDetail = async () => {
|
||||
try {
|
||||
const [examRes, questionsRes] = await Promise.all([
|
||||
examAPI.getDetail(examId),
|
||||
questionAPI.getExamQuestions(examId, 0, 10) // Load first 10 for preview
|
||||
])
|
||||
|
||||
setExam(examRes.data)
|
||||
setQuestions(questionsRes.data.questions)
|
||||
} catch (error) {
|
||||
console.error('Failed to load exam:', error)
|
||||
toast.error('加载题库失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pollExamStatus = async () => {
|
||||
try {
|
||||
const response = await examAPI.getDetail(examId)
|
||||
const newExam = response.data
|
||||
|
||||
// If status changed from processing to ready
|
||||
if (exam?.status === 'processing' && newExam.status === 'ready') {
|
||||
toast.success('文档解析完成!')
|
||||
await loadExamDetail() // Reload to get updated questions
|
||||
} else if (exam?.status === 'processing' && newExam.status === 'failed') {
|
||||
toast.error('文档解析失败')
|
||||
}
|
||||
|
||||
setExam(newExam)
|
||||
} catch (error) {
|
||||
console.error('Failed to poll exam:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAppendDocument = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!uploadFile) {
|
||||
toast.error('请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidFileType(uploadFile.name)) {
|
||||
toast.error('不支持的文件类型')
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
|
||||
try {
|
||||
await examAPI.appendDocument(examId, uploadFile)
|
||||
toast.success('文档上传成功,正在解析并去重...')
|
||||
setShowUploadModal(false)
|
||||
setUploadFile(null)
|
||||
await loadExamDetail()
|
||||
} catch (error) {
|
||||
console.error('Failed to append document:', error)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartQuiz = () => {
|
||||
if (exam.current_index >= exam.total_questions) {
|
||||
if (window.confirm('已经完成所有题目,是否从头开始?')) {
|
||||
navigate(`/quiz/${examId}?reset=true`)
|
||||
}
|
||||
} else {
|
||||
navigate(`/quiz/${examId}`)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
const isProcessing = exam.status === 'processing'
|
||||
const isReady = exam.status === 'ready'
|
||||
const isFailed = exam.status === 'failed'
|
||||
const progress = calculateProgress(exam.current_index, exam.total_questions)
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="p-4 md:p-8">
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={() => navigate('/exams')}
|
||||
className="mb-6 flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
返回题库列表
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 mb-2">
|
||||
{exam.title}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-3 py-1 text-sm font-medium rounded-full ${getStatusColor(exam.status)}`}>
|
||||
{getStatusText(exam.status)}
|
||||
</span>
|
||||
{isProcessing && (
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
正在处理中...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-4 md:mt-0 flex flex-col sm:flex-row gap-2">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
disabled={isProcessing}
|
||||
className="bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
添加题目文档
|
||||
</button>
|
||||
|
||||
{isReady && exam.total_questions > 0 && (
|
||||
<button
|
||||
onClick={handleStartQuiz}
|
||||
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"
|
||||
>
|
||||
<Play className="h-5 w-5" />
|
||||
{exam.current_index > 0 ? '继续刷题' : '开始刷题'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 mb-1">题目总数</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{exam.total_questions}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 mb-1">已完成</p>
|
||||
<p className="text-2xl font-bold text-primary-600">{exam.current_index}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 mb-1">剩余</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{Math.max(0, exam.total_questions - exam.current_index)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 mb-1">完成度</p>
|
||||
<p className="text-2xl font-bold text-green-600">{progress}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{exam.total_questions > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className="bg-primary-600 h-3 rounded-full transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 text-sm text-gray-600">
|
||||
<p>创建时间:{formatDate(exam.created_at)}</p>
|
||||
<p>最后更新:{formatDate(exam.updated_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Failed Status Warning */}
|
||||
{isFailed && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-6 w-6 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-red-900 mb-1">文档解析失败</h3>
|
||||
<p className="text-sm text-red-700">
|
||||
请检查文档格式是否正确,或尝试重新上传。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Questions Preview */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||
题目预览 {questions.length > 0 && `(前 ${questions.length} 题)`}
|
||||
</h2>
|
||||
|
||||
{questions.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">
|
||||
{isProcessing ? '正在解析文档,请稍候...' : '暂无题目'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{questions.map((q, index) => (
|
||||
<div key={q.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-8 h-8 bg-primary-100 text-primary-600 rounded-full flex items-center justify-center font-medium">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-600 rounded">
|
||||
{getQuestionTypeText(q.type)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-900">{q.content}</p>
|
||||
{q.options && q.options.length > 0 && (
|
||||
<ul className="mt-2 space-y-1 text-sm text-gray-600">
|
||||
{q.options.map((opt, i) => (
|
||||
<li key={i}>{opt}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-xl max-w-md w-full p-6">
|
||||
<h2 className="text-2xl font-bold mb-4">添加题目文档</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
上传新文档后,系统会自动解析题目并去除重复题目。
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleAppendDocument} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
选择文档
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => setUploadFile(e.target.files[0])}
|
||||
required
|
||||
accept=".txt,.pdf,.doc,.docx,.xlsx,.xls"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
支持:TXT, PDF, DOC, DOCX, XLSX, XLS
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowUploadModal(false)
|
||||
setUploadFile(null)
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
className="flex-1 bg-primary-600 text-white py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader className="h-5 w-5 animate-spin" />
|
||||
上传中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-5 w-5" />
|
||||
上传
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExamDetail
|
||||
304
frontend/src/pages/ExamList.jsx
Normal file
304
frontend/src/pages/ExamList.jsx
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Exam List Page
|
||||
*/
|
||||
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'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
getStatusColor,
|
||||
getStatusText,
|
||||
formatRelativeTime,
|
||||
calculateProgress,
|
||||
isValidFileType
|
||||
} from '../utils/helpers'
|
||||
|
||||
export const ExamList = () => {
|
||||
const navigate = useNavigate()
|
||||
const [exams, setExams] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [pollInterval, setPollInterval] = useState(null)
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
file: null
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadExams()
|
||||
|
||||
// Start polling for processing exams
|
||||
const interval = setInterval(() => {
|
||||
checkProcessingExams()
|
||||
}, 3000) // Poll every 3 seconds
|
||||
|
||||
setPollInterval(interval)
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadExams = async () => {
|
||||
try {
|
||||
const response = await examAPI.getList()
|
||||
setExams(response.data.exams)
|
||||
} catch (error) {
|
||||
console.error('Failed to load exams:', error)
|
||||
toast.error('加载题库失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkProcessingExams = async () => {
|
||||
try {
|
||||
const response = await examAPI.getList()
|
||||
const newExams = response.data.exams
|
||||
|
||||
// Check if any processing exam is now ready
|
||||
const oldProcessing = exams.filter(e => e.status === 'processing')
|
||||
const newReady = newExams.filter(e =>
|
||||
oldProcessing.some(old => old.id === e.id && e.status === 'ready')
|
||||
)
|
||||
|
||||
if (newReady.length > 0) {
|
||||
toast.success(`${newReady.length} 个题库解析完成!`)
|
||||
}
|
||||
|
||||
setExams(newExams)
|
||||
} catch (error) {
|
||||
console.error('Failed to poll exams:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.file) {
|
||||
toast.error('请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidFileType(formData.file.name)) {
|
||||
toast.error('不支持的文件类型')
|
||||
return
|
||||
}
|
||||
|
||||
setCreating(true)
|
||||
|
||||
try {
|
||||
const response = await examAPI.create(formData.title, formData.file)
|
||||
toast.success('题库创建成功,正在解析文档...')
|
||||
setShowCreateModal(false)
|
||||
setFormData({ title: '', file: null })
|
||||
await loadExams()
|
||||
} catch (error) {
|
||||
console.error('Failed to create exam:', error)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (examId) => {
|
||||
if (!window.confirm('确定要删除这个题库吗?删除后无法恢复。')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await examAPI.delete(examId)
|
||||
toast.success('题库已删除')
|
||||
await loadExams()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete exam:', error)
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">题库管理</h1>
|
||||
<p className="text-gray-600 mt-1">共 {exams.length} 个题库</p>
|
||||
</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"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
创建题库
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Exam Grid */}
|
||||
{exams.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<FolderOpen className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">还没有题库</h3>
|
||||
<p className="text-gray-500 mb-6">创建第一个题库开始刷题吧!</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="bg-primary-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
创建题库
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{exams.map((exam) => (
|
||||
<div
|
||||
key={exam.id}
|
||||
className="bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow p-6"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex-1 pr-2">
|
||||
{exam.title}
|
||||
</h3>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(exam.status)}`}>
|
||||
{getStatusText(exam.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">题目数量</span>
|
||||
<span className="font-medium">{exam.total_questions}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">已完成</span>
|
||||
<span className="font-medium">
|
||||
{exam.current_index} / {exam.total_questions}
|
||||
</span>
|
||||
</div>
|
||||
{exam.total_questions > 0 && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${calculateProgress(exam.current_index, exam.total_questions)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
创建于 {formatRelativeTime(exam.created_at)}
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/exams/${exam.id}`)}
|
||||
className="flex-1 bg-primary-600 text-white py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(exam.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-xl max-w-md w-full p-6">
|
||||
<h2 className="text-2xl font-bold mb-4">创建新题库</h2>
|
||||
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
题库名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="例如:数据结构期末复习"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
上传文档
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => setFormData({ ...formData, file: e.target.files[0] })}
|
||||
required
|
||||
accept=".txt,.pdf,.doc,.docx,.xlsx,.xls"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
支持:TXT, PDF, DOC, DOCX, XLSX, XLS
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false)
|
||||
setFormData({ title: '', file: null })
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="flex-1 bg-primary-600 text-white py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader className="h-5 w-5 animate-spin" />
|
||||
创建中...
|
||||
</>
|
||||
) : (
|
||||
'创建'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExamList
|
||||
122
frontend/src/pages/Login.jsx
Normal file
122
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Login Page
|
||||
*/
|
||||
import React, { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { BookOpen } from 'lucide-react'
|
||||
|
||||
export const Login = () => {
|
||||
const navigate = useNavigate()
|
||||
const { login } = useAuth()
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const success = await login(formData.username, formData.password)
|
||||
if (success) {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full">
|
||||
{/* Logo and Title */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="bg-primary-600 p-3 rounded-2xl">
|
||||
<BookOpen className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">QQuiz</h1>
|
||||
<p className="text-gray-600 mt-2">智能刷题与题库管理平台</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">登录</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Register Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-gray-600">
|
||||
还没有账号?{' '}
|
||||
<Link to="/register" className="text-primary-600 font-medium hover:text-primary-700">
|
||||
立即注册
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center text-sm text-gray-500">
|
||||
<p>默认管理员账号:admin / admin123</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
170
frontend/src/pages/MistakeList.jsx
Normal file
170
frontend/src/pages/MistakeList.jsx
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Mistake List Page (错题本)
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { mistakeAPI } from '../api/client'
|
||||
import Layout from '../components/Layout'
|
||||
import { XCircle, Loader, Trash2, BookOpen } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { getQuestionTypeText, formatRelativeTime } from '../utils/helpers'
|
||||
|
||||
export const MistakeList = () => {
|
||||
const [mistakes, setMistakes] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedId, setExpandedId] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadMistakes()
|
||||
}, [])
|
||||
|
||||
const loadMistakes = async () => {
|
||||
try {
|
||||
const response = await mistakeAPI.getList()
|
||||
setMistakes(response.data.mistakes)
|
||||
} catch (error) {
|
||||
console.error('Failed to load mistakes:', error)
|
||||
toast.error('加载错题本失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (mistakeId) => {
|
||||
if (!window.confirm('确定要从错题本中移除这道题吗?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await mistakeAPI.remove(mistakeId)
|
||||
toast.success('已移除')
|
||||
await loadMistakes()
|
||||
} catch (error) {
|
||||
console.error('Failed to remove mistake:', error)
|
||||
toast.error('移除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
setExpandedId(expandedId === id ? null : id)
|
||||
}
|
||||
|
||||
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="mb-6">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">错题本</h1>
|
||||
<p className="text-gray-600 mt-1">共 {mistakes.length} 道错题</p>
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{mistakes.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<XCircle className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">错题本是空的</h3>
|
||||
<p className="text-gray-500">继续刷题,错题会自动添加到这里</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{mistakes.map((mistake) => {
|
||||
const q = mistake.question
|
||||
const isExpanded = expandedId === mistake.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={mistake.id}
|
||||
className="bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
{/* Question Preview */}
|
||||
<div
|
||||
className="p-4 md:p-6 cursor-pointer"
|
||||
onClick={() => toggleExpand(mistake.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-10 h-10 bg-red-100 text-red-600 rounded-full flex items-center justify-center">
|
||||
<XCircle className="h-5 w-5" />
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-600 rounded">
|
||||
{getQuestionTypeText(q.type)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatRelativeTime(mistake.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className={`text-gray-900 ${!isExpanded ? 'line-clamp-2' : ''}`}>
|
||||
{q.content}
|
||||
</p>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* Options */}
|
||||
{q.options && q.options.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{q.options.map((opt, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-3 bg-gray-50 rounded-lg text-sm text-gray-700"
|
||||
>
|
||||
{opt}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Answer */}
|
||||
<div className="p-3 bg-green-50 rounded-lg">
|
||||
<p className="text-sm font-medium text-green-900 mb-1">
|
||||
正确答案
|
||||
</p>
|
||||
<p className="text-sm text-green-700">{q.answer}</p>
|
||||
</div>
|
||||
|
||||
{/* Analysis */}
|
||||
{q.analysis && (
|
||||
<div className="p-3 bg-blue-50 rounded-lg">
|
||||
<p className="text-sm font-medium text-blue-900 mb-1">
|
||||
解析
|
||||
</p>
|
||||
<p className="text-sm text-blue-700">{q.analysis}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemove(mistake.id)
|
||||
}}
|
||||
className="flex-shrink-0 p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default MistakeList
|
||||
397
frontend/src/pages/QuizPlayer.jsx
Normal file
397
frontend/src/pages/QuizPlayer.jsx
Normal file
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Quiz Player Page - Core quiz functionality
|
||||
*/
|
||||
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'
|
||||
import toast from 'react-hot-toast'
|
||||
import { getQuestionTypeText } from '../utils/helpers'
|
||||
|
||||
export const QuizPlayer = () => {
|
||||
const { examId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const [exam, setExam] = useState(null)
|
||||
const [question, setQuestion] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [userAnswer, setUserAnswer] = useState('')
|
||||
const [multipleAnswers, setMultipleAnswers] = useState([])
|
||||
const [result, setResult] = useState(null)
|
||||
const [inMistakeBook, setInMistakeBook] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadQuiz()
|
||||
}, [examId])
|
||||
|
||||
const loadQuiz = async () => {
|
||||
try {
|
||||
// Check if reset flag is present
|
||||
const shouldReset = searchParams.get('reset') === 'true'
|
||||
if (shouldReset) {
|
||||
await examAPI.updateProgress(examId, 0)
|
||||
}
|
||||
|
||||
const examRes = await examAPI.getDetail(examId)
|
||||
setExam(examRes.data)
|
||||
|
||||
await loadCurrentQuestion()
|
||||
} catch (error) {
|
||||
console.error('Failed to load quiz:', error)
|
||||
toast.error('加载题目失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCurrentQuestion = async () => {
|
||||
try {
|
||||
const response = await questionAPI.getCurrentQuestion(examId)
|
||||
setQuestion(response.data)
|
||||
setResult(null)
|
||||
setUserAnswer('')
|
||||
setMultipleAnswers([])
|
||||
await checkIfInMistakeBook(response.data.id)
|
||||
} catch (error) {
|
||||
if (error.response?.status === 404) {
|
||||
toast.success('恭喜!所有题目已完成!')
|
||||
navigate(`/exams/${examId}`)
|
||||
} else {
|
||||
console.error('Failed to load question:', error)
|
||||
toast.error('加载题目失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checkIfInMistakeBook = async (questionId) => {
|
||||
try {
|
||||
const response = await mistakeAPI.getList(0, 1000) // TODO: Optimize this
|
||||
const inBook = response.data.mistakes.some(m => m.question_id === questionId)
|
||||
setInMistakeBook(inBook)
|
||||
} catch (error) {
|
||||
console.error('Failed to check mistake book:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitAnswer = async () => {
|
||||
let answer = userAnswer
|
||||
|
||||
// For multiple choice, join selected options
|
||||
if (question.type === 'multiple') {
|
||||
if (multipleAnswers.length === 0) {
|
||||
toast.error('请至少选择一个选项')
|
||||
return
|
||||
}
|
||||
answer = multipleAnswers.sort().join('')
|
||||
}
|
||||
|
||||
if (!answer.trim()) {
|
||||
toast.error('请输入答案')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await questionAPI.checkAnswer(question.id, answer)
|
||||
setResult(response.data)
|
||||
|
||||
if (response.data.correct) {
|
||||
toast.success('回答正确!')
|
||||
} else {
|
||||
toast.error('回答错误')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check answer:', error)
|
||||
toast.error('提交答案失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNext = async () => {
|
||||
try {
|
||||
const newIndex = exam.current_index + 1
|
||||
await examAPI.updateProgress(examId, newIndex)
|
||||
|
||||
const examRes = await examAPI.getDetail(examId)
|
||||
setExam(examRes.data)
|
||||
|
||||
await loadCurrentQuestion()
|
||||
} catch (error) {
|
||||
console.error('Failed to move to next question:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleMistake = async () => {
|
||||
try {
|
||||
if (inMistakeBook) {
|
||||
await mistakeAPI.removeByQuestionId(question.id)
|
||||
setInMistakeBook(false)
|
||||
toast.success('已从错题本移除')
|
||||
} else {
|
||||
await mistakeAPI.add(question.id)
|
||||
setInMistakeBook(true)
|
||||
toast.success('已加入错题本')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle mistake:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMultipleChoice = (option) => {
|
||||
const letter = option.charAt(0)
|
||||
if (multipleAnswers.includes(letter)) {
|
||||
setMultipleAnswers(multipleAnswers.filter(a => a !== letter))
|
||||
} else {
|
||||
setMultipleAnswers([...multipleAnswers, letter])
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<button
|
||||
onClick={() => navigate(`/exams/${examId}`)}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
返回
|
||||
</button>
|
||||
|
||||
<div className="text-sm text-gray-600">
|
||||
进度: {exam.current_index + 1} / {exam.total_questions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Question Card */}
|
||||
<div className="bg-white rounded-xl shadow-md p-6 md:p-8 mb-6">
|
||||
{/* Question Header */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex-shrink-0 w-10 h-10 bg-primary-100 text-primary-600 rounded-full flex items-center justify-center font-bold">
|
||||
{exam.current_index + 1}
|
||||
</span>
|
||||
<span className="px-3 py-1 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium">
|
||||
{getQuestionTypeText(question.type)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleToggleMistake}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${
|
||||
inMistakeBook
|
||||
? 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{inMistakeBook ? (
|
||||
<>
|
||||
<BookmarkX className="h-5 w-5" />
|
||||
<span className="hidden sm:inline">移出错题本</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BookmarkPlus className="h-5 w-5" />
|
||||
<span className="hidden sm:inline">加入错题本</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Question Content */}
|
||||
<div className="mb-6">
|
||||
<p className="text-lg md:text-xl text-gray-900 leading-relaxed">
|
||||
{question.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Options (for choice questions) */}
|
||||
{question.options && question.options.length > 0 && (
|
||||
<div className="space-y-3 mb-6">
|
||||
{question.options.map((option, index) => {
|
||||
const letter = option.charAt(0)
|
||||
const isSelected = question.type === 'multiple'
|
||||
? multipleAnswers.includes(letter)
|
||||
: userAnswer === letter
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
if (!result) {
|
||||
if (question.type === 'multiple') {
|
||||
handleMultipleChoice(option)
|
||||
} else {
|
||||
setUserAnswer(letter)
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={!!result}
|
||||
className={`w-full text-left p-4 rounded-lg border-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
} ${result ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className="text-gray-900">{option}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Short Answer Input */}
|
||||
{question.type === 'short' && !result && (
|
||||
<div className="mb-6">
|
||||
<textarea
|
||||
value={userAnswer}
|
||||
onChange={(e) => setUserAnswer(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-lg focus:border-primary-500 focus:outline-none"
|
||||
placeholder="请输入你的答案..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Judge Input */}
|
||||
{question.type === 'judge' && !result && (
|
||||
<div className="flex gap-4 mb-6">
|
||||
<button
|
||||
onClick={() => setUserAnswer('A')}
|
||||
className={`flex-1 py-3 rounded-lg border-2 transition-all ${
|
||||
userAnswer === 'A'
|
||||
? 'border-green-500 bg-green-50 text-green-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
正确
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUserAnswer('B')}
|
||||
className={`flex-1 py-3 rounded-lg border-2 transition-all ${
|
||||
userAnswer === 'B'
|
||||
? 'border-red-500 bg-red-50 text-red-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
错误
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
{!result && (
|
||||
<button
|
||||
onClick={handleSubmitAnswer}
|
||||
disabled={submitting}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader className="h-5 w-5 animate-spin" />
|
||||
提交中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="h-5 w-5" />
|
||||
提交答案
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className={`rounded-xl p-6 mb-6 ${
|
||||
result.correct ? 'bg-green-50 border-2 border-green-200' : 'bg-red-50 border-2 border-red-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
{result.correct ? (
|
||||
<Check className="h-6 w-6 text-green-600 mt-0.5" />
|
||||
) : (
|
||||
<X className="h-6 w-6 text-red-600 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-bold text-lg mb-2 ${result.correct ? 'text-green-900' : 'text-red-900'}`}>
|
||||
{result.correct ? '回答正确!' : '回答错误'}
|
||||
</h3>
|
||||
|
||||
{!result.correct && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-gray-700">
|
||||
<span className="font-medium">你的答案:</span>{result.user_answer}
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
<span className="font-medium">正确答案:</span>{result.correct_answer}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Score for short answers */}
|
||||
{result.ai_score !== null && result.ai_score !== undefined && (
|
||||
<div className="mt-3 p-3 bg-white rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-1">
|
||||
AI 评分:{(result.ai_score * 100).toFixed(0)}%
|
||||
</p>
|
||||
{result.ai_feedback && (
|
||||
<p className="text-sm text-gray-600">{result.ai_feedback}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analysis */}
|
||||
{result.analysis && (
|
||||
<div className="mt-3 p-3 bg-white rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-1">解析:</p>
|
||||
<p className="text-sm text-gray-600">{result.analysis}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next Button */}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
下一题
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuizPlayer
|
||||
159
frontend/src/pages/Register.jsx
Normal file
159
frontend/src/pages/Register.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Register Page
|
||||
*/
|
||||
import React, { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { BookOpen } from 'lucide-react'
|
||||
|
||||
export const Register = () => {
|
||||
const navigate = useNavigate()
|
||||
const { register } = useAuth()
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
// Validate
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.password.length < 6) {
|
||||
setError('密码至少需要 6 位')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const success = await register(formData.username, formData.password)
|
||||
if (success) {
|
||||
navigate('/login')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
setError('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full">
|
||||
{/* Logo and Title */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="bg-primary-600 p-3 rounded-2xl">
|
||||
<BookOpen className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">QQuiz</h1>
|
||||
<p className="text-gray-600 mt-2">智能刷题与题库管理平台</p>
|
||||
</div>
|
||||
|
||||
{/* Register Form */}
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">注册</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={50}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="3-50 位字符"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="至少 6 位"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
确认密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="再次输入密码"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? '注册中...' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-gray-600">
|
||||
已有账号?{' '}
|
||||
<Link to="/login" className="text-primary-600 font-medium hover:text-primary-700">
|
||||
立即登录
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Register
|
||||
117
frontend/src/utils/helpers.js
Normal file
117
frontend/src/utils/helpers.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Utility Helper Functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format date to readable string
|
||||
*/
|
||||
export const formatDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "2 days ago")
|
||||
*/
|
||||
export const formatRelativeTime = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 7) {
|
||||
return formatDate(dateString)
|
||||
} else if (days > 0) {
|
||||
return `${days} 天前`
|
||||
} else if (hours > 0) {
|
||||
return `${hours} 小时前`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes} 分钟前`
|
||||
} else {
|
||||
return '刚刚'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get exam status badge color
|
||||
*/
|
||||
export const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
pending: 'bg-gray-100 text-gray-800',
|
||||
processing: 'bg-blue-100 text-blue-800',
|
||||
ready: 'bg-green-100 text-green-800',
|
||||
failed: 'bg-red-100 text-red-800'
|
||||
}
|
||||
return colors[status] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get exam status text
|
||||
*/
|
||||
export const getStatusText = (status) => {
|
||||
const texts = {
|
||||
pending: '等待中',
|
||||
processing: '处理中',
|
||||
ready: '就绪',
|
||||
failed: '失败'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
/**
|
||||
* Get question type text
|
||||
*/
|
||||
export const getQuestionTypeText = (type) => {
|
||||
const texts = {
|
||||
single: '单选题',
|
||||
multiple: '多选题',
|
||||
judge: '判断题',
|
||||
short: '简答题'
|
||||
}
|
||||
return texts[type] || type
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate progress percentage
|
||||
*/
|
||||
export const calculateProgress = (current, total) => {
|
||||
if (total === 0) return 0
|
||||
return Math.round((current / total) * 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file type
|
||||
*/
|
||||
export const isValidFileType = (filename) => {
|
||||
const allowedExtensions = ['txt', 'pdf', 'doc', 'docx', 'xlsx', 'xls']
|
||||
const extension = filename.split('.').pop().toLowerCase()
|
||||
return allowedExtensions.includes(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size
|
||||
*/
|
||||
export const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text
|
||||
*/
|
||||
export const truncateText = (text, maxLength = 100) => {
|
||||
if (text.length <= maxLength) return text
|
||||
return text.substring(0, maxLength) + '...'
|
||||
}
|
||||
26
frontend/tailwind.config.js
Normal file
26
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
19
frontend/vite.config.js
Normal file
19
frontend/vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.REACT_APP_API_URL || 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'build'
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user