8 Commits

Author SHA1 Message Date
466fa50aa8 ESA人机验证 2026-01-08 12:42:12 +08:00
3e4157f021 错题本乱序和顺序刷题 2025-12-18 01:59:30 +08:00
3d47e568f6 翻页样式完善 2025-12-18 00:50:35 +08:00
e88716b1ea 第一阶段bug修复完毕 2025-12-18 00:46:37 +08:00
Simon
4b53e74729 Merge pull request #1 from handsomezhuzhu/codex/fix-security-vulnerabilities-in-qquiz
Document secure secrets and prune unused assets
2025-12-13 12:38:31 +08:00
4606407356 安全修复和管理员账号密码自定义 2025-12-13 12:37:18 +08:00
Simon
7d924bb81e Merge pull request #2 from handsomezhuzhu/copilot/sub-pr-1
Security audit confirms no secret leakage and proper enforcement
2025-12-13 01:47:05 +08:00
Simon
66f9a64c1c Add libmagic dependency for python-magic 2025-12-13 01:43:45 +08:00
24 changed files with 1161 additions and 379 deletions

View File

@@ -10,6 +10,9 @@ DATABASE_URL=sqlite+aiosqlite:///./qquiz.db
# JWT Secret (must be at least 32 characters; generate randomly for production) # JWT Secret (must be at least 32 characters; generate randomly for production)
SECRET_KEY= SECRET_KEY=
# Default admin username (must be at least 3 characters; default: admin)
ADMIN_USERNAME=admin
# Default admin password (must be at least 12 characters; generate randomly for production) # Default admin password (must be at least 12 characters; generate randomly for production)
ADMIN_PASSWORD= ADMIN_PASSWORD=
@@ -49,3 +52,7 @@ CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
# Upload Directory # Upload Directory
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
# ESA Human Verification
VITE_ESA_PREFIX=
VITE_ESA_SCENE_ID=

View File

@@ -21,6 +21,11 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# 安装操作系统依赖python-magic 需要 libmagic
RUN apt-get update \
&& apt-get install -y --no-install-recommends libmagic1 \
&& rm -rf /var/lib/apt/lists/*
# 复制后端依赖文件 # 复制后端依赖文件
COPY backend/requirements.txt ./ COPY backend/requirements.txt ./

View File

@@ -86,6 +86,11 @@ async def init_default_config(db: AsyncSession):
"ai_provider": os.getenv("AI_PROVIDER", "openai"), "ai_provider": os.getenv("AI_PROVIDER", "openai"),
} }
# Validate admin credentials
admin_username = os.getenv("ADMIN_USERNAME", "admin")
if not admin_username or len(admin_username) < 3:
raise ValueError("ADMIN_USERNAME must be at least 3 characters long")
admin_password = os.getenv("ADMIN_PASSWORD") admin_password = os.getenv("ADMIN_PASSWORD")
if not admin_password or len(admin_password) < 12: if not admin_password or len(admin_password) < 12:
raise ValueError("ADMIN_PASSWORD must be set and at least 12 characters long") raise ValueError("ADMIN_PASSWORD must be set and at least 12 characters long")
@@ -99,15 +104,15 @@ async def init_default_config(db: AsyncSession):
db.add(config) db.add(config)
print(f"✅ Created default config: {key} = {value}") print(f"✅ Created default config: {key} = {value}")
# Create default admin user if not exists # Create or update default admin user
result = await db.execute(select(User).where(User.username == "admin")) result = await db.execute(select(User).where(User.username == admin_username))
admin = result.scalar_one_or_none() admin = result.scalar_one_or_none()
default_admin_id = admin.id if admin else None default_admin_id = admin.id if admin else None
if not admin: if not admin:
admin_user = User( admin_user = User(
username="admin", username=admin_username,
hashed_password=pwd_context.hash(admin_password), hashed_password=pwd_context.hash(admin_password),
is_admin=True is_admin=True
) )
@@ -115,8 +120,12 @@ async def init_default_config(db: AsyncSession):
await db.commit() await db.commit()
await db.refresh(admin_user) await db.refresh(admin_user)
default_admin_id = admin_user.id default_admin_id = admin_user.id
print("✅ Created default admin user (username: admin)") print(f"✅ Created default admin user (username: {admin_username})")
else: else:
# Update password if it has changed (verify current password doesn't match)
if not pwd_context.verify(admin_password, admin.hashed_password):
admin.hashed_password = pwd_context.hash(admin_password)
print(f"🔄 Updated default admin password (username: {admin_username})")
await db.commit() await db.commit()
if default_admin_id is not None: if default_admin_id is not None:

View File

@@ -1,7 +1,7 @@
""" """
Authentication Router Authentication Router
""" """
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from datetime import timedelta from datetime import timedelta
@@ -66,6 +66,7 @@ async def register(
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
@limiter.limit("5/minute") @limiter.limit("5/minute")
async def login( async def login(
request: Request,
user_data: UserLogin, user_data: UserLogin,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):

View File

@@ -1,7 +1,7 @@
""" """
Exam Router - Handles exam creation, file upload, and deduplication Exam Router - Handles exam creation, file upload, and deduplication
""" """
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, BackgroundTasks from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, BackgroundTasks, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_ from sqlalchemy import select, func, and_
@@ -11,6 +11,7 @@ import os
import aiofiles import aiofiles
import json import json
import magic import magic
import random
from database import get_db from database import get_db
from models import User, Exam, Question, ExamStatus, SystemConfig from models import User, Exam, Question, ExamStatus, SystemConfig
@@ -142,9 +143,8 @@ async def generate_ai_reference_answer(
# Build prompt based on question type # Build prompt based on question type
if question_type in ["single", "multiple"] and options: if question_type in ["single", "multiple"] and options:
options_text = "\n".join(options) options_text = "\n".join(options)
prompt = f"""这是一道{ q_type_text = '单选题' if question_type == 'single' else '多选题'
'单选题' if question_type == 'single' else '多选题' prompt = f"""这是一道{q_type_text},但文档中没有提供答案。请根据题目内容,推理出最可能的正确答案。
},但文档中没有提供答案。请根据题目内容,推理出最可能的正确答案。
题目:{question_content} 题目:{question_content}
@@ -205,7 +205,8 @@ async def process_questions_with_dedup(
exam_id: int, exam_id: int,
questions_data: List[dict], questions_data: List[dict],
db: AsyncSession, db: AsyncSession,
llm_service=None llm_service=None,
is_random: bool = False
) -> ParseResult: ) -> ParseResult:
""" """
Process parsed questions with fuzzy deduplication logic. Process parsed questions with fuzzy deduplication logic.
@@ -238,6 +239,11 @@ async def process_questions_with_dedup(
print(f"[Dedup] Checking against {len(existing_questions)} existing questions in database") print(f"[Dedup] Checking against {len(existing_questions)} existing questions in database")
# Shuffle questions if random mode is enabled
if is_random:
print(f"[Dedup] Random mode enabled - shuffling {len(questions_data)} questions before saving")
random.shuffle(questions_data)
# Insert only new questions # Insert only new questions
for q_data in questions_data: for q_data in questions_data:
content_hash = q_data.get("content_hash") content_hash = q_data.get("content_hash")
@@ -313,7 +319,8 @@ async def async_parse_and_save(
exam_id: int, exam_id: int,
file_content: bytes, file_content: bytes,
filename: str, filename: str,
db_url: str db_url: str,
is_random: bool = False
): ):
""" """
Background task to parse document and save questions with deduplication. Background task to parse document and save questions with deduplication.
@@ -487,7 +494,7 @@ async def async_parse_and_save(
)) ))
print(f"[Exam {exam_id}] Processing questions with deduplication...") print(f"[Exam {exam_id}] Processing questions with deduplication...")
parse_result = await process_questions_with_dedup(exam_id, questions_data, db, llm_service) parse_result = await process_questions_with_dedup(exam_id, questions_data, db, llm_service, is_random)
# Update exam status and total questions # Update exam status and total questions
result = await db.execute(select(Exam).where(Exam.id == exam_id)) result = await db.execute(select(Exam).where(Exam.id == exam_id))
@@ -537,8 +544,10 @@ async def async_parse_and_save(
@router.post("/create", response_model=ExamUploadResponse, status_code=status.HTTP_201_CREATED) @router.post("/create", response_model=ExamUploadResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def create_exam_with_upload( async def create_exam_with_upload(
request: Request,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
title: str = Form(...), title: str = Form(...),
is_random: bool = Form(False),
file: UploadFile = File(...), file: UploadFile = File(...),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
@@ -573,7 +582,8 @@ async def create_exam_with_upload(
new_exam.id, new_exam.id,
file_content, file_content,
file.filename, file.filename,
os.getenv("DATABASE_URL") os.getenv("DATABASE_URL"),
is_random
) )
return ExamUploadResponse( return ExamUploadResponse(
@@ -587,6 +597,7 @@ async def create_exam_with_upload(
@router.post("/{exam_id}/append", response_model=ExamUploadResponse) @router.post("/{exam_id}/append", response_model=ExamUploadResponse)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def append_document_to_exam( async def append_document_to_exam(
request: Request,
exam_id: int, exam_id: int,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
file: UploadFile = File(...), file: UploadFile = File(...),

View File

@@ -19,6 +19,53 @@ from services.config_service import load_llm_config
router = APIRouter() router = APIRouter()
@router.get("/", response_model=QuestionListResponse)
async def get_all_questions(
skip: int = 0,
limit: int = 50,
exam_id: Optional[int] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get all questions with optional exam filter"""
# Build query
query = select(Question).order_by(Question.id)
count_query = select(func.count(Question.id))
# Apply exam filter if provided
if exam_id is not None:
# Verify exam ownership/access
result = await db.execute(
select(Exam).where(
and_(Exam.id == exam_id, Exam.user_id == current_user.id)
)
)
exam = result.scalar_one_or_none()
if not exam:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Exam not found"
)
query = query.where(Question.exam_id == exam_id)
count_query = count_query.where(Question.exam_id == exam_id)
else:
# If no exam filter, only show questions from exams owned by user
query = query.join(Exam).where(Exam.user_id == current_user.id)
count_query = count_query.join(Exam).where(Exam.user_id == current_user.id)
# Get total count
result = await db.execute(count_query)
total = result.scalar()
# Get questions
result = await db.execute(query.offset(skip).limit(limit))
questions = result.scalars().all()
return QuestionListResponse(questions=questions, total=total)
@router.get("/exam/{exam_id}/questions", response_model=QuestionListResponse) @router.get("/exam/{exam_id}/questions", response_model=QuestionListResponse)
async def get_exam_questions( async def get_exam_questions(
exam_id: int, exam_id: int,

View File

@@ -11,41 +11,12 @@ services:
container_name: qquiz container_name: qquiz
ports: ports:
- "8000:8000" - "8000:8000"
env_file:
- .env
environment: environment:
# 数据库配置SQLite 默认) # 数据库配置SQLite 默认,使用持久化卷
- DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db - DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db
# JWT 密钥(生产环境必须设置为随机字符串)
- SECRET_KEY=${SECRET_KEY:?Set SECRET_KEY to a random string of at least 32 characters}
# 管理员密码(生产环境必须设置为随机强密码,至少 12 位)
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?Set ADMIN_PASSWORD to a strong password of at least 12 characters}
# AI 提供商配置
- AI_PROVIDER=gemini
- GEMINI_API_KEY=${GEMINI_API_KEY}
- GEMINI_BASE_URL=${GEMINI_BASE_URL:-https://generativelanguage.googleapis.com}
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.0-flash-exp}
# OpenAI 配置(可选)
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://api.openai.com/v1}
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o-mini}
# Anthropic 配置(可选)
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- ANTHROPIC_MODEL=${ANTHROPIC_MODEL:-claude-3-haiku-20240307}
# Qwen 配置(可选)
- QWEN_API_KEY=${QWEN_API_KEY:-}
- QWEN_BASE_URL=${QWEN_BASE_URL:-https://dashscope.aliyuncs.com/compatible-mode/v1}
- QWEN_MODEL=${QWEN_MODEL:-qwen-plus}
# 系统配置
- ALLOW_REGISTRATION=true
- MAX_UPLOAD_SIZE_MB=10
- MAX_DAILY_UPLOADS=20
volumes: volumes:
# 持久化数据卷 # 持久化数据卷
- qquiz_data:/app/data # 数据库文件 - qquiz_data:/app/data # 数据库文件

View File

@@ -6,6 +6,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="QQuiz - 智能刷题与题库管理平台" /> <meta name="description" content="QQuiz - 智能刷题与题库管理平台" />
<title>QQuiz - 智能刷题平台</title> <title>QQuiz - 智能刷题平台</title>
<!-- ESA 人机认证配置 -->
<script>
window.AliyunCaptchaConfig = {
region: "cn",
prefix: "%VITE_ESA_PREFIX%",
};
</script>
<script src="https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js" async></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

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

View File

@@ -73,10 +73,11 @@ export const authAPI = {
// ============ Exam APIs ============ // ============ Exam APIs ============
export const examAPI = { export const examAPI = {
// Create exam with first document // Create exam with first document
create: (title, file) => { create: (title, file, isRandom = false) => {
const formData = new FormData() const formData = new FormData()
formData.append('title', title) formData.append('title', title)
formData.append('file', file) formData.append('file', file)
formData.append('is_random', isRandom)
return api.post('/exams/create', formData, { return api.post('/exams/create', formData, {
headers: { 'Content-Type': 'multipart/form-data' } headers: { 'Content-Type': 'multipart/form-data' }
}) })
@@ -110,6 +111,13 @@ export const examAPI = {
// ============ Question APIs ============ // ============ Question APIs ============
export const questionAPI = { export const questionAPI = {
// Get all questions (Question Bank)
getAll: (skip = 0, limit = 50, examId = null) => {
const params = { skip, limit }
if (examId) params.exam_id = examId
return api.get('/questions/', { params })
},
// Get all questions for an exam // Get all questions for an exam
getExamQuestions: (examId, skip = 0, limit = 50) => getExamQuestions: (examId, skip = 0, limit = 50) =>
api.get(`/questions/exam/${examId}/questions`, { params: { skip, limit } }), api.get(`/questions/exam/${examId}/questions`, { params: { skip, limit } }),

View File

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

View File

@@ -0,0 +1,87 @@
import React, { useState, useEffect } from 'react'
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react'
const Pagination = ({
currentPage,
totalItems,
pageSize,
onPageChange,
onPageSizeChange,
pageSizeOptions = [10, 20, 50, 100]
}) => {
const totalPages = Math.ceil(totalItems / pageSize)
const [inputPage, setInputPage] = useState(currentPage)
useEffect(() => {
setInputPage(currentPage)
}, [currentPage])
const handlePageSubmit = (e) => {
e.preventDefault()
let page = parseInt(inputPage)
if (isNaN(page)) page = 1
if (page < 1) page = 1
if (page > totalPages) page = totalPages
onPageChange(page)
setInputPage(page)
}
if (totalItems === 0) return null
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 py-4 border-t border-gray-100 mt-4">
{/* Info */}
<div className="text-sm text-gray-500">
显示 {Math.min((currentPage - 1) * pageSize + 1, totalItems)} - {Math.min(currentPage * pageSize, totalItems)} {totalItems}
</div>
<div className="flex items-center gap-2 sm:gap-4">
{/* Page Size Selector */}
<div className="relative group">
<select
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="appearance-none bg-white border border-gray-300 text-gray-700 py-2 pl-3 pr-8 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent cursor-pointer hover:border-gray-400 transition-colors"
>
{pageSizeOptions.map(size => (
<option key={size} value={size}>{size} /</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
</div>
{/* Navigation */}
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="h-4 w-4" />
</button>
{/* Manual Input */}
<form onSubmit={handlePageSubmit} className="flex items-center">
<input
type="text"
value={inputPage}
onChange={(e) => setInputPage(e.target.value)}
className="w-12 text-center py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent mx-1"
/>
<span className="text-gray-500 text-sm mx-1">/ {totalPages}</span>
</form>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
)
}
export default Pagination

View File

@@ -106,42 +106,21 @@ export const AdminPanel = () => {
} }
return ( return (
<div className="min-h-screen bg-gray-100"> <div className="p-4 md:p-8">
{/* Header */} <div className="mb-6">
<div className="bg-white shadow"> <h1 className="text-2xl md:text-3xl font-bold text-gray-900">管理员面板</h1>
<div className="max-w-7xl mx-auto px-4 py-6"> <p className="text-gray-600 mt-1">系统统计与用户管理</p>
<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> </div>
{/* Tabs */} {/* 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"> <div className="flex gap-4 border-b border-gray-200 mb-6">
<button <button
onClick={() => setActiveTab('stats')} onClick={() => setActiveTab('stats')}
className={`pb-3 px-4 font-medium border-b-2 transition-colors ${ className={`pb-3 px-4 font-medium border-b-2 transition-colors ${activeTab === 'stats'
activeTab === 'stats' ? 'border-primary-600 text-primary-600'
? 'border-primary-600 text-primary-600' : 'border-transparent text-gray-600 hover:text-gray-900'
: 'border-transparent text-gray-600 hover:text-gray-900' }`}
}`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" /> <BarChart3 className="h-5 w-5" />
@@ -150,11 +129,10 @@ export const AdminPanel = () => {
</button> </button>
<button <button
onClick={() => setActiveTab('users')} onClick={() => setActiveTab('users')}
className={`pb-3 px-4 font-medium border-b-2 transition-colors ${ className={`pb-3 px-4 font-medium border-b-2 transition-colors ${activeTab === 'users'
activeTab === 'users' ? 'border-primary-600 text-primary-600'
? 'border-primary-600 text-primary-600' : 'border-transparent text-gray-600 hover:text-gray-900'
: 'border-transparent text-gray-600 hover:text-gray-900' }`}
}`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="h-5 w-5" /> <Users className="h-5 w-5" />
@@ -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" 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" /> <Plus className="h-5 w-5" />
创建用户 <span className="hidden md:inline">创建用户</span>
<span className="md:hidden">新建</span>
</button> </button>
</div> </div>
</div> </div>
{/* Users Table */} {/* 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"> <table className="w-full">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>

View File

@@ -117,31 +117,14 @@ export const AdminSettings = () => {
} }
return ( return (
<div className="min-h-screen bg-gray-100"> <div className="p-4 md:p-8">
{/* Header */} <div className="mb-6">
<div className="bg-white shadow"> <h1 className="text-2xl md:text-3xl font-bold text-gray-900">系统设置</h1>
<div className="max-w-5xl mx-auto px-4 py-6"> <p className="text-gray-600 mt-1">配置系统参数与 AI 接口</p>
<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> </div>
{/* Content */} {/* Content */}
<div className="max-w-5xl mx-auto px-4 py-8 space-y-6"> <div className="space-y-6">
{/* Basic Settings */} {/* Basic Settings */}
<div className="bg-white rounded-xl shadow-md p-6 space-y-6"> <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> <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 { useNavigate } from 'react-router-dom'
import { examAPI, mistakeAPI } from '../api/client' import { examAPI, mistakeAPI } from '../api/client'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import Layout from '../components/Layout'
import { import {
FolderOpen, XCircle, TrendingUp, BookOpen, ArrowRight, Settings, Shield FolderOpen, XCircle, TrendingUp, BookOpen, ArrowRight, Settings, Shield
} from 'lucide-react' } from 'lucide-react'
@@ -58,7 +58,7 @@ export const Dashboard = () => {
} }
return ( return (
<Layout> <>
<div className="p-4 md:p-8"> <div className="p-4 md:p-8">
{/* Welcome */} {/* Welcome */}
<div className="mb-8"> <div className="mb-8">
@@ -83,14 +83,17 @@ export const Dashboard = () => {
<p className="text-sm text-gray-600">题库总数</p> <p className="text-sm text-gray-600">题库总数</p>
</div> </div>
<div className="bg-white rounded-xl shadow-sm p-6"> <div
className="bg-white rounded-xl shadow-sm p-6 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => navigate('/questions')}
>
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="bg-blue-100 p-2 rounded-lg"> <div className="bg-blue-100 p-2 rounded-lg">
<BookOpen className="h-5 w-5 text-blue-600" /> <BookOpen className="h-5 w-5 text-blue-600" />
</div> </div>
<span className="text-2xl font-bold text-gray-900">{stats.totalQuestions}</span> <span className="text-2xl font-bold text-gray-900">{stats.totalQuestions}</span>
</div> </div>
<p className="text-sm text-gray-600">题目总数</p> <p className="text-sm text-gray-600">题目总数 (点击查看)</p>
</div> </div>
<div className="bg-white rounded-xl shadow-sm p-6"> <div className="bg-white rounded-xl shadow-sm p-6">
@@ -158,12 +161,17 @@ export const Dashboard = () => {
</div> </div>
{exam.total_questions > 0 && ( {exam.total_questions > 0 && (
<div className="w-full bg-gray-200 rounded-full h-2 mt-3"> <>
<div <div className="w-full bg-gray-200 rounded-full h-2 mt-3">
className="bg-primary-600 h-2 rounded-full transition-all" <div
style={{ width: `${calculateProgress(exam.current_index, exam.total_questions)}%` }} className="bg-primary-600 h-2 rounded-full transition-all"
></div> style={{ width: `${calculateProgress(exam.current_index, exam.total_questions)}%` }}
</div> ></div>
</div>
<span className="text-xs text-gray-500 mt-1 block text-right">
{calculateProgress(exam.current_index, exam.total_questions)}%
</span>
</>
)} )}
</div> </div>
))} ))}
@@ -171,35 +179,8 @@ export const Dashboard = () => {
)} )}
</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>
<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> </div>
</Layout> </>
) )
} }

View File

@@ -4,10 +4,9 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { examAPI, questionAPI } from '../api/client' import { examAPI, questionAPI } from '../api/client'
import Layout from '../components/Layout'
import ParsingProgress from '../components/ParsingProgress' import ParsingProgress from '../components/ParsingProgress'
import { import {
ArrowLeft, Upload, Play, Loader, FileText, AlertCircle, RefreshCw ArrowLeft, Upload, Play, Loader, FileText, AlertCircle, RefreshCw, ArrowRight
} from 'lucide-react' } from 'lucide-react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { import {
@@ -24,7 +23,6 @@ export const ExamDetail = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [exam, setExam] = useState(null) const [exam, setExam] = useState(null)
const [questions, setQuestions] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [showUploadModal, setShowUploadModal] = useState(false) const [showUploadModal, setShowUploadModal] = useState(false)
@@ -47,13 +45,8 @@ export const ExamDetail = () => {
const loadExamDetail = async () => { const loadExamDetail = async () => {
try { try {
const [examRes, questionsRes] = await Promise.all([ const examRes = await examAPI.getDetail(examId)
examAPI.getDetail(examId),
questionAPI.getExamQuestions(examId, 0, 10) // Load first 10 for preview
])
setExam(examRes.data) setExam(examRes.data)
setQuestions(questionsRes.data.questions)
// Connect to SSE if exam is processing // Connect to SSE if exam is processing
if (examRes.data.status === 'processing') { if (examRes.data.status === 'processing') {
@@ -161,22 +154,18 @@ export const ExamDetail = () => {
if (loading) { if (loading) {
return ( return (
<Layout> <div className="flex items-center justify-center h-screen">
<div className="flex items-center justify-center h-screen"> <Loader className="h-8 w-8 animate-spin text-primary-600" />
<Loader className="h-8 w-8 animate-spin text-primary-600" /> </div>
</div>
</Layout>
) )
} }
if (!exam) { if (!exam) {
return ( return (
<Layout> <div className="flex flex-col items-center justify-center h-screen">
<div className="flex flex-col items-center justify-center h-screen"> <AlertCircle className="h-16 w-16 text-gray-300 mb-4" />
<AlertCircle className="h-16 w-16 text-gray-300 mb-4" /> <p className="text-gray-600">题库不存在</p>
<p className="text-gray-600">题库不存在</p> </div>
</div>
</Layout>
) )
} }
@@ -186,7 +175,7 @@ export const ExamDetail = () => {
const quizProgress = calculateProgress(exam.current_index, exam.total_questions) const quizProgress = calculateProgress(exam.current_index, exam.total_questions)
return ( return (
<Layout> <>
<div className="p-4 md:p-8"> <div className="p-4 md:p-8">
{/* Back Button */} {/* Back Button */}
<button <button
@@ -263,7 +252,7 @@ export const ExamDetail = () => {
</div> </div>
<div className="bg-gray-50 rounded-lg p-4"> <div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-600 mb-1">完成度</p> <p className="text-sm text-gray-600 mb-1">完成度</p>
<p className="text-2xl font-bold text-green-600">{progress}%</p> <p className="text-2xl font-bold text-green-600">{isProcessing ? progress : quizProgress}%</p>
</div> </div>
</div> </div>
@@ -301,47 +290,23 @@ export const ExamDetail = () => {
</div> </div>
)} )}
{/* Questions Preview */} {/* View All Questions Link */}
<div className="bg-white rounded-xl shadow-sm p-6"> <div
<h2 className="text-xl font-bold text-gray-900 mb-4"> className="bg-white rounded-xl shadow-sm p-6 cursor-pointer hover:shadow-md transition-shadow flex items-center justify-between group"
题目预览 {questions.length > 0 && `(前 ${questions.length} 题)`} onClick={() => navigate(`/questions?examId=${examId}`)}
</h2> >
<div className="flex items-center gap-4">
{questions.length === 0 ? ( <div className="bg-blue-100 p-3 rounded-full text-blue-600 group-hover:bg-blue-600 group-hover:text-white transition-colors">
<div className="text-center py-12"> <FileText className="h-6 w-6" />
<FileText className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500">
{isProcessing ? '正在解析文档,请稍候...' : '暂无题目'}
</p>
</div> </div>
) : ( <div>
<div className="space-y-4"> <h2 className="text-lg font-bold text-gray-900">查看题库所有题目</h2>
{questions.map((q, index) => ( <p className="text-gray-600">浏览搜索和查看该题库中的所有题目详情</p>
<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>
<div className="bg-gray-100 p-2 rounded-full text-gray-400 group-hover:bg-blue-100 group-hover:text-blue-600 transition-colors">
<ArrowRight className="h-5 w-5" />
</div>
</div> </div>
</div> </div>
@@ -404,7 +369,7 @@ export const ExamDetail = () => {
</div> </div>
</div> </div>
)} )}
</Layout> </>
) )
} }

View File

@@ -4,7 +4,6 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { examAPI } from '../api/client' import { examAPI } from '../api/client'
import Layout from '../components/Layout'
import { import {
Plus, FolderOpen, Loader, AlertCircle, Trash2, Upload Plus, FolderOpen, Loader, AlertCircle, Trash2, Upload
} from 'lucide-react' } from 'lucide-react'
@@ -27,7 +26,8 @@ export const ExamList = () => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
title: '', title: '',
file: null file: null,
isRandom: false
}) })
useEffect(() => { useEffect(() => {
@@ -94,10 +94,10 @@ export const ExamList = () => {
setCreating(true) setCreating(true)
try { try {
const response = await examAPI.create(formData.title, formData.file) const response = await examAPI.create(formData.title, formData.file, formData.isRandom)
toast.success('题库创建成功,正在解析文档...') toast.success('题库创建成功,正在解析文档...')
setShowCreateModal(false) setShowCreateModal(false)
setFormData({ title: '', file: null }) setFormData({ title: '', file: null, isRandom: false })
// 跳转到新创建的试卷详情页 // 跳转到新创建的试卷详情页
if (response.data && response.data.exam_id) { if (response.data && response.data.exam_id) {
@@ -130,16 +130,14 @@ export const ExamList = () => {
if (loading) { if (loading) {
return ( return (
<Layout> <div className="flex items-center justify-center h-screen">
<div className="flex items-center justify-center h-screen"> <Loader className="h-8 w-8 animate-spin text-primary-600" />
<Loader className="h-8 w-8 animate-spin text-primary-600" /> </div>
</div>
</Layout>
) )
} }
return ( return (
<Layout> <>
<div className="p-4 md:p-8"> <div className="p-4 md:p-8">
{/* Header */} {/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6"> <div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
@@ -149,10 +147,11 @@ export const ExamList = () => {
</div> </div>
<button <button
onClick={() => setShowCreateModal(true)} 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> </button>
</div> </div>
@@ -274,13 +273,44 @@ export const ExamList = () => {
</p> </p>
</div> </div>
{/* Order Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
题目顺序
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={!formData.isRandom}
onChange={() => setFormData({ ...formData, isRandom: false })}
className="text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-gray-700">顺序按文档原序</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={formData.isRandom}
onChange={() => setFormData({ ...formData, isRandom: true })}
className="text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-gray-700">乱序随机打乱</span>
</label>
</div>
<p className="text-xs text-gray-500 mt-1">
注意创建后题目顺序将固定无法再次更改
</p>
</div>
{/* Buttons */} {/* Buttons */}
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setShowCreateModal(false) setShowCreateModal(false)
setFormData({ title: '', file: null }) setShowCreateModal(false)
setFormData({ title: '', file: null, isRandom: false })
}} }}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
> >
@@ -305,7 +335,7 @@ export const ExamList = () => {
</div> </div>
</div> </div>
)} )}
</Layout> </>
) )
} }

View File

@@ -1,7 +1,7 @@
/** /**
* Login Page * Login Page
*/ */
import React, { useState } from 'react' import React, { useState, useEffect } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { BookOpen } from 'lucide-react' import { BookOpen } from 'lucide-react'
@@ -15,21 +15,110 @@ export const Login = () => {
password: '' password: ''
}) })
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [captchaInstance, setCaptchaInstance] = useState(null)
const handleSubmit = async (e) => { useEffect(() => {
e.preventDefault() // 确保 window.initAliyunCaptcha 存在且 DOM 元素已渲染
const initCaptcha = () => {
if (window.initAliyunCaptcha && document.getElementById('captcha-element')) {
try {
window.initAliyunCaptcha({
SceneId: import.meta.env.VITE_ESA_SCENE_ID, // 从环境变量读取场景ID
mode: "popup", // 弹出式
element: "#captcha-element", // 渲染验证码的元素
button: "#login-btn", // 触发验证码的按钮ID
success: async function (captchaVerifyParam) {
// 验证成功后的回调
// 这里我们获取到了验证参数,虽然文档说要发给后端,
// 但 ESA 边缘拦截其实是在请求发出时检查 Cookie/Header
// 对于“一点即过”或“滑块”SDK 会自动处理验证逻辑
// 这里的 verifiedParam 是用来回传给服务端做二次校验的
// 由于我们此时还没有登录逻辑,我们可以在这里直接提交表单
// 即把 verifyParam 存下来,或者直接调用 login
// 注意:由于是 form 的 onSubmit 触发,这里我们其实是在 form 提交被阻止(preventDefault)后
// 由用户点击按钮触发了验证码,验证码成功后再执行真正的登录
// 但 React 的 form 处理通常是 onSubmit
// 我们可以让按钮类型为 button 而不是 submit点击触发验证码
// 验证码成功后手动调用 handleSubmit 的逻辑
console.log('Captcha Success:', captchaVerifyParam);
handleLoginSubmit(captchaVerifyParam);
},
fail: function (result) {
console.error('Captcha Failed:', result);
},
getInstance: function (instance) {
setCaptchaInstance(instance);
},
slideStyle: {
width: 360,
height: 40,
}
});
} catch (error) {
console.error("Captcha init error:", error);
}
}
};
// 如果脚本还没加载完,可能需要等待。为了简单起见,且我们在 index.html 加了 async
// 我们做一个简单的轮询或者依赖 script onload但在 index.html 比较难控制)
// 或者直接延迟一下初始化
const timer = setTimeout(initCaptcha, 500);
return () => clearTimeout(timer);
}, []);
const handleLoginSubmit = async (captchaParam) => {
setLoading(true) setLoading(true)
try { try {
// 这里的 login 可能需要改造以接受验证码参数,或者利用 fetch 的拦截器
// 如果是 ESA 边缘拦截,通常它会看请求里带不带特定的 Header/Cookie
// 文档示例里是手动 fetch 并且带上了 header: 'captcha-Verify-param'
// 暂时我们假设 login 函数内部不需要显式传参(通过 ESA 自动拦截),或者 ESA 需要 headers
// 为了安全,建议把 captchaParam 传给 login让 login 放到 headers 里
// 但现在我们先维持原样,或者您可以把 captchaParam 放到 sessionStorage 里由 axios 拦截器读取
// 注意:上面的 success 回调里我们直接调用了这个,说明验证通过了
const success = await login(formData.username, formData.password) const success = await login(formData.username, formData.password)
if (success) { if (success) {
navigate('/dashboard') navigate('/dashboard')
} }
} finally { } finally {
setLoading(false) setLoading(false)
if(captchaInstance) captchaInstance.refresh(); // 失败或完成后刷新验证码
} }
} }
// 这里的 handleSubmit 变成只是触发验证码(如果也没通过验证的话)
// 但 ESA 示例是绑定 button点击 button 直接出验证码
// 所以我们可以把 type="submit" 变成 type="button" 且 id="login-btn"
const handlePreSubmit = (e) => {
e.preventDefault();
// 此时不需要做任何事,因为按钮被 ESA 接管了,点击会自动弹窗
// 只有验证成功了才会走 success -> handleLoginSubmit
// 但是!如果没填用户名密码怎么办?
// 最好在点击前校验表单。
// ESA 的 button 参数会劫持点击事件。
// 我们可以不绑定 button 参数,而是手动验证表单后,调用 captchaInstance.show() (如果是无痕或弹窗)
// 官方文档说绑定 button 是“触发验证码弹窗或无痕验证的元素”
// 如果我们保留 form submit拦截它如果表单有效则手动 captchaInstance.show() (如果 SDK 支持)
// 文档说“无痕模式首次验证不支持 show/hide”。
// 咱们还是按官方推荐绑定 button但是这会导致校验逻辑变复杂
// 简化方案:为了不破坏现有逻辑,我们不绑定 button ?
// 不,必须绑定。那我们把“登录”按钮作为触发器。
// 可是如果不填表单直接点登录 -> 验证码 -> 成功 -> 提交空表单 -> 报错。流程不太对。
// 更好的流程:
// 用户填表 -> 点击登录 -> 校验表单 -> (有效) -> 弹出验证码 -> (成功) -> 提交后端
// 我们可以做一个不可见的 button 绑定给 ESA验证表单通过后用代码模拟点击这个 button
// 或者直接用 id="login-btn" 绑定当前的登录按钮,
// 但是在 success 回调里检查 formData 是否为空?
}
const handleChange = (e) => { const handleChange = (e) => {
setFormData({ setFormData({
...formData, ...formData,
@@ -55,7 +144,8 @@ export const Login = () => {
<div className="bg-white rounded-2xl shadow-xl p-8"> <div className="bg-white rounded-2xl shadow-xl p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6">登录</h2> <h2 className="text-2xl font-bold text-gray-900 mb-6">登录</h2>
<form onSubmit={handleSubmit} className="space-y-6"> {/* 为了能正确使用 ESA我们将 formonSubmit 移除,改由按钮触发,或者保留 form 但不做提交 */}
<form className="space-y-6" onSubmit={(e) => e.preventDefault()}>
{/* Username */} {/* Username */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
@@ -89,9 +179,14 @@ export const Login = () => {
/> />
</div> </div>
{/* ESA Captcha Container */}
<div id="captcha-element"></div>
{/* Submit Button */} {/* Submit Button */}
{/* 绑定 id="login-btn" 供 ESA 使用 */}
<button <button
type="submit" type="button"
id="login-btn"
disabled={loading} 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" 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"
> >

View File

@@ -2,9 +2,10 @@
* Mistake List Page (错题本) * Mistake List Page (错题本)
*/ */
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { mistakeAPI } from '../api/client' import { mistakeAPI } from '../api/client'
import Layout from '../components/Layout' import Pagination from '../components/Pagination'
import { XCircle, Loader, Trash2, BookOpen } from 'lucide-react' import { XCircle, Loader, Trash2, BookOpen, Play, ChevronRight } from 'lucide-react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { getQuestionTypeText, formatRelativeTime } from '../utils/helpers' import { getQuestionTypeText, formatRelativeTime } from '../utils/helpers'
@@ -12,15 +13,25 @@ export const MistakeList = () => {
const [mistakes, setMistakes] = useState([]) const [mistakes, setMistakes] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState(null) const [expandedId, setExpandedId] = useState(null)
const [showModeModal, setShowModeModal] = useState(false)
// Pagination
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(10)
const [total, setTotal] = useState(0)
const navigate = useNavigate()
useEffect(() => { useEffect(() => {
loadMistakes() loadMistakes()
}, []) }, [page, limit])
const loadMistakes = async () => { const loadMistakes = async () => {
try { try {
const response = await mistakeAPI.getList() setLoading(true)
const skip = (page - 1) * limit
const response = await mistakeAPI.getList(skip, limit)
setMistakes(response.data.mistakes) setMistakes(response.data.mistakes)
setTotal(response.data.total)
} catch (error) { } catch (error) {
console.error('Failed to load mistakes:', error) console.error('Failed to load mistakes:', error)
toast.error('加载错题本失败') toast.error('加载错题本失败')
@@ -50,21 +61,32 @@ export const MistakeList = () => {
if (loading) { if (loading) {
return ( return (
<Layout> <div className="flex items-center justify-center h-screen">
<div className="flex items-center justify-center h-screen"> <Loader className="h-8 w-8 animate-spin text-primary-600" />
<Loader className="h-8 w-8 animate-spin text-primary-600" /> </div>
</div>
</Layout>
) )
} }
return ( return (
<Layout> <>
<div className="p-4 md:p-8"> <div className="p-4 md:p-8">
{/* Header */} {/* Header */}
<div className="mb-6"> <div className="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">错题本</h1> <div>
<p className="text-gray-600 mt-1"> {mistakes.length} 错题</p> <h1 className="text-2xl md:text-3xl font-bold text-gray-900">错题</h1>
<p className="text-gray-600 mt-1"> {total} 道错题</p>
</div>
{mistakes.length > 0 && (
<button
onClick={() => setShowModeModal(true)}
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-4 w-4 md:h-5 md:w-5" />
<span className="hidden md:inline">开始刷错题</span>
<span className="md:hidden">刷题</span>
</button>
)}
</div> </div>
{/* Empty State */} {/* Empty State */}
@@ -160,10 +182,60 @@ export const MistakeList = () => {
</div> </div>
) )
})} })}
{/* Pagination */}
<Pagination
currentPage={page}
totalItems={total}
pageSize={limit}
onPageChange={setPage}
onPageSizeChange={(newLimit) => {
setLimit(newLimit)
setPage(1)
}}
/>
</div> </div>
)} )}
</div> </div>
</Layout>
{/* Mode Selection Modal */}
{showModeModal && (
<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-sm w-full p-6">
<h2 className="text-xl font-bold mb-4 text-center">选择刷题模式</h2>
<div className="space-y-3">
<button
onClick={() => navigate('/mistake-quiz?mode=sequential')}
className="w-full p-4 border-2 border-primary-100 bg-primary-50 rounded-xl hover:bg-primary-100 transition-colors flex items-center justify-between group"
>
<div className="text-left">
<p className="font-bold text-primary-900">顺序刷题</p>
<p className="text-sm text-primary-700">按照加入错题本的时间顺序</p>
</div>
<ChevronRight className="h-5 w-5 text-primary-400 group-hover:text-primary-600" />
</button>
<button
onClick={() => navigate('/mistake-quiz?mode=random')}
className="w-full p-4 border-2 border-purple-100 bg-purple-50 rounded-xl hover:bg-purple-100 transition-colors flex items-center justify-between group"
>
<div className="text-left">
<p className="font-bold text-purple-900">随机刷题</p>
<p className="text-sm text-purple-700">打乱顺序进行练习</p>
</div>
<ChevronRight className="h-5 w-5 text-purple-400 group-hover:text-purple-600" />
</button>
</div>
<button
onClick={() => setShowModeModal(false)}
className="mt-4 w-full py-2 text-gray-500 hover:text-gray-700"
>
取消
</button>
</div>
</div>
)}
</>
) )
} }

View File

@@ -0,0 +1,412 @@
/**
* Mistake Player Page - Re-do wrong questions
*/
import React, { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { mistakeAPI, questionAPI } from '../api/client'
import {
ArrowLeft, ArrowRight, Check, X, Loader, Trash2, AlertCircle
} from 'lucide-react'
import toast from 'react-hot-toast'
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 [randomMistakes, setRandomMistakes] = useState([]) // Store full mistake objects
const [submitting, setSubmitting] = useState(false)
const [userAnswer, setUserAnswer] = useState('')
const [multipleAnswers, setMultipleAnswers] = useState([])
const [result, setResult] = useState(null)
useEffect(() => {
loadMistake()
}, [currentIndex, mode])
const loadMistake = async () => {
try {
setLoading(true)
let currentMistake = null
if (mode === 'random') {
// Random Mode Logic
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 mistakes
const shuffled = [...allMistakes]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
setRandomMistakes(shuffled)
currentMistake = shuffled[0]
}
} else {
// Subsequent loads: use stored mistakes
if (currentIndex < randomMistakes.length) {
currentMistake = randomMistakes[currentIndex]
}
}
} else {
// Sequential Mode Logic
const response = await mistakeAPI.getList(currentIndex, 1)
setTotal(response.data.total)
if (response.data.mistakes.length > 0) {
currentMistake = response.data.mistakes[0]
}
}
if (currentMistake) {
// Ensure options exist for judge type
if (currentMistake.question.type === 'judge' && (!currentMistake.question.options || currentMistake.question.options.length === 0)) {
currentMistake.question.options = ['A. 正确', 'B. 错误']
}
setMistake(currentMistake)
console.log('Mistake loaded:', currentMistake)
setResult(null)
setUserAnswer('')
setMultipleAnswers([])
} else {
setMistake(null)
}
} catch (error) {
console.error('Failed to load mistake:', error)
toast.error('加载错题失败')
} finally {
setLoading(false)
console.log('Loading finished')
}
}
const handleSubmitAnswer = async () => {
let answer = userAnswer
if (mistake.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(mistake.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 = () => {
if (currentIndex < total - 1) {
setCurrentIndex(prev => prev + 1)
} else {
toast.success('已完成所有错题!')
navigate('/mistakes')
}
}
const handleRemove = async () => {
if (!window.confirm('确定要从错题本中移除这道题吗?')) {
return
}
try {
await mistakeAPI.remove(mistake.id)
toast.success('已移除')
// Reload current index (which will now be the next item or empty)
// 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 = randomMistakes.filter(m => m.id !== mistake.id)
setRandomMistakes(newRandomList)
setTotal(newRandomList.length)
if (currentIndex >= newRandomList.length && newRandomList.length > 0) {
setCurrentIndex(prev => prev - 1)
} else if (newRandomList.length === 0) {
setMistake(null)
} else {
// Force reload with new list
const nextMistake = newRandomList[currentIndex]
if (nextMistake.question.type === 'judge' && (!nextMistake.question.options || nextMistake.question.options.length === 0)) {
nextMistake.question.options = ['A. 正确', 'B. 错误']
}
setMistake(nextMistake)
setResult(null)
setUserAnswer('')
setMultipleAnswers([])
}
} else {
if (currentIndex >= total - 1 && total > 1) {
setCurrentIndex(prev => prev - 1)
} else {
loadMistake()
}
}
} catch (error) {
console.error('Failed to remove mistake:', error)
toast.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 && !mistake) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<Loader className="h-8 w-8 animate-spin text-primary-600" />
</div>
)
}
if (!mistake) {
return (
<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
onClick={() => navigate('/mistakes')}
className="mt-4 text-primary-600 hover:text-primary-700 font-medium"
>
返回错题列表
</button>
</div>
)
}
const question = mistake.question
if (!question) {
return (
<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">
<button
onClick={() => navigate('/mistakes')}
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">
进度: {currentIndex + 1} / {total}
</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-red-100 text-red-600 rounded-full flex items-center justify-center font-bold">
{currentIndex + 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={handleRemove}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
>
<Trash2 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 */}
{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>
)}
{/* 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 */}
{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"
>
{currentIndex < total - 1 ? '下一题' : '完成复习'}
<ArrowRight className="h-5 w-5" />
</button>
</div>
)}
</div>
</>
)
}
export default MistakePlayer

View File

@@ -0,0 +1,156 @@
/**
* Question Bank Page - View all questions
*/
import React, { useState, useEffect } from 'react'
import { questionAPI } from '../api/client'
import Pagination from '../components/Pagination'
import { FileText, Loader, Search } from 'lucide-react'
import toast from 'react-hot-toast'
import { getQuestionTypeText, formatRelativeTime } from '../utils/helpers'
export const QuestionBank = () => {
const [questions, setQuestions] = useState([])
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState(null)
// Pagination
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(10)
const [total, setTotal] = useState(0)
useEffect(() => {
loadQuestions()
}, [page, limit])
const loadQuestions = async () => {
try {
setLoading(true)
const skip = (page - 1) * limit
const response = await questionAPI.getAll(skip, limit)
setQuestions(response.data.questions)
setTotal(response.data.total)
} catch (error) {
console.error('Failed to load questions:', error)
toast.error('加载题库失败')
} finally {
setLoading(false)
}
}
const toggleExpand = (id) => {
setExpandedId(expandedId === id ? null : id)
}
if (loading && questions.length === 0) {
return (
<div className="flex items-center justify-center h-screen">
<Loader className="h-8 w-8 animate-spin text-primary-600" />
</div>
)
}
return (
<>
<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"> {total} 道题目</p>
</div>
{/* List */}
<div className="space-y-4">
{questions.map((q) => {
const isExpanded = expandedId === q.id
return (
<div
key={q.id}
className="bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow"
>
<div
className="p-4 md:p-6 cursor-pointer"
onClick={() => toggleExpand(q.id)}
>
<div className="flex items-start gap-3">
<span className="flex-shrink-0 w-10 h-10 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center">
<FileText 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">
ID: {q.id}
</span>
<span className="text-xs text-gray-500">
{formatRelativeTime(q.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>
</div>
</div>
</div>
)
})}
</div>
{/* Pagination */}
<Pagination
currentPage={page}
totalItems={total}
pageSize={limit}
onPageChange={setPage}
onPageSizeChange={(newLimit) => {
setLimit(newLimit)
setPage(1)
}}
/>
</div>
</>
)
}
export default QuestionBank

View File

@@ -4,7 +4,6 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom' import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { examAPI, questionAPI, mistakeAPI } from '../api/client' import { examAPI, questionAPI, mistakeAPI } from '../api/client'
import Layout from '../components/Layout'
import { import {
ArrowLeft, ArrowRight, Check, X, Loader, BookmarkPlus, BookmarkX, AlertCircle ArrowLeft, ArrowRight, Check, X, Loader, BookmarkPlus, BookmarkX, AlertCircle
} from 'lucide-react' } from 'lucide-react'
@@ -52,6 +51,10 @@ export const QuizPlayer = () => {
const loadCurrentQuestion = async () => { const loadCurrentQuestion = async () => {
try { try {
const response = await questionAPI.getCurrentQuestion(examId) const response = await questionAPI.getCurrentQuestion(examId)
// For judge questions, ensure options exist
if (response.data.type === 'judge' && (!response.data.options || response.data.options.length === 0)) {
response.data.options = ['A. 正确', 'B. 错误']
}
setQuestion(response.data) setQuestion(response.data)
setResult(null) setResult(null)
setUserAnswer('') setUserAnswer('')
@@ -155,27 +158,23 @@ export const QuizPlayer = () => {
if (loading) { if (loading) {
return ( return (
<Layout> <div className="flex items-center justify-center h-screen">
<div className="flex items-center justify-center h-screen"> <Loader className="h-8 w-8 animate-spin text-primary-600" />
<Loader className="h-8 w-8 animate-spin text-primary-600" /> </div>
</div>
</Layout>
) )
} }
if (!question) { if (!question) {
return ( return (
<Layout> <div className="flex flex-col items-center justify-center h-screen">
<div className="flex flex-col items-center justify-center h-screen"> <AlertCircle className="h-16 w-16 text-gray-300 mb-4" />
<AlertCircle className="h-16 w-16 text-gray-300 mb-4" /> <p className="text-gray-600">没有更多题目了</p>
<p className="text-gray-600">没有更多题目了</p> </div>
</div>
</Layout>
) )
} }
return ( return (
<Layout> <>
<div className="max-w-4xl mx-auto p-4 md:p-8"> <div className="max-w-4xl mx-auto p-4 md:p-8">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
@@ -207,11 +206,10 @@ export const QuizPlayer = () => {
<button <button
onClick={handleToggleMistake} onClick={handleToggleMistake}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${ className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${inMistakeBook
inMistakeBook ? 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200'
? 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200' }`}
}`}
> >
{inMistakeBook ? ( {inMistakeBook ? (
<> <>
@@ -256,11 +254,10 @@ export const QuizPlayer = () => {
} }
}} }}
disabled={!!result} disabled={!!result}
className={`w-full text-left p-4 rounded-lg border-2 transition-all ${ className={`w-full text-left p-4 rounded-lg border-2 transition-all ${isSelected
isSelected ? 'border-primary-500 bg-primary-50'
? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
: 'border-gray-200 hover:border-gray-300' } ${result ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}`}
} ${result ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}`}
> >
<span className="text-gray-900">{option}</span> <span className="text-gray-900">{option}</span>
</button> </button>
@@ -282,31 +279,7 @@ export const QuizPlayer = () => {
</div> </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 */} {/* Submit Button */}
{!result && ( {!result && (
@@ -332,9 +305,8 @@ export const QuizPlayer = () => {
{/* Result */} {/* Result */}
{result && ( {result && (
<div className={`rounded-xl p-6 mb-6 ${ <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'
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"> <div className="flex items-start gap-3 mb-4">
{result.correct ? ( {result.correct ? (
<Check className="h-6 w-6 text-green-600 mt-0.5" /> <Check className="h-6 w-6 text-green-600 mt-0.5" />
@@ -390,7 +362,7 @@ export const QuizPlayer = () => {
</div> </div>
)} )}
</div> </div>
</Layout> </>
) )
} }

View File

@@ -74,7 +74,7 @@ export const getQuestionTypeText = (type) => {
const texts = { const texts = {
single: '单选题', single: '单选题',
multiple: '多选题', multiple: '多选题',
judge: '判断题', judge: '判断题 (单选)',
short: '简答题' short: '简答题'
} }
return texts[type] || type return texts[type] || type

View File

@@ -1,19 +1,35 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [react()], // Assume running from frontend directory
server: { const envDir = path.resolve(process.cwd(), '..')
host: '0.0.0.0', const env = loadEnv(mode, envDir, '')
port: 3000,
proxy: { return {
'/api': { envDir, // Tell Vite to look for .env files in the project root
target: process.env.REACT_APP_API_URL || 'http://localhost:8000', plugins: [
changeOrigin: true, react(),
{
name: 'html-transform',
transformIndexHtml(html) {
return html.replace(/%VITE_ESA_PREFIX%/g, env.VITE_ESA_PREFIX || '')
},
} }
],
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
target: env.VITE_API_URL || env.REACT_APP_API_URL || 'http://localhost:8000',
changeOrigin: true,
}
}
},
build: {
outDir: 'build'
} }
},
build: {
outDir: 'build'
} }
}) })