mirror of
https://github.com/handsomezhuzhu/QQuiz.git
synced 2026-04-18 14:32:54 +00:00
refactor: remove legacy frontend code and implement new Next.js structure
- Deleted the old Register page and utility functions. - Removed Tailwind CSS configuration and Vite configuration files. - Added a new script for starting a single container with FastAPI and Next.js. - Updated README to reflect the current status of the Next.js frontend. - Implemented new login and registration API routes with improved error handling. - Refactored frontend API calls to use the new proxy structure. - Enhanced error handling in API response processing. - Updated components to align with the new API endpoints and structure.
This commit is contained in:
19
.github/workflows/docker-publish.yml
vendored
19
.github/workflows/docker-publish.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Build and Publish Docker Images
|
||||
name: Build and Publish Single-Container Image
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -18,15 +18,6 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- image_suffix: backend
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
- image_suffix: frontend
|
||||
context: ./web
|
||||
file: ./web/Dockerfile
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -46,7 +37,7 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-${{ matrix.image_suffix }}
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
@@ -57,13 +48,13 @@ jobs:
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.file }}
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.image_suffix }}
|
||||
cache-to: type=gha,mode=max,scope=single-container
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Image digest
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -44,8 +44,6 @@ yarn-error.log
|
||||
.pnp.js
|
||||
|
||||
# Build
|
||||
frontend/build/
|
||||
frontend/dist/
|
||||
.next/
|
||||
web/.next/
|
||||
web/out/
|
||||
|
||||
16
AGENTS.md
16
AGENTS.md
@@ -2,19 +2,19 @@
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- `backend/`: FastAPI API. Keep HTTP entrypoints in `routers/`, reusable business logic in `services/`, database definitions in `models.py`, request/response schemas in `schemas.py`, and migrations in `alembic/`.
|
||||
- `frontend/`: React 18 + Vite client. Put route screens in `src/pages/`, shared UI in `src/components/`, auth state in `src/context/`, API wrappers in `src/api/`, and helpers in `src/utils/`.
|
||||
- `docs/` holds deployment and architecture notes, `scripts/run_local.sh` bootstraps local Linux/macOS development, `test_data/` contains sample question files, and `.github/workflows/docker-publish.yml` publishes container images.
|
||||
- `web/`: Next.js frontend for active development. Keep route screens under `src/app/` or related route segments, shared UI in `src/components/`, API wrappers in `src/lib/`, and helpers close to their consumers.
|
||||
- `docs/` holds deployment and architecture notes, `scripts/run_local.sh` bootstraps local Linux/macOS development, `test_data/` contains sample question files, and `.github/workflows/docker-publish.yml` publishes the single-container image.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `docker compose up -d --build`: start MySQL, backend on `:8000`, and frontend on `:3000`.
|
||||
- `docker compose -f docker-compose-single.yml up -d --build`: start the single-container SQLite deployment.
|
||||
- `docker compose -f docker-compose-single.yml up -d --build`: start the default single-container deployment with FastAPI proxying the embedded Next.js frontend.
|
||||
- `docker compose up -d --build`: start the split development stack with backend on `:8000` and frontend on `:3000`.
|
||||
- `cd backend && pip install -r requirements.txt && alembic upgrade head && uvicorn main:app --reload --host 0.0.0.0 --port 8000`: run the API locally.
|
||||
- `cd frontend && npm install && npm run dev`: start the Vite dev server.
|
||||
- `cd frontend && npm run build`: create a production frontend bundle.
|
||||
- `cd web && npm install && npm run dev`: start the Next.js dev server.
|
||||
- `cd web && npm run build`: create a production frontend bundle.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Python uses 4-space indentation, `snake_case` for modules/functions, and `PascalCase` for ORM or Pydantic classes.
|
||||
- React files use `PascalCase.jsx` for pages/components and `camelCase` for state, helpers, and API wrappers.
|
||||
- React and Next.js files use the naming conventions already established in `web/`; preserve route segment and component naming patterns in place.
|
||||
- Keep route handlers thin: validation in schemas, orchestration in routers, reusable logic in `backend/services/`.
|
||||
- No formatter or lint script is enforced today, so match surrounding style before making broad formatting changes.
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
- The repository currently has no committed automated test suite or coverage gate.
|
||||
- Before opening a PR, smoke-test auth, exam creation/upload, parsing progress, quiz playback, mistake review, and admin settings.
|
||||
- Use `test_data/sample_questions*.txt` for parser and import checks.
|
||||
- If you add tests, place backend tests under `backend/tests/test_*.py` and frontend tests under `frontend/src/__tests__/`.
|
||||
- If you add tests, place backend tests under `backend/tests/test_*.py` and frontend tests under `web/src/__tests__/`.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Recent history favors short, focused subjects, often imperative and sometimes Chinese, such as `安全修复和管理员账号密码自定义`.
|
||||
|
||||
51
Dockerfile
51
Dockerfile
@@ -1,51 +1,50 @@
|
||||
# ==================== 多阶段构建:前后端整合单容器 ====================
|
||||
# Stage 1: 构建前端
|
||||
FROM node:18-slim AS frontend-builder
|
||||
# ==================== 多阶段构建:单容器运行 FastAPI + Next.js ====================
|
||||
# Stage 1: 构建 Next.js 前端
|
||||
FROM node:20-slim AS web-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
WORKDIR /web
|
||||
|
||||
# 复制前端依赖文件
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# 安装依赖
|
||||
COPY web/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# 复制前端源代码
|
||||
COPY frontend/ ./
|
||||
COPY web/ ./
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# 构建前端(生成静态文件到 dist 目录)
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: 构建后端并整合前端
|
||||
FROM python:3.11-slim
|
||||
# Stage 2: 运行 FastAPI + Next.js
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装操作系统依赖(python-magic 需要 libmagic)
|
||||
# 安装 Python 运行时和操作系统依赖
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libmagic1 \
|
||||
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv libmagic1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 复制后端依赖文件
|
||||
COPY backend/requirements.txt ./
|
||||
RUN python3 -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# 安装 Python 依赖(使用预编译wheel包,无需gcc)
|
||||
RUN pip install -r requirements.txt
|
||||
# 安装后端依赖
|
||||
COPY backend/requirements.txt ./backend-requirements.txt
|
||||
RUN python -m pip install --no-cache-dir -r backend-requirements.txt
|
||||
|
||||
# 复制后端代码
|
||||
# 复制后端代码和启动脚本
|
||||
COPY backend/ ./
|
||||
COPY scripts/start_single_container.py ./scripts/start_single_container.py
|
||||
|
||||
# 从前端构建阶段复制静态文件到后端 static 目录
|
||||
COPY --from=frontend-builder /frontend/build ./static
|
||||
# 复制 Next.js standalone 产物
|
||||
COPY --from=web-builder /web/.next/standalone ./web
|
||||
COPY --from=web-builder /web/.next/static ./web/.next/static
|
||||
|
||||
# 创建上传目录
|
||||
RUN mkdir -p ./uploads
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000
|
||||
|
||||
# 设置环境变量
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV NEXT_SERVER_URL=http://127.0.0.1:3000
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# 启动命令
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["python", "scripts/start_single_container.py"]
|
||||
|
||||
94
README.md
94
README.md
@@ -16,7 +16,9 @@ QQuiz 是一个用于题库导入、刷题训练和错题管理的全栈应用
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 方式一:直接运行 GitHub Actions 构建好的镜像
|
||||
QQuiz 默认以单容器形式发布和部署。GitHub Actions 只构建根目录 `Dockerfile` 生成的单容器镜像,README 也以这个路径为主。
|
||||
|
||||
### 方式一:直接运行 GitHub Actions 构建好的单容器镜像
|
||||
|
||||
适合只想快速启动,不想先克隆仓库。
|
||||
|
||||
@@ -58,58 +60,61 @@ GEMINI_API_KEY=your-real-gemini-api-key
|
||||
#### 3. 拉取镜像
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/handsomezhuzhu/qquiz-backend:latest
|
||||
docker pull ghcr.io/handsomezhuzhu/qquiz-frontend:latest
|
||||
docker pull ghcr.io/handsomezhuzhu/qquiz:latest
|
||||
```
|
||||
|
||||
#### 4. 创建网络和数据卷
|
||||
#### 4. 创建数据卷
|
||||
|
||||
```bash
|
||||
docker network create qquiz_net
|
||||
docker volume create qquiz_sqlite_data
|
||||
docker volume create qquiz_upload_files
|
||||
docker volume create qquiz_data
|
||||
docker volume create qquiz_uploads
|
||||
```
|
||||
|
||||
#### 5. 启动后端
|
||||
#### 5. 启动容器
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name qquiz_backend \
|
||||
--network qquiz_net \
|
||||
--name qquiz \
|
||||
--env-file .env \
|
||||
-e DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db \
|
||||
-e UPLOAD_DIR=/app/uploads \
|
||||
-v qquiz_sqlite_data:/app/data \
|
||||
-v qquiz_upload_files:/app/uploads \
|
||||
-v qquiz_data:/app/data \
|
||||
-v qquiz_uploads:/app/uploads \
|
||||
-p 8000:8000 \
|
||||
ghcr.io/handsomezhuzhu/qquiz-backend:latest
|
||||
```
|
||||
|
||||
#### 6. 启动前端
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name qquiz_frontend \
|
||||
--network qquiz_net \
|
||||
-e API_BASE_URL=http://qquiz_backend:8000 \
|
||||
-p 3000:3000 \
|
||||
ghcr.io/handsomezhuzhu/qquiz-frontend:latest
|
||||
--restart unless-stopped \
|
||||
ghcr.io/handsomezhuzhu/qquiz:latest
|
||||
```
|
||||
|
||||
访问:
|
||||
|
||||
- 前端:`http://localhost:3000`
|
||||
- 后端:`http://localhost:8000`
|
||||
- 应用:`http://localhost:8000`
|
||||
- API 文档:`http://localhost:8000/docs`
|
||||
|
||||
停止:
|
||||
|
||||
```bash
|
||||
docker rm -f qquiz_frontend qquiz_backend
|
||||
docker rm -f qquiz
|
||||
```
|
||||
|
||||
### 方式二:从源码用 Docker Compose 启动
|
||||
### 方式二:从源码启动单容器
|
||||
|
||||
#### 前后端分离,推荐
|
||||
适合需要自行构建镜像或修改代码后再部署。
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose-single.yml up -d --build
|
||||
```
|
||||
|
||||
访问:
|
||||
|
||||
- 应用:`http://localhost:8000`
|
||||
- API 文档:`http://localhost:8000/docs`
|
||||
|
||||
### 可选:开发或兼容性部署
|
||||
|
||||
以下方式保留用于开发调试或兼容场景,不再作为默认部署方案:
|
||||
|
||||
#### 前后端分离开发栈
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
@@ -121,24 +126,14 @@ docker compose up -d --build
|
||||
- 前端:`http://localhost:3000`
|
||||
- 后端:`http://localhost:8000`
|
||||
|
||||
#### 使用 MySQL
|
||||
#### 分离栈叠加 MySQL
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.yml -f docker-compose.mysql.yml up -d --build
|
||||
```
|
||||
|
||||
#### 单容器模式
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose-single.yml up -d --build
|
||||
```
|
||||
|
||||
访问:
|
||||
|
||||
- 应用:`http://localhost:8000`
|
||||
- API 文档:`http://localhost:8000/docs`
|
||||
MySQL 相关说明见 [docs/MYSQL_SETUP.md](docs/MYSQL_SETUP.md)。
|
||||
|
||||
## 本地开发
|
||||
|
||||
@@ -163,8 +158,8 @@ npm run dev
|
||||
|
||||
说明:
|
||||
|
||||
- `web/` 是当前主前端,基于 Next.js
|
||||
- `frontend/` 是保留中的旧 Vite 前端,主要用于单容器兼容路径
|
||||
- `web/` 是唯一前端工程,基于 Next.js
|
||||
- 单容器镜像会在同一个容器里运行 FastAPI 和 Next.js,并由 FastAPI 代理前端请求
|
||||
|
||||
## 关键环境变量
|
||||
|
||||
@@ -191,14 +186,13 @@ npm run dev
|
||||
```text
|
||||
QQuiz/
|
||||
├─ backend/ FastAPI 后端
|
||||
├─ web/ Next.js 前端
|
||||
├─ frontend/ Legacy Vite 前端
|
||||
├─ web/ Next.js 前端工程
|
||||
├─ docs/ 文档与截图
|
||||
├─ test_data/ 示例题库文件
|
||||
├─ docker-compose.yml 前后端分离部署
|
||||
├─ docker-compose.mysql.yml MySQL overlay
|
||||
├─ docker-compose-single.yml 单容器部署
|
||||
└─ Dockerfile 单容器镜像构建
|
||||
├─ docker-compose-single.yml 单容器部署(默认)
|
||||
├─ Dockerfile 单容器镜像构建(默认)
|
||||
├─ docker-compose.yml 前后端分离开发/兼容部署
|
||||
└─ docker-compose.mysql.yml MySQL overlay(可选)
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
@@ -210,7 +204,7 @@ QQuiz/
|
||||
|
||||
```bash
|
||||
cd web && npm run build
|
||||
docker compose build backend frontend
|
||||
docker compose -f docker-compose-single.yml build
|
||||
```
|
||||
|
||||
建议至少手动验证:
|
||||
|
||||
208
backend/main.py
208
backend/main.py
@@ -1,16 +1,16 @@
|
||||
"""
|
||||
QQuiz FastAPI Application - 单容器模式(前后端整合)
|
||||
QQuiz FastAPI Application - single-container API and frontend proxy.
|
||||
"""
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from contextlib import asynccontextmanager
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
import httpx
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from database import init_db, init_default_config, get_db_context
|
||||
from rate_limit import limiter
|
||||
@@ -18,6 +18,22 @@ from rate_limit import limiter
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
NEXT_SERVER_URL = os.getenv("NEXT_SERVER_URL", "http://127.0.0.1:3000").rstrip("/")
|
||||
INTERNAL_API_URL = os.getenv("INTERNAL_API_URL", "http://127.0.0.1:8000").rstrip("/")
|
||||
SESSION_COOKIE_NAME = "access_token"
|
||||
FRONTEND_PROXY_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||
HOP_BY_HOP_HEADERS = {
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"content-length",
|
||||
}
|
||||
|
||||
|
||||
async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
|
||||
return JSONResponse(
|
||||
@@ -32,6 +48,11 @@ async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
print("🚀 Starting QQuiz Application...")
|
||||
|
||||
app.state.frontend_client = httpx.AsyncClient(
|
||||
follow_redirects=False,
|
||||
timeout=httpx.Timeout(30.0, connect=5.0),
|
||||
)
|
||||
|
||||
# Initialize database
|
||||
await init_db()
|
||||
|
||||
@@ -49,6 +70,7 @@ async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
await app.state.frontend_client.aclose()
|
||||
print("👋 Shutting down QQuiz Application...")
|
||||
|
||||
|
||||
@@ -89,44 +111,152 @@ app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "healthy"}
|
||||
|
||||
try:
|
||||
response = await app.state.frontend_client.get(
|
||||
f"{NEXT_SERVER_URL}/login",
|
||||
headers={"Accept-Encoding": "identity"},
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"status": "degraded",
|
||||
"api": "healthy",
|
||||
"frontend": "unavailable",
|
||||
},
|
||||
)
|
||||
|
||||
frontend_status = "healthy" if response.status_code < 500 else "unavailable"
|
||||
if frontend_status != "healthy":
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"status": "degraded",
|
||||
"api": "healthy",
|
||||
"frontend": frontend_status,
|
||||
},
|
||||
)
|
||||
|
||||
return {"status": "healthy", "api": "healthy", "frontend": "healthy"}
|
||||
|
||||
|
||||
# ============ 静态文件服务(前后端整合) ============
|
||||
def build_frontend_target(request: Request, full_path: str) -> str:
|
||||
normalized_path = f"/{full_path}" if full_path else "/"
|
||||
query = request.url.query
|
||||
return f"{NEXT_SERVER_URL}{normalized_path}{f'?{query}' if query else ''}"
|
||||
|
||||
# 检查静态文件目录是否存在
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
if STATIC_DIR.exists():
|
||||
# 挂载静态资源(JS、CSS、图片等)
|
||||
app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="static_assets")
|
||||
|
||||
# 前端应用的所有路由(SPA路由)
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_frontend(full_path: str):
|
||||
"""
|
||||
服务前端应用
|
||||
- API 路由已在上面定义,优先匹配
|
||||
- 其他所有路由返回 index.html(SPA 单页应用)
|
||||
"""
|
||||
index_file = STATIC_DIR / "index.html"
|
||||
if index_file.exists():
|
||||
return FileResponse(index_file)
|
||||
else:
|
||||
return {
|
||||
"message": "Frontend not built yet",
|
||||
"hint": "Run 'cd frontend && npm run build' to build the frontend"
|
||||
def build_internal_api_target(request: Request, full_path: str, trailing_slash: bool = False) -> str:
|
||||
normalized_path = full_path.strip("/")
|
||||
if trailing_slash and normalized_path:
|
||||
normalized_path = f"{normalized_path}/"
|
||||
query = request.url.query
|
||||
return f"{INTERNAL_API_URL}/api/{normalized_path}{f'?{query}' if query else ''}"
|
||||
|
||||
|
||||
def filter_proxy_headers(request: Request) -> dict[str, str]:
|
||||
headers = {
|
||||
key: value
|
||||
for key, value in request.headers.items()
|
||||
if key.lower() not in HOP_BY_HOP_HEADERS and key.lower() != "host"
|
||||
}
|
||||
else:
|
||||
print("⚠️ 静态文件目录不存在,前端功能不可用")
|
||||
print("提示:请先构建前端应用或使用开发模式")
|
||||
# Avoid sending compressed payloads through the proxy so response headers stay accurate.
|
||||
headers["Accept-Encoding"] = "identity"
|
||||
return headers
|
||||
|
||||
# 如果没有静态文件,显示 API 信息
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"message": "Welcome to QQuiz API",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"note": "Frontend not built. Please build frontend or use docker-compose."
|
||||
}
|
||||
|
||||
def apply_proxy_headers(proxy_response: StreamingResponse, upstream_headers: httpx.Headers) -> None:
|
||||
proxy_response.raw_headers = [
|
||||
(key.encode("latin-1"), value.encode("latin-1"))
|
||||
for key, value in upstream_headers.multi_items()
|
||||
if key.lower() not in HOP_BY_HOP_HEADERS
|
||||
]
|
||||
|
||||
|
||||
@app.api_route("/frontend-api/proxy/{full_path:path}", methods=FRONTEND_PROXY_METHODS, include_in_schema=False)
|
||||
async def proxy_browser_api(request: Request, full_path: str):
|
||||
"""
|
||||
Serve browser-originated API calls directly from FastAPI in single-container mode.
|
||||
This avoids relying on Next.js route handlers for the /frontend-api/proxy/* namespace.
|
||||
"""
|
||||
target = build_internal_api_target(request, full_path)
|
||||
body = await request.body()
|
||||
client: httpx.AsyncClient = app.state.frontend_client
|
||||
headers = filter_proxy_headers(request)
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
try:
|
||||
async def send_request(target_url: str) -> httpx.Response:
|
||||
upstream_request = client.build_request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
headers=headers,
|
||||
content=body if body else None,
|
||||
)
|
||||
return await client.send(
|
||||
upstream_request,
|
||||
stream=True,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
upstream_response = await send_request(target)
|
||||
if (
|
||||
request.method in {"GET", "HEAD"}
|
||||
and upstream_response.status_code == 404
|
||||
and full_path
|
||||
and not full_path.endswith("/")
|
||||
):
|
||||
await upstream_response.aclose()
|
||||
upstream_response = await send_request(
|
||||
build_internal_api_target(request, full_path, trailing_slash=True)
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"detail": "Backend API is unavailable."},
|
||||
)
|
||||
|
||||
proxy_response = StreamingResponse(
|
||||
upstream_response.aiter_raw(),
|
||||
status_code=upstream_response.status_code,
|
||||
background=BackgroundTask(upstream_response.aclose),
|
||||
)
|
||||
apply_proxy_headers(proxy_response, upstream_response.headers)
|
||||
return proxy_response
|
||||
|
||||
|
||||
@app.api_route("/", methods=FRONTEND_PROXY_METHODS, include_in_schema=False)
|
||||
@app.api_route("/{full_path:path}", methods=FRONTEND_PROXY_METHODS, include_in_schema=False)
|
||||
async def proxy_frontend(request: Request, full_path: str = ""):
|
||||
"""
|
||||
Forward all non-API traffic to the embedded Next.js server.
|
||||
FastAPI keeps ownership of /api/*, /docs, /openapi.json, /redoc and /health.
|
||||
"""
|
||||
target = build_frontend_target(request, full_path)
|
||||
body = await request.body()
|
||||
client: httpx.AsyncClient = app.state.frontend_client
|
||||
|
||||
try:
|
||||
upstream_request = client.build_request(
|
||||
method=request.method,
|
||||
url=target,
|
||||
headers=filter_proxy_headers(request),
|
||||
content=body if body else None,
|
||||
)
|
||||
upstream_response = await client.send(upstream_request, stream=True)
|
||||
except httpx.HTTPError:
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"detail": "Frontend server is unavailable."},
|
||||
)
|
||||
|
||||
proxy_response = StreamingResponse(
|
||||
upstream_response.aiter_raw(),
|
||||
status_code=upstream_response.status_code,
|
||||
background=BackgroundTask(upstream_response.aclose),
|
||||
)
|
||||
apply_proxy_headers(proxy_response, upstream_response.headers)
|
||||
return proxy_response
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# ==================== 单容器部署配置 ====================
|
||||
# 使用方法:docker-compose -f docker-compose-single.yml up -d
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
qquiz:
|
||||
build:
|
||||
@@ -34,4 +32,8 @@ services:
|
||||
|
||||
volumes:
|
||||
qquiz_data:
|
||||
# Reuse the previous split-stack SQLite volume during migration.
|
||||
name: qquiz_sqlite_data
|
||||
qquiz_uploads:
|
||||
# Reuse the previous split-stack uploads volume during migration.
|
||||
name: qquiz_upload_files
|
||||
|
||||
@@ -1,262 +1,140 @@
|
||||
# MySQL 安装与配置指南
|
||||
# MySQL 可选配置指南
|
||||
|
||||
QQuiz 使用 MySQL 8.0 作为数据库,你可以选择 Docker 部署或本地安装。
|
||||
QQuiz 默认部署路径是单容器 + SQLite。README、根目录 `Dockerfile`、`docker-compose-single.yml` 和 GitHub Actions 发布镜像都围绕这个模式设计。
|
||||
|
||||
## 方式一:使用 Docker (推荐)
|
||||
只有在你明确需要把数据库独立出去时,才需要 MySQL。常见原因:
|
||||
|
||||
### 优点
|
||||
- 无需手动安装 MySQL
|
||||
- 自动配置和初始化
|
||||
- 隔离环境,不影响系统
|
||||
- 需要多个应用实例共享同一数据库
|
||||
- 已有 MySQL 运维体系
|
||||
- 希望把应用容器和数据库生命周期分开
|
||||
|
||||
### 使用步骤
|
||||
## 场景一:源码部署时附加 MySQL 容器
|
||||
|
||||
1. **安装 Docker Desktop**
|
||||
- 下载地址:https://www.docker.com/products/docker-desktop/
|
||||
- 安装后启动 Docker Desktop
|
||||
这是当前最直接的 MySQL 用法,适合你已经克隆仓库并接受“应用容器 + MySQL 容器”的可选部署方式。
|
||||
|
||||
2. **运行启动脚本**
|
||||
```bash
|
||||
scripts\fix_and_start.bat
|
||||
```
|
||||
选择 **[1] Use Docker**
|
||||
|
||||
3. **完成!**
|
||||
- Docker 会自动下载 MySQL 镜像
|
||||
- 自动创建数据库和用户
|
||||
- 自动启动服务
|
||||
|
||||
---
|
||||
|
||||
## 方式二:本地安装 MySQL
|
||||
|
||||
### 下载 MySQL
|
||||
|
||||
1. 访问 MySQL 官网下载页面:
|
||||
https://dev.mysql.com/downloads/installer/
|
||||
|
||||
2. 选择 **MySQL Installer for Windows**
|
||||
|
||||
3. 下载 `mysql-installer-community-8.0.x.x.msi`
|
||||
|
||||
### 安装步骤
|
||||
|
||||
1. **运行安装程序**
|
||||
- 双击下载的 .msi 文件
|
||||
|
||||
2. **选择安装类型**
|
||||
- 选择 "Developer Default" 或 "Server only"
|
||||
- 点击 Next
|
||||
|
||||
3. **配置 MySQL Server**
|
||||
- **Config Type**: Development Computer
|
||||
- **Port**: 3306 (默认)
|
||||
- **Authentication Method**: 选择 "Use Strong Password Encryption"
|
||||
|
||||
4. **设置 Root 密码**
|
||||
- 输入并记住 root 用户的密码
|
||||
- 建议密码:`root` (开发环境)
|
||||
|
||||
5. **Windows Service 配置**
|
||||
- ✅ Configure MySQL Server as a Windows Service
|
||||
- Service Name: MySQL80
|
||||
- ✅ Start the MySQL Server at System Startup
|
||||
|
||||
6. **完成安装**
|
||||
- 点击 Execute 开始安装
|
||||
- 等待安装完成
|
||||
- 点击 Finish
|
||||
|
||||
### 验证安装
|
||||
|
||||
打开命令提示符,运行:
|
||||
1. 复制环境变量模板:
|
||||
|
||||
```bash
|
||||
mysql --version
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
应该显示:`mysql Ver 8.0.x for Win64 on x86_64`
|
||||
Windows PowerShell:
|
||||
|
||||
### 配置 QQuiz 数据库
|
||||
|
||||
**方式 A:使用脚本自动创建 (推荐)**
|
||||
|
||||
运行:
|
||||
```bash
|
||||
scripts\fix_and_start.bat
|
||||
```
|
||||
选择 **[2] Use Local MySQL**
|
||||
|
||||
**方式 B:手动创建**
|
||||
|
||||
1. 打开 MySQL 命令行客户端:
|
||||
```bash
|
||||
mysql -u root -p
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
```
|
||||
|
||||
2. 输入 root 密码
|
||||
2. 把 `.env` 中的数据库连接改成 MySQL 容器地址:
|
||||
|
||||
3. 创建数据库和用户:
|
||||
```sql
|
||||
CREATE DATABASE qquiz_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'qquiz'@'localhost' IDENTIFIED BY 'qquiz_password';
|
||||
GRANT ALL PRIVILEGES ON qquiz_db.* TO 'qquiz'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
EXIT;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据库配置说明
|
||||
|
||||
### .env 文件配置
|
||||
|
||||
确保 `.env` 文件中的数据库连接字符串正确:
|
||||
|
||||
**本地 MySQL:**
|
||||
```env
|
||||
DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@localhost:3306/qquiz_db
|
||||
```
|
||||
|
||||
**Docker MySQL:**
|
||||
```env
|
||||
DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@mysql:3306/qquiz_db
|
||||
```
|
||||
|
||||
### 连接参数说明
|
||||
3. 启动应用和 MySQL:
|
||||
|
||||
- `mysql+aiomysql://` - 使用 aiomysql 异步驱动
|
||||
- `qquiz` - 数据库用户名
|
||||
- `qquiz_password` - 数据库密码
|
||||
- `localhost` 或 `mysql` - 数据库主机地址
|
||||
- `3306` - MySQL 默认端口
|
||||
- `qquiz_db` - 数据库名称
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 端口 3306 被占用
|
||||
|
||||
**错误信息:**
|
||||
```
|
||||
Error: Port 3306 is already in use
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
- 检查是否已经有 MySQL 运行:`netstat -ano | findstr :3306`
|
||||
- 停止现有的 MySQL 服务
|
||||
- 或修改 `.env` 中的端口号
|
||||
|
||||
### 2. 无法连接到 MySQL
|
||||
|
||||
**错误信息:**
|
||||
```
|
||||
Can't connect to MySQL server on 'localhost'
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
|
||||
1. **检查 MySQL 服务是否运行**
|
||||
- 按 Win+R,输入 `services.msc`
|
||||
- 查找 "MySQL80" 服务
|
||||
- 确认状态为 "正在运行"
|
||||
|
||||
2. **启动 MySQL 服务**
|
||||
```bash
|
||||
net start MySQL80
|
||||
docker compose -f docker-compose.yml -f docker-compose.mysql.yml up -d --build
|
||||
```
|
||||
|
||||
3. **检查防火墙设置**
|
||||
- 确保防火墙允许 MySQL 端口 3306
|
||||
4. 访问:
|
||||
|
||||
### 3. 密码验证失败
|
||||
- 前端:`http://localhost:3000`
|
||||
- 后端:`http://localhost:8000`
|
||||
|
||||
**错误信息:**
|
||||
```
|
||||
Access denied for user 'qquiz'@'localhost'
|
||||
```
|
||||
说明:
|
||||
|
||||
**解决方案:**
|
||||
- 这条路径是 MySQL 兼容部署,不是默认发布路径
|
||||
- 默认发布镜像仍然是根目录单容器镜像
|
||||
|
||||
## 场景二:单容器应用连接外部 MySQL
|
||||
|
||||
如果你想继续使用单容器应用镜像,但数据库由外部 MySQL 托管,可以直接让应用容器连接现有数据库。
|
||||
|
||||
### 1. 准备 MySQL 8.0 数据库
|
||||
|
||||
执行以下 SQL 创建数据库和账号:
|
||||
|
||||
重新创建用户并设置密码:
|
||||
```sql
|
||||
mysql -u root -p
|
||||
DROP USER IF EXISTS 'qquiz'@'localhost';
|
||||
CREATE USER 'qquiz'@'localhost' IDENTIFIED BY 'qquiz_password';
|
||||
GRANT ALL PRIVILEGES ON qquiz_db.* TO 'qquiz'@'localhost';
|
||||
CREATE DATABASE qquiz_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'qquiz'@'%' IDENTIFIED BY 'qquiz_password';
|
||||
GRANT ALL PRIVILEGES ON qquiz_db.* TO 'qquiz'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
### 4. 字符集问题
|
||||
### 2. 修改 `.env`
|
||||
|
||||
**解决方案:**
|
||||
把 `DATABASE_URL` 改成你的 MySQL 地址,例如:
|
||||
|
||||
```env
|
||||
DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@mysql.example.com:3306/qquiz_db
|
||||
UPLOAD_DIR=/app/uploads
|
||||
```
|
||||
|
||||
### 3. 启动单容器镜像
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/handsomezhuzhu/qquiz:latest
|
||||
|
||||
docker volume create qquiz_uploads
|
||||
|
||||
docker run -d \
|
||||
--name qquiz \
|
||||
--env-file .env \
|
||||
-v qquiz_uploads:/app/uploads \
|
||||
-p 8000:8000 \
|
||||
--restart unless-stopped \
|
||||
ghcr.io/handsomezhuzhu/qquiz:latest
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 这里不需要本地 SQLite 数据卷,因为数据库已经外置到 MySQL
|
||||
- 仍然建议保留上传目录卷,避免容器重建后丢失上传文件
|
||||
|
||||
## 本地开发连接 MySQL
|
||||
|
||||
如果你是在本机直接跑后端,`.env` 中可使用本地 MySQL 地址:
|
||||
|
||||
```env
|
||||
DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@localhost:3306/qquiz_db
|
||||
```
|
||||
|
||||
然后分别启动后端和前端:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
alembic upgrade head
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 连接不上 MySQL
|
||||
|
||||
检查以下几项:
|
||||
|
||||
- `DATABASE_URL` 中的主机名、端口、用户名和密码是否正确
|
||||
- MySQL 是否允许对应来源地址连接
|
||||
- 3306 端口是否开放
|
||||
|
||||
### 2. 容器里能连,宿主机里不能连
|
||||
|
||||
这是因为容器内部和宿主机访问地址不同:
|
||||
|
||||
- 容器之间互联时通常使用服务名,例如 `mysql`
|
||||
- 宿主机连接本机 MySQL 时通常使用 `localhost`
|
||||
|
||||
### 3. 字符集异常
|
||||
|
||||
建议数据库和表统一使用 `utf8mb4`:
|
||||
|
||||
确保数据库使用 UTF-8 字符集:
|
||||
```sql
|
||||
ALTER DATABASE qquiz_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 管理工具推荐
|
||||
|
||||
### 1. MySQL Workbench (官方)
|
||||
- 下载:https://dev.mysql.com/downloads/workbench/
|
||||
- 功能:可视化数据库管理、SQL 编辑器、备份还原
|
||||
|
||||
### 2. DBeaver (免费开源)
|
||||
- 下载:https://dbeaver.io/download/
|
||||
- 功能:多数据库支持、数据导入导出、ER 图
|
||||
|
||||
### 3. phpMyAdmin (Web 界面)
|
||||
- 适合习惯 Web 界面的用户
|
||||
|
||||
---
|
||||
|
||||
## 数据库备份与恢复
|
||||
|
||||
### 备份数据库
|
||||
|
||||
```bash
|
||||
mysqldump -u qquiz -p qquiz_db > backup.sql
|
||||
```
|
||||
|
||||
### 恢复数据库
|
||||
|
||||
```bash
|
||||
mysql -u qquiz -p qquiz_db < backup.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 切换回 PostgreSQL
|
||||
|
||||
如果需要切换回 PostgreSQL:
|
||||
|
||||
1. 修改 `requirements.txt`:
|
||||
```
|
||||
asyncpg==0.29.0 # 替换 aiomysql
|
||||
```
|
||||
|
||||
2. 修改 `.env`:
|
||||
```
|
||||
DATABASE_URL=postgresql+asyncpg://qquiz:qquiz_password@localhost:5432/qquiz_db
|
||||
```
|
||||
|
||||
3. 修改 `docker-compose.yml`:
|
||||
- 将 `mysql` 服务改回 `postgres`
|
||||
|
||||
4. 重新安装依赖:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术支持
|
||||
|
||||
如遇到其他问题,请:
|
||||
1. 检查 MySQL 错误日志
|
||||
2. 确认防火墙和网络配置
|
||||
3. 查看项目 issues: https://github.com/handsomezhuzhu/QQuiz/issues
|
||||
|
||||
@@ -30,36 +30,17 @@ QQuiz/
|
||||
│ ├── alembic.ini # Alembic 配置
|
||||
│ └── Dockerfile # 后端 Docker 镜像
|
||||
│
|
||||
├── frontend/ # React 前端
|
||||
├── web/ # Next.js 前端
|
||||
│ ├── src/
|
||||
│ │ ├── api/
|
||||
│ │ │ └── client.js # API 客户端(Axios)⭐
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── Layout.jsx # 主布局(导航栏)
|
||||
│ │ │ └── ProtectedRoute.jsx # 路由保护
|
||||
│ │ ├── context/
|
||||
│ │ │ └── AuthContext.jsx # 认证上下文
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── Login.jsx # 登录页
|
||||
│ │ │ ├── Register.jsx # 注册页
|
||||
│ │ │ ├── Dashboard.jsx # 仪表盘
|
||||
│ │ │ ├── ExamList.jsx # 题库列表 ⭐
|
||||
│ │ │ ├── ExamDetail.jsx # 题库详情(追加上传)⭐
|
||||
│ │ │ ├── QuizPlayer.jsx # 刷题核心页面 ⭐
|
||||
│ │ │ ├── MistakeList.jsx # 错题本
|
||||
│ │ │ └── AdminSettings.jsx # 系统设置
|
||||
│ │ ├── utils/
|
||||
│ │ │ └── helpers.js # 工具函数
|
||||
│ │ ├── App.jsx # 应用根组件
|
||||
│ │ ├── index.jsx # 应用入口
|
||||
│ │ └── index.css # 全局样式
|
||||
│ ├── public/
|
||||
│ │ └── index.html # HTML 模板
|
||||
│ │ ├── app/ # App Router 页面、布局、Route Handlers
|
||||
│ │ ├── components/ # 共享 UI 组件
|
||||
│ │ ├── lib/ # API、认证、格式化等公共逻辑
|
||||
│ │ └── middleware.ts # 登录态守卫
|
||||
│ ├── package.json # Node 依赖
|
||||
│ ├── vite.config.js # Vite 配置
|
||||
│ ├── tailwind.config.js # Tailwind CSS 配置
|
||||
│ ├── postcss.config.js # PostCSS 配置
|
||||
│ └── Dockerfile # 前端 Docker 镜像
|
||||
│ ├── next.config.mjs # Next.js 配置
|
||||
│ ├── tailwind.config.ts # Tailwind CSS 配置
|
||||
│ ├── postcss.config.mjs # PostCSS 配置
|
||||
│ └── Dockerfile # 分离部署前端镜像
|
||||
│
|
||||
├── docker-compose.yml # Docker 编排配置 ⭐
|
||||
├── .env.example # 环境变量模板
|
||||
@@ -133,74 +114,31 @@ for q in questions_data:
|
||||
|
||||
### 前端核心
|
||||
|
||||
#### `client.js` - API 客户端
|
||||
封装了所有后端 API:
|
||||
- `authAPI`: 登录、注册、用户信息
|
||||
- `examAPI`: 题库 CRUD、追加文档
|
||||
- `questionAPI`: 获取题目、答题
|
||||
- `mistakeAPI`: 错题本管理
|
||||
- `adminAPI`: 系统配置
|
||||
#### `src/lib/api/server.ts` - 服务端 API 访问
|
||||
用于 Next Server Components 访问后端:
|
||||
- 从 `HttpOnly` Cookie 读取会话令牌
|
||||
- 直接请求 FastAPI `/api/*`
|
||||
- 401 时自动重定向回登录页
|
||||
|
||||
**特性:**
|
||||
- 自动添加 JWT Token
|
||||
- 统一错误处理和 Toast 提示
|
||||
- 401 自动跳转登录
|
||||
#### `src/lib/api/browser.ts` - 浏览器端 API 访问
|
||||
用于客户端交互:
|
||||
- 请求同源 `/frontend-api/proxy/*`
|
||||
- 统一处理错误信息
|
||||
- 默认禁用缓存,保持刷题和后台状态最新
|
||||
|
||||
#### `ExamDetail.jsx` - 题库详情
|
||||
最复杂的前端页面,包含:
|
||||
- **追加上传**: 上传新文档并去重
|
||||
- **状态轮询**: 每 3 秒轮询一次状态
|
||||
- **智能按钮**:
|
||||
- 处理中时禁用「添加文档」
|
||||
- 就绪后显示「开始/继续刷题」
|
||||
- **进度展示**: 题目数、完成度、进度条
|
||||
#### `src/components/exams/exam-detail-client.tsx` - 题库详情
|
||||
负责:
|
||||
- 追加上传文档
|
||||
- 展示解析进度
|
||||
- 通过 `/frontend-api/exams/{examId}/progress` 订阅同源 SSE
|
||||
- 处理解析完成/失败后的页面刷新
|
||||
|
||||
**状态轮询实现:**
|
||||
```javascript
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
pollExamStatus() // 轮询状态
|
||||
}, 3000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [examId])
|
||||
|
||||
const pollExamStatus = async () => {
|
||||
const newExam = await examAPI.getDetail(examId)
|
||||
|
||||
// 检测状态变化
|
||||
if (exam?.status === 'processing' && newExam.status === 'ready') {
|
||||
toast.success('文档解析完成!')
|
||||
await loadExamDetail() // 重新加载数据
|
||||
}
|
||||
|
||||
setExam(newExam)
|
||||
}
|
||||
```
|
||||
|
||||
#### `QuizPlayer.jsx` - 刷题核心
|
||||
实现完整的刷题流程:
|
||||
1. 基于 `current_index` 加载当前题目
|
||||
2. 根据题型显示不同的答题界面
|
||||
3. 提交答案并检查(简答题调用 AI 评分)
|
||||
4. 答错自动加入错题本
|
||||
5. 点击下一题自动更新进度
|
||||
|
||||
**断点续做实现:**
|
||||
```javascript
|
||||
// 始终基于 exam.current_index 加载题目
|
||||
const loadCurrentQuestion = async () => {
|
||||
const question = await questionAPI.getCurrentQuestion(examId)
|
||||
// 后端会根据 current_index 返回对应题目
|
||||
}
|
||||
|
||||
// 下一题时更新进度
|
||||
const handleNext = async () => {
|
||||
const newIndex = exam.current_index + 1
|
||||
await examAPI.updateProgress(examId, newIndex)
|
||||
await loadCurrentQuestion()
|
||||
}
|
||||
```
|
||||
#### `src/components/practice/quiz-player-client.tsx` - 刷题核心
|
||||
负责:
|
||||
- 加载当前题目
|
||||
- 提交答案并展示结果
|
||||
- 推进刷题进度
|
||||
- 管理简答题与错题练习等交互
|
||||
|
||||
---
|
||||
|
||||
@@ -323,17 +261,17 @@ CREATE UNIQUE INDEX ix_user_mistakes_unique ON user_mistakes(user_id, question_i
|
||||
- **OpenAI/Anthropic/Qwen**: AI 解析和评分
|
||||
|
||||
### 前端
|
||||
- **Next.js 14 App Router**: 前端运行时
|
||||
- **React 18**: UI 框架
|
||||
- **Vite**: 构建工具(比 CRA 更快)
|
||||
- **TypeScript**: 类型系统
|
||||
- **Tailwind CSS**: 原子化 CSS
|
||||
- **Axios**: HTTP 客户端
|
||||
- **React Router**: 路由管理
|
||||
- **React Hot Toast**: 消息提示
|
||||
- **TanStack Query**: 客户端缓存和数据同步
|
||||
- **Route Handlers**: 同源认证与代理层
|
||||
|
||||
### 部署
|
||||
- **Docker + Docker Compose**: 容器化部署
|
||||
- **PostgreSQL 15**: 关系型数据库
|
||||
- **Nginx** (可选): 反向代理
|
||||
- **SQLite / MySQL**: 关系型数据库
|
||||
- **FastAPI reverse proxy**: 单容器模式下代理 Next.js
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -19,17 +19,17 @@ Audit date: 2026-04-17
|
||||
|
||||
### Frontend
|
||||
|
||||
- Runtime: React 18 + Vite SPA
|
||||
- Routing: `react-router-dom`
|
||||
- Auth state: client-only `localStorage` token + context
|
||||
- API transport: axios interceptor with browser redirects
|
||||
- Styling: Tailwind CSS with page-local utility classes
|
||||
- Runtime: Next.js App Router + TypeScript
|
||||
- Routing: file-system routing + middleware guards
|
||||
- Auth state: `HttpOnly` cookie managed by Next route handlers
|
||||
- API transport: server/client fetch helpers with same-origin proxy routes
|
||||
- Styling: Tailwind CSS + shadcn/ui patterns
|
||||
|
||||
### Deployment
|
||||
|
||||
- `docker-compose.yml`: development-oriented split stack
|
||||
- `docker-compose-single.yml`: monolith container with SQLite
|
||||
- `Dockerfile`: FastAPI serves the built SPA as static assets
|
||||
- `docker-compose.yml`: split development stack
|
||||
- `docker-compose-single.yml`: default single-container deployment
|
||||
- `Dockerfile`: single image running FastAPI + embedded Next.js
|
||||
|
||||
## Target Architecture
|
||||
|
||||
@@ -51,20 +51,20 @@ Audit date: 2026-04-17
|
||||
|
||||
### Deployment
|
||||
|
||||
- Split deployment becomes the primary production shape
|
||||
- Monolith mode remains secondary compatibility mode
|
||||
- Development and production Compose files must be separated
|
||||
- Single-container deployment is the primary release path
|
||||
- Split deployment remains available for development and compatibility testing
|
||||
- Development and production Compose files must stay explicitly separated
|
||||
|
||||
## Core Constraints
|
||||
|
||||
1. Do not overwrite existing uncommitted user changes in the legacy frontend.
|
||||
2. Keep the legacy `frontend/` app available until the new `web/` app reaches functional parity.
|
||||
3. Preserve backend API contracts where possible during the frontend migration.
|
||||
4. Fix deployment/documentation drift before treating new frontend work as production-ready.
|
||||
1. Preserve backend API contracts where possible across frontend changes.
|
||||
2. Keep single-container and split-stack behavior aligned on the same `web/` frontend.
|
||||
3. Fix deployment/documentation drift before treating changes as production-ready.
|
||||
4. Avoid reintroducing duplicate frontend implementations.
|
||||
|
||||
## Immediate Workstreams
|
||||
|
||||
1. Remove abandoned ESA captcha wiring from the legacy frontend.
|
||||
2. Write audit documents and freeze the migration backlog.
|
||||
3. Scaffold the new `web/` frontend without disturbing the legacy app.
|
||||
4. Fix first-order deployment issues such as health checks and documented mount paths.
|
||||
1. Keep single-container delivery using the same `web/` frontend as split deployment.
|
||||
2. Continue moving backend orchestration into typed services.
|
||||
3. Tighten health checks and deployment docs around the embedded Next runtime.
|
||||
4. Cover remaining functional gaps with smoke tests.
|
||||
|
||||
@@ -1,70 +1,50 @@
|
||||
# Frontend Migration Plan
|
||||
# Frontend Cutover Notes
|
||||
|
||||
## Decision
|
||||
|
||||
The legacy Vite SPA remains in `frontend/` as a fallback.
|
||||
`web/` is now the only frontend in the repository.
|
||||
|
||||
The new frontend is being built in `web/` with:
|
||||
The previous Vite SPA has been removed so that:
|
||||
|
||||
- Next.js App Router
|
||||
- TypeScript
|
||||
- Tailwind CSS
|
||||
- shadcn/ui component model
|
||||
- split deployment and single-container deployment use the same UI
|
||||
- documentation no longer has to describe two competing frontend stacks
|
||||
- future frontend changes only need to be implemented once
|
||||
|
||||
The abandoned ESA captcha integration has been removed from the legacy login page.
|
||||
|
||||
## Why a Rewrite Instead of an In-Place Port
|
||||
|
||||
The legacy frontend mixes too many browser-only assumptions into core runtime
|
||||
boundaries:
|
||||
|
||||
- token storage in `localStorage`
|
||||
- `window.location` redirects inside transport code
|
||||
- client-only route protection
|
||||
- SSE token passing in query strings
|
||||
|
||||
Those patterns do not map cleanly onto Next App Router and server-first auth.
|
||||
|
||||
## New Runtime Model
|
||||
## Runtime Model
|
||||
|
||||
### Auth
|
||||
|
||||
- Login goes through Next route handlers
|
||||
- Login goes through Next route handlers under `/frontend-api/auth/*`
|
||||
- Backend JWT is stored in an `HttpOnly` cookie
|
||||
- Browser code never reads the raw token
|
||||
|
||||
### Data
|
||||
|
||||
- Server pages use server-side fetch helpers
|
||||
- Client mutations use browser-side fetch helpers against Next proxy routes
|
||||
- URL state is used for pagination and filters
|
||||
- Server pages use server-side fetch helpers against FastAPI
|
||||
- Client mutations use browser-side fetch helpers against `/frontend-api/proxy/*`
|
||||
- FastAPI continues to own the public `/api/*` surface
|
||||
|
||||
### Streaming
|
||||
|
||||
- Browser connects to a same-origin Next progress route
|
||||
- Browser connects to `/frontend-api/exams/{examId}/progress`
|
||||
- The route reads the session cookie and proxies backend SSE
|
||||
- Backend URL tokens are hidden from the browser
|
||||
- Backend token query parameters stay hidden from the browser
|
||||
|
||||
## Directory Map
|
||||
## Deployment Outcome
|
||||
|
||||
```text
|
||||
web/
|
||||
src/app/
|
||||
src/components/
|
||||
src/lib/
|
||||
src/middleware.ts
|
||||
```
|
||||
### Split Stack
|
||||
|
||||
## Migration Order
|
||||
- `backend` serves API traffic on `:8000`
|
||||
- `web` serves Next.js on `:3000`
|
||||
|
||||
1. Auth shell, layouts, middleware, and proxy routes
|
||||
2. Dashboard, exams list, questions list, and admin overview
|
||||
3. Exam detail upload and progress streaming
|
||||
4. Quiz and mistake-practice flows
|
||||
5. Cutover, smoke testing, and legacy frontend retirement
|
||||
### Single Container
|
||||
|
||||
## Non-Goals for This First Slice
|
||||
- the container runs both FastAPI and Next.js
|
||||
- FastAPI stays on `:8000`
|
||||
- non-API requests are proxied from FastAPI to the embedded Next server
|
||||
|
||||
- No immediate removal of the legacy `frontend/`
|
||||
- No backend contract rewrite yet
|
||||
- No server actions as the primary data mutation layer
|
||||
## Follow-up Expectations
|
||||
|
||||
1. New frontend work lands only in `web/`
|
||||
2. Single-container smoke tests must validate both UI and API paths
|
||||
3. Deployment docs must continue to describe `web/` as the sole frontend
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
build
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
.env
|
||||
.env.local
|
||||
@@ -1,4 +0,0 @@
|
||||
# Frontend Environment Variables
|
||||
|
||||
# API URL
|
||||
VITE_API_URL=http://localhost:8000
|
||||
@@ -1,18 +0,0 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start development server
|
||||
CMD ["npm", "start"]
|
||||
@@ -1,14 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="QQuiz - 智能刷题与题库管理平台" />
|
||||
<title>QQuiz - 智能刷题平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2928
frontend/package-lock.json
generated
2928
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"name": "qquiz-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "QQuiz Frontend - React Application",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"axios": "^1.6.5",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"lucide-react": "^0.309.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.0.11"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import React from 'react'
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||
import Layout from './components/Layout'
|
||||
|
||||
// Auth Pages
|
||||
import Login from './pages/Login'
|
||||
import Register from './pages/Register'
|
||||
|
||||
// Main Pages
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import ExamList from './pages/ExamList'
|
||||
import ExamDetail from './pages/ExamDetail'
|
||||
import QuizPlayer from './pages/QuizPlayer'
|
||||
import MistakeList from './pages/MistakeList'
|
||||
import MistakePlayer from './pages/MistakePlayer'
|
||||
import QuestionBank from './pages/QuestionBank'
|
||||
|
||||
// Admin Pages
|
||||
import AdminPanel from './pages/AdminPanel'
|
||||
import AdminSettings from './pages/AdminSettings'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<AuthProvider>
|
||||
<div className="App">
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 3000,
|
||||
style: {
|
||||
background: '#363636',
|
||||
color: '#fff',
|
||||
},
|
||||
success: {
|
||||
duration: 3000,
|
||||
iconTheme: {
|
||||
primary: '#10b981',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
duration: 4000,
|
||||
iconTheme: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
|
||||
{/* Protected Routes with Layout */}
|
||||
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/exams" element={<ExamList />} />
|
||||
<Route path="/exams/:examId" element={<ExamDetail />} />
|
||||
<Route path="/quiz/:examId" element={<QuizPlayer />} />
|
||||
<Route path="/mistakes" element={<MistakeList />} />
|
||||
<Route path="/mistake-quiz" element={<MistakePlayer />} />
|
||||
<Route path="/questions" element={<QuestionBank />} />
|
||||
|
||||
{/* Admin Only Routes */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute adminOnly>
|
||||
<AdminPanel />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/settings"
|
||||
element={
|
||||
<ProtectedRoute adminOnly>
|
||||
<AdminSettings />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* Default Route */}
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
{/* 404 */}
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,240 +0,0 @@
|
||||
/**
|
||||
* API Client for QQuiz Backend
|
||||
*/
|
||||
import axios from 'axios'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
|
||||
export const AUTH_TOKEN_STORAGE_KEY = 'access_token'
|
||||
|
||||
const AUTH_USER_STORAGE_KEY = 'user'
|
||||
const PUBLIC_REQUEST_PATHS = ['/auth/login', '/auth/register']
|
||||
|
||||
const getRequestPath = (config) => {
|
||||
const url = config?.url || ''
|
||||
|
||||
if (!url) return ''
|
||||
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
try {
|
||||
return new URL(url).pathname
|
||||
} catch (error) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
return url.startsWith('/') ? url : `/${url}`
|
||||
}
|
||||
|
||||
const isPublicRequest = (config) => {
|
||||
if (config?.skipAuthHandling === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
const path = getRequestPath(config)
|
||||
return PUBLIC_REQUEST_PATHS.some((publicPath) => path.endsWith(publicPath))
|
||||
}
|
||||
|
||||
export const buildApiUrl = (path) => {
|
||||
const base = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
return `${base}${normalizedPath}`
|
||||
}
|
||||
|
||||
export const getAccessToken = () => localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)
|
||||
|
||||
export const clearAuthStorage = () => {
|
||||
localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY)
|
||||
localStorage.removeItem(AUTH_USER_STORAGE_KEY)
|
||||
}
|
||||
|
||||
// Create axios instance
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// Request interceptor - Add auth token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = getAccessToken()
|
||||
if (token && !isPublicRequest(config)) {
|
||||
config.headers = config.headers || {}
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor - Handle errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const status = error.response?.status
|
||||
const message = error.response?.data?.detail || 'An error occurred'
|
||||
const requestConfig = error.config || {}
|
||||
const hasAuthHeader = Boolean(
|
||||
requestConfig.headers?.Authorization || requestConfig.headers?.authorization
|
||||
)
|
||||
|
||||
if (status === 401 && !isPublicRequest(requestConfig) && hasAuthHeader) {
|
||||
clearAuthStorage()
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
toast.error('Session expired. Please login again.')
|
||||
} else if (status === 401) {
|
||||
toast.error(message)
|
||||
} else if (status === 403) {
|
||||
toast.error('Permission denied')
|
||||
} else if (status === 429) {
|
||||
toast.error(message)
|
||||
} else if (status >= 500) {
|
||||
toast.error('Server error. Please try again later.')
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// ============ Auth APIs ============
|
||||
export const authAPI = {
|
||||
register: (username, password) =>
|
||||
api.post('/auth/register', { username, password }, { skipAuthHandling: true }),
|
||||
|
||||
login: (username, password) =>
|
||||
api.post('/auth/login', { username, password }, { skipAuthHandling: true }),
|
||||
|
||||
getCurrentUser: () =>
|
||||
api.get('/auth/me'),
|
||||
|
||||
changePassword: (oldPassword, newPassword) =>
|
||||
api.post('/auth/change-password', null, {
|
||||
params: { old_password: oldPassword, new_password: newPassword }
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Exam APIs ============
|
||||
export const examAPI = {
|
||||
// Create exam with first document
|
||||
create: (title, file, isRandom = false) => {
|
||||
const formData = new FormData()
|
||||
formData.append('title', title)
|
||||
formData.append('file', file)
|
||||
formData.append('is_random', isRandom)
|
||||
return api.post('/exams/create', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
|
||||
// Append document to existing exam
|
||||
appendDocument: (examId, file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return api.post(`/exams/${examId}/append`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
|
||||
// Get user's exam list
|
||||
getList: (skip = 0, limit = 20) =>
|
||||
api.get('/exams/', { params: { skip, limit } }),
|
||||
|
||||
// Get exam detail
|
||||
getDetail: (examId) =>
|
||||
api.get(`/exams/${examId}`),
|
||||
|
||||
// Delete exam
|
||||
delete: (examId) =>
|
||||
api.delete(`/exams/${examId}`),
|
||||
|
||||
// Update quiz progress
|
||||
updateProgress: (examId, currentIndex) =>
|
||||
api.put(`/exams/${examId}/progress`, { current_index: currentIndex })
|
||||
}
|
||||
|
||||
// ============ Question APIs ============
|
||||
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
|
||||
getExamQuestions: (examId, skip = 0, limit = 50) =>
|
||||
api.get(`/questions/exam/${examId}/questions`, { params: { skip, limit } }),
|
||||
|
||||
// Get current question (based on exam's current_index)
|
||||
getCurrentQuestion: (examId) =>
|
||||
api.get(`/questions/exam/${examId}/current`),
|
||||
|
||||
// Get question by ID
|
||||
getById: (questionId) =>
|
||||
api.get(`/questions/${questionId}`),
|
||||
|
||||
// Check answer
|
||||
checkAnswer: (questionId, userAnswer) =>
|
||||
api.post('/questions/check', {
|
||||
question_id: questionId,
|
||||
user_answer: userAnswer
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Mistake APIs ============
|
||||
export const mistakeAPI = {
|
||||
// Get user's mistake book
|
||||
getList: (skip = 0, limit = 50, examId = null) => {
|
||||
const params = { skip, limit }
|
||||
if (examId) params.exam_id = examId
|
||||
return api.get('/mistakes/', { params })
|
||||
},
|
||||
|
||||
// Add to mistake book
|
||||
add: (questionId) =>
|
||||
api.post('/mistakes/add', { question_id: questionId }),
|
||||
|
||||
// Remove from mistake book by mistake ID
|
||||
remove: (mistakeId) =>
|
||||
api.delete(`/mistakes/${mistakeId}`),
|
||||
|
||||
// Remove from mistake book by question ID
|
||||
removeByQuestionId: (questionId) =>
|
||||
api.delete(`/mistakes/question/${questionId}`)
|
||||
}
|
||||
|
||||
// ============ Admin APIs ============
|
||||
export const adminAPI = {
|
||||
// Config
|
||||
getConfig: () => api.get('/admin/config'),
|
||||
updateConfig: (config) => api.put('/admin/config', config),
|
||||
|
||||
// Users
|
||||
getUsers: (skip = 0, limit = 50, search = null) =>
|
||||
api.get('/admin/users', { params: { skip, limit, search } }),
|
||||
createUser: (username, password, is_admin = false) =>
|
||||
api.post('/admin/users', { username, password, is_admin }),
|
||||
updateUser: (userId, data) =>
|
||||
api.put(`/admin/users/${userId}`, data),
|
||||
deleteUser: (userId) =>
|
||||
api.delete(`/admin/users/${userId}`),
|
||||
|
||||
// Statistics
|
||||
getStatistics: () => api.get('/admin/statistics'),
|
||||
getHealth: () => api.get('/admin/health'),
|
||||
|
||||
// Export
|
||||
exportUsers: () => api.get('/admin/export/users', { responseType: 'blob' }),
|
||||
exportStatistics: () => api.get('/admin/export/statistics', { responseType: 'blob' })
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* Main Layout Component with Navigation
|
||||
*/
|
||||
import React, { useState } from 'react'
|
||||
import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import {
|
||||
BookOpen,
|
||||
LayoutDashboard,
|
||||
FolderOpen,
|
||||
XCircle,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Shield
|
||||
} from 'lucide-react'
|
||||
|
||||
export const Layout = () => {
|
||||
const { user, logout, isAdmin } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{ name: '首页', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ name: '题库管理', href: '/exams', icon: FolderOpen },
|
||||
{ name: '错题本', href: '/mistakes', icon: XCircle },
|
||||
]
|
||||
|
||||
if (isAdmin) {
|
||||
navigation.push({ name: '管理面板', href: '/admin', icon: Shield })
|
||||
navigation.push({ name: '系统设置', href: '/admin/settings', icon: Settings })
|
||||
}
|
||||
|
||||
const isActive = (href) => location.pathname === href
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* Mobile Header */}
|
||||
<div className="lg:hidden bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="h-6 w-6 text-primary-600" />
|
||||
<span className="font-bold text-lg">QQuiz</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
{mobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="border-t border-gray-200 px-4 py-3 space-y-2">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive(item.href)
|
||||
? 'bg-primary-50 text-primary-600'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
{/* Desktop Sidebar */}
|
||||
<div className="hidden lg:flex lg:flex-col lg:w-64 lg:fixed lg:inset-y-0">
|
||||
<div className="flex flex-col flex-1 bg-white border-r border-gray-200">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 px-6 py-6 border-b border-gray-200">
|
||||
<div className="bg-primary-600 p-2 rounded-lg">
|
||||
<BookOpen className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-bold text-lg">QQuiz</h1>
|
||||
<p className="text-xs text-gray-500">{user?.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 py-6 space-y-2">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive(item.href)
|
||||
? 'bg-primary-50 text-primary-600'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="px-4 py-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 lg:pl-64">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
@@ -1,87 +0,0 @@
|
||||
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
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Parsing Progress Component
|
||||
* Displays real-time progress for document parsing
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Loader, CheckCircle, XCircle, FileText, Layers } from 'lucide-react'
|
||||
|
||||
export const ParsingProgress = ({ progress }) => {
|
||||
if (!progress) return null
|
||||
|
||||
const { status, message, progress: percentage, total_chunks, current_chunk, questions_extracted, questions_added, duplicates_removed } = progress
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-6 w-6 text-green-500" />
|
||||
case 'failed':
|
||||
return <XCircle className="h-6 w-6 text-red-500" />
|
||||
default:
|
||||
return <Loader className="h-6 w-6 text-primary-500 animate-spin" />
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-500'
|
||||
case 'failed':
|
||||
return 'bg-red-500'
|
||||
case 'processing_chunk':
|
||||
return 'bg-blue-500'
|
||||
default:
|
||||
return 'bg-primary-500'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
{getStatusIcon()}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{/* Status Message */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{status === 'completed' ? '解析完成' : status === 'failed' ? '解析失败' : '正在解析文档'}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">{message}</p>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{status !== 'completed' && status !== 'failed' && (
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-2">
|
||||
<span>进度</span>
|
||||
<span>{percentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className={`h-3 ${getStatusColor()} transition-all duration-300 ease-out`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
||||
{total_chunks > 0 && (
|
||||
<div className="bg-blue-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Layers className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-xs text-blue-600 font-medium">文档拆分</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-blue-900">
|
||||
{current_chunk}/{total_chunks}
|
||||
</p>
|
||||
<p className="text-xs text-blue-600">部分</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{questions_extracted > 0 && (
|
||||
<div className="bg-purple-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<FileText className="h-4 w-4 text-purple-600" />
|
||||
<span className="text-xs text-purple-600 font-medium">已提取</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-purple-900">{questions_extracted}</p>
|
||||
<p className="text-xs text-purple-600">题目</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{questions_added > 0 && (
|
||||
<div className="bg-green-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<span className="text-xs text-green-600 font-medium">已添加</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-green-900">{questions_added}</p>
|
||||
<p className="text-xs text-green-600">题目</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{duplicates_removed > 0 && (
|
||||
<div className="bg-orange-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<XCircle className="h-4 w-4 text-orange-600" />
|
||||
<span className="text-xs text-orange-600 font-medium">已去重</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-orange-900">{duplicates_removed}</p>
|
||||
<p className="text-xs text-orange-600">题目</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ParsingProgress
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Protected Route Component
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export const ProtectedRoute = ({ children, adminOnly = false }) => {
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
if (adminOnly && !user.is_admin) {
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* Authentication Context
|
||||
*/
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { authAPI, AUTH_TOKEN_STORAGE_KEY, clearAuthStorage } from '../api/client'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Load user from localStorage on mount
|
||||
useEffect(() => {
|
||||
const loadUser = async () => {
|
||||
const token = localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)
|
||||
if (token) {
|
||||
try {
|
||||
const response = await authAPI.getCurrentUser()
|
||||
setUser(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load user:', error)
|
||||
clearAuthStorage()
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
loadUser()
|
||||
}, [])
|
||||
|
||||
const login = async (username, password) => {
|
||||
try {
|
||||
const response = await authAPI.login(username, password)
|
||||
const { access_token } = response.data
|
||||
|
||||
// Save token
|
||||
localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, access_token)
|
||||
|
||||
// Get user info
|
||||
const userResponse = await authAPI.getCurrentUser()
|
||||
setUser(userResponse.data)
|
||||
|
||||
toast.success('Login successful!')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const register = async (username, password) => {
|
||||
try {
|
||||
await authAPI.register(username, password)
|
||||
toast.success('Registration successful! Please login.')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Registration failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
clearAuthStorage()
|
||||
setUser(null)
|
||||
toast.success('Logged out successfully')
|
||||
}
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
isAuthenticated: !!user,
|
||||
isAdmin: user?.is_admin || false
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -1,356 +0,0 @@
|
||||
/**
|
||||
* Admin Panel - 完整的管理员面板
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { adminAPI } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import {
|
||||
Users, BarChart3, Settings, Trash2, Plus, Search,
|
||||
ArrowLeft, Shield, Activity, Database, Download
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export const AdminPanel = () => {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState('stats')
|
||||
|
||||
// 统计数据
|
||||
const [stats, setStats] = useState(null)
|
||||
const [health, setHealth] = useState(null)
|
||||
|
||||
// 用户数据
|
||||
const [users, setUsers] = useState([])
|
||||
const [usersTotal, setUsersTotal] = useState(0)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [newUser, setNewUser] = useState({ username: '', password: '', is_admin: false })
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
loadHealth()
|
||||
loadUsers()
|
||||
}, [])
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const res = await adminAPI.getStatistics()
|
||||
setStats(res.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load statistics:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadHealth = async () => {
|
||||
try {
|
||||
const res = await adminAPI.getHealth()
|
||||
setHealth(res.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load health:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const res = await adminAPI.getUsers(0, 100, searchQuery || null)
|
||||
setUsers(res.data.users)
|
||||
setUsersTotal(res.data.total)
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error)
|
||||
toast.error('加载用户列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateUser = async () => {
|
||||
if (!newUser.username || !newUser.password) {
|
||||
toast.error('请填写用户名和密码')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await adminAPI.createUser(newUser.username, newUser.password, newUser.is_admin)
|
||||
toast.success('用户创建成功')
|
||||
setShowCreateModal(false)
|
||||
setNewUser({ username: '', password: '', is_admin: false })
|
||||
loadUsers()
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || '创建用户失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async (userId, username) => {
|
||||
if (!confirm(`确定删除用户 ${username}?`)) return
|
||||
try {
|
||||
await adminAPI.deleteUser(userId)
|
||||
toast.success('用户已删除')
|
||||
loadUsers()
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportUsers = async () => {
|
||||
try {
|
||||
const response = await adminAPI.exportUsers()
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.setAttribute('download', 'users.csv')
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
toast.success('导出成功')
|
||||
} catch (error) {
|
||||
toast.error('导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">管理员面板</h1>
|
||||
<p className="text-gray-600 mt-1">系统统计与用户管理</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6">
|
||||
<div className="flex gap-4 border-b border-gray-200 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('stats')}
|
||||
className={`pb-3 px-4 font-medium border-b-2 transition-colors ${activeTab === 'stats'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
系统统计
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
className={`pb-3 px-4 font-medium border-b-2 transition-colors ${activeTab === 'users'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
用户管理
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Tab */}
|
||||
{activeTab === 'stats' && stats && (
|
||||
<div className="space-y-6">
|
||||
{/* Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm">用户总数</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.users?.total || 0}</p>
|
||||
</div>
|
||||
<Users className="h-12 w-12 text-blue-500 opacity-20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm">题库总数</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.exams?.total || 0}</p>
|
||||
</div>
|
||||
<Database className="h-12 w-12 text-green-500 opacity-20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm">题目总数</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.questions?.total || 0}</p>
|
||||
</div>
|
||||
<Activity className="h-12 w-12 text-purple-500 opacity-20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm">今日活跃</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.activity?.today_active_users || 0}</p>
|
||||
</div>
|
||||
<Shield className="h-12 w-12 text-orange-500 opacity-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Health */}
|
||||
{health && (
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">系统状态</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">状态</span>
|
||||
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm font-medium">
|
||||
{health.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">数据库</span>
|
||||
<span className="text-gray-900">{health.system?.database_url || 'SQLite'}</span>
|
||||
</div>
|
||||
{health.database?.size_mb && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">数据库大小</span>
|
||||
<span className="text-gray-900">{health.database.size_mb} MB</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Tab */}
|
||||
{activeTab === 'users' && (
|
||||
<div className="space-y-6">
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索用户..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && loadUsers()}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleExportUsers}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
导出
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
<span className="hidden md:inline">创建用户</span>
|
||||
<span className="md:hidden">新建</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white rounded-xl shadow overflow-hidden overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">用户名</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">角色</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">题库数</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">错题数</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">注册时间</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 text-sm text-gray-900">{u.id}</td>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">{u.username}</td>
|
||||
<td className="px-6 py-4">
|
||||
{u.is_admin ? (
|
||||
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs font-medium rounded">管理员</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-700 text-xs font-medium rounded">普通用户</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{u.exam_count || 0}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{u.mistake_count || 0}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{new Date(u.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
onClick={() => handleDeleteUser(u.id, u.username)}
|
||||
disabled={u.username === 'admin'}
|
||||
className="text-red-600 hover:text-red-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create User Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">创建新用户</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUser.username}
|
||||
onChange={(e) => setNewUser({ ...newUser, username: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newUser.password}
|
||||
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newUser.is_admin}
|
||||
onChange={(e) => setNewUser({ ...newUser, is_admin: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<label className="text-sm text-gray-700">设为管理员</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateUser}
|
||||
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminPanel
|
||||
@@ -1,562 +0,0 @@
|
||||
/**
|
||||
* Admin Settings Page - Enhanced with API Configuration
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { adminAPI } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { Settings, Save, Loader, Key, Link as LinkIcon, Eye, EyeOff, ArrowLeft } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export const AdminSettings = () => {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showApiKeys, setShowApiKeys] = useState({
|
||||
openai: false,
|
||||
anthropic: false,
|
||||
qwen: false,
|
||||
gemini: false
|
||||
})
|
||||
|
||||
const [config, setConfig] = useState({
|
||||
allow_registration: true,
|
||||
max_upload_size_mb: 10,
|
||||
max_daily_uploads: 20,
|
||||
ai_provider: 'gemini',
|
||||
// OpenAI
|
||||
openai_api_key: '',
|
||||
openai_base_url: 'https://api.openai.com/v1',
|
||||
openai_model: 'gpt-4o-mini',
|
||||
// Anthropic
|
||||
anthropic_api_key: '',
|
||||
anthropic_model: 'claude-3-haiku-20240307',
|
||||
// Qwen
|
||||
qwen_api_key: '',
|
||||
qwen_base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
qwen_model: 'qwen-plus',
|
||||
// Gemini
|
||||
gemini_api_key: '',
|
||||
gemini_base_url: '',
|
||||
gemini_model: 'gemini-2.0-flash-exp'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
}, [])
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const response = await adminAPI.getConfig()
|
||||
setConfig(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load config:', error)
|
||||
toast.error('加载配置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await adminAPI.updateConfig(config)
|
||||
toast.success('配置保存成功!')
|
||||
} catch (error) {
|
||||
console.error('Failed to save config:', error)
|
||||
toast.error('保存配置失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
setConfig({
|
||||
...config,
|
||||
[key]: value
|
||||
})
|
||||
}
|
||||
|
||||
const toggleApiKeyVisibility = (provider) => {
|
||||
setShowApiKeys({
|
||||
...showApiKeys,
|
||||
[provider]: !showApiKeys[provider]
|
||||
})
|
||||
}
|
||||
|
||||
// Get complete API endpoint URL
|
||||
const getCompleteEndpoint = (provider) => {
|
||||
const endpoints = {
|
||||
openai: '/chat/completions',
|
||||
anthropic: '/messages',
|
||||
qwen: '/chat/completions'
|
||||
}
|
||||
|
||||
let baseUrl = ''
|
||||
if (provider === 'openai') {
|
||||
baseUrl = config.openai_base_url || 'https://api.openai.com/v1'
|
||||
} else if (provider === 'anthropic') {
|
||||
baseUrl = 'https://api.anthropic.com/v1'
|
||||
} else if (provider === 'qwen') {
|
||||
baseUrl = config.qwen_base_url || 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
||||
}
|
||||
|
||||
// Remove trailing slash
|
||||
baseUrl = baseUrl.replace(/\/$/, '')
|
||||
|
||||
return `${baseUrl}${endpoints[provider]}`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<Loader className="h-8 w-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">系统设置</h1>
|
||||
<p className="text-gray-600 mt-1">配置系统参数与 AI 接口</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-6">
|
||||
{/* Basic Settings */}
|
||||
<div className="bg-white rounded-xl shadow-md p-6 space-y-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">基础设置</h2>
|
||||
|
||||
{/* Allow Registration */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">允许用户注册</h3>
|
||||
<p className="text-sm text-gray-500">关闭后新用户无法注册</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.allow_registration}
|
||||
onChange={(e) => handleChange('allow_registration', e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Max Upload Size */}
|
||||
<div>
|
||||
<label className="block font-medium text-gray-900 mb-2">
|
||||
最大上传文件大小 (MB)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={config.max_upload_size_mb}
|
||||
onChange={(e) => handleChange('max_upload_size_mb', parseInt(e.target.value))}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">建议:5-20 MB</p>
|
||||
</div>
|
||||
|
||||
{/* Max Daily Uploads */}
|
||||
<div>
|
||||
<label className="block font-medium text-gray-900 mb-2">
|
||||
每日上传次数限制
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={config.max_daily_uploads}
|
||||
onChange={(e) => handleChange('max_daily_uploads', parseInt(e.target.value))}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">建议:10-50 次</p>
|
||||
</div>
|
||||
|
||||
{/* AI Provider */}
|
||||
<div>
|
||||
<label className="block font-medium text-gray-900 mb-2">
|
||||
AI 提供商
|
||||
</label>
|
||||
<select
|
||||
value={config.ai_provider}
|
||||
onChange={(e) => handleChange('ai_provider', e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="gemini">Google Gemini (推荐)</option>
|
||||
<option value="openai">OpenAI (GPT)</option>
|
||||
<option value="anthropic">Anthropic (Claude)</option>
|
||||
<option value="qwen">Qwen (通义千问)</option>
|
||||
</select>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
选择后在下方配置对应的 API 密钥。Gemini 支持原生 PDF 解析
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OpenAI Configuration */}
|
||||
<div className={`bg-white rounded-xl shadow-md p-6 space-y-4 ${config.ai_provider === 'openai' ? 'ring-2 ring-primary-500' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5 text-green-600" />
|
||||
<h2 className="text-xl font-bold text-gray-900">OpenAI 配置</h2>
|
||||
{config.ai_provider === 'openai' && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-primary-100 text-primary-700 rounded-full">当前使用</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text-only warning */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p className="text-sm text-amber-800">
|
||||
⚠️ OpenAI 仅支持文本解析,不支持 PDF 原生理解。PDF 文件将通过文本提取处理,可能丢失格式和图片信息。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label className="block font-medium text-gray-900 mb-2">
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKeys.openai ? 'text' : 'password'}
|
||||
value={config.openai_api_key || ''}
|
||||
onChange={(e) => handleChange('openai_api_key', e.target.value)}
|
||||
placeholder="sk-proj-..."
|
||||
className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleApiKeyVisibility('openai')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showApiKeys.openai ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">从 https://platform.openai.com/api-keys 获取</p>
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
<div>
|
||||
<label className="block font-medium text-gray-900 mb-2">
|
||||
Base URL
|
||||
</label>
|
||||
<div className="relative">
|
||||
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={config.openai_base_url}
|
||||
onChange={(e) => handleChange('openai_base_url', e.target.value)}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className="w-full pl-10 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
完整 endpoint: <code className="bg-gray-100 px-2 py-0.5 rounded">{getCompleteEndpoint('openai')}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div>
|
||||
<label className="block font-medium text-gray-900 mb-2">
|
||||
模型
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
list="openai-models"
|
||||
value={config.openai_model}
|
||||
onChange={(e) => handleChange('openai_model', e.target.value)}
|
||||
placeholder="gpt-4o-mini"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
||||
/>
|
||||
<datalist id="openai-models">
|
||||
<option value="gpt-4o">gpt-4o (最强)</option>
|
||||
<option value="gpt-4o-mini">gpt-4o-mini (推荐)</option>
|
||||
<option value="gpt-4-turbo">gpt-4-turbo</option>
|
||||
<option value="gpt-3.5-turbo">gpt-3.5-turbo</option>
|
||||
</datalist>
|
||||
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称,或从建议中选择</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anthropic Configuration */}
|
||||
<div className={`bg-white rounded-xl shadow-md p-6 space-y-4 ${config.ai_provider === 'anthropic' ? 'ring-2 ring-primary-500' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5 text-orange-600" />
|
||||
<h2 className="text-xl font-bold text-gray-900">Anthropic 配置</h2>
|
||||
{config.ai_provider === 'anthropic' && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-primary-100 text-primary-700 rounded-full">当前使用</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text-only warning */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p className="text-sm text-amber-800">
|
||||
⚠️ Anthropic 仅支持文本解析,不支持 PDF 原生理解。PDF 文件将通过文本提取处理,可能丢失格式和图片信息。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label className="block font-medium text-gray-900 mb-2">
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKeys.anthropic ? 'text' : 'password'}
|
||||
value={config.anthropic_api_key || ''}
|
||||
onChange={(e) => handleChange('anthropic_api_key', e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleApiKeyVisibility('anthropic')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showApiKeys.anthropic ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">从 https://console.anthropic.com/settings/keys 获取</p>
|
||||
</div>
|
||||
|
||||
{/* Base URL (fixed for Anthropic) */}
|
||||
<div>
|
||||
<label className="block font-medium text-gray-900 mb-2">
|
||||
Base URL (固定)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value="https://api.anthropic.com/v1"
|
||||
disabled
|
||||
className="w-full pl-10 px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
完整 endpoint: <code className="bg-gray-100 px-2 py-0.5 rounded">{getCompleteEndpoint('anthropic')}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div>
|
||||
<label className="block font-medium text-gray-900 mb-2">
|
||||
模型
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
list="anthropic-models"
|
||||
value={config.anthropic_model}
|
||||
onChange={(e) => handleChange('anthropic_model', e.target.value)}
|
||||
placeholder="claude-3-haiku-20240307"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
||||
/>
|
||||
<datalist id="anthropic-models">
|
||||
<option value="claude-3-5-sonnet-20241022">claude-3-5-sonnet (最强)</option>
|
||||
<option value="claude-3-haiku-20240307">claude-3-haiku (推荐)</option>
|
||||
<option value="claude-3-opus-20240229">claude-3-opus</option>
|
||||
<option value="claude-3-sonnet-20240229">claude-3-sonnet</option>
|
||||
</datalist>
|
||||
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称,或从建议中选择</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Qwen Configuration */}
|
||||
<div className={`bg-white rounded-xl shadow-md p-6 space-y-4 ${config.ai_provider === 'qwen' ? 'ring-2 ring-primary-500' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5 text-blue-600" />
|
||||
<h2 className="text-xl font-bold text-gray-900">通义千问 配置</h2>
|
||||
{config.ai_provider === 'qwen' && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-primary-100 text-primary-700 rounded-full">当前使用</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text-only warning */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p className="text-sm text-amber-800">
|
||||
⚠️ 通义千问 仅支持文本解析,不支持 PDF 原生理解。PDF 文件将通过文本提取处理,可能丢失格式和图片信息。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label className="block font-medium text-gray-900 mb-2">
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKeys.qwen ? 'text' : 'password'}
|
||||
value={config.qwen_api_key || ''}
|
||||
onChange={(e) => handleChange('qwen_api_key', e.target.value)}
|
||||
placeholder="sk-..."
|
||||
className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleApiKeyVisibility('qwen')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showApiKeys.qwen ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">从 https://dashscope.console.aliyun.com/apiKey 获取</p>
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
<div>
|
||||
<label className="block font-medium text-gray-900 mb-2">
|
||||
Base URL
|
||||
</label>
|
||||
<div className="relative">
|
||||
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={config.qwen_base_url}
|
||||
onChange={(e) => handleChange('qwen_base_url', e.target.value)}
|
||||
placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
className="w-full pl-10 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
完整 endpoint: <code className="bg-gray-100 px-2 py-0.5 rounded">{getCompleteEndpoint('qwen')}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div>
|
||||
<label className="block font-medium text-gray-900 mb-2">
|
||||
模型
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
list="qwen-models"
|
||||
value={config.qwen_model}
|
||||
onChange={(e) => handleChange('qwen_model', e.target.value)}
|
||||
placeholder="qwen-plus"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
||||
/>
|
||||
<datalist id="qwen-models">
|
||||
<option value="qwen-max">qwen-max (最强)</option>
|
||||
<option value="qwen-plus">qwen-plus (推荐)</option>
|
||||
<option value="qwen-turbo">qwen-turbo (快速)</option>
|
||||
<option value="qwen-long">qwen-long (长文本)</option>
|
||||
</datalist>
|
||||
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称,或从建议中选择</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gemini Configuration */}
|
||||
<div className={`bg-white rounded-xl shadow-md p-6 space-y-4 ${config.ai_provider === 'gemini' ? 'ring-2 ring-primary-500' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5 text-purple-600" />
|
||||
<h2 className="text-xl font-bold text-gray-900">Google Gemini 配置</h2>
|
||||
{config.ai_provider === 'gemini' && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-primary-100 text-primary-700 rounded-full">当前使用</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PDF support highlight */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
|
||||
<p className="text-sm text-green-800">
|
||||
✅ Gemini 支持原生 PDF 理解,可直接处理 PDF 文件(最多 1000 页),完整保留图片、表格、公式等内容。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label className="block font-medium text-gray-900 mb-2">
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKeys.gemini ? 'text' : 'password'}
|
||||
value={config.gemini_api_key || ''}
|
||||
onChange={(e) => handleChange('gemini_api_key', e.target.value)}
|
||||
placeholder="AIza..."
|
||||
className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleApiKeyVisibility('gemini')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showApiKeys.gemini ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">从 https://aistudio.google.com/apikey 获取</p>
|
||||
</div>
|
||||
|
||||
{/* Base URL (optional) */}
|
||||
<div>
|
||||
<label className="block font-medium text-gray-900 mb-2">
|
||||
Base URL (可选)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={config.gemini_base_url}
|
||||
onChange={(e) => handleChange('gemini_base_url', e.target.value)}
|
||||
placeholder="https://generativelanguage.googleapis.com(留空使用默认)"
|
||||
className="w-full pl-10 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
可配置自定义代理或中转服务(支持 Key 轮训等)。留空则使用 Google 官方 API
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div>
|
||||
<label className="block font-medium text-gray-900 mb-2">
|
||||
模型
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
list="gemini-models"
|
||||
value={config.gemini_model}
|
||||
onChange={(e) => handleChange('gemini_model', e.target.value)}
|
||||
placeholder="gemini-2.0-flash-exp"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
||||
/>
|
||||
<datalist id="gemini-models">
|
||||
<option value="gemini-2.0-flash-exp">gemini-2.0-flash-exp (最新,推荐)</option>
|
||||
<option value="gemini-1.5-pro">gemini-1.5-pro (最强)</option>
|
||||
<option value="gemini-1.5-flash">gemini-1.5-flash (快速)</option>
|
||||
<option value="gemini-1.0-pro">gemini-1.0-pro</option>
|
||||
</datalist>
|
||||
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称,或从建议中选择</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="bg-white rounded-xl shadow-md p-6">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader className="h-5 w-5 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-5 w-5" />
|
||||
保存所有设置
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminSettings
|
||||
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* Dashboard Page
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { examAPI, mistakeAPI } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
import {
|
||||
FolderOpen, XCircle, TrendingUp, BookOpen, ArrowRight, Settings, Shield
|
||||
} from 'lucide-react'
|
||||
import { getStatusColor, getStatusText, formatRelativeTime, calculateProgress } from '../utils/helpers'
|
||||
|
||||
export const Dashboard = () => {
|
||||
const { user, isAdmin } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [stats, setStats] = useState({
|
||||
totalExams: 0,
|
||||
totalQuestions: 0,
|
||||
completedQuestions: 0,
|
||||
mistakeCount: 0
|
||||
})
|
||||
|
||||
const [recentExams, setRecentExams] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData()
|
||||
}, [])
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
const [examsRes, mistakesRes] = await Promise.all([
|
||||
examAPI.getList(0, 5),
|
||||
mistakeAPI.getList(0, 1)
|
||||
])
|
||||
|
||||
const exams = examsRes.data.exams
|
||||
|
||||
// Calculate stats
|
||||
const totalQuestions = exams.reduce((sum, e) => sum + e.total_questions, 0)
|
||||
const completedQuestions = exams.reduce((sum, e) => sum + e.current_index, 0)
|
||||
|
||||
setStats({
|
||||
totalExams: exams.length,
|
||||
totalQuestions,
|
||||
completedQuestions,
|
||||
mistakeCount: mistakesRes.data.total
|
||||
})
|
||||
|
||||
setRecentExams(exams)
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-4 md:p-8">
|
||||
{/* Welcome */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||
欢迎回来,{user?.username}!
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">继续你的学习之旅</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-sm p-6 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => navigate('/exams')}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-primary-100 p-2 rounded-lg">
|
||||
<FolderOpen className="h-5 w-5 text-primary-600" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-gray-900">{stats.totalExams}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">题库总数</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-sm p-6 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => navigate('/questions')}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-blue-100 p-2 rounded-lg">
|
||||
<BookOpen className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-gray-900">{stats.totalQuestions}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">题目总数 (点击查看)</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-green-100 p-2 rounded-lg">
|
||||
<TrendingUp className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-gray-900">{stats.completedQuestions}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">已完成</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-sm p-6 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => navigate('/mistakes')}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-red-100 p-2 rounded-lg">
|
||||
<XCircle className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-gray-900">{stats.mistakeCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">错题数量</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Exams */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900">最近的题库</h2>
|
||||
<button
|
||||
onClick={() => navigate('/exams')}
|
||||
className="text-primary-600 hover:text-primary-700 flex items-center gap-1 text-sm font-medium"
|
||||
>
|
||||
查看全部
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{recentExams.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<FolderOpen className="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500">还没有题库,快去创建一个吧!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentExams.map((exam) => (
|
||||
<div
|
||||
key={exam.id}
|
||||
onClick={() => navigate(`/exams/${exam.id}`)}
|
||||
className="border border-gray-200 rounded-lg p-4 hover:border-primary-300 hover:bg-primary-50 transition-all cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="font-semibold text-gray-900">{exam.title}</h3>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(exam.status)}`}>
|
||||
{getStatusText(exam.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-600">
|
||||
<span>
|
||||
{exam.current_index} / {exam.total_questions} 题
|
||||
</span>
|
||||
<span>{formatRelativeTime(exam.updated_at)}</span>
|
||||
</div>
|
||||
|
||||
{exam.total_questions > 0 && (
|
||||
<>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-3">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${calculateProgress(exam.current_index, exam.total_questions)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 mt-1 block text-right">
|
||||
{calculateProgress(exam.current_index, exam.total_questions)}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
@@ -1,387 +0,0 @@
|
||||
/**
|
||||
* Exam Detail Page - with real-time parsing progress via SSE
|
||||
*/
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { examAPI, buildApiUrl, getAccessToken } from '../api/client'
|
||||
import ParsingProgress from '../components/ParsingProgress'
|
||||
import {
|
||||
ArrowLeft, Upload, Play, Loader, FileText, AlertCircle, RefreshCw, ArrowRight
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
getStatusColor,
|
||||
getStatusText,
|
||||
formatDate,
|
||||
calculateProgress,
|
||||
isValidFileType,
|
||||
getQuestionTypeText
|
||||
} from '../utils/helpers'
|
||||
|
||||
export const ExamDetail = () => {
|
||||
const { examId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [exam, setExam] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [showUploadModal, setShowUploadModal] = useState(false)
|
||||
const [uploadFile, setUploadFile] = useState(null)
|
||||
const [progress, setProgress] = useState(null)
|
||||
|
||||
const eventSourceRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadExamDetail()
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
}
|
||||
}, [examId])
|
||||
|
||||
const loadExamDetail = async () => {
|
||||
try {
|
||||
const examRes = await examAPI.getDetail(examId)
|
||||
setExam(examRes.data)
|
||||
|
||||
// Connect to SSE if exam is processing
|
||||
if (examRes.data.status === 'processing') {
|
||||
connectSSE()
|
||||
} else {
|
||||
setProgress(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load exam:', error)
|
||||
toast.error('加载题库失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const connectSSE = () => {
|
||||
// Close existing connection if any
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
|
||||
console.log('[SSE] Connecting to progress stream for exam', examId)
|
||||
|
||||
const token = getAccessToken()
|
||||
|
||||
if (!token) {
|
||||
console.error('[SSE] Missing access token')
|
||||
return
|
||||
}
|
||||
|
||||
const url = `${buildApiUrl(`/exams/${examId}/progress`)}?token=${encodeURIComponent(token)}`
|
||||
|
||||
const eventSource = new EventSource(url)
|
||||
eventSourceRef.current = eventSource
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const progressData = JSON.parse(event.data)
|
||||
console.log('[SSE] Progress update:', progressData)
|
||||
|
||||
setProgress(progressData)
|
||||
|
||||
// Update exam status if completed or failed
|
||||
if (progressData.status === 'completed') {
|
||||
toast.success(progressData.message)
|
||||
setExam(prev => ({ ...prev, status: 'ready' }))
|
||||
loadExamDetail() // Reload to get updated questions
|
||||
eventSource.close()
|
||||
eventSourceRef.current = null
|
||||
} else if (progressData.status === 'failed') {
|
||||
toast.error(progressData.message)
|
||||
setExam(prev => ({ ...prev, status: 'failed' }))
|
||||
eventSource.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SSE] Failed to parse progress data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('[SSE] Connection error:', error)
|
||||
eventSource.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('[SSE] Connection established')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAppendDocument = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!uploadFile) {
|
||||
toast.error('请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidFileType(uploadFile.name)) {
|
||||
toast.error('不支持的文件类型')
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
|
||||
try {
|
||||
await examAPI.appendDocument(examId, uploadFile)
|
||||
toast.success('文档上传成功,正在解析并去重...')
|
||||
setShowUploadModal(false)
|
||||
setUploadFile(null)
|
||||
setExam(prev => ({ ...prev, status: 'processing' }))
|
||||
|
||||
// Connect to SSE for real-time progress
|
||||
connectSSE()
|
||||
} catch (error) {
|
||||
console.error('Failed to append document:', error)
|
||||
toast.error('文档上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartQuiz = () => {
|
||||
if (exam.current_index >= exam.total_questions) {
|
||||
if (window.confirm('已经完成所有题目,是否从头开始?')) {
|
||||
navigate(`/quiz/${examId}?reset=true`)
|
||||
}
|
||||
} else {
|
||||
navigate(`/quiz/${examId}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<Loader className="h-8 w-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!exam) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen">
|
||||
<AlertCircle className="h-16 w-16 text-gray-300 mb-4" />
|
||||
<p className="text-gray-600">题库不存在</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isProcessing = exam.status === 'processing'
|
||||
const isReady = exam.status === 'ready'
|
||||
const isFailed = exam.status === 'failed'
|
||||
const quizProgress = calculateProgress(exam.current_index, exam.total_questions)
|
||||
const completionProgress = isProcessing
|
||||
? Math.round(Number(progress?.progress ?? 0))
|
||||
: quizProgress
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-4 md:p-8">
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={() => navigate('/exams')}
|
||||
className="mb-6 flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
返回题库列表
|
||||
</button>
|
||||
|
||||
{/* Parsing Progress (only shown when processing) */}
|
||||
{isProcessing && progress && (
|
||||
<ParsingProgress progress={progress} />
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 mb-2">
|
||||
{exam.title}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-3 py-1 text-sm font-medium rounded-full ${getStatusColor(exam.status)}`}>
|
||||
{getStatusText(exam.status)}
|
||||
</span>
|
||||
{isProcessing && (
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
正在处理中...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-4 md:mt-0 flex flex-col sm:flex-row gap-2">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
disabled={isProcessing}
|
||||
className="bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
添加题目文档
|
||||
</button>
|
||||
|
||||
{isReady && exam.total_questions > 0 && (
|
||||
<button
|
||||
onClick={handleStartQuiz}
|
||||
className="bg-primary-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Play className="h-5 w-5" />
|
||||
{exam.current_index > 0 ? '继续刷题' : '开始刷题'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 mb-1">题目总数</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{exam.total_questions}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 mb-1">已完成</p>
|
||||
<p className="text-2xl font-bold text-primary-600">{exam.current_index}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 mb-1">剩余</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{Math.max(0, exam.total_questions - exam.current_index)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 mb-1">完成度</p>
|
||||
<p className="text-2xl font-bold text-green-600">{completionProgress}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{(isProcessing || exam.total_questions > 0) && (
|
||||
<div className="mt-6">
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className="bg-primary-600 h-3 rounded-full transition-all"
|
||||
style={{ width: `${completionProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 text-sm text-gray-600">
|
||||
<p>创建时间:{formatDate(exam.created_at)}</p>
|
||||
<p>最后更新:{formatDate(exam.updated_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Failed Status Warning */}
|
||||
{isFailed && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-6 w-6 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-red-900 mb-1">文档解析失败</h3>
|
||||
<p className="text-sm text-red-700">
|
||||
请检查文档格式是否正确,或尝试重新上传。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View All Questions Link */}
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-sm p-6 cursor-pointer hover:shadow-md transition-shadow flex items-center justify-between group"
|
||||
onClick={() => navigate(`/questions?examId=${examId}`)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-blue-100 p-3 rounded-full text-blue-600 group-hover:bg-blue-600 group-hover:text-white transition-colors">
|
||||
<FileText className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900">查看题库所有题目</h2>
|
||||
<p className="text-gray-600">浏览、搜索和查看该题库中的所有题目详情</p>
|
||||
</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>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-xl max-w-md w-full p-6">
|
||||
<h2 className="text-2xl font-bold mb-4">添加题目文档</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
上传新文档后,系统会自动解析题目并去除重复题目。
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleAppendDocument} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
选择文档
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => setUploadFile(e.target.files[0])}
|
||||
required
|
||||
accept=".txt,.pdf,.doc,.docx,.xlsx,.xls"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
支持:TXT, PDF, DOC, DOCX, XLSX, XLS
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowUploadModal(false)
|
||||
setUploadFile(null)
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
className="flex-1 bg-primary-600 text-white py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader className="h-5 w-5 animate-spin" />
|
||||
上传中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-5 w-5" />
|
||||
上传
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExamDetail
|
||||
@@ -1,342 +0,0 @@
|
||||
/**
|
||||
* Exam List Page
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { examAPI } from '../api/client'
|
||||
import {
|
||||
Plus, FolderOpen, Loader, AlertCircle, Trash2, Upload
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
getStatusColor,
|
||||
getStatusText,
|
||||
formatRelativeTime,
|
||||
calculateProgress,
|
||||
isValidFileType
|
||||
} from '../utils/helpers'
|
||||
|
||||
export const ExamList = () => {
|
||||
const navigate = useNavigate()
|
||||
const [exams, setExams] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [pollInterval, setPollInterval] = useState(null)
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
file: null,
|
||||
isRandom: false
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadExams()
|
||||
|
||||
// Start polling for processing exams
|
||||
const interval = setInterval(() => {
|
||||
checkProcessingExams()
|
||||
}, 3000) // Poll every 3 seconds
|
||||
|
||||
setPollInterval(interval)
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadExams = async () => {
|
||||
try {
|
||||
const response = await examAPI.getList()
|
||||
setExams(response.data.exams)
|
||||
} catch (error) {
|
||||
console.error('Failed to load exams:', error)
|
||||
toast.error('加载题库失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkProcessingExams = async () => {
|
||||
try {
|
||||
const response = await examAPI.getList()
|
||||
const newExams = response.data.exams
|
||||
|
||||
// Check if any processing exam is now ready
|
||||
const oldProcessing = exams.filter(e => e.status === 'processing')
|
||||
const newReady = newExams.filter(e =>
|
||||
oldProcessing.some(old => old.id === e.id && e.status === 'ready')
|
||||
)
|
||||
|
||||
if (newReady.length > 0) {
|
||||
toast.success(`${newReady.length} 个题库解析完成!`)
|
||||
}
|
||||
|
||||
setExams(newExams)
|
||||
} catch (error) {
|
||||
console.error('Failed to poll exams:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.file) {
|
||||
toast.error('请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidFileType(formData.file.name)) {
|
||||
toast.error('不支持的文件类型')
|
||||
return
|
||||
}
|
||||
|
||||
setCreating(true)
|
||||
|
||||
try {
|
||||
const response = await examAPI.create(formData.title, formData.file, formData.isRandom)
|
||||
toast.success('题库创建成功,正在解析文档...')
|
||||
setShowCreateModal(false)
|
||||
setFormData({ title: '', file: null, isRandom: false })
|
||||
|
||||
// 跳转到新创建的试卷详情页
|
||||
if (response.data && response.data.exam_id) {
|
||||
navigate(`/exams/${response.data.exam_id}`)
|
||||
} else {
|
||||
// 如果没有返回 exam_id,刷新列表
|
||||
await loadExams()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create exam:', error)
|
||||
toast.error('创建失败:' + (error.response?.data?.detail || error.message))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (examId) => {
|
||||
if (!window.confirm('确定要删除这个题库吗?删除后无法恢复。')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await examAPI.delete(examId)
|
||||
toast.success('题库已删除')
|
||||
await loadExams()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete exam:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">题库管理</h1>
|
||||
<p className="text-gray-600 mt-1">共 {exams.length} 个题库</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="mt-4 md:mt-0 bg-primary-600 text-white px-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-4 w-4 md:h-5 md:w-5" />
|
||||
<span className="hidden md:inline">创建题库</span>
|
||||
<span className="md:hidden">新建</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Exam Grid */}
|
||||
{exams.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<FolderOpen className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">还没有题库</h3>
|
||||
<p className="text-gray-500 mb-6">创建第一个题库开始刷题吧!</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="bg-primary-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
创建题库
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{exams.map((exam) => (
|
||||
<div
|
||||
key={exam.id}
|
||||
className="bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow p-6"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex-1 pr-2">
|
||||
{exam.title}
|
||||
</h3>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(exam.status)}`}>
|
||||
{getStatusText(exam.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">题目数量</span>
|
||||
<span className="font-medium">{exam.total_questions}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">已完成</span>
|
||||
<span className="font-medium">
|
||||
{exam.current_index} / {exam.total_questions}
|
||||
</span>
|
||||
</div>
|
||||
{exam.total_questions > 0 && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${calculateProgress(exam.current_index, exam.total_questions)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
创建于 {formatRelativeTime(exam.created_at)}
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/exams/${exam.id}`)}
|
||||
className="flex-1 bg-primary-600 text-white py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(exam.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-xl max-w-md w-full p-6">
|
||||
<h2 className="text-2xl font-bold mb-4">创建新题库</h2>
|
||||
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
题库名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="例如:数据结构期末复习"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
上传文档
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => setFormData({ ...formData, file: e.target.files[0] })}
|
||||
required
|
||||
accept=".txt,.pdf,.doc,.docx,.xlsx,.xls"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
支持:TXT, PDF, DOC, DOCX, XLSX, XLS
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false)
|
||||
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"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="flex-1 bg-primary-600 text-white py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader className="h-5 w-5 animate-spin" />
|
||||
创建中...
|
||||
</>
|
||||
) : (
|
||||
'创建'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExamList
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Login Page
|
||||
*/
|
||||
import React, { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { BookOpen } from 'lucide-react'
|
||||
|
||||
export const Login = () => {
|
||||
const navigate = useNavigate()
|
||||
const { login } = useAuth()
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const success = await login(formData.username, formData.password)
|
||||
if (success) {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full">
|
||||
{/* Logo and Title */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="bg-primary-600 p-3 rounded-2xl">
|
||||
<BookOpen className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">QQuiz</h1>
|
||||
<p className="text-gray-600 mt-2">智能刷题与题库管理平台</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">登录</h2>
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Register Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-gray-600">
|
||||
还没有账号?{' '}
|
||||
<Link to="/register" className="text-primary-600 font-medium hover:text-primary-700">
|
||||
立即注册
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* Mistake List Page (错题本)
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { mistakeAPI } from '../api/client'
|
||||
import Pagination from '../components/Pagination'
|
||||
import { XCircle, Loader, Trash2, BookOpen, Play, ChevronRight } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { getQuestionTypeText, formatRelativeTime } from '../utils/helpers'
|
||||
|
||||
export const MistakeList = () => {
|
||||
const [mistakes, setMistakes] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedId, setExpandedId] = useState(null)
|
||||
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(() => {
|
||||
loadMistakes()
|
||||
}, [page, limit])
|
||||
|
||||
const loadMistakes = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const skip = (page - 1) * limit
|
||||
const response = await mistakeAPI.getList(skip, limit)
|
||||
setMistakes(response.data.mistakes)
|
||||
setTotal(response.data.total)
|
||||
} catch (error) {
|
||||
console.error('Failed to load mistakes:', error)
|
||||
toast.error('加载错题本失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (mistakeId) => {
|
||||
if (!window.confirm('确定要从错题本中移除这道题吗?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await mistakeAPI.remove(mistakeId)
|
||||
toast.success('已移除')
|
||||
await loadMistakes()
|
||||
} catch (error) {
|
||||
console.error('Failed to remove mistake:', error)
|
||||
toast.error('移除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
setExpandedId(expandedId === id ? null : id)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{/* Empty State */}
|
||||
{mistakes.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<XCircle className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">错题本是空的</h3>
|
||||
<p className="text-gray-500">继续刷题,错题会自动添加到这里</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{mistakes.map((mistake) => {
|
||||
const q = mistake.question
|
||||
const isExpanded = expandedId === mistake.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={mistake.id}
|
||||
className="bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
{/* Question Preview */}
|
||||
<div
|
||||
className="p-4 md:p-6 cursor-pointer"
|
||||
onClick={() => toggleExpand(mistake.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-10 h-10 bg-red-100 text-red-600 rounded-full flex items-center justify-center">
|
||||
<XCircle className="h-5 w-5" />
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-600 rounded">
|
||||
{getQuestionTypeText(q.type)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatRelativeTime(mistake.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className={`text-gray-900 ${!isExpanded ? 'line-clamp-2' : ''}`}>
|
||||
{q.content}
|
||||
</p>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* Options */}
|
||||
{q.options && q.options.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{q.options.map((opt, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-3 bg-gray-50 rounded-lg text-sm text-gray-700"
|
||||
>
|
||||
{opt}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Answer */}
|
||||
<div className="p-3 bg-green-50 rounded-lg">
|
||||
<p className="text-sm font-medium text-green-900 mb-1">
|
||||
正确答案
|
||||
</p>
|
||||
<p className="text-sm text-green-700">{q.answer}</p>
|
||||
</div>
|
||||
|
||||
{/* Analysis */}
|
||||
{q.analysis && (
|
||||
<div className="p-3 bg-blue-50 rounded-lg">
|
||||
<p className="text-sm font-medium text-blue-900 mb-1">
|
||||
解析
|
||||
</p>
|
||||
<p className="text-sm text-blue-700">{q.analysis}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemove(mistake.id)
|
||||
}}
|
||||
className="flex-shrink-0 p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalItems={total}
|
||||
pageSize={limit}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(newLimit) => {
|
||||
setLimit(newLimit)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MistakeList
|
||||
@@ -1,412 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* Question Bank Page - View all questions
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { questionAPI } from '../api/client'
|
||||
import Pagination from '../components/Pagination'
|
||||
import { FileText, Loader } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { getQuestionTypeText, formatRelativeTime } from '../utils/helpers'
|
||||
|
||||
export const QuestionBank = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
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)
|
||||
const examIdParam = searchParams.get('examId')
|
||||
const examIdFilter = /^\d+$/.test(examIdParam || '') ? Number(examIdParam) : null
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
setExpandedId(null)
|
||||
}, [examIdFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadQuestions()
|
||||
}, [page, limit, examIdFilter])
|
||||
|
||||
const loadQuestions = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const skip = (page - 1) * limit
|
||||
const response = await questionAPI.getAll(skip, limit, examIdFilter)
|
||||
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)
|
||||
}
|
||||
|
||||
const title = examIdFilter ? `题库 ${examIdFilter} 题目` : '全站题库'
|
||||
const subtitle = examIdFilter
|
||||
? `当前仅显示该题库下的 ${total} 道题目`
|
||||
: `共 ${total} 道题目`
|
||||
|
||||
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">{title}</h1>
|
||||
<p className="text-gray-600 mt-1">{subtitle}</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
|
||||
@@ -1,369 +0,0 @@
|
||||
/**
|
||||
* Quiz Player Page - Core quiz functionality
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { examAPI, questionAPI, mistakeAPI } from '../api/client'
|
||||
import {
|
||||
ArrowLeft, ArrowRight, Check, X, Loader, BookmarkPlus, BookmarkX, AlertCircle
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { getQuestionTypeText } from '../utils/helpers'
|
||||
|
||||
export const QuizPlayer = () => {
|
||||
const { examId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const [exam, setExam] = useState(null)
|
||||
const [question, setQuestion] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [userAnswer, setUserAnswer] = useState('')
|
||||
const [multipleAnswers, setMultipleAnswers] = useState([])
|
||||
const [result, setResult] = useState(null)
|
||||
const [inMistakeBook, setInMistakeBook] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadQuiz()
|
||||
}, [examId])
|
||||
|
||||
const loadQuiz = async () => {
|
||||
try {
|
||||
// Check if reset flag is present
|
||||
const shouldReset = searchParams.get('reset') === 'true'
|
||||
if (shouldReset) {
|
||||
await examAPI.updateProgress(examId, 0)
|
||||
}
|
||||
|
||||
const examRes = await examAPI.getDetail(examId)
|
||||
setExam(examRes.data)
|
||||
|
||||
await loadCurrentQuestion()
|
||||
} catch (error) {
|
||||
console.error('Failed to load quiz:', error)
|
||||
toast.error('加载题目失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCurrentQuestion = async () => {
|
||||
try {
|
||||
const response = await questionAPI.getCurrentQuestion(examId)
|
||||
// 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)
|
||||
setResult(null)
|
||||
setUserAnswer('')
|
||||
setMultipleAnswers([])
|
||||
await checkIfInMistakeBook(response.data.id)
|
||||
} catch (error) {
|
||||
if (error.response?.status === 404) {
|
||||
toast.success('恭喜!所有题目已完成!')
|
||||
navigate(`/exams/${examId}`)
|
||||
} else {
|
||||
console.error('Failed to load question:', error)
|
||||
toast.error('加载题目失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checkIfInMistakeBook = async (questionId) => {
|
||||
try {
|
||||
const response = await mistakeAPI.getList(0, 1000) // TODO: Optimize this
|
||||
const inBook = response.data.mistakes.some(m => m.question_id === questionId)
|
||||
setInMistakeBook(inBook)
|
||||
} catch (error) {
|
||||
console.error('Failed to check mistake book:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitAnswer = async () => {
|
||||
let answer = userAnswer
|
||||
|
||||
// For multiple choice, join selected options
|
||||
if (question.type === 'multiple') {
|
||||
if (multipleAnswers.length === 0) {
|
||||
toast.error('请至少选择一个选项')
|
||||
return
|
||||
}
|
||||
answer = multipleAnswers.sort().join('')
|
||||
}
|
||||
|
||||
if (!answer.trim()) {
|
||||
toast.error('请输入答案')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await questionAPI.checkAnswer(question.id, answer)
|
||||
setResult(response.data)
|
||||
|
||||
if (response.data.correct) {
|
||||
toast.success('回答正确!')
|
||||
} else {
|
||||
toast.error('回答错误')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check answer:', error)
|
||||
toast.error('提交答案失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNext = async () => {
|
||||
try {
|
||||
const newIndex = exam.current_index + 1
|
||||
await examAPI.updateProgress(examId, newIndex)
|
||||
|
||||
const examRes = await examAPI.getDetail(examId)
|
||||
setExam(examRes.data)
|
||||
|
||||
await loadCurrentQuestion()
|
||||
} catch (error) {
|
||||
console.error('Failed to move to next question:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleMistake = async () => {
|
||||
try {
|
||||
if (inMistakeBook) {
|
||||
await mistakeAPI.removeByQuestionId(question.id)
|
||||
setInMistakeBook(false)
|
||||
toast.success('已从错题本移除')
|
||||
} else {
|
||||
await mistakeAPI.add(question.id)
|
||||
setInMistakeBook(true)
|
||||
toast.success('已加入错题本')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle mistake:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMultipleChoice = (option) => {
|
||||
const letter = option.charAt(0)
|
||||
if (multipleAnswers.includes(letter)) {
|
||||
setMultipleAnswers(multipleAnswers.filter(a => a !== letter))
|
||||
} else {
|
||||
setMultipleAnswers([...multipleAnswers, letter])
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<Loader className="h-8 w-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!question) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen">
|
||||
<AlertCircle className="h-16 w-16 text-gray-300 mb-4" />
|
||||
<p className="text-gray-600">没有更多题目了</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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(`/exams/${examId}`)}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
返回
|
||||
</button>
|
||||
|
||||
<div className="text-sm text-gray-600">
|
||||
进度: {exam.current_index + 1} / {exam.total_questions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Question Card */}
|
||||
<div className="bg-white rounded-xl shadow-md p-6 md:p-8 mb-6">
|
||||
{/* Question Header */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex-shrink-0 w-10 h-10 bg-primary-100 text-primary-600 rounded-full flex items-center justify-center font-bold">
|
||||
{exam.current_index + 1}
|
||||
</span>
|
||||
<span className="px-3 py-1 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium">
|
||||
{getQuestionTypeText(question.type)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleToggleMistake}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${inMistakeBook
|
||||
? 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{inMistakeBook ? (
|
||||
<>
|
||||
<BookmarkX className="h-5 w-5" />
|
||||
<span className="hidden sm:inline">移出错题本</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BookmarkPlus className="h-5 w-5" />
|
||||
<span className="hidden sm:inline">加入错题本</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Question Content */}
|
||||
<div className="mb-6">
|
||||
<p className="text-lg md:text-xl text-gray-900 leading-relaxed">
|
||||
{question.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Options (for choice questions) */}
|
||||
{question.options && question.options.length > 0 && (
|
||||
<div className="space-y-3 mb-6">
|
||||
{question.options.map((option, index) => {
|
||||
const letter = option.charAt(0)
|
||||
const isSelected = question.type === 'multiple'
|
||||
? multipleAnswers.includes(letter)
|
||||
: userAnswer === letter
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
if (!result) {
|
||||
if (question.type === 'multiple') {
|
||||
handleMultipleChoice(option)
|
||||
} else {
|
||||
setUserAnswer(letter)
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={!!result}
|
||||
className={`w-full text-left p-4 rounded-lg border-2 transition-all ${isSelected
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
} ${result ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className="text-gray-900">{option}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Short Answer Input */}
|
||||
{question.type === 'short' && !result && (
|
||||
<div className="mb-6">
|
||||
<textarea
|
||||
value={userAnswer}
|
||||
onChange={(e) => setUserAnswer(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-lg focus:border-primary-500 focus:outline-none"
|
||||
placeholder="请输入你的答案..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Submit Button */}
|
||||
{!result && (
|
||||
<button
|
||||
onClick={handleSubmitAnswer}
|
||||
disabled={submitting}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader className="h-5 w-5 animate-spin" />
|
||||
提交中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="h-5 w-5" />
|
||||
提交答案
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className={`rounded-xl p-6 mb-6 ${result.correct ? 'bg-green-50 border-2 border-green-200' : 'bg-red-50 border-2 border-red-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
{result.correct ? (
|
||||
<Check className="h-6 w-6 text-green-600 mt-0.5" />
|
||||
) : (
|
||||
<X className="h-6 w-6 text-red-600 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-bold text-lg mb-2 ${result.correct ? 'text-green-900' : 'text-red-900'}`}>
|
||||
{result.correct ? '回答正确!' : '回答错误'}
|
||||
</h3>
|
||||
|
||||
{!result.correct && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-gray-700">
|
||||
<span className="font-medium">你的答案:</span>{result.user_answer}
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
<span className="font-medium">正确答案:</span>{result.correct_answer}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Score for short answers */}
|
||||
{result.ai_score !== null && result.ai_score !== undefined && (
|
||||
<div className="mt-3 p-3 bg-white rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-1">
|
||||
AI 评分:{(result.ai_score * 100).toFixed(0)}%
|
||||
</p>
|
||||
{result.ai_feedback && (
|
||||
<p className="text-sm text-gray-600">{result.ai_feedback}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analysis */}
|
||||
{result.analysis && (
|
||||
<div className="mt-3 p-3 bg-white rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-1">解析:</p>
|
||||
<p className="text-sm text-gray-600">{result.analysis}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next Button */}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
下一题
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuizPlayer
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* Register Page
|
||||
*/
|
||||
import React, { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { BookOpen } from 'lucide-react'
|
||||
|
||||
export const Register = () => {
|
||||
const navigate = useNavigate()
|
||||
const { register } = useAuth()
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
// Validate
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.password.length < 6) {
|
||||
setError('密码至少需要 6 位')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const success = await register(formData.username, formData.password)
|
||||
if (success) {
|
||||
navigate('/login')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
setError('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full">
|
||||
{/* Logo and Title */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="bg-primary-600 p-3 rounded-2xl">
|
||||
<BookOpen className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">QQuiz</h1>
|
||||
<p className="text-gray-600 mt-2">智能刷题与题库管理平台</p>
|
||||
</div>
|
||||
|
||||
{/* Register Form */}
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">注册</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={50}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="3-50 位字符"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="至少 6 位"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
确认密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="再次输入密码"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? '注册中...' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-gray-600">
|
||||
已有账号?{' '}
|
||||
<Link to="/login" className="text-primary-600 font-medium hover:text-primary-700">
|
||||
立即登录
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Register
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Utility Helper Functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format date to readable string
|
||||
*/
|
||||
export const formatDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "2 days ago")
|
||||
*/
|
||||
export const formatRelativeTime = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 7) {
|
||||
return formatDate(dateString)
|
||||
} else if (days > 0) {
|
||||
return `${days} 天前`
|
||||
} else if (hours > 0) {
|
||||
return `${hours} 小时前`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes} 分钟前`
|
||||
} else {
|
||||
return '刚刚'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get exam status badge color
|
||||
*/
|
||||
export const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
pending: 'bg-gray-100 text-gray-800',
|
||||
processing: 'bg-blue-100 text-blue-800',
|
||||
ready: 'bg-green-100 text-green-800',
|
||||
failed: 'bg-red-100 text-red-800'
|
||||
}
|
||||
return colors[status] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get exam status text
|
||||
*/
|
||||
export const getStatusText = (status) => {
|
||||
const texts = {
|
||||
pending: '等待中',
|
||||
processing: '处理中',
|
||||
ready: '就绪',
|
||||
failed: '失败'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
/**
|
||||
* Get question type text
|
||||
*/
|
||||
export const getQuestionTypeText = (type) => {
|
||||
const texts = {
|
||||
single: '单选题',
|
||||
multiple: '多选题',
|
||||
judge: '判断题 (单选)',
|
||||
short: '简答题'
|
||||
}
|
||||
return texts[type] || type
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate progress percentage
|
||||
*/
|
||||
export const calculateProgress = (current, total) => {
|
||||
if (total === 0) return 0
|
||||
return Math.round((current / total) * 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file type
|
||||
*/
|
||||
export const isValidFileType = (filename) => {
|
||||
const allowedExtensions = ['txt', 'pdf', 'doc', 'docx', 'xlsx', 'xls']
|
||||
const extension = filename.split('.').pop().toLowerCase()
|
||||
return allowedExtensions.includes(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size
|
||||
*/
|
||||
export const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text
|
||||
*/
|
||||
export const truncateText = (text, maxLength = 100) => {
|
||||
if (text.length <= maxLength) return text
|
||||
return text.substring(0, maxLength) + '...'
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Assume running from frontend directory
|
||||
const envDir = path.resolve(process.cwd(), '..')
|
||||
const env = loadEnv(mode, envDir, '')
|
||||
|
||||
return {
|
||||
envDir, // Tell Vite to look for .env files in the project root
|
||||
plugins: [react()],
|
||||
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'
|
||||
}
|
||||
}
|
||||
})
|
||||
98
scripts/start_single_container.py
Normal file
98
scripts/start_single_container.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
ROOT_DIR = "/app"
|
||||
WEB_DIR = "/app/web"
|
||||
|
||||
|
||||
def terminate_process(process: subprocess.Popen | None, label: str) -> None:
|
||||
if process is None or process.poll() is not None:
|
||||
return
|
||||
|
||||
print(f"Stopping {label}...")
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
shared_env = os.environ.copy()
|
||||
shared_env.setdefault("API_BASE_URL", "http://127.0.0.1:8000")
|
||||
shared_env.setdefault("NEXT_SERVER_URL", "http://127.0.0.1:3000")
|
||||
shared_env.setdefault("NEXT_TELEMETRY_DISABLED", "1")
|
||||
|
||||
next_env = shared_env.copy()
|
||||
next_env["NODE_ENV"] = "production"
|
||||
next_env["HOSTNAME"] = "0.0.0.0"
|
||||
next_env["PORT"] = "3000"
|
||||
|
||||
next_process = subprocess.Popen(
|
||||
["node", "server.js"],
|
||||
cwd=WEB_DIR,
|
||||
env=next_env,
|
||||
)
|
||||
|
||||
api_process: subprocess.Popen | None = None
|
||||
|
||||
def shutdown(signum, _frame):
|
||||
print(f"Received signal {signum}, shutting down...")
|
||||
terminate_process(api_process, "FastAPI")
|
||||
terminate_process(next_process, "Next.js")
|
||||
raise SystemExit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, shutdown)
|
||||
signal.signal(signal.SIGTERM, shutdown)
|
||||
|
||||
try:
|
||||
migrate_result = subprocess.run(
|
||||
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||
cwd=ROOT_DIR,
|
||||
env=shared_env,
|
||||
check=False,
|
||||
)
|
||||
if migrate_result.returncode != 0:
|
||||
terminate_process(next_process, "Next.js")
|
||||
return migrate_result.returncode
|
||||
|
||||
api_process = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"main:app",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"8000",
|
||||
],
|
||||
cwd=ROOT_DIR,
|
||||
env=shared_env,
|
||||
)
|
||||
|
||||
while True:
|
||||
next_returncode = next_process.poll()
|
||||
api_returncode = api_process.poll()
|
||||
|
||||
if next_returncode is not None:
|
||||
terminate_process(api_process, "FastAPI")
|
||||
return next_returncode
|
||||
|
||||
if api_returncode is not None:
|
||||
terminate_process(next_process, "Next.js")
|
||||
return api_returncode
|
||||
|
||||
time.sleep(1)
|
||||
finally:
|
||||
terminate_process(api_process, "FastAPI")
|
||||
terminate_process(next_process, "Next.js")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,14 +1,13 @@
|
||||
# QQuiz Web
|
||||
|
||||
This directory contains the new Next.js frontend scaffold for the QQuiz
|
||||
refactor.
|
||||
This directory contains the Next.js frontend for QQuiz.
|
||||
|
||||
## Status
|
||||
|
||||
- App Router skeleton: added
|
||||
- Auth/session proxy routes: added
|
||||
- Legacy Vite frontend replacement: in progress
|
||||
- shadcn/ui component foundation: added
|
||||
- App Router application: active
|
||||
- Auth/session proxy routes: active
|
||||
- Single-container deployment target: active
|
||||
- Split-stack frontend: active
|
||||
|
||||
## Environment
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
getResponseErrorMessage,
|
||||
isRecord,
|
||||
readResponsePayload
|
||||
} from "@/lib/api/response";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -22,7 +27,7 @@ export default function LoginPage() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
const response = await fetch("/frontend-api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -30,9 +35,13 @@ export default function LoginPage() {
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
const payload = await readResponsePayload(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.detail || "登录失败");
|
||||
throw new Error(getResponseErrorMessage(payload, "登录失败"));
|
||||
}
|
||||
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("登录接口返回了无效响应");
|
||||
}
|
||||
|
||||
toast.success("登录成功");
|
||||
|
||||
@@ -9,6 +9,11 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
getResponseErrorMessage,
|
||||
isRecord,
|
||||
readResponsePayload
|
||||
} from "@/lib/api/response";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
@@ -21,7 +26,7 @@ export default function RegisterPage() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/proxy/auth/register", {
|
||||
const response = await fetch("/frontend-api/proxy/auth/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -29,9 +34,13 @@ export default function RegisterPage() {
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
const payload = await readResponsePayload(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.detail || "注册失败");
|
||||
throw new Error(getResponseErrorMessage(payload, "注册失败"));
|
||||
}
|
||||
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("注册接口返回了无效响应");
|
||||
}
|
||||
|
||||
toast.success("注册成功,请登录");
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { SESSION_COOKIE_NAME, buildBackendUrl } from "@/lib/api/config";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const forwardedProto = request.headers.get("x-forwarded-proto");
|
||||
const isSecureRequest =
|
||||
request.nextUrl.protocol === "https:" || forwardedProto === "https";
|
||||
|
||||
const response = await fetch(buildBackendUrl("/auth/login"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store"
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
|
||||
cookies().set({
|
||||
name: SESSION_COOKIE_NAME,
|
||||
value: payload.access_token,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: isSecureRequest,
|
||||
path: "/"
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
SESSION_COOKIE_NAME,
|
||||
buildBackendUrl
|
||||
} from "@/lib/api/config";
|
||||
|
||||
export async function GET() {
|
||||
const token = cookies().get(SESSION_COOKIE_NAME)?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ detail: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = await fetch(buildBackendUrl("/auth/me"), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
cache: "no-store"
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
if (response.status === 401) {
|
||||
cookies().delete(SESSION_COOKIE_NAME);
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
60
web/src/app/frontend-api/auth/login/route.ts
Normal file
60
web/src/app/frontend-api/auth/login/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { SESSION_COOKIE_NAME, buildBackendUrl } from "@/lib/api/config";
|
||||
import {
|
||||
getResponseErrorMessage,
|
||||
isRecord,
|
||||
readResponsePayload
|
||||
} from "@/lib/api/response";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const forwardedProto = request.headers.get("x-forwarded-proto");
|
||||
const isSecureRequest =
|
||||
request.nextUrl.protocol === "https:" || forwardedProto === "https";
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(buildBackendUrl("/auth/login"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store"
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ detail: "Backend API is unavailable." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const payload = await readResponsePayload(response);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ detail: getResponseErrorMessage(payload, "登录失败") },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRecord(payload) || typeof payload.access_token !== "string") {
|
||||
return NextResponse.json(
|
||||
{ detail: "Backend returned an invalid login response." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
cookies().set({
|
||||
name: SESSION_COOKIE_NAME,
|
||||
value: payload.access_token,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: isSecureRequest,
|
||||
path: "/"
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
57
web/src/app/frontend-api/auth/me/route.ts
Normal file
57
web/src/app/frontend-api/auth/me/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
SESSION_COOKIE_NAME,
|
||||
buildBackendUrl
|
||||
} from "@/lib/api/config";
|
||||
import {
|
||||
getResponseErrorMessage,
|
||||
isRecord,
|
||||
readResponsePayload
|
||||
} from "@/lib/api/response";
|
||||
|
||||
export async function GET() {
|
||||
const token = cookies().get(SESSION_COOKIE_NAME)?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ detail: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(buildBackendUrl("/auth/me"), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
cache: "no-store"
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ detail: "Backend API is unavailable." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const payload = await readResponsePayload(response);
|
||||
|
||||
if (response.status === 401) {
|
||||
cookies().delete(SESSION_COOKIE_NAME);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ detail: getResponseErrorMessage(payload, "获取当前用户失败") },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRecord(payload)) {
|
||||
return NextResponse.json(
|
||||
{ detail: "Backend returned an invalid auth response." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
@@ -16,13 +16,23 @@ export async function GET(
|
||||
}
|
||||
|
||||
const target = `${buildBackendUrl(`/exams/${params.examId}/progress`)}?token=${encodeURIComponent(token)}`;
|
||||
const response = await fetch(target, {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(target, {
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"Cache-Control": "no-cache"
|
||||
},
|
||||
cache: "no-store"
|
||||
});
|
||||
} catch {
|
||||
return new NextResponse("Backend API is unavailable.", {
|
||||
status: 502,
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
const payload = await response.text();
|
||||
@@ -34,7 +34,16 @@ async function proxyRequest(
|
||||
init.body = await request.arrayBuffer();
|
||||
}
|
||||
|
||||
const response = await fetch(target, init);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(target, init);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ detail: "Backend API is unavailable." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const responseHeaders = new Headers(response.headers);
|
||||
responseHeaders.delete("content-encoding");
|
||||
responseHeaders.delete("content-length");
|
||||
@@ -10,7 +10,7 @@ export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogout() {
|
||||
const response = await fetch("/api/auth/logout", {
|
||||
const response = await fetch("/frontend-api/auth/logout", {
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export function ExamDetailClient({
|
||||
return;
|
||||
}
|
||||
|
||||
const source = new EventSource(`/api/exams/${exam.id}/progress`);
|
||||
const source = new EventSource(`/frontend-api/exams/${exam.id}/progress`);
|
||||
eventSourceRef.current = source;
|
||||
|
||||
source.onmessage = (event) => {
|
||||
|
||||
@@ -31,8 +31,12 @@ export function MistakePracticeClient() {
|
||||
async function loadMistakes() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = await browserApi<MistakeListResponse>("/mistakes/?skip=0&limit=1000", {
|
||||
method: "GET"
|
||||
const payload = await browserApi<MistakeListResponse>("/mistakes", {
|
||||
method: "GET",
|
||||
query: {
|
||||
skip: 0,
|
||||
limit: 1000
|
||||
}
|
||||
});
|
||||
|
||||
let nextMistakes = payload.mistakes;
|
||||
|
||||
@@ -47,8 +47,12 @@ export function QuizPlayerClient({
|
||||
const [examPayload, questionPayload, mistakesPayload] = await Promise.all([
|
||||
browserApi<ExamSummary>(`/exams/${examId}`, { method: "GET" }),
|
||||
browserApi<QuestionDetail>(`/questions/exam/${examId}/current`, { method: "GET" }),
|
||||
browserApi<{ mistakes: Array<{ question_id: number }> }>("/mistakes/?skip=0&limit=1000", {
|
||||
method: "GET"
|
||||
browserApi<{ mistakes: Array<{ question_id: number }> }>("/mistakes", {
|
||||
method: "GET",
|
||||
query: {
|
||||
skip: 0,
|
||||
limit: 1000
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { buildProxyUrl } from "@/lib/api/config";
|
||||
import {
|
||||
getResponseErrorMessage,
|
||||
getUnexpectedJsonMessage,
|
||||
isRecord,
|
||||
readResponsePayload
|
||||
} from "@/lib/api/response";
|
||||
|
||||
type BrowserApiOptions = Omit<RequestInit, "body"> & {
|
||||
body?: BodyInit | null;
|
||||
@@ -27,7 +33,8 @@ export async function browserApi<T>(
|
||||
options: BrowserApiOptions = {}
|
||||
): Promise<T> {
|
||||
const { query, headers, ...init } = options;
|
||||
const response = await fetch(buildProxyUrl(path, buildSearchParams(query)), {
|
||||
const target = buildProxyUrl(path, buildSearchParams(query));
|
||||
const response = await fetch(target, {
|
||||
...init,
|
||||
headers: {
|
||||
...(headers || {})
|
||||
@@ -38,20 +45,18 @@ export async function browserApi<T>(
|
||||
|
||||
if (!response.ok) {
|
||||
const fallback = `Request failed with status ${response.status}`;
|
||||
try {
|
||||
const data = await response.json();
|
||||
throw new Error(data?.detail || fallback);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(fallback);
|
||||
}
|
||||
const payload = await readResponsePayload(response);
|
||||
throw new Error(getResponseErrorMessage(payload, fallback));
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
const payload = await readResponsePayload(response);
|
||||
if (!isRecord(payload) && !Array.isArray(payload)) {
|
||||
throw new Error(getUnexpectedJsonMessage(response));
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,6 @@ export function buildProxyUrl(path: string, search?: URLSearchParams) {
|
||||
const query = search?.toString();
|
||||
|
||||
return query
|
||||
? `/api/proxy/${normalizedPath}?${query}`
|
||||
: `/api/proxy/${normalizedPath}`;
|
||||
? `/frontend-api/proxy/${normalizedPath}?${query}`
|
||||
: `/frontend-api/proxy/${normalizedPath}`;
|
||||
}
|
||||
|
||||
61
web/src/lib/api/response.ts
Normal file
61
web/src/lib/api/response.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
function isJsonContentType(contentType: string | null) {
|
||||
return Boolean(contentType && (contentType.includes("application/json") || contentType.includes("+json")));
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export async function readResponsePayload(response: Response): Promise<unknown> {
|
||||
if (response.status === 204 || response.status === 205) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = await response.text();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isJsonContentType(response.headers.get("content-type"))) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export function getResponseErrorMessage(payload: unknown, fallback: string) {
|
||||
if (isRecord(payload)) {
|
||||
const detail = payload.detail;
|
||||
if (typeof detail === "string" && detail.trim()) {
|
||||
return detail;
|
||||
}
|
||||
|
||||
const message = payload.message;
|
||||
if (typeof message === "string" && message.trim()) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof payload === "string" && payload.trim()) {
|
||||
const trimmed = payload.trim();
|
||||
if (trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html")) {
|
||||
return `${fallback} 接口返回了 HTML 而不是 JSON,请检查前端代理和后端服务。`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function getUnexpectedJsonMessage(response: Response) {
|
||||
const contentType = response.headers.get("content-type") || "unknown content type";
|
||||
if (contentType.includes("text/html")) {
|
||||
return "接口返回了 HTML 而不是 JSON,请检查前端代理和后端服务。";
|
||||
}
|
||||
|
||||
return `接口返回了非 JSON 响应:${contentType}`;
|
||||
}
|
||||
@@ -5,6 +5,12 @@ import {
|
||||
SESSION_COOKIE_NAME,
|
||||
buildBackendUrl
|
||||
} from "@/lib/api/config";
|
||||
import {
|
||||
getResponseErrorMessage,
|
||||
getUnexpectedJsonMessage,
|
||||
isRecord,
|
||||
readResponsePayload
|
||||
} from "@/lib/api/response";
|
||||
|
||||
type ServerApiOptions = RequestInit & {
|
||||
next?: { revalidate?: number };
|
||||
@@ -31,20 +37,18 @@ export async function serverApi<T>(
|
||||
|
||||
if (!response.ok) {
|
||||
const fallback = `Request failed with status ${response.status}`;
|
||||
try {
|
||||
const data = await response.json();
|
||||
throw new Error(data?.detail || fallback);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(fallback);
|
||||
}
|
||||
const payload = await readResponsePayload(response);
|
||||
throw new Error(getResponseErrorMessage(payload, fallback));
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
const payload = await readResponsePayload(response);
|
||||
if (!isRecord(payload) && !Array.isArray(payload)) {
|
||||
throw new Error(getUnexpectedJsonMessage(response));
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user