mirror of
https://github.com/handsomezhuzhu/QQuiz.git
synced 2026-04-19 06:52:54 +00:00
Compare commits
8 Commits
copilot/su
...
466fa50aa8
| Author | SHA1 | Date | |
|---|---|---|---|
| 466fa50aa8 | |||
| 3e4157f021 | |||
| 3d47e568f6 | |||
| e88716b1ea | |||
|
|
4b53e74729 | ||
| 4606407356 | |||
|
|
7d924bb81e | ||
|
|
66f9a64c1c |
@@ -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=
|
||||||
|
|||||||
@@ -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 ./
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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(...),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 # 数据库文件
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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 } }),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
87
frontend/src/components/Pagination.jsx
Normal file
87
frontend/src/components/Pagination.jsx
Normal 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
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,我们将 form 的 onSubmit 移除,改由按钮触发,或者保留 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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
412
frontend/src/pages/MistakePlayer.jsx
Normal file
412
frontend/src/pages/MistakePlayer.jsx
Normal 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
|
||||||
156
frontend/src/pages/QuestionBank.jsx
Normal file
156
frontend/src/pages/QuestionBank.jsx
Normal 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
|
||||||
@@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user