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:
2026-04-17 21:15:06 +08:00
parent cab8b3b483
commit 9a1a9d3247
60 changed files with 819 additions and 7988 deletions

View File

@@ -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
View File

@@ -44,8 +44,6 @@ yarn-error.log
.pnp.js
# Build
frontend/build/
frontend/dist/
.next/
web/.next/
web/out/

View File

@@ -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 `安全修复和管理员账号密码自定义`.

View File

@@ -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"]

View File

@@ -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
```
建议至少手动验证:

View File

@@ -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.htmlSPA 单页应用)
"""
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

View File

@@ -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

View File

@@ -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

View File

@@ -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
---

View File

@@ -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.

View File

@@ -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

View File

@@ -1,9 +0,0 @@
node_modules
npm-debug.log
build
.git
.gitignore
.dockerignore
Dockerfile
.env
.env.local

View File

@@ -1,4 +0,0 @@
# Frontend Environment Variables
# API URL
VITE_API_URL=http://localhost:8000

View File

@@ -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"]

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -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"
]
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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>
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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) + '...'
}

View File

@@ -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: [],
}

View File

@@ -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'
}
}
})

View 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())

View File

@@ -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

View File

@@ -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("登录成功");

View File

@@ -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("注册成功,请登录");

View File

@@ -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 });
}

View File

@@ -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 });
}

View 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 });
}

View 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 });
}

View File

@@ -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();

View File

@@ -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");

View File

@@ -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"
});

View File

@@ -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) => {

View File

@@ -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;

View File

@@ -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
}
})
]);

View File

@@ -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;
}

View File

@@ -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}`;
}

View 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}`;
}

View File

@@ -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;
}