8 Commits

Author SHA1 Message Date
9a1a9d3247 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.
2026-04-17 21:15:06 +08:00
cab8b3b483 更新 README.md,完善功能描述和快速开始指南 2026-04-17 20:02:28 +08:00
31916e68a6 完善文档与前端迁移,补充开源协议 2026-04-17 19:48:13 +08:00
466fa50aa8 ESA人机验证 2026-01-08 12:42:12 +08:00
3e4157f021 错题本乱序和顺序刷题 2025-12-18 01:59:30 +08:00
3d47e568f6 翻页样式完善 2025-12-18 00:50:35 +08:00
e88716b1ea 第一阶段bug修复完毕 2025-12-18 00:46:37 +08:00
Simon
4b53e74729 Merge pull request #1 from handsomezhuzhu/codex/fix-security-vulnerabilities-in-qquiz
Document secure secrets and prune unused assets
2025-12-13 12:38:31 +08:00
119 changed files with 7562 additions and 7361 deletions

View File

@@ -1,4 +1,4 @@
name: Build and Publish Docker Image
name: Build and Publish Single-Container Image
on:
push:
@@ -45,6 +45,7 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
with:
context: .
@@ -53,8 +54,8 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: type=gha,mode=max,scope=single-container
platforms: linux/amd64,linux/arm64
- name: Image digest
run: echo "Image pushed with digest ${{ steps.build-and-push.outputs.digest }}"
run: echo "Image pushed with digest ${{ steps.build.outputs.digest }}"

5
.gitignore vendored
View File

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

34
AGENTS.md Normal file
View File

@@ -0,0 +1,34 @@
# Repository Guidelines
## 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/`.
- `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 -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 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 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.
## Testing Guidelines
- 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 `web/src/__tests__/`.
## Commit & Pull Request Guidelines
- Recent history favors short, focused subjects, often imperative and sometimes Chinese, such as `安全修复和管理员账号密码自定义`.
- Keep each commit scoped to one change. PRs should include a summary, affected areas, config or migration notes, linked issues, and UI screenshots or GIFs for frontend changes.
## Security & Configuration Tips
- Copy `.env.example` to `.env`; never commit real API keys or passwords.
- `SECRET_KEY` must be at least 32 characters, and `ADMIN_PASSWORD` at least 12.
- Update `.env.example` and relevant docs whenever configuration keys or security-sensitive defaults change.

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

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 handsomezhuzhu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

397
README.md
View File

@@ -1,281 +1,220 @@
# QQuiz - 智能刷题与题库管理平台
# QQuiz
QQuiz 是一个支持 Docker/源码双模部署的智能刷题平台,核心功能包括多文件上传、自动去重、异步解析、断点续做和错题本管理
QQuiz 是一个用于题库导入、刷题训练和错题管理的全栈应用,支持文档解析、题目去重、断点续做、管理员配置和多模型接入
## 功能特性
![QQuiz 界面截图](docs/cover.png)
- 📚 **多文件上传与去重**: 支持向同一题库追加文档,自动识别并过滤重复题目
- 🤖 **AI 智能解析**: 支持 Google Gemini (推荐) / OpenAI / Anthropic / Qwen 多种 AI 提供商
- 📄 **原生 PDF 理解**: Gemini 支持直接处理 PDF最多1000页完整保留图片、表格、公式等内容
- 🎓 **AI 参考答案**: 对于没有提供答案的题目,自动生成 AI 参考答案
- 📊 **断点续做**: 自动记录刷题进度,随时继续
- **错题本管理**: 自动收集错题,支持手动添加/移除
- 🎯 **多题型支持**: 单选、多选、判断、简答
- 🔐 **权限管理**: 管理员配置、用户隔离
- 📱 **移动端优先**: 完美适配手机端
## 功能
- 文档导入:支持 TXT / PDF / DOC / DOCX / XLS / XLSX
- 异步解析:后台解析文档并回传进度
- 题目去重:同题库内自动去重
- 刷题与续做:记录当前进度,支持继续作答
- 错题本:自动收集错误题目
- 管理后台:用户管理、系统配置、模型配置
- AI 提供商Gemini / OpenAI / Anthropic / Qwen
## 快速开始
### 使用预构建镜像(最快)
QQuiz 默认以单容器形式发布和部署。GitHub Actions 只构建根目录 `Dockerfile` 生成的单容器镜像README 也以这个路径为主。
直接使用 GitHub 自动构建的镜像,无需等待本地构建:
### 方式一:直接运行 GitHub Actions 构建好的单容器镜像
适合只想快速启动,不想先克隆仓库。
#### 1. 下载环境变量模板
Linux / macOS:
```bash
curl -L https://raw.githubusercontent.com/handsomezhuzhu/QQuiz/main/.env.example -o .env
```
Windows PowerShell:
```powershell
Invoke-WebRequest `
-Uri "https://raw.githubusercontent.com/handsomezhuzhu/QQuiz/main/.env.example" `
-OutFile ".env"
```
#### 2. 编辑 `.env`
至少填写以下字段:
```env
SECRET_KEY=replace-with-a-random-32-char-secret
ADMIN_USERNAME=admin
ADMIN_PASSWORD=replace-with-a-strong-password
AI_PROVIDER=gemini
GEMINI_API_KEY=your-real-gemini-api-key
```
如果你不用 Gemini也可以改成
- `AI_PROVIDER=openai` 并填写 `OPENAI_API_KEY`
- `AI_PROVIDER=anthropic` 并填写 `ANTHROPIC_API_KEY`
- `AI_PROVIDER=qwen` 并填写 `QWEN_API_KEY`
#### 3. 拉取镜像
```bash
# 1. 拉取最新镜像
docker pull ghcr.io/handsomezhuzhu/qquiz:latest
```
# 2. 配置环境变量
cp .env.example .env
# 编辑 .env填入你的 API Key
#### 4. 创建数据卷
# 3. 运行容器
```bash
docker volume create qquiz_data
docker volume create qquiz_uploads
```
#### 5. 启动容器
```bash
docker run -d \
--name qquiz \
-p 8000:8000 \
-v $(pwd)/qquiz_data:/app/qquiz_data \
--env-file .env \
-e DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db \
-e UPLOAD_DIR=/app/uploads \
-v qquiz_data:/app/data \
-v qquiz_uploads:/app/uploads \
-p 8000:8000 \
--restart unless-stopped \
ghcr.io/handsomezhuzhu/qquiz:latest
# 4. 访问应用: http://localhost:8000
```
**镜像说明:**
- **镜像地址**: `ghcr.io/handsomezhuzhu/qquiz:latest`
- **构建来源**: GitHub Actions 自动构建,每次 push 到 main 分支自动更新
- **架构支持**: linux/amd64, linux/arm64支持树莓派、Apple Silicon 等)
- **大小**: 约 400-500MB包含前后端完整运行环境
- **标签说明**:
- `latest`: 最新主分支版本
- `v1.0.0`: 特定版本号(如果有 tag
访问:
**数据持久化:** 使用 `-v` 参数挂载 `qquiz_data` 目录,包含 SQLite 数据库和上传文件,确保数据不会丢失。
- 应用:`http://localhost:8000`
- API 文档:`http://localhost:8000/docs`
### 单容器部署(自行构建)
从源码构建,一个容器包含前后端和 SQLite 数据库:
停止:
```bash
docker rm -f qquiz
```
### 方式二:从源码启动单容器
适合需要自行构建镜像或修改代码后再部署。
```bash
# 1. 配置环境变量(必须提供强密码和密钥)
cp .env.example .env
# 编辑 .env填入至少 32 位的 SECRET_KEY 和至少 12 位的 ADMIN_PASSWORD建议使用随机生成值
# 2. 启动服务(未设置强密码/密钥会直接报错终止)
SECRET_KEY=$(openssl rand -base64 48) \
ADMIN_PASSWORD=$(openssl rand -base64 16) \
docker-compose -f docker-compose-single.yml up -d
# 3. 访问应用: http://localhost:8000
# API 文档: http://localhost:8000/docs
docker compose -f docker-compose-single.yml up -d --build
```
### 传统部署3 个容器)
访问:
前后端分离 + MySQL
- 应用:`http://localhost:8000`
- API 文档:`http://localhost:8000/docs`
### 可选:开发或兼容性部署
以下方式保留用于开发调试或兼容场景,不再作为默认部署方案:
#### 前后端分离开发栈
```bash
# 启动服务(建议直接在命令行生成强密钥和管理员密码)
SECRET_KEY=$(openssl rand -base64 48) \
ADMIN_PASSWORD=$(openssl rand -base64 16) \
docker-compose up -d
# 前端: http://localhost:3000
# 后端: http://localhost:8000
```
### 方式二:本地运行
#### 前置要求
- Python 3.11+
- Node.js 18+
- MySQL 8.0+ 或 Docker (用于运行 MySQL)
**Linux/macOS 用户:**
```bash
# 1. 配置环境变量
cp .env.example .env
# 编辑 .env修改 DATABASE_URL 为本地数据库地址
# 2. 启动 MySQL
# macOS: brew services start mysql
# Linux: sudo systemctl start mysql
# 3. 运行启动脚本
chmod +x scripts/run_local.sh
./scripts/run_local.sh
docker compose up -d --build
```
**MySQL 安装指南:** 详见 [docs/MYSQL_SETUP.md](docs/MYSQL_SETUP.md)
访问:
## GitHub Actions 自动构建设置
- 前端:`http://localhost:3000`
- 后端:`http://localhost:8000`
如果你 fork 了本项目并想启用自动构建 Docker 镜像功能:
#### 分离栈叠加 MySQL
1. **启用 GitHub Actions**:
- 进入你的仓库 Settings → Actions → General
- 确保 "Actions permissions" 设置为 "Allow all actions"
2. **启用 Packages 写入权限**:
- Settings → Actions → General
- 找到 "Workflow permissions"
- 选择 "Read and write permissions"
- 勾选 "Allow GitHub Actions to create and approve pull requests"
3. **触发构建**:
- 推送代码到 `main` 分支会自动触发构建
- 或者创建 tag`v1.0.0`)会构建带版本号的镜像
- 也可以在 Actions 页面手动触发 "Build and Publish Docker Image" workflow
4. **查看构建的镜像**:
- 构建完成后,镜像会自动发布到 `ghcr.io/handsomezhuzhu/qquiz`
- 在仓库主页右侧 "Packages" 可以看到已发布的镜像
- 镜像默认是私有的,如需公开:进入 Package 页面 → Package settings → Change visibility
**镜像地址:**
```bash
# 拉取最新镜像
docker pull ghcr.io/handsomezhuzhu/qquiz:latest
cp .env.example .env
docker compose -f docker-compose.yml -f docker-compose.mysql.yml up -d --build
```
## 默认账户
MySQL 相关说明见 [docs/MYSQL_SETUP.md](docs/MYSQL_SETUP.md)。
**管理员账户:**
- 用户名: `admin`
- 密码: 取自环境变量 `ADMIN_PASSWORD`(必须至少 12 位,建议随机生成)
## 本地开发
⚠️ **重要**: 在部署前就必须设置强管理员密码;如果需要轮换密码,请更新环境变量后重启服务。
### 后端
```bash
cd backend
pip install -r requirements.txt
alembic upgrade head
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
### 前端
当前主前端在 `web/`
```bash
cd web
npm install
npm run dev
```
说明:
- `web/` 是唯一前端工程,基于 Next.js
- 单容器镜像会在同一个容器里运行 FastAPI 和 Next.js并由 FastAPI 代理前端请求
## 关键环境变量
| 变量 | 说明 |
| --- | --- |
| `DATABASE_URL` | 数据库连接字符串 |
| `SECRET_KEY` | JWT 密钥,至少 32 位 |
| `ADMIN_USERNAME` | 默认管理员用户名 |
| `ADMIN_PASSWORD` | 默认管理员密码,至少 12 位 |
| `AI_PROVIDER` | `gemini` / `openai` / `anthropic` / `qwen` |
| `GEMINI_API_KEY` | Gemini API Key |
| `OPENAI_API_KEY` | OpenAI API Key |
| `OPENAI_BASE_URL` | OpenAI 或兼容网关地址 |
| `ANTHROPIC_API_KEY` | Anthropic API Key |
| `QWEN_API_KEY` | Qwen API Key |
| `ALLOW_REGISTRATION` | 是否允许注册 |
| `MAX_UPLOAD_SIZE_MB` | 单次上传大小限制 |
| `MAX_DAILY_UPLOADS` | 每日上传次数限制 |
完整模板见 [`.env.example`](.env.example)。
## 项目结构
```
```text
QQuiz/
├─ backend/ # FastAPI 后端
│ ├── alembic/ # 数据库迁移
│ ├── routers/ # API 路由
│ ├── services/ # 业务逻辑
│ ├── models.py # 数据模型
│ ├── database.py # 数据库配置
│ ├── main.py # 应用入口
│ └── requirements.txt # Python 依赖
├── frontend/ # React 前端
│ ├── src/
│ │ ├── api/ # API 客户端
│ │ ├── pages/ # 页面组件
│ │ ├── components/ # 通用组件
│ │ └── App.jsx # 应用入口
│ ├── package.json # Node 依赖
│ └── vite.config.js # Vite 配置
├── scripts/ # 部署和启动脚本
│ └── run_local.sh # Linux/macOS 启动脚本
├── docs/ # 文档目录
│ ├── MYSQL_SETUP.md # MySQL 安装配置指南
│ └── PROJECT_STRUCTURE.md # 项目架构详解
├── test_data/ # 测试数据
│ └── sample_questions.txt # 示例题目
├── docker-compose.yml # Docker 编排
├── .env.example # 环境变量模板
└── README.md # 项目说明
├─ backend/ FastAPI 后端
├─ web/ Next.js 前端工程
├─ docs/ 文档与截图
├─ test_data/ 示例题库文件
├─ docker-compose-single.yml 单容器部署(默认)
├─ Dockerfile 单容器镜像构建(默认)
├─ docker-compose.yml 前后端分离开发/兼容部署
└─ docker-compose.mysql.yml MySQL overlay可选
```
## 核心业务流程
### 1. 创建题库
用户首次上传文档时,创建新的 Exam (题库容器)
### 2. 追加文档
在已有题库详情页点击 "添加题目文档",上传新文件
### 3. 去重逻辑
- 对题目内容进行标准化处理 (去空格、标点、转小写)
- 计算 MD5 Hash
- 仅在当前题库范围内查询去重
- 仅插入 Hash 不存在的题目
### 4. 异步处理
- 后台任务处理 AI 解析
- 状态: `pending``processing``ready` / `failed`
- 前端轮询状态,自动刷新
### 5. 刷题体验
- 基于 `current_index` 实现断点续做
- 错题自动加入错题本
- 简答题 AI 评分
## 环境变量说明
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `DATABASE_URL` | 数据库连接字符串 | - |
| `SECRET_KEY` | JWT 密钥(必须至少 32 位随机字符串) | - |
| `ADMIN_PASSWORD` | 默认管理员密码(必须至少 12 位,建议随机生成) | - |
| `AI_PROVIDER` | AI 提供商 (gemini/openai/anthropic/qwen) | gemini |
| `GEMINI_API_KEY` | Google Gemini API 密钥 | - |
| `GEMINI_BASE_URL` | Gemini API 地址(可选,支持代理) | https://generativelanguage.googleapis.com |
| `GEMINI_MODEL` | Gemini 模型 | gemini-2.0-flash-exp |
| `OPENAI_API_KEY` | OpenAI API 密钥 | - |
| `OPENAI_BASE_URL` | OpenAI API 地址 | https://api.openai.com/v1 |
| `OPENAI_MODEL` | OpenAI 模型 | gpt-4o-mini |
| `ANTHROPIC_API_KEY` | Anthropic API 密钥 | - |
| `ANTHROPIC_MODEL` | Anthropic 模型 | claude-3-haiku-20240307 |
| `QWEN_API_KEY` | 通义千问 API 密钥 | - |
| `QWEN_BASE_URL` | 通义千问 API 地址 | https://dashscope.aliyuncs.com/compatible-mode/v1 |
| `QWEN_MODEL` | 通义千问模型 | qwen-plus |
| `ALLOW_REGISTRATION` | 是否允许注册 | true |
| `MAX_UPLOAD_SIZE_MB` | 最大上传文件大小 (MB) | 10 |
| `MAX_DAILY_UPLOADS` | 每日上传次数限制 | 20 |
### AI 提供商对比
| 提供商 | PDF 原生支持 | 文本解析 | 推荐度 | 说明 |
|--------|--------------|----------|--------|------|
| **Google Gemini** | ✅ | ✅ | ⭐⭐⭐⭐⭐ | 支持原生 PDF最多1000页保留图片、表格、公式 |
| OpenAI | ❌ | ✅ | ⭐⭐⭐⭐ | 仅文本提取PDF 会丢失格式和图片 |
| Anthropic | ❌ | ✅ | ⭐⭐⭐⭐ | 仅文本提取PDF 会丢失格式和图片 |
| Qwen (通义千问) | ❌ | ✅ | ⭐⭐⭐ | 仅文本提取PDF 会丢失格式和图片 |
**推荐使用 Gemini**:如果你的题库包含 PDF 文件(特别是含有图片、公式、表格的学科试卷),强烈推荐使用 Gemini。
### 如何获取 API Key
- **Google Gemini**: https://aistudio.google.com/apikey (免费额度充足)
- **OpenAI**: https://platform.openai.com/api-keys
- **Anthropic**: https://console.anthropic.com/settings/keys
- **Qwen (通义千问)**: https://dashscope.console.aliyun.com/apiKey
### AI 配置方式
QQuiz 支持两种配置方式:
1. **环境变量配置** (`.env` 文件):适合 Docker 部署和开发环境
2. **数据库配置** (管理员后台):适合生产环境,支持在线修改,无需重启服务
**推荐流程**:首次部署使用环境变量,部署成功后通过管理员后台修改配置。
**Gemini 自定义代理**: 如果需要使用 Key 轮训服务或代理,可以在管理员后台配置 `GEMINI_BASE_URL`,支持自定义 Gemini API 地址。
## 技术栈
**后端:**
- FastAPI - 现代化 Python Web 框架
- SQLAlchemy 2.0 - 异步 ORM
- Alembic - 数据库迁移
- MySQL 8.0 - 数据库
- aiomysql - MySQL 异步驱动
- Pydantic - 数据验证
- 后端FastAPI、SQLAlchemy、Alembic、SQLite / MySQL、httpx
- 前端Next.js 14、React 18、TypeScript、Tailwind CSS、TanStack Query
**前端:**
- React 18 - UI 框架
- Vite - 构建工具
- Tailwind CSS - 样式框架
- React Router - 路由
- Axios - HTTP 客户端
## 提交前建议检查
## 开发进度
```bash
cd web && npm run build
docker compose -f docker-compose-single.yml build
```
- [x] **Step 1**: Foundation & Models ✅
- [ ] **Step 2**: Backend Core Logic
- [ ] **Step 3**: Frontend Config & API
- [ ] **Step 4**: Frontend Complex UI
建议至少手动验证:
## License
- 登录 / 退出
- 创建题库 / 上传文档 / 查看解析进度
- 刷题 / 续做 / 错题加入
- 管理员配置
- 大数据量列表分页
MIT
## 开源协议
本项目采用 [MIT License](LICENSE)。

View File

@@ -1,24 +1,35 @@
FROM python:3.11-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libmagic1 \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies (gcc for compiling Python packages)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libmagic1 \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
# Copy application code
COPY --from=builder /opt/venv /opt/venv
COPY . .
# Create uploads directory
RUN mkdir -p uploads
# Expose port
EXPOSE 8000
# Run database migrations and start server
CMD alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000

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"
}
else:
print("⚠️ 静态文件目录不存在,前端功能不可用")
print("提示:请先构建前端应用或使用开发模式")
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 ''}"
# 如果没有静态文件,显示 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 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"
}
# Avoid sending compressed payloads through the proxy so response headers stay accurate.
headers["Accept-Encoding"] = "identity"
return headers
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

@@ -2,7 +2,7 @@
Admin Router - 完备的管理员功能模块
参考 OpenWebUI 设计
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Query
from fastapi.responses import StreamingResponse, FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_, desc
@@ -16,7 +16,8 @@ from database import get_db, engine
from models import User, SystemConfig, Exam, Question, UserMistake, ExamStatus
from schemas import (
SystemConfigUpdate, SystemConfigResponse,
UserResponse, UserCreate, UserUpdate, UserListResponse
UserResponse, UserCreate, UserUpdate, UserListResponse,
UserPasswordResetRequest, AdminUserSummary
)
from services.auth_service import get_current_admin_user
@@ -36,6 +37,11 @@ async def get_default_admin_id(db: AsyncSession) -> Optional[int]:
return None
async def get_admin_count(db: AsyncSession) -> int:
result = await db.execute(select(func.count(User.id)).where(User.is_admin == True))
return result.scalar() or 0
@router.get("/config", response_model=SystemConfigResponse)
async def get_system_config(
current_admin: User = Depends(get_current_admin_user),
@@ -84,6 +90,9 @@ async def update_system_config(
update_data = config_update.dict(exclude_unset=True)
for key, value in update_data.items():
if key.endswith("_api_key") and isinstance(value, str) and "..." in value:
continue
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == key)
)
@@ -108,9 +117,9 @@ async def update_system_config(
@router.get("/users", response_model=UserListResponse)
async def get_users(
skip: int = 0,
limit: int = 50,
search: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
search: Optional[str] = Query(None, min_length=1, max_length=50),
current_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
@@ -121,10 +130,11 @@ async def get_users(
- search: 搜索关键词(用户名)
"""
query = select(User)
normalized_search = search.strip() if search else None
# 搜索过滤
if search:
query = query.where(User.username.ilike(f"%{search}%"))
if normalized_search:
query = query.where(User.username.ilike(f"%{normalized_search}%"))
# 统计总数
count_query = select(func.count()).select_from(query.subquery())
@@ -136,34 +146,41 @@ async def get_users(
result = await db.execute(query)
users = result.scalars().all()
# 为每个用户添加统计信息
user_ids = [user.id for user in users]
exam_count_map = {}
mistake_count_map = {}
if user_ids:
exam_count_result = await db.execute(
select(Exam.user_id, func.count(Exam.id))
.where(Exam.user_id.in_(user_ids))
.group_by(Exam.user_id)
)
exam_count_map = {user_id: count for user_id, count in exam_count_result.all()}
mistake_count_result = await db.execute(
select(UserMistake.user_id, func.count(UserMistake.id))
.where(UserMistake.user_id.in_(user_ids))
.group_by(UserMistake.user_id)
)
mistake_count_map = {
user_id: count for user_id, count in mistake_count_result.all()
}
user_list = []
for user in users:
# 统计用户的题库数
exam_count_query = select(func.count(Exam.id)).where(Exam.user_id == user.id)
exam_result = await db.execute(exam_count_query)
exam_count = exam_result.scalar()
user_list.append(
AdminUserSummary(
id=user.id,
username=user.username,
is_admin=user.is_admin,
created_at=user.created_at,
exam_count=exam_count_map.get(user.id, 0),
mistake_count=mistake_count_map.get(user.id, 0)
)
)
# 统计用户的错题数
mistake_count_query = select(func.count(UserMistake.id)).where(UserMistake.user_id == user.id)
mistake_result = await db.execute(mistake_count_query)
mistake_count = mistake_result.scalar()
user_list.append({
"id": user.id,
"username": user.username,
"is_admin": user.is_admin,
"created_at": user.created_at,
"exam_count": exam_count,
"mistake_count": mistake_count
})
return {
"users": user_list,
"total": total,
"skip": skip,
"limit": limit
}
return UserListResponse(users=user_list, total=total, skip=skip, limit=limit)
@router.post("/users", response_model=UserResponse)
@@ -215,7 +232,19 @@ async def update_user(
detail="User not found"
)
if user_data.username and user_data.username != user.username:
existing_user_result = await db.execute(
select(User).where(User.username == user_data.username)
)
existing_user = existing_user_result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already exists"
)
protected_admin_id = await get_default_admin_id(db)
admin_count = await get_admin_count(db)
# 不允许修改默认管理员的管理员状态
if protected_admin_id and user.id == protected_admin_id and user_data.is_admin is not None:
@@ -224,6 +253,12 @@ async def update_user(
detail="Cannot modify default admin user's admin status"
)
if user.is_admin and user_data.is_admin is False and admin_count <= 1:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="At least one admin user must remain"
)
# 更新字段
update_data = user_data.dict(exclude_unset=True)
if "password" in update_data:
@@ -238,6 +273,29 @@ async def update_user(
return user
@router.post("/users/{user_id}/reset-password")
async def reset_user_password(
user_id: int,
payload: UserPasswordResetRequest,
current_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""重置用户密码(仅管理员)"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
user.hashed_password = pwd_context.hash(payload.new_password)
await db.commit()
return {"message": "Password reset successfully"}
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
@@ -255,6 +313,7 @@ async def delete_user(
)
protected_admin_id = await get_default_admin_id(db)
admin_count = await get_admin_count(db)
# 不允许删除默认管理员
if protected_admin_id and user.id == protected_admin_id:
@@ -270,6 +329,12 @@ async def delete_user(
detail="Cannot delete yourself"
)
if user.is_admin and admin_count <= 1:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="At least one admin user must remain"
)
await db.delete(user)
await db.commit()

View File

@@ -4,19 +4,20 @@ Exam Router - Handles exam creation, file upload, and deduplication
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, BackgroundTasks, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from sqlalchemy import select, func, and_, case
from typing import List, Optional
from datetime import datetime, timedelta
import os
import aiofiles
import json
import magic
import random
from database import get_db
from models import User, Exam, Question, ExamStatus, SystemConfig
from schemas import (
ExamCreate, ExamResponse, ExamListResponse,
ExamUploadResponse, ParseResult, QuizProgressUpdate
ExamUploadResponse, ParseResult, QuizProgressUpdate, ExamSummaryResponse
)
from services.auth_service import get_current_user
from services.document_parser import document_parser
@@ -142,9 +143,8 @@ async def generate_ai_reference_answer(
# Build prompt based on question type
if question_type in ["single", "multiple"] and options:
options_text = "\n".join(options)
prompt = f"""这是一道{
'单选题' if question_type == 'single' else '多选题'
},但文档中没有提供答案。请根据题目内容,推理出最可能的正确答案。
q_type_text = '单选题' if question_type == 'single' else '多选题'
prompt = f"""这是一道{q_type_text},但文档中没有提供答案。请根据题目内容,推理出最可能的正确答案。
题目:{question_content}
@@ -205,7 +205,8 @@ async def process_questions_with_dedup(
exam_id: int,
questions_data: List[dict],
db: AsyncSession,
llm_service=None
llm_service=None,
is_random: bool = False
) -> ParseResult:
"""
Process parsed questions with fuzzy deduplication logic.
@@ -238,6 +239,11 @@ async def process_questions_with_dedup(
print(f"[Dedup] Checking against {len(existing_questions)} existing questions in database")
# Shuffle questions if random mode is enabled
if is_random:
print(f"[Dedup] Random mode enabled - shuffling {len(questions_data)} questions before saving")
random.shuffle(questions_data)
# Insert only new questions
for q_data in questions_data:
content_hash = q_data.get("content_hash")
@@ -313,7 +319,8 @@ async def async_parse_and_save(
exam_id: int,
file_content: bytes,
filename: str,
db_url: str
db_url: str,
is_random: bool = False
):
"""
Background task to parse document and save questions with deduplication.
@@ -487,7 +494,7 @@ async def async_parse_and_save(
))
print(f"[Exam {exam_id}] Processing questions with deduplication...")
parse_result = await process_questions_with_dedup(exam_id, questions_data, db, llm_service)
parse_result = await process_questions_with_dedup(exam_id, questions_data, db, llm_service, is_random)
# Update exam status and total questions
result = await db.execute(select(Exam).where(Exam.id == exam_id))
@@ -540,6 +547,7 @@ async def create_exam_with_upload(
request: Request,
background_tasks: BackgroundTasks,
title: str = Form(...),
is_random: bool = Form(False),
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
@@ -574,7 +582,8 @@ async def create_exam_with_upload(
new_exam.id,
file_content,
file.filename,
os.getenv("DATABASE_URL")
os.getenv("DATABASE_URL"),
is_random
)
return ExamUploadResponse(
@@ -675,6 +684,57 @@ async def get_user_exams(
return ExamListResponse(exams=exams, total=total)
@router.get("/summary", response_model=ExamSummaryResponse)
async def get_exam_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get aggregated exam statistics for current user."""
summary_query = select(
func.count(Exam.id),
func.coalesce(func.sum(Exam.total_questions), 0),
func.coalesce(func.sum(Exam.current_index), 0),
func.coalesce(
func.sum(
case((Exam.status == ExamStatus.PROCESSING, 1), else_=0)
),
0
),
func.coalesce(
func.sum(
case((Exam.status == ExamStatus.READY, 1), else_=0)
),
0
),
func.coalesce(
func.sum(
case((Exam.status == ExamStatus.FAILED, 1), else_=0)
),
0
)
).where(Exam.user_id == current_user.id)
result = await db.execute(summary_query)
(
total_exams,
total_questions,
completed_questions,
processing_exams,
ready_exams,
failed_exams
) = result.one()
return ExamSummaryResponse(
total_exams=total_exams or 0,
total_questions=total_questions or 0,
completed_questions=completed_questions or 0,
processing_exams=processing_exams or 0,
ready_exams=ready_exams or 0,
failed_exams=failed_exams or 0
)
@router.get("/{exam_id}", response_model=ExamResponse)
async def get_exam_detail(
exam_id: int,

View File

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

View File

@@ -38,6 +38,11 @@ class UserLogin(BaseModel):
password: str
class PasswordChangeRequest(BaseModel):
old_password: str = Field(..., min_length=1)
new_password: str = Field(..., min_length=6)
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
@@ -53,14 +58,23 @@ class UserResponse(BaseModel):
from_attributes = True
class AdminUserSummary(UserResponse):
exam_count: int = 0
mistake_count: int = 0
class UserListResponse(BaseModel):
"""用户列表响应(包含分页信息)"""
users: List[dict] # 包含额外统计信息的用户列表
users: List[AdminUserSummary]
total: int
skip: int
limit: int
class UserPasswordResetRequest(BaseModel):
new_password: str = Field(..., min_length=6)
# ============ System Config Schemas ============
class SystemConfigUpdate(BaseModel):
allow_registration: Optional[bool] = None
@@ -124,6 +138,15 @@ class ExamListResponse(BaseModel):
total: int
class ExamSummaryResponse(BaseModel):
total_exams: int
total_questions: int
completed_questions: int
processing_exams: int
ready_exams: int
failed_exams: int
class ExamUploadResponse(BaseModel):
exam_id: int
title: str
@@ -140,19 +163,31 @@ class ParseResult(BaseModel):
# ============ Question Schemas ============
class QuestionBase(BaseModel):
class QuestionPublicBase(BaseModel):
content: str
type: QuestionType
options: Optional[List[str]] = None
answer: str
analysis: Optional[str] = None
class QuestionBase(QuestionPublicBase):
answer: str
class QuestionCreate(QuestionBase):
exam_id: int
class QuestionResponse(QuestionBase):
class QuestionResponse(QuestionPublicBase):
id: int
exam_id: int
created_at: datetime
class Config:
from_attributes = True
class QuestionWithAnswerResponse(QuestionBase):
id: int
exam_id: int
created_at: datetime
@@ -194,7 +229,7 @@ class MistakeResponse(BaseModel):
id: int
user_id: int
question_id: int
question: QuestionResponse
question: QuestionWithAnswerResponse
created_at: datetime
class Config:

View File

@@ -15,6 +15,25 @@ from utils import calculate_content_hash
class LLMService:
"""Service for interacting with various LLM providers"""
@staticmethod
def _normalize_openai_base_url(base_url: Optional[str], default: str) -> str:
normalized = (base_url or default).rstrip("/")
if normalized.endswith("/v1"):
return normalized
if normalized.count("/") <= 2:
return f"{normalized}/v1"
return normalized
@staticmethod
def _openai_compat_headers() -> Dict[str, str]:
"""
Some OpenAI-compatible gateways block the default OpenAI SDK user agent.
Use a neutral UA so requests behave like a generic HTTP client.
"""
return {"User-Agent": "QQuiz/1.0"}
def __init__(self, config: Optional[Dict[str, str]] = None):
"""
Initialize LLM Service with optional configuration.
@@ -28,7 +47,10 @@ class LLMService:
if self.provider == "openai":
api_key = (config or {}).get("openai_api_key") or os.getenv("OPENAI_API_KEY")
base_url = (config or {}).get("openai_base_url") or os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
base_url = self._normalize_openai_base_url(
(config or {}).get("openai_base_url") or os.getenv("OPENAI_BASE_URL"),
"https://api.openai.com/v1"
)
self.model = (config or {}).get("openai_model") or os.getenv("OPENAI_MODEL", "gpt-4o-mini")
if not api_key:
@@ -37,6 +59,7 @@ class LLMService:
self.client = AsyncOpenAI(
api_key=api_key,
base_url=base_url,
default_headers=self._openai_compat_headers(),
timeout=120.0, # 增加超时时间到 120 秒
max_retries=3 # 自动重试 3 次
)
@@ -69,6 +92,7 @@ class LLMService:
self.client = AsyncOpenAI(
api_key=api_key,
base_url=base_url,
default_headers=self._openai_compat_headers(),
timeout=120.0, # 增加超时时间到 120 秒
max_retries=3 # 自动重试 3 次
)

View File

@@ -1,8 +1,6 @@
# ==================== 单容器部署配置 ====================
# 使用方法docker-compose -f docker-compose-single.yml up -d
version: '3.8'
services:
qquiz:
build:
@@ -16,6 +14,7 @@ services:
environment:
# 数据库配置SQLite 默认,使用持久化卷)
- DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db
- UPLOAD_DIR=/app/uploads
volumes:
# 持久化数据卷
@@ -25,7 +24,7 @@ services:
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
test: ["CMD", "python", "-c", "import sys, urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5); sys.exit(0)"]
interval: 30s
timeout: 10s
retries: 3
@@ -33,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

29
docker-compose.mysql.yml Normal file
View File

@@ -0,0 +1,29 @@
services:
mysql:
image: mysql:8.0
container_name: qquiz_mysql
environment:
MYSQL_ROOT_PASSWORD: root_password
MYSQL_DATABASE: qquiz_db
MYSQL_USER: qquiz
MYSQL_PASSWORD: qquiz_password
volumes:
- mysql_data:/var/lib/mysql
ports:
- "3306:3306"
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "qquiz", "-pqquiz_password"]
interval: 10s
timeout: 5s
retries: 5
backend:
environment:
- DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@mysql:3306/qquiz_db
depends_on:
mysql:
condition: service_healthy
volumes:
mysql_data:

View File

@@ -1,61 +1,47 @@
services:
mysql:
image: mysql:8.0
container_name: qquiz_mysql
environment:
MYSQL_ROOT_PASSWORD: root_password
MYSQL_DATABASE: qquiz_db
MYSQL_USER: qquiz
MYSQL_PASSWORD: qquiz_password
volumes:
- mysql_data:/var/lib/mysql
ports:
- "3306:3306"
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "qquiz", "-pqquiz_password"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: qquiz_backend
environment:
- DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@mysql:3306/qquiz_db
- DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db
- SECRET_KEY=${SECRET_KEY:?Set SECRET_KEY to a random string of at least 32 characters}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?Set ADMIN_PASSWORD to a strong password of at least 12 characters}
- UPLOAD_DIR=/app/uploads
env_file:
- .env
volumes:
- ./backend:/app
- sqlite_data:/app/data
- upload_files:/app/uploads
ports:
- "8000:8000"
depends_on:
mysql:
condition: service_healthy
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
healthcheck:
test: ["CMD", "python", "-c", "import sys, urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5); sys.exit(0)"]
interval: 30s
timeout: 10s
retries: 5
start_period: 40s
frontend:
build:
context: ./frontend
context: ./web
dockerfile: Dockerfile
container_name: qquiz_frontend
volumes:
- ./frontend:/app
- /app/node_modules
ports:
- "3000:3000"
environment:
- VITE_API_URL=/api
- REACT_APP_API_URL=http://backend:8000
- API_BASE_URL=http://backend:8000
depends_on:
- backend
command: npm start
backend:
condition: service_started
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/login').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 30s
timeout: 10s
retries: 5
start_period: 40s
volumes:
mysql_data:
sqlite_data:
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
```
2. 输入 root 密码
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
```powershell
Copy-Item .env.example .env
```
**Docker MySQL:**
2.`.env` 中的数据库连接改成 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
```bash
docker compose -f docker-compose.yml -f docker-compose.mysql.yml up -d --build
```
**解决方案:**
- 检查是否已经有 MySQL 运行:`netstat -ano | findstr :3306`
- 停止现有的 MySQL 服务
- 或修改 `.env` 中的端口号
4. 访问:
### 2. 无法连接到 MySQL
- 前端:`http://localhost:3000`
- 后端:`http://localhost:8000`
**错误信息:**
```
Can't connect to MySQL server on 'localhost'
```
说明:
**解决方案:**
- 这条路径是 MySQL 兼容部署,不是默认发布路径
- 默认发布镜像仍然是根目录单容器镜像
1. **检查 MySQL 服务是否运行**
- 按 Win+R输入 `services.msc`
- 查找 "MySQL80" 服务
- 确认状态为 "正在运行"
## 场景二:单容器应用连接外部 MySQL
2. **启动 MySQL 服务**
```bash
net start MySQL80
```
如果你想继续使用单容器应用镜像,但数据库由外部 MySQL 托管,可以直接让应用容器连接现有数据库。
3. **检查防火墙设置**
- 确保防火墙允许 MySQL 端口 3306
### 1. 准备 MySQL 8.0 数据库
### 3. 密码验证失败
执行以下 SQL 创建数据库和账号:
**错误信息:**
```
Access denied for user 'qquiz'@'localhost'
```
**解决方案:**
重新创建用户并设置密码:
```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

86
docs/PLAN.md Normal file
View File

@@ -0,0 +1,86 @@
# QQuiz Execution Plan
更新时间2026-04-17
## 目标
把当前项目推进到可以持续开发和稳定验收的状态,重点落实:
1. 默认 SQLite兼容 MySQL
2. 前端界面简洁、可直接操作、少说明文字
3. 用户管理达到可上市产品的基础要求
4. push 后 GitHub 自动构建镜像
5. 逐步完成旧 Vite 前端到新 Next 前端的替换
## 已完成的代码级工作
- 默认 Docker 拓扑已切到 SQLite
- MySQL 兼容拓扑已拆到 `docker-compose.mysql.yml`
- 新前端容器已接入并替换 Docker 默认前端
- 管理员用户管理已经接入真实接口
- 管理员设置页已经接入真实配置接口
- 侧边栏选中态 bug 已修复
- 新前端色彩已收敛为更简洁的产品风格
## 当前代码扫描后的主要问题
### 后端
1. 数据库迁移体系仍然不完整
2. `LLMService` 仍存在启动期副作用
3. 文档解析任务仍依赖进程内后台任务
4. 题库导入并发与去重约束还没彻底补完
5. 管理模块还缺用户状态、审计、批量能力
### 前端
1. Exam detail / Question / Mistake / Quiz 仍是占位或半占位页面
2. 部分页面仍有旧迁移骨架内容,需要继续清理
3. 确认类交互还没统一替换为更正式的对话框方案
4. 视觉层还需要统一列表、表单、状态、分页组件
### 部署与文档
1. Compose 仍缺 dev/prod 分离
2. 文档体系仍需把运行方式统一到 SQLite 默认 + MySQL 兼容
3. CI 只有主干镜像构建,还缺 PR 验证与 smoke test
## 后续执行顺序
### 第一阶段:后端稳定性
1. Alembic 基线迁移
2. 去掉 `create_all` 正式职责
3. 去掉 LLM import side effect
4. 统一事务边界
5. 补用户状态字段与审计日志模型
### 第二阶段:前端业务页
1. 接通 `exams/[examId]`
2. 接通 `questions`
3. 接通 `mistakes`
4. 接通 `quiz/[examId]`
5. 接通 `mistake-quiz`
### 第三阶段:用户管理产品化
1. 用户状态管理
2. 审计日志
3. 批量操作
4. 更完整的密码与安全策略
### 第四阶段:工程化
1. Compose dev/prod 分离
2. PR workflow
3. SQLite/MySQL 双栈 smoke
4. 文档统一
## 前端视觉要求
1. 主色:深蓝,作为动作与选中态
2. 背景:浅灰蓝,不用大面积高饱和装饰
3. 卡片:白底、细边框、轻阴影
4. 状态色:成功绿、警告橙、错误红
5. 页面信息结构:标题、数据、动作优先,减少解释文字

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

92
docs/TASKS.md Normal file
View File

@@ -0,0 +1,92 @@
# QQuiz Task Checklist
更新时间2026-04-17
## P0 运行基线
- [x] 默认 Docker 拓扑切回 SQLite
- [x] 保留 MySQL 兼容 Compose 覆盖文件
- [x] 前后端容器可启动并完成最小探活
- [x] GitHub Actions 改成 push 后自动构建 backend/frontend 镜像
- [ ] 补开发/生产分离 Compose
- [ ] 补 PR 级别 build/smoke workflow
- [ ] 清理根目录 Docker 文档漂移
## P1 后端稳定性
- [x] 管理员配置接口忽略打码后的密钥回写
- [x] 用户列表返回改为强类型
- [x] 用户列表统计去掉 N+1 查询
- [x] 最后一个管理员保护
- [x] 管理员密码重置接口
- [ ] 去掉启动期 `create_all` 作为正式迁移方式
- [ ] 建 Alembic 初始迁移
- [ ] 去掉 `LLMService` import side effect
- [ ] 收敛事务边界
- [ ] 修 ingestion 并发与唯一约束
- [ ] 规范健康检查和错误模型
## P2 用户管理
- [x] 用户搜索
- [x] 创建用户
- [x] 编辑用户
- [x] 重置密码
- [x] 删除用户
- [ ] 用户状态字段(启用/禁用/锁定)
- [ ] 审计日志
- [ ] 批量操作
- [ ] 密码强度与重置流程优化
- [ ] 默认管理员保护策略文档化
## P3 新前端基础层
- [x] Next.js App Router 骨架
- [x] BFF 登录/登出/`/me` 代理
- [x] 同源 API 代理
- [x] SSE 代理入口
- [x] 移除旧前端 ESA 人机验证
- [ ] 中间件与服务端守卫完善
- [ ] 错误页/空状态统一
- [ ] URL 状态策略统一
## P4 页面迁移
### 已接入真实数据
- [x] Dashboard
- [x] Exams list
- [x] Exam detail
- [x] Questions list
- [x] Mistakes list
- [x] Quiz player
- [x] Mistake quiz
- [x] Admin user management
- [x] Admin settings
### 待继续
- [ ] 上传/进度/失败重试链路
## P5 前端视觉与交互
- [x] 侧边栏选中态修复
- [x] 新前端配色收敛为更简洁的产品风格
- [x] 去掉大段迁移说明文案
- [ ] 统一表格、表单、按钮、状态徽标
- [ ] 清理页面中的占位内容
- [ ] 替换 `window.confirm` 为统一对话框
- [ ] 移动端布局细化
## P6 测试与验收
- [x] 旧前端构建通过
- [x] 新前端构建通过
- [x] Docker 最小登录链路验证
- [x] 管理员配置、用户管理、上传解析、题目、错题、刷题链路验证
- [x] 管理员与普通用户登录验证
- [x] PowerShell smoke 脚本固化全流程验证
- [ ] 后端集成测试
- [ ] 前端 E2E 烟测
- [ ] SQLite / MySQL 双栈验证
- [ ] 用户管理回归用例

View File

@@ -0,0 +1,70 @@
# QQuiz Architecture Audit
## Scope
This document records the current system shape and the approved target
direction for the ongoing refactor.
Audit date: 2026-04-17
## Current Architecture
### Backend
- Runtime: FastAPI + SQLAlchemy async
- Database access: direct ORM session injection per request
- Task execution: in-process `BackgroundTasks`
- Progress streaming: in-memory `ProgressService`
- Schema management: mixed `create_all()` and Alembic placeholders
### Frontend
- 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`: split development stack
- `docker-compose-single.yml`: default single-container deployment
- `Dockerfile`: single image running FastAPI + embedded Next.js
## Target Architecture
### Backend
- Keep FastAPI as the system API boundary
- Move heavy router logic into typed services
- Use Alembic as the only schema migration path
- Introduce durable ingestion execution semantics
- Replace implicit transaction patterns with explicit service-level boundaries
### Frontend
- New app in `web/`
- Stack: Next.js App Router + TypeScript + Tailwind + shadcn/ui
- Auth: `HttpOnly` session cookie mediated by Next route handlers
- Data fetching: `fetch` wrappers for server/client usage
- Streaming: Next proxy route for exam progress SSE
### Deployment
- 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. 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. 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

@@ -0,0 +1,86 @@
# Backend Findings
## Critical Findings
### Schema lifecycle is unsafe
- App startup still calls `create_all()`
- Alembic metadata exists but the migration chain is effectively empty
- This prevents controlled upgrades and rollbacks
Files:
- `backend/main.py`
- `backend/database.py`
- `backend/alembic/versions/.gitkeep`
### Parsing tasks are not durable
- Document ingestion runs inside FastAPI `BackgroundTasks`
- Progress state lives in-process only
- Process restarts or horizontal scaling can strand exams in `pending` or `processing`
Files:
- `backend/routers/exam.py`
- `backend/services/progress_service.py`
### Transaction boundaries are inconsistent
- `get_db()` performs commit/rollback automatically
- Routers and background tasks also call `commit()` directly
- SSE endpoints keep a database dependency open for long-lived streams
Files:
- `backend/database.py`
- `backend/routers/exam.py`
## High-Priority Bugs
### Admin config can destroy secrets
- `GET /api/admin/config` masks API keys
- `PUT /api/admin/config` persists whatever the frontend sends back
- A round-trip save can replace the real secret with the masked placeholder
Files:
- `backend/routers/admin.py`
### LLM service has import-time side effects
- `LLMService()` is instantiated at module import time
- Missing environment variables can break startup before DB-backed config is loaded
Files:
- `backend/services/llm_service.py`
### Ingestion deduplication is race-prone
- No unique DB constraint on `(exam_id, content_hash)`
- Multiple append operations can race and insert duplicates
Files:
- `backend/models.py`
- `backend/routers/exam.py`
### Answer checking degrades incorrectly on infra failure
- Short-answer grading failures are converted into zero scores
- User mistake data can be polluted by provider outages or config errors
Files:
- `backend/services/llm_service.py`
- `backend/routers/question.py`
## Refactor Order
1. Replace runtime schema creation with Alembic-first migrations.
2. Move ingestion, config, and answer checking into service classes.
3. Introduce explicit transaction boundaries and idempotent ingestion rules.
4. Add durable task execution and real status/error semantics.
5. Add integration tests for config round-trips, ingestion races, and answer normalization.

View File

@@ -0,0 +1,49 @@
# Deployment Findings
## Current Problems
### Monolith persistence documentation is wrong
- Existing `docker run` examples mounted the wrong path
- SQLite and upload persistence must target `/app/data` and `/app/uploads`
### Monolith health check was broken
- `docker-compose-single.yml` used `curl`
- The image does not guarantee `curl` exists
- The health check has been switched to Python stdlib HTTP probing
### Split Compose is development-oriented
- Source mounts are enabled
- Backend runs with `uvicorn --reload`
- Frontend runs a dev server
- This is not a production deployment model
### Security posture is weak
- Compose contains hard-coded MySQL credentials
- MySQL is exposed on `3306`
- Environment guidance is inconsistent across README, Compose, and `.env.example`
## Approved Direction
1. Treat split deployment as the default production topology.
2. Keep monolith deployment as a compatibility target only.
3. Separate development assets from production assets.
4. Validate all release images with smoke checks before publishing.
## Backlog
### Short term
- Create `compose.dev.yml` and `compose.prod.yml`
- Remove dev-server assumptions from production documentation
- Add backend runtime dependencies explicitly to image builds
- Align README with actual mount paths and health checks
### Medium term
- Add PR build, typecheck, lint, and smoke-test workflows
- Publish separate images for API and Next web app
- Document rollback by image tag and Compose profile

View File

@@ -0,0 +1,50 @@
# Frontend Cutover Notes
## Decision
`web/` is now the only frontend in the repository.
The previous Vite SPA has been removed so that:
- 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
## Runtime Model
### Auth
- 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 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 `/frontend-api/exams/{examId}/progress`
- The route reads the session cookie and proxies backend SSE
- Backend token query parameters stay hidden from the browser
## Deployment Outcome
### Split Stack
- `backend` serves API traffic on `:8000`
- `web` serves Next.js on `:3000`
### Single Container
- the container runs both FastAPI and Next.js
- FastAPI stays on `:8000`
- non-API requests are proxied from FastAPI to the embedded Next server
## 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

BIN
docs/cover.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

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,134 +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'
// Auth Pages
import Login from './pages/Login'
import Register from './pages/Register'
// Main Pages
import Dashboard from './pages/Dashboard'
import ExamList from './pages/ExamList'
import ExamDetail from './pages/ExamDetail'
import QuizPlayer from './pages/QuizPlayer'
import MistakeList from './pages/MistakeList'
// Admin Pages
import 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 */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/exams"
element={
<ProtectedRoute>
<ExamList />
</ProtectedRoute>
}
/>
<Route
path="/exams/:examId"
element={
<ProtectedRoute>
<ExamDetail />
</ProtectedRoute>
}
/>
<Route
path="/quiz/:examId"
element={
<ProtectedRoute>
<QuizPlayer />
</ProtectedRoute>
}
/>
<Route
path="/mistakes"
element={
<ProtectedRoute>
<MistakeList />
</ProtectedRoute>
}
/>
{/* Admin Only Routes */}
<Route
path="/admin"
element={
<ProtectedRoute adminOnly>
<AdminPanel />
</ProtectedRoute>
}
/>
<Route
path="/admin/settings"
element={
<ProtectedRoute adminOnly>
<AdminSettings />
</ProtectedRoute>
}
/>
{/* Default Route */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* 404 */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</div>
</AuthProvider>
</Router>
)
}
export default App

View File

@@ -1,180 +0,0 @@
/**
* API Client for QQuiz Backend
*/
import axios from 'axios'
import toast from 'react-hot-toast'
// Create axios instance
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
// Request interceptor - Add auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor - Handle errors
api.interceptors.response.use(
(response) => response,
(error) => {
const message = error.response?.data?.detail || 'An error occurred'
if (error.response?.status === 401) {
// Unauthorized - Clear token and redirect to login
localStorage.removeItem('access_token')
localStorage.removeItem('user')
window.location.href = '/login'
toast.error('Session expired. Please login again.')
} else if (error.response?.status === 403) {
toast.error('Permission denied')
} else if (error.response?.status === 429) {
toast.error(message)
} else if (error.response?.status >= 500) {
toast.error('Server error. Please try again later.')
} else {
toast.error(message)
}
return Promise.reject(error)
}
)
// ============ Auth APIs ============
export const authAPI = {
register: (username, password) =>
api.post('/auth/register', { username, password }),
login: (username, password) =>
api.post('/auth/login', { username, password }),
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) => {
const formData = new FormData()
formData.append('title', title)
formData.append('file', file)
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 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 } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import {
BookOpen,
LayoutDashboard,
FolderOpen,
XCircle,
Settings,
LogOut,
Menu,
X
} from 'lucide-react'
export const Layout = ({ children }) => {
const { user, logout, isAdmin } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const handleLogout = () => {
logout()
navigate('/login')
}
const navigation = [
{ name: '首页', href: '/dashboard', icon: LayoutDashboard },
{ name: '题库管理', href: '/exams', icon: FolderOpen },
{ name: '错题本', href: '/mistakes', icon: XCircle },
]
if (isAdmin) {
navigation.push({ name: '系统设置', href: '/admin/settings', icon: Settings })
}
const isActive = (href) => location.pathname === href
return (
<div className="min-h-screen bg-gray-100">
{/* Mobile Header */}
<div className="lg:hidden bg-white shadow-sm">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<BookOpen className="h-6 w-6 text-primary-600" />
<span className="font-bold text-lg">QQuiz</span>
</div>
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="p-2 rounded-lg hover:bg-gray-100"
>
{mobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</button>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="border-t border-gray-200 px-4 py-3 space-y-2">
{navigation.map((item) => (
<Link
key={item.name}
to={item.href}
onClick={() => setMobileMenuOpen(false)}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
isActive(item.href)
? 'bg-primary-50 text-primary-600'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<item.icon className="h-5 w-5" />
<span>{item.name}</span>
</Link>
))}
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100"
>
<LogOut className="h-5 w-5" />
<span>退出登录</span>
</button>
</div>
)}
</div>
<div className="flex">
{/* Desktop Sidebar */}
<div className="hidden lg:flex lg:flex-col lg:w-64 lg:fixed lg:inset-y-0">
<div className="flex flex-col flex-1 bg-white border-r border-gray-200">
{/* Logo */}
<div className="flex items-center gap-3 px-6 py-6 border-b border-gray-200">
<div className="bg-primary-600 p-2 rounded-lg">
<BookOpen className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="font-bold text-lg">QQuiz</h1>
<p className="text-xs text-gray-500">{user?.username}</p>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-2">
{navigation.map((item) => (
<Link
key={item.name}
to={item.href}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
isActive(item.href)
? 'bg-primary-50 text-primary-600'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<item.icon className="h-5 w-5" />
<span>{item.name}</span>
</Link>
))}
</nav>
{/* Logout */}
<div className="px-4 py-4 border-t border-gray-200">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors"
>
<LogOut className="h-5 w-5" />
<span>退出登录</span>
</button>
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1 lg:pl-64">
{children}
</div>
</div>
</div>
)
}
export default Layout

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,95 +0,0 @@
/**
* Authentication Context
*/
import React, { createContext, useContext, useState, useEffect } from 'react'
import { authAPI } from '../api/client'
import toast from 'react-hot-toast'
const AuthContext = createContext(null)
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
// Load user from localStorage on mount
useEffect(() => {
const loadUser = async () => {
const token = localStorage.getItem('access_token')
if (token) {
try {
const response = await authAPI.getCurrentUser()
setUser(response.data)
} catch (error) {
console.error('Failed to load user:', error)
localStorage.removeItem('access_token')
localStorage.removeItem('user')
}
}
setLoading(false)
}
loadUser()
}, [])
const login = async (username, password) => {
try {
const response = await authAPI.login(username, password)
const { access_token } = response.data
// Save token
localStorage.setItem('access_token', access_token)
// Get user info
const userResponse = await authAPI.getCurrentUser()
setUser(userResponse.data)
toast.success('Login successful!')
return true
} catch (error) {
console.error('Login failed:', error)
return false
}
}
const register = async (username, password) => {
try {
await authAPI.register(username, password)
toast.success('Registration successful! Please login.')
return true
} catch (error) {
console.error('Registration failed:', error)
return false
}
}
const logout = () => {
localStorage.removeItem('access_token')
localStorage.removeItem('user')
setUser(null)
toast.success('Logged out successfully')
}
const value = {
user,
loading,
login,
register,
logout,
isAuthenticated: !!user,
isAdmin: user?.is_admin || false
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}

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,377 +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="min-h-screen bg-gray-100">
{/* Header */}
<div className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={() => navigate(-1)} className="p-2 hover:bg-gray-100 rounded-lg">
<ArrowLeft className="h-6 w-6 text-gray-600" />
</button>
<Shield className="h-8 w-8 text-primary-600" />
<div>
<h1 className="text-2xl font-bold text-gray-900">管理员面板</h1>
<p className="text-gray-600">{user?.username}</p>
</div>
</div>
<button
onClick={() => navigate('/admin/settings')}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
>
<Settings className="h-5 w-5" />
系统设置
</button>
</div>
</div>
</div>
{/* Tabs */}
<div className="max-w-7xl mx-auto px-4 py-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" />
创建用户
</button>
</div>
</div>
{/* Users Table */}
<div className="bg-white rounded-xl shadow overflow-hidden">
<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,579 +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="min-h-screen bg-gray-100">
{/* Header */}
<div className="bg-white shadow">
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => navigate(-1)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="返回"
>
<ArrowLeft className="h-6 w-6 text-gray-600" />
</button>
<Settings className="h-8 w-8 text-primary-600" />
<div>
<h1 className="text-2xl font-bold text-gray-900">系统设置</h1>
<p className="text-gray-600">管理员{user?.username}</p>
</div>
</div>
</div>
</div>
</div>
{/* Content */}
<div className="max-w-5xl mx-auto px-4 py-8 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,206 +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 Layout from '../components/Layout'
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 (
<Layout>
<div className="p-4 md:p-8">
{/* Welcome */}
<div className="mb-8">
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
欢迎回来{user?.username}
</h1>
<p className="text-gray-600 mt-1">继续你的学习之旅</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div
className="bg-white rounded-xl shadow-sm p-6 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => navigate('/exams')}
>
<div className="flex items-center gap-3 mb-2">
<div className="bg-primary-100 p-2 rounded-lg">
<FolderOpen className="h-5 w-5 text-primary-600" />
</div>
<span className="text-2xl font-bold text-gray-900">{stats.totalExams}</span>
</div>
<p className="text-sm text-gray-600">题库总数</p>
</div>
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center gap-3 mb-2">
<div className="bg-blue-100 p-2 rounded-lg">
<BookOpen className="h-5 w-5 text-blue-600" />
</div>
<span className="text-2xl font-bold text-gray-900">{stats.totalQuestions}</span>
</div>
<p className="text-sm text-gray-600">题目总数</p>
</div>
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center gap-3 mb-2">
<div className="bg-green-100 p-2 rounded-lg">
<TrendingUp className="h-5 w-5 text-green-600" />
</div>
<span className="text-2xl font-bold text-gray-900">{stats.completedQuestions}</span>
</div>
<p className="text-sm text-gray-600">已完成</p>
</div>
<div
className="bg-white rounded-xl shadow-sm p-6 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => navigate('/mistakes')}
>
<div className="flex items-center gap-3 mb-2">
<div className="bg-red-100 p-2 rounded-lg">
<XCircle className="h-5 w-5 text-red-600" />
</div>
<span className="text-2xl font-bold text-gray-900">{stats.mistakeCount}</span>
</div>
<p className="text-sm text-gray-600">错题数量</p>
</div>
</div>
{/* Recent Exams */}
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-900">最近的题库</h2>
<button
onClick={() => navigate('/exams')}
className="text-primary-600 hover:text-primary-700 flex items-center gap-1 text-sm font-medium"
>
查看全部
<ArrowRight className="h-4 w-4" />
</button>
</div>
{recentExams.length === 0 ? (
<div className="text-center py-8">
<FolderOpen className="h-12 w-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500">还没有题库快去创建一个吧</p>
</div>
) : (
<div className="space-y-3">
{recentExams.map((exam) => (
<div
key={exam.id}
onClick={() => navigate(`/exams/${exam.id}`)}
className="border border-gray-200 rounded-lg p-4 hover:border-primary-300 hover:bg-primary-50 transition-all cursor-pointer"
>
<div className="flex items-start justify-between mb-2">
<h3 className="font-semibold text-gray-900">{exam.title}</h3>
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(exam.status)}`}>
{getStatusText(exam.status)}
</span>
</div>
<div className="flex items-center justify-between text-sm text-gray-600">
<span>
{exam.current_index} / {exam.total_questions}
</span>
<span>{formatRelativeTime(exam.updated_at)}</span>
</div>
{exam.total_questions > 0 && (
<div className="w-full bg-gray-200 rounded-full h-2 mt-3">
<div
className="bg-primary-600 h-2 rounded-full transition-all"
style={{ width: `${calculateProgress(exam.current_index, exam.total_questions)}%` }}
></div>
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Admin Quick Access */}
{isAdmin && (
<div className="mt-6 bg-gradient-to-r from-primary-500 to-primary-600 rounded-xl shadow-sm p-6 text-white">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold mb-1">管理员功能</h3>
<p className="text-sm text-primary-100">用户管理系统统计配置设置</p>
</div>
<div className="flex gap-2">
<button
onClick={() => navigate('/admin')}
className="bg-white text-primary-600 px-4 py-2 rounded-lg font-medium hover:bg-primary-50 transition-colors flex items-center gap-2"
>
<Shield className="h-5 w-5" />
管理面板
</button>
<button
onClick={() => navigate('/admin/settings')}
className="bg-white/90 text-primary-600 px-4 py-2 rounded-lg font-medium hover:bg-white transition-colors flex items-center gap-2"
>
<Settings className="h-5 w-5" />
系统设置
</button>
</div>
</div>
</div>
)}
</div>
</Layout>
)
}
export default Dashboard

View File

@@ -1,411 +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, questionAPI } from '../api/client'
import Layout from '../components/Layout'
import ParsingProgress from '../components/ParsingProgress'
import {
ArrowLeft, Upload, Play, Loader, FileText, AlertCircle, RefreshCw
} from 'lucide-react'
import toast from 'react-hot-toast'
import {
getStatusColor,
getStatusText,
formatDate,
calculateProgress,
isValidFileType,
getQuestionTypeText
} from '../utils/helpers'
export const ExamDetail = () => {
const { examId } = useParams()
const navigate = useNavigate()
const [exam, setExam] = useState(null)
const [questions, setQuestions] = useState([])
const [loading, setLoading] = useState(true)
const [uploading, setUploading] = useState(false)
const [showUploadModal, setShowUploadModal] = useState(false)
const [uploadFile, setUploadFile] = useState(null)
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, questionsRes] = await Promise.all([
examAPI.getDetail(examId),
questionAPI.getExamQuestions(examId, 0, 10) // Load first 10 for preview
])
setExam(examRes.data)
setQuestions(questionsRes.data.questions)
// Connect to SSE if exam is processing
if (examRes.data.status === 'processing') {
connectSSE()
}
} 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 = localStorage.getItem('token')
const url = `/api/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 (
<Layout>
<div className="flex items-center justify-center h-screen">
<Loader className="h-8 w-8 animate-spin text-primary-600" />
</div>
</Layout>
)
}
if (!exam) {
return (
<Layout>
<div className="flex flex-col items-center justify-center h-screen">
<AlertCircle className="h-16 w-16 text-gray-300 mb-4" />
<p className="text-gray-600">题库不存在</p>
</div>
</Layout>
)
}
const isProcessing = exam.status === 'processing'
const isReady = exam.status === 'ready'
const isFailed = exam.status === 'failed'
const quizProgress = calculateProgress(exam.current_index, exam.total_questions)
return (
<Layout>
<div className="p-4 md:p-8">
{/* Back Button */}
<button
onClick={() => navigate('/exams')}
className="mb-6 flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors"
>
<ArrowLeft className="h-5 w-5" />
返回题库列表
</button>
{/* 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">{progress}%</p>
</div>
</div>
{/* Progress Bar */}
{exam.total_questions > 0 && (
<div className="mt-6">
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-primary-600 h-3 rounded-full transition-all"
style={{ width: `${quizProgress}%` }}
></div>
</div>
</div>
)}
{/* Info */}
<div className="mt-6 pt-6 border-t border-gray-200 text-sm text-gray-600">
<p>创建时间{formatDate(exam.created_at)}</p>
<p>最后更新{formatDate(exam.updated_at)}</p>
</div>
</div>
{/* Failed Status Warning */}
{isFailed && (
<div className="bg-red-50 border border-red-200 rounded-xl p-6 mb-6">
<div className="flex items-start gap-3">
<AlertCircle className="h-6 w-6 text-red-600 mt-0.5" />
<div>
<h3 className="font-medium text-red-900 mb-1">文档解析失败</h3>
<p className="text-sm text-red-700">
请检查文档格式是否正确或尝试重新上传
</p>
</div>
</div>
</div>
)}
{/* Questions Preview */}
<div className="bg-white rounded-xl shadow-sm p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">
题目预览 {questions.length > 0 && `(前 ${questions.length} 题)`}
</h2>
{questions.length === 0 ? (
<div className="text-center py-12">
<FileText className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500">
{isProcessing ? '正在解析文档,请稍候...' : '暂无题目'}
</p>
</div>
) : (
<div className="space-y-4">
{questions.map((q, index) => (
<div key={q.id} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<span className="flex-shrink-0 w-8 h-8 bg-primary-100 text-primary-600 rounded-full flex items-center justify-center font-medium">
{index + 1}
</span>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-600 rounded">
{getQuestionTypeText(q.type)}
</span>
</div>
<p className="text-gray-900">{q.content}</p>
{q.options && q.options.length > 0 && (
<ul className="mt-2 space-y-1 text-sm text-gray-600">
{q.options.map((opt, i) => (
<li key={i}>{opt}</li>
))}
</ul>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Upload Modal */}
{showUploadModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold mb-4">添加题目文档</h2>
<p className="text-sm text-gray-600 mb-4">
上传新文档后系统会自动解析题目并去除重复题目
</p>
<form onSubmit={handleAppendDocument} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
选择文档
</label>
<input
type="file"
onChange={(e) => setUploadFile(e.target.files[0])}
required
accept=".txt,.pdf,.doc,.docx,.xlsx,.xls"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
<p className="text-xs text-gray-500 mt-1">
支持TXT, PDF, DOC, DOCX, XLSX, XLS
</p>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => {
setShowUploadModal(false)
setUploadFile(null)
}}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
取消
</button>
<button
type="submit"
disabled={uploading}
className="flex-1 bg-primary-600 text-white py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{uploading ? (
<>
<Loader className="h-5 w-5 animate-spin" />
上传中...
</>
) : (
<>
<Upload className="h-5 w-5" />
上传
</>
)}
</button>
</div>
</form>
</div>
</div>
)}
</Layout>
)
}
export default ExamDetail

View File

@@ -1,312 +0,0 @@
/**
* Exam List Page
*/
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { examAPI } from '../api/client'
import Layout from '../components/Layout'
import {
Plus, FolderOpen, Loader, AlertCircle, Trash2, Upload
} from 'lucide-react'
import toast from 'react-hot-toast'
import {
getStatusColor,
getStatusText,
formatRelativeTime,
calculateProgress,
isValidFileType
} from '../utils/helpers'
export const ExamList = () => {
const navigate = useNavigate()
const [exams, setExams] = useState([])
const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false)
const [creating, setCreating] = useState(false)
const [pollInterval, setPollInterval] = useState(null)
const [formData, setFormData] = useState({
title: '',
file: null
})
useEffect(() => {
loadExams()
// Start polling for processing exams
const interval = setInterval(() => {
checkProcessingExams()
}, 3000) // Poll every 3 seconds
setPollInterval(interval)
return () => {
if (interval) clearInterval(interval)
}
}, [])
const loadExams = async () => {
try {
const response = await examAPI.getList()
setExams(response.data.exams)
} catch (error) {
console.error('Failed to load exams:', error)
toast.error('加载题库失败')
} finally {
setLoading(false)
}
}
const checkProcessingExams = async () => {
try {
const response = await examAPI.getList()
const newExams = response.data.exams
// Check if any processing exam is now ready
const oldProcessing = exams.filter(e => e.status === 'processing')
const newReady = newExams.filter(e =>
oldProcessing.some(old => old.id === e.id && e.status === 'ready')
)
if (newReady.length > 0) {
toast.success(`${newReady.length} 个题库解析完成!`)
}
setExams(newExams)
} catch (error) {
console.error('Failed to poll exams:', error)
}
}
const handleCreate = async (e) => {
e.preventDefault()
if (!formData.file) {
toast.error('请选择文件')
return
}
if (!isValidFileType(formData.file.name)) {
toast.error('不支持的文件类型')
return
}
setCreating(true)
try {
const response = await examAPI.create(formData.title, formData.file)
toast.success('题库创建成功,正在解析文档...')
setShowCreateModal(false)
setFormData({ title: '', file: null })
// 跳转到新创建的试卷详情页
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 (
<Layout>
<div className="flex items-center justify-center h-screen">
<Loader className="h-8 w-8 animate-spin text-primary-600" />
</div>
</Layout>
)
}
return (
<Layout>
<div className="p-4 md:p-8">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">题库管理</h1>
<p className="text-gray-600 mt-1"> {exams.length} 个题库</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="mt-4 md:mt-0 bg-primary-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors flex items-center gap-2 justify-center"
>
<Plus className="h-5 w-5" />
创建题库
</button>
</div>
{/* Exam Grid */}
{exams.length === 0 ? (
<div className="text-center py-12">
<FolderOpen className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">还没有题库</h3>
<p className="text-gray-500 mb-6">创建第一个题库开始刷题吧</p>
<button
onClick={() => setShowCreateModal(true)}
className="bg-primary-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors inline-flex items-center gap-2"
>
<Plus className="h-5 w-5" />
创建题库
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{exams.map((exam) => (
<div
key={exam.id}
className="bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow p-6"
>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 flex-1 pr-2">
{exam.title}
</h3>
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(exam.status)}`}>
{getStatusText(exam.status)}
</span>
</div>
{/* Stats */}
<div className="space-y-2 mb-4">
<div className="flex justify-between text-sm">
<span className="text-gray-600">题目数量</span>
<span className="font-medium">{exam.total_questions}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">已完成</span>
<span className="font-medium">
{exam.current_index} / {exam.total_questions}
</span>
</div>
{exam.total_questions > 0 && (
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all"
style={{ width: `${calculateProgress(exam.current_index, exam.total_questions)}%` }}
></div>
</div>
)}
</div>
{/* Time */}
<p className="text-xs text-gray-500 mb-4">
创建于 {formatRelativeTime(exam.created_at)}
</p>
{/* Actions */}
<div className="flex gap-2">
<button
onClick={() => navigate(`/exams/${exam.id}`)}
className="flex-1 bg-primary-600 text-white py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors"
>
查看详情
</button>
<button
onClick={() => handleDelete(exam.id)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="h-5 w-5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold mb-4">创建新题库</h2>
<form onSubmit={handleCreate} className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
题库名称
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="例如:数据结构期末复习"
/>
</div>
{/* File */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
上传文档
</label>
<input
type="file"
onChange={(e) => setFormData({ ...formData, file: e.target.files[0] })}
required
accept=".txt,.pdf,.doc,.docx,.xlsx,.xls"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
<p className="text-xs text-gray-500 mt-1">
支持TXT, PDF, DOC, DOCX, XLSX, XLS
</p>
</div>
{/* Buttons */}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => {
setShowCreateModal(false)
setFormData({ title: '', file: null })
}}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
取消
</button>
<button
type="submit"
disabled={creating}
className="flex-1 bg-primary-600 text-white py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{creating ? (
<>
<Loader className="h-5 w-5 animate-spin" />
创建中...
</>
) : (
'创建'
)}
</button>
</div>
</form>
</div>
</div>
)}
</Layout>
)
}
export default ExamList

View File

@@ -1,118 +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 onSubmit={handleSubmit} className="space-y-6">
{/* Username */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
用户名
</label>
<input
type="text"
name="username"
value={formData.username}
onChange={handleChange}
required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="请输入用户名"
/>
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
密码
</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
required
minLength={6}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="请输入密码"
/>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
{/* Register Link */}
<div className="mt-6 text-center">
<p className="text-gray-600">
还没有账号{' '}
<Link to="/register" className="text-primary-600 font-medium hover:text-primary-700">
立即注册
</Link>
</p>
</div>
</div>
</div>
</div>
)
}
export default Login

View File

@@ -1,170 +0,0 @@
/**
* Mistake List Page (错题本)
*/
import React, { useState, useEffect } from 'react'
import { mistakeAPI } from '../api/client'
import Layout from '../components/Layout'
import { XCircle, Loader, Trash2, BookOpen } from 'lucide-react'
import toast from 'react-hot-toast'
import { getQuestionTypeText, formatRelativeTime } from '../utils/helpers'
export const MistakeList = () => {
const [mistakes, setMistakes] = useState([])
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState(null)
useEffect(() => {
loadMistakes()
}, [])
const loadMistakes = async () => {
try {
const response = await mistakeAPI.getList()
setMistakes(response.data.mistakes)
} catch (error) {
console.error('Failed to load mistakes:', error)
toast.error('加载错题本失败')
} finally {
setLoading(false)
}
}
const handleRemove = async (mistakeId) => {
if (!window.confirm('确定要从错题本中移除这道题吗?')) {
return
}
try {
await mistakeAPI.remove(mistakeId)
toast.success('已移除')
await loadMistakes()
} catch (error) {
console.error('Failed to remove mistake:', error)
toast.error('移除失败')
}
}
const toggleExpand = (id) => {
setExpandedId(expandedId === id ? null : id)
}
if (loading) {
return (
<Layout>
<div className="flex items-center justify-center h-screen">
<Loader className="h-8 w-8 animate-spin text-primary-600" />
</div>
</Layout>
)
}
return (
<Layout>
<div className="p-4 md:p-8">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">错题本</h1>
<p className="text-gray-600 mt-1"> {mistakes.length} 道错题</p>
</div>
{/* Empty State */}
{mistakes.length === 0 ? (
<div className="text-center py-12">
<XCircle className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">错题本是空的</h3>
<p className="text-gray-500">继续刷题错题会自动添加到这里</p>
</div>
) : (
<div className="space-y-4">
{mistakes.map((mistake) => {
const q = mistake.question
const isExpanded = expandedId === mistake.id
return (
<div
key={mistake.id}
className="bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow"
>
{/* Question Preview */}
<div
className="p-4 md:p-6 cursor-pointer"
onClick={() => toggleExpand(mistake.id)}
>
<div className="flex items-start gap-3">
<span className="flex-shrink-0 w-10 h-10 bg-red-100 text-red-600 rounded-full flex items-center justify-center">
<XCircle className="h-5 w-5" />
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-600 rounded">
{getQuestionTypeText(q.type)}
</span>
<span className="text-xs text-gray-500">
{formatRelativeTime(mistake.created_at)}
</span>
</div>
<p className={`text-gray-900 ${!isExpanded ? 'line-clamp-2' : ''}`}>
{q.content}
</p>
{isExpanded && (
<div className="mt-4 space-y-3">
{/* Options */}
{q.options && q.options.length > 0 && (
<div className="space-y-2">
{q.options.map((opt, i) => (
<div
key={i}
className="p-3 bg-gray-50 rounded-lg text-sm text-gray-700"
>
{opt}
</div>
))}
</div>
)}
{/* Answer */}
<div className="p-3 bg-green-50 rounded-lg">
<p className="text-sm font-medium text-green-900 mb-1">
正确答案
</p>
<p className="text-sm text-green-700">{q.answer}</p>
</div>
{/* Analysis */}
{q.analysis && (
<div className="p-3 bg-blue-50 rounded-lg">
<p className="text-sm font-medium text-blue-900 mb-1">
解析
</p>
<p className="text-sm text-blue-700">{q.analysis}</p>
</div>
)}
</div>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation()
handleRemove(mistake.id)
}}
className="flex-shrink-0 p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="h-5 w-5" />
</button>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</Layout>
)
}
export default MistakeList

View File

@@ -1,397 +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 Layout from '../components/Layout'
import {
ArrowLeft, ArrowRight, Check, X, Loader, BookmarkPlus, BookmarkX, AlertCircle
} from 'lucide-react'
import toast from 'react-hot-toast'
import { getQuestionTypeText } from '../utils/helpers'
export const QuizPlayer = () => {
const { examId } = useParams()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [exam, setExam] = useState(null)
const [question, setQuestion] = useState(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [userAnswer, setUserAnswer] = useState('')
const [multipleAnswers, setMultipleAnswers] = useState([])
const [result, setResult] = useState(null)
const [inMistakeBook, setInMistakeBook] = useState(false)
useEffect(() => {
loadQuiz()
}, [examId])
const loadQuiz = async () => {
try {
// Check if reset flag is present
const shouldReset = searchParams.get('reset') === 'true'
if (shouldReset) {
await examAPI.updateProgress(examId, 0)
}
const examRes = await examAPI.getDetail(examId)
setExam(examRes.data)
await loadCurrentQuestion()
} catch (error) {
console.error('Failed to load quiz:', error)
toast.error('加载题目失败')
} finally {
setLoading(false)
}
}
const loadCurrentQuestion = async () => {
try {
const response = await questionAPI.getCurrentQuestion(examId)
setQuestion(response.data)
setResult(null)
setUserAnswer('')
setMultipleAnswers([])
await checkIfInMistakeBook(response.data.id)
} catch (error) {
if (error.response?.status === 404) {
toast.success('恭喜!所有题目已完成!')
navigate(`/exams/${examId}`)
} else {
console.error('Failed to load question:', error)
toast.error('加载题目失败')
}
}
}
const checkIfInMistakeBook = async (questionId) => {
try {
const response = await mistakeAPI.getList(0, 1000) // TODO: Optimize this
const inBook = response.data.mistakes.some(m => m.question_id === questionId)
setInMistakeBook(inBook)
} catch (error) {
console.error('Failed to check mistake book:', error)
}
}
const handleSubmitAnswer = async () => {
let answer = userAnswer
// For multiple choice, join selected options
if (question.type === 'multiple') {
if (multipleAnswers.length === 0) {
toast.error('请至少选择一个选项')
return
}
answer = multipleAnswers.sort().join('')
}
if (!answer.trim()) {
toast.error('请输入答案')
return
}
setSubmitting(true)
try {
const response = await questionAPI.checkAnswer(question.id, answer)
setResult(response.data)
if (response.data.correct) {
toast.success('回答正确!')
} else {
toast.error('回答错误')
}
} catch (error) {
console.error('Failed to check answer:', error)
toast.error('提交答案失败')
} finally {
setSubmitting(false)
}
}
const handleNext = async () => {
try {
const newIndex = exam.current_index + 1
await examAPI.updateProgress(examId, newIndex)
const examRes = await examAPI.getDetail(examId)
setExam(examRes.data)
await loadCurrentQuestion()
} catch (error) {
console.error('Failed to move to next question:', error)
}
}
const handleToggleMistake = async () => {
try {
if (inMistakeBook) {
await mistakeAPI.removeByQuestionId(question.id)
setInMistakeBook(false)
toast.success('已从错题本移除')
} else {
await mistakeAPI.add(question.id)
setInMistakeBook(true)
toast.success('已加入错题本')
}
} catch (error) {
console.error('Failed to toggle mistake:', error)
}
}
const handleMultipleChoice = (option) => {
const letter = option.charAt(0)
if (multipleAnswers.includes(letter)) {
setMultipleAnswers(multipleAnswers.filter(a => a !== letter))
} else {
setMultipleAnswers([...multipleAnswers, letter])
}
}
if (loading) {
return (
<Layout>
<div className="flex items-center justify-center h-screen">
<Loader className="h-8 w-8 animate-spin text-primary-600" />
</div>
</Layout>
)
}
if (!question) {
return (
<Layout>
<div className="flex flex-col items-center justify-center h-screen">
<AlertCircle className="h-16 w-16 text-gray-300 mb-4" />
<p className="text-gray-600">没有更多题目了</p>
</div>
</Layout>
)
}
return (
<Layout>
<div className="max-w-4xl mx-auto p-4 md:p-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate(`/exams/${examId}`)}
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors"
>
<ArrowLeft className="h-5 w-5" />
返回
</button>
<div className="text-sm text-gray-600">
进度: {exam.current_index + 1} / {exam.total_questions}
</div>
</div>
{/* Question Card */}
<div className="bg-white rounded-xl shadow-md p-6 md:p-8 mb-6">
{/* Question Header */}
<div className="flex items-start justify-between mb-6">
<div className="flex items-center gap-3">
<span className="flex-shrink-0 w-10 h-10 bg-primary-100 text-primary-600 rounded-full flex items-center justify-center font-bold">
{exam.current_index + 1}
</span>
<span className="px-3 py-1 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium">
{getQuestionTypeText(question.type)}
</span>
</div>
<button
onClick={handleToggleMistake}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${
inMistakeBook
? 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{inMistakeBook ? (
<>
<BookmarkX className="h-5 w-5" />
<span className="hidden sm:inline">移出错题本</span>
</>
) : (
<>
<BookmarkPlus className="h-5 w-5" />
<span className="hidden sm:inline">加入错题本</span>
</>
)}
</button>
</div>
{/* Question Content */}
<div className="mb-6">
<p className="text-lg md:text-xl text-gray-900 leading-relaxed">
{question.content}
</p>
</div>
{/* Options (for choice questions) */}
{question.options && question.options.length > 0 && (
<div className="space-y-3 mb-6">
{question.options.map((option, index) => {
const letter = option.charAt(0)
const isSelected = question.type === 'multiple'
? multipleAnswers.includes(letter)
: userAnswer === letter
return (
<button
key={index}
onClick={() => {
if (!result) {
if (question.type === 'multiple') {
handleMultipleChoice(option)
} else {
setUserAnswer(letter)
}
}
}}
disabled={!!result}
className={`w-full text-left p-4 rounded-lg border-2 transition-all ${
isSelected
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
} ${result ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}`}
>
<span className="text-gray-900">{option}</span>
</button>
)
})}
</div>
)}
{/* Short Answer Input */}
{question.type === 'short' && !result && (
<div className="mb-6">
<textarea
value={userAnswer}
onChange={(e) => setUserAnswer(e.target.value)}
rows={4}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-lg focus:border-primary-500 focus:outline-none"
placeholder="请输入你的答案..."
/>
</div>
)}
{/* Judge Input */}
{question.type === 'judge' && !result && (
<div className="flex gap-4 mb-6">
<button
onClick={() => setUserAnswer('A')}
className={`flex-1 py-3 rounded-lg border-2 transition-all ${
userAnswer === 'A'
? 'border-green-500 bg-green-50 text-green-700'
: 'border-gray-200 hover:border-gray-300'
}`}
>
正确
</button>
<button
onClick={() => setUserAnswer('B')}
className={`flex-1 py-3 rounded-lg border-2 transition-all ${
userAnswer === 'B'
? 'border-red-500 bg-red-50 text-red-700'
: 'border-gray-200 hover:border-gray-300'
}`}
>
错误
</button>
</div>
)}
{/* Submit Button */}
{!result && (
<button
onClick={handleSubmitAnswer}
disabled={submitting}
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{submitting ? (
<>
<Loader className="h-5 w-5 animate-spin" />
提交中...
</>
) : (
<>
<Check className="h-5 w-5" />
提交答案
</>
)}
</button>
)}
</div>
{/* Result */}
{result && (
<div className={`rounded-xl p-6 mb-6 ${
result.correct ? 'bg-green-50 border-2 border-green-200' : 'bg-red-50 border-2 border-red-200'
}`}>
<div className="flex items-start gap-3 mb-4">
{result.correct ? (
<Check className="h-6 w-6 text-green-600 mt-0.5" />
) : (
<X className="h-6 w-6 text-red-600 mt-0.5" />
)}
<div className="flex-1">
<h3 className={`font-bold text-lg mb-2 ${result.correct ? 'text-green-900' : 'text-red-900'}`}>
{result.correct ? '回答正确!' : '回答错误'}
</h3>
{!result.correct && (
<div className="space-y-2 text-sm">
<p className="text-gray-700">
<span className="font-medium">你的答案</span>{result.user_answer}
</p>
<p className="text-gray-700">
<span className="font-medium">正确答案</span>{result.correct_answer}
</p>
</div>
)}
{/* AI Score for short answers */}
{result.ai_score !== null && result.ai_score !== undefined && (
<div className="mt-3 p-3 bg-white rounded-lg">
<p className="text-sm font-medium text-gray-700 mb-1">
AI 评分{(result.ai_score * 100).toFixed(0)}%
</p>
{result.ai_feedback && (
<p className="text-sm text-gray-600">{result.ai_feedback}</p>
)}
</div>
)}
{/* Analysis */}
{result.analysis && (
<div className="mt-3 p-3 bg-white rounded-lg">
<p className="text-sm font-medium text-gray-700 mb-1">解析</p>
<p className="text-sm text-gray-600">{result.analysis}</p>
</div>
)}
</div>
</div>
{/* Next Button */}
<button
onClick={handleNext}
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors flex items-center justify-center gap-2"
>
下一题
<ArrowRight className="h-5 w-5" />
</button>
</div>
)}
</div>
</Layout>
)
}
export default QuizPlayer

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,19 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
target: process.env.REACT_APP_API_URL || 'http://localhost:8000',
changeOrigin: true,
}
}
},
build: {
outDir: 'build'
}
})

225
scripts/smoke_e2e.ps1 Normal file
View File

@@ -0,0 +1,225 @@
param(
[string]$FrontendBase = "http://127.0.0.1:3000",
[string]$AdminUsername = "admin",
[string]$AdminPassword = "AdminTest2026!",
[string]$ProviderBaseUrl = "https://api.openai.com/v1",
[string]$ProviderApiKey = "sk-your-openai-api-key",
[string]$ProviderModel = "gpt-5.4",
[string]$SamplePath = "E:\QQuiz\test_data\sample_questions.txt"
)
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Net.Http
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
Invoke-WebRequest -UseBasicParsing `
-Uri "$FrontendBase/api/auth/login" `
-Method POST `
-WebSession $session `
-ContentType "application/json" `
-Body (@{
username = $AdminUsername
password = $AdminPassword
} | ConvertTo-Json) | Out-Null
Invoke-WebRequest -UseBasicParsing `
-Uri "$FrontendBase/api/proxy/admin/config" `
-Method PUT `
-WebSession $session `
-ContentType "application/json" `
-Body (@{
ai_provider = "openai"
openai_base_url = $ProviderBaseUrl
openai_api_key = $ProviderApiKey
openai_model = $ProviderModel
} | ConvertTo-Json) | Out-Null
$testUsername = "e2e_user"
$testPassword = "E2ETest2026!"
$resetPassword = "E2EPassword2026!"
try {
Invoke-WebRequest -UseBasicParsing `
-Uri "$FrontendBase/api/proxy/admin/users" `
-Method POST `
-WebSession $session `
-ContentType "application/json" `
-Body (@{
username = $testUsername
password = $testPassword
is_admin = $false
} | ConvertTo-Json) | Out-Null
} catch {
if (-not $_.Exception.Response -or $_.Exception.Response.StatusCode.value__ -ne 400) {
throw
}
}
$usersPayload = (
Invoke-WebRequest -UseBasicParsing `
-Uri "$FrontendBase/api/proxy/admin/users?skip=0&limit=100" `
-WebSession $session
).Content | ConvertFrom-Json
$testUser = $usersPayload.users | Where-Object { $_.username -eq $testUsername } | Select-Object -First 1
if (-not $testUser) {
throw "Failed to find $testUsername."
}
Invoke-WebRequest -UseBasicParsing `
-Uri "$FrontendBase/api/proxy/admin/users/$($testUser.id)" `
-Method PUT `
-WebSession $session `
-ContentType "application/json" `
-Body (@{
username = $testUsername
is_admin = $false
} | ConvertTo-Json) | Out-Null
Invoke-WebRequest -UseBasicParsing `
-Uri "$FrontendBase/api/proxy/admin/users/$($testUser.id)/reset-password" `
-Method POST `
-WebSession $session `
-ContentType "application/json" `
-Body (@{
new_password = $resetPassword
} | ConvertTo-Json) | Out-Null
$client = [System.Net.Http.HttpClient]::new()
$tokenCookie = $session.Cookies.GetCookies($FrontendBase) | Where-Object { $_.Name -eq "access_token" } | Select-Object -First 1
if (-not $tokenCookie) {
throw "Login cookie not found."
}
$client.DefaultRequestHeaders.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new("Bearer", $tokenCookie.Value)
$apiHeaders = @{
Authorization = "Bearer $($tokenCookie.Value)"
}
$multipart = [System.Net.Http.MultipartFormDataContent]::new()
$multipart.Add([System.Net.Http.StringContent]::new("E2E Full Flow Exam"), "title")
$multipart.Add([System.Net.Http.StringContent]::new("false"), "is_random")
$bytes = [System.IO.File]::ReadAllBytes($SamplePath)
$fileContent = [System.Net.Http.ByteArrayContent]::new($bytes)
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse("text/plain")
$multipart.Add($fileContent, "file", [System.IO.Path]::GetFileName($SamplePath))
$createResponse = $client.PostAsync("http://127.0.0.1:8000/api/exams/create", $multipart).Result
$createBody = $createResponse.Content.ReadAsStringAsync().Result
if ($createResponse.StatusCode.value__ -ne 201) {
throw "Exam create failed: $createBody"
}
$createPayload = $createBody | ConvertFrom-Json
$examId = $createPayload.exam_id
$deadline = (Get-Date).AddMinutes(4)
$exam = $null
while ((Get-Date) -lt $deadline) {
$exam = (
Invoke-WebRequest -UseBasicParsing `
-Uri "http://127.0.0.1:8000/api/exams/$examId" `
-Headers $apiHeaders
).Content | ConvertFrom-Json
if ($exam.status -eq "ready" -or $exam.status -eq "failed") {
break
}
Start-Sleep -Seconds 5
}
if (-not $exam) {
throw "Exam polling returned no data."
}
if ($exam.status -ne "ready") {
throw "Exam parsing failed or timed out. Final status: $($exam.status)"
}
$questionsPayload = (
Invoke-WebRequest -UseBasicParsing `
-Uri "http://127.0.0.1:8000/api/questions/?exam_id=$examId&skip=0&limit=10" `
-Headers $apiHeaders
).Content | ConvertFrom-Json
if ($questionsPayload.total -lt 1) {
throw "Question list returned no questions."
}
$currentQuestion = (
Invoke-WebRequest -UseBasicParsing `
-Uri "http://127.0.0.1:8000/api/questions/exam/$examId/current" `
-Headers $apiHeaders
).Content | ConvertFrom-Json
$checkPayload = (
Invoke-WebRequest -UseBasicParsing `
-Uri "http://127.0.0.1:8000/api/questions/check" `
-Method POST `
-Headers $apiHeaders `
-ContentType "application/json" `
-Body (@{
question_id = $currentQuestion.id
user_answer = "Z"
} | ConvertTo-Json)
).Content | ConvertFrom-Json
if ($checkPayload.correct -eq $true) {
throw "Expected the forced wrong answer to be incorrect."
}
$mistakesPayload = (
Invoke-WebRequest -UseBasicParsing `
-Uri "http://127.0.0.1:8000/api/mistakes/?skip=0&limit=50" `
-Headers $apiHeaders
).Content | ConvertFrom-Json
if ($mistakesPayload.total -lt 1) {
throw "Mistake list did not record the wrong answer."
}
Invoke-WebRequest -UseBasicParsing `
-Uri "http://127.0.0.1:8000/api/exams/$examId/progress" `
-Method PUT `
-Headers $apiHeaders `
-ContentType "application/json" `
-Body '{"current_index":1}' | Out-Null
$summaryPayload = (
Invoke-WebRequest -UseBasicParsing `
-Uri "http://127.0.0.1:8000/api/exams/summary" `
-Headers $apiHeaders
).Content | ConvertFrom-Json
if ($summaryPayload.total_exams -lt 1) {
throw "Exam summary endpoint returned invalid totals."
}
$testSession = New-Object Microsoft.PowerShell.Commands.WebRequestSession
Invoke-WebRequest -UseBasicParsing `
-Uri "$FrontendBase/api/auth/login" `
-Method POST `
-WebSession $testSession `
-ContentType "application/json" `
-Body (@{
username = $testUsername
password = $resetPassword
} | ConvertTo-Json) | Out-Null
$me = (
Invoke-WebRequest -UseBasicParsing `
-Uri "$FrontendBase/api/auth/me" `
-WebSession $testSession
).Content | ConvertFrom-Json
[pscustomobject]@{
exam_id = $examId
exam_status = $exam.status
total_questions = $exam.total_questions
users_total = $usersPayload.total
mistakes_total = $mistakesPayload.total
summary_total_exams = $summaryPayload.total_exams
test_user = $me.username
test_user_is_admin = $me.is_admin
} | ConvertTo-Json -Depth 4

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

5
web/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.next
npm-debug.log*
.env
.env.*

1
web/.env.example Normal file
View File

@@ -0,0 +1 @@
API_BASE_URL=http://localhost:8000

33
web/Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

14
web/README.md Normal file
View File

@@ -0,0 +1,14 @@
# QQuiz Web
This directory contains the Next.js frontend for QQuiz.
## Status
- App Router application: active
- Auth/session proxy routes: active
- Single-container deployment target: active
- Split-stack frontend: active
## Environment
Copy `.env.example` and point `API_BASE_URL` at the FastAPI backend.

18
web/components.json Normal file
View File

@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
}
}

5
web/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

6
web/next.config.mjs Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone"
};
export default nextConfig;

1817
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
web/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "qquiz-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@tanstack/react-query": "^5.51.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.462.0",
"next": "^14.2.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2"
},
"devDependencies": {
"@types/node": "^22.7.4",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.2"
}
}

View File

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

View File

@@ -0,0 +1,44 @@
import { PageHeader } from "@/components/app-shell/page-header";
import { UserManagementPanel } from "@/components/admin/user-management-panel";
import { requireAdminUser } from "@/lib/auth/guards";
import { serverApi } from "@/lib/api/server";
import { DEFAULT_PAGE_SIZE, getOffset, parsePositiveInt } from "@/lib/pagination";
import { UserListResponse } from "@/lib/types";
export default async function AdminPage({
searchParams
}: {
searchParams?: {
page?: string | string[];
search?: string | string[];
};
}) {
await requireAdminUser();
const page = parsePositiveInt(searchParams?.page);
const search = Array.isArray(searchParams?.search)
? searchParams?.search[0]
: searchParams?.search;
const query = new URLSearchParams({
skip: String(getOffset(page, DEFAULT_PAGE_SIZE)),
limit: String(DEFAULT_PAGE_SIZE)
});
if (search?.trim()) {
query.set("search", search.trim());
}
const data = await serverApi<UserListResponse>(`/admin/users?${query.toString()}`);
return (
<div className="space-y-8">
<PageHeader eyebrow="Admin" title="用户管理" />
<UserManagementPanel
initialPage={page}
initialSearch={search?.trim() || ""}
initialTotal={data.total}
initialUsers={data.users}
pageSize={DEFAULT_PAGE_SIZE}
/>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { SettingsPanel } from "@/components/admin/settings-panel";
import { PageHeader } from "@/components/app-shell/page-header";
import { requireAdminUser } from "@/lib/auth/guards";
import { serverApi } from "@/lib/api/server";
import { SystemConfigResponse } from "@/lib/types";
export default async function AdminSettingsPage() {
await requireAdminUser();
const config = await serverApi<SystemConfigResponse>("/admin/config");
return (
<div className="space-y-8">
<PageHeader eyebrow="Settings" title="系统设置" />
<SettingsPanel initialConfig={config} />
</div>
);
}

View File

@@ -0,0 +1,107 @@
import Link from "next/link";
import { BookOpen, FolderOpen, Shield, TriangleAlert } from "lucide-react";
import { PageHeader } from "@/components/app-shell/page-header";
import { StatCard } from "@/components/app-shell/stat-card";
import { StatusBadge } from "@/components/app-shell/status-badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatRelativeTime } from "@/lib/formatters";
import { requireCurrentUser } from "@/lib/auth/guards";
import { serverApi } from "@/lib/api/server";
import {
AdminStatisticsResponse,
ExamListResponse,
ExamSummaryStats,
MistakeListResponse
} from "@/lib/types";
export default async function DashboardPage() {
const currentUser = await requireCurrentUser();
const [exams, summary, mistakes, stats] = await Promise.all([
serverApi<ExamListResponse>("/exams/?skip=0&limit=5"),
serverApi<ExamSummaryStats>("/exams/summary"),
serverApi<MistakeListResponse>("/mistakes/?skip=0&limit=1"),
currentUser.is_admin
? serverApi<AdminStatisticsResponse>("/admin/statistics")
: Promise.resolve(null)
]);
return (
<div className="space-y-8">
<PageHeader eyebrow="Dashboard" title={`你好,${currentUser.username}`} />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard
icon={FolderOpen}
label="题库"
value={String(summary.total_exams)}
detail={`${summary.ready_exams} 就绪 / ${summary.processing_exams} 处理中`}
/>
<StatCard
icon={BookOpen}
label="题目"
value={String(summary.total_questions)}
detail={`已完成 ${summary.completed_questions}`}
/>
<StatCard
icon={TriangleAlert}
label="错题"
value={String(mistakes.total)}
detail={mistakes.total > 0 ? "待复习" : "暂无错题"}
/>
<StatCard
icon={Shield}
label="角色"
value={currentUser.is_admin ? "管理员" : "用户"}
detail={currentUser.is_admin && stats ? `全站 ${stats.users.total} 用户` : undefined}
/>
</div>
<Card className="border-slate-200/70 bg-white/90">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{exams.exams.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-slate-200">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{exams.exams.map((exam) => (
<tr key={exam.id} className="border-t border-slate-200">
<td className="px-4 py-3">
<Link className="font-medium text-slate-900 hover:underline" href={`/exams/${exam.id}`}>
{exam.title}
</Link>
</td>
<td className="px-4 py-3">
<StatusBadge status={exam.status} />
</td>
<td className="px-4 py-3 text-slate-600">
{exam.current_index}/{exam.total_questions}
</td>
<td className="px-4 py-3 text-slate-600">
{formatRelativeTime(exam.updated_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { ExamDetailClient } from "@/components/exams/exam-detail-client";
import { PageHeader } from "@/components/app-shell/page-header";
import { serverApi } from "@/lib/api/server";
import { ExamSummary } from "@/lib/types";
export default async function ExamDetailPage({
params
}: {
params: { examId: string };
}) {
const exam = await serverApi<ExamSummary>(`/exams/${params.examId}`);
return (
<div className="space-y-8">
<PageHeader eyebrow={`Exam #${params.examId}`} title="题库详情" />
<ExamDetailClient initialExam={exam} />
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { PageHeader } from "@/components/app-shell/page-header";
import { ExamsPageClient } from "@/components/exams/exams-page-client";
import { serverApi } from "@/lib/api/server";
import { DEFAULT_PAGE_SIZE, getOffset, parsePositiveInt } from "@/lib/pagination";
import { ExamListResponse } from "@/lib/types";
export default async function ExamsPage({
searchParams
}: {
searchParams?: { page?: string | string[] };
}) {
const page = parsePositiveInt(searchParams?.page);
const data = await serverApi<ExamListResponse>(
`/exams/?skip=${getOffset(page, DEFAULT_PAGE_SIZE)}&limit=${DEFAULT_PAGE_SIZE}`
);
return (
<div className="space-y-8">
<PageHeader eyebrow="Exams" title="题库" />
<ExamsPageClient
initialExams={data.exams}
initialTotal={data.total}
page={page}
pageSize={DEFAULT_PAGE_SIZE}
/>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { AppSidebar } from "@/components/app-shell/app-sidebar";
import { LogoutButton } from "@/components/app-shell/logout-button";
import { requireCurrentUser } from "@/lib/auth/guards";
export default async function AppLayout({
children
}: {
children: React.ReactNode;
}) {
const currentUser = await requireCurrentUser();
return (
<div className="min-h-screen xl:flex">
<AppSidebar isAdmin={currentUser.is_admin} />
<div className="min-h-screen flex-1">
<div className="flex items-center justify-between border-b border-slate-200/80 bg-white/70 px-6 py-4 backdrop-blur">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">QQuiz</p>
<p className="text-sm text-slate-600">
{currentUser.username} · {currentUser.is_admin ? "管理员" : "普通用户"}
</p>
</div>
<LogoutButton />
</div>
<main className="container py-8">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { PageHeader } from "@/components/app-shell/page-header";
import { MistakePracticeClient } from "@/components/practice/mistake-practice-client";
export default function MistakeQuizPage() {
return (
<div className="space-y-8">
<PageHeader eyebrow="Mistake Practice" title="错题练习" />
<MistakePracticeClient />
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { MistakeListClient } from "@/components/mistakes/mistake-list-client";
import { PageHeader } from "@/components/app-shell/page-header";
import { serverApi } from "@/lib/api/server";
import { DEFAULT_PAGE_SIZE, getOffset, parsePositiveInt } from "@/lib/pagination";
import { MistakeListResponse } from "@/lib/types";
export default async function MistakesPage({
searchParams
}: {
searchParams?: { page?: string | string[] };
}) {
const page = parsePositiveInt(searchParams?.page);
const data = await serverApi<MistakeListResponse>(
`/mistakes/?skip=${getOffset(page, DEFAULT_PAGE_SIZE)}&limit=${DEFAULT_PAGE_SIZE}`
);
return (
<div className="space-y-8">
<PageHeader eyebrow="Mistakes" title="错题" />
<MistakeListClient
initialMistakes={data.mistakes}
initialTotal={data.total}
page={page}
pageSize={DEFAULT_PAGE_SIZE}
/>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { PageHeader } from "@/components/app-shell/page-header";
import { QuestionList } from "@/components/questions/question-list";
import { serverApi } from "@/lib/api/server";
import {
DEFAULT_PAGE_SIZE,
getOffset,
parseOptionalPositiveInt,
parsePositiveInt
} from "@/lib/pagination";
import { QuestionListResponse } from "@/lib/types";
export default async function QuestionsPage({
searchParams
}: {
searchParams?: {
page?: string | string[];
examId?: string | string[];
};
}) {
const page = parsePositiveInt(searchParams?.page);
const examId = parseOptionalPositiveInt(searchParams?.examId);
const examFilter = examId ? `&exam_id=${examId}` : "";
const data = await serverApi<QuestionListResponse>(
`/questions/?skip=${getOffset(page, DEFAULT_PAGE_SIZE)}&limit=${DEFAULT_PAGE_SIZE}${examFilter}`
);
return (
<div className="space-y-8">
<PageHeader
eyebrow="Questions"
title="题目"
description={examId ? `当前仅显示题库 #${examId} 的题目。` : undefined}
/>
<QuestionList
examId={examId}
page={page}
pageSize={DEFAULT_PAGE_SIZE}
questions={data.questions}
total={data.total}
/>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { PageHeader } from "@/components/app-shell/page-header";
import { QuizPlayerClient } from "@/components/practice/quiz-player-client";
export default function QuizPage({
params
}: {
params: { examId: string };
}) {
return (
<div className="space-y-8">
<PageHeader eyebrow={`Quiz #${params.examId}`} title="刷题" />
<QuizPlayerClient examId={params.examId} />
</div>
);
}

View File

@@ -0,0 +1,120 @@
"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { ArrowRight, ShieldCheck } from "lucide-react";
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();
const searchParams = useSearchParams();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
try {
const response = await fetch("/frontend-api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ username, password })
});
const payload = await readResponsePayload(response);
if (!response.ok) {
throw new Error(getResponseErrorMessage(payload, "登录失败"));
}
if (!isRecord(payload)) {
throw new Error("登录接口返回了无效响应");
}
toast.success("登录成功");
router.push(searchParams.get("next") || "/dashboard");
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "登录失败");
} finally {
setLoading(false);
}
}
return (
<main className="relative flex min-h-screen items-center justify-center overflow-hidden p-6">
<div className="absolute inset-0 bg-brand-grid bg-[size:34px_34px] opacity-40" />
<div className="relative grid w-full max-w-5xl gap-6 lg:grid-cols-[1.1fr_480px]">
<Card className="hidden border-slate-900/10 bg-slate-950 text-white lg:block">
<CardHeader className="pb-4">
<div className="flex items-center gap-3 text-sm uppercase tracking-[0.2em] text-slate-300">
<ShieldCheck className="h-4 w-4" />
QQuiz Web
</div>
<CardTitle className="text-4xl leading-tight"></CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm leading-7 text-slate-300">
<p>使</p>
</CardContent>
</Card>
<Card className="border-white/80 bg-white/92">
<CardHeader>
<CardTitle className="text-3xl"></CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700"></label>
<Input
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="请输入用户名"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700"></label>
<Input
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
required
/>
</div>
<Button className="w-full" disabled={loading} type="submit">
{loading ? "登录中..." : "登录"}
<ArrowRight className="h-4 w-4" />
</Button>
<p className="text-sm text-slate-600">
<Link className="ml-2 font-medium text-primary underline-offset-4 hover:underline" href="/register">
</Link>
</p>
</form>
</CardContent>
</Card>
</div>
</main>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { ArrowRight } from "lucide-react";
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();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
try {
const response = await fetch("/frontend-api/proxy/auth/register", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ username, password })
});
const payload = await readResponsePayload(response);
if (!response.ok) {
throw new Error(getResponseErrorMessage(payload, "注册失败"));
}
if (!isRecord(payload)) {
throw new Error("注册接口返回了无效响应");
}
toast.success("注册成功,请登录");
router.push("/login");
} catch (error) {
toast.error(error instanceof Error ? error.message : "注册失败");
} finally {
setLoading(false);
}
}
return (
<main className="flex min-h-screen items-center justify-center p-6">
<Card className="w-full max-w-lg border-white/80 bg-white/92">
<CardHeader>
<CardTitle className="text-3xl"></CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700"></label>
<Input
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="3-50 位字母、数字、_ 或 -"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700"></label>
<Input
autoComplete="new-password"
type="password"
minLength={6}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少 6 位"
required
/>
</div>
<Button className="w-full" disabled={loading} type="submit">
{loading ? "提交中..." : "注册"}
<ArrowRight className="h-4 w-4" />
</Button>
<p className="text-sm text-slate-600">
<Link className="ml-2 font-medium text-primary underline-offset-4 hover:underline" href="/login">
</Link>
</p>
</form>
</CardContent>
</Card>
</main>
);
}

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,17 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { SESSION_COOKIE_NAME } from "@/lib/api/config";
async function clearSession() {
cookies().delete(SESSION_COOKIE_NAME);
return NextResponse.json({ ok: true });
}
export async function POST() {
return clearSession();
}
export async function GET() {
return clearSession();
}

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

@@ -0,0 +1,52 @@
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import {
SESSION_COOKIE_NAME,
buildBackendUrl
} from "@/lib/api/config";
export async function GET(
_request: NextRequest,
{ params }: { params: { examId: string } }
) {
const token = cookies().get(SESSION_COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json({ detail: "Unauthorized" }, { status: 401 });
}
const target = `${buildBackendUrl(`/exams/${params.examId}/progress`)}?token=${encodeURIComponent(token)}`;
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();
return new NextResponse(payload || "Failed to open exam progress stream", {
status: response.status
});
}
return new NextResponse(response.body, {
status: response.status,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive"
}
});
}

View File

@@ -0,0 +1,91 @@
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import {
SESSION_COOKIE_NAME,
buildBackendUrl
} from "@/lib/api/config";
async function proxyRequest(
request: NextRequest,
params: { path: string[] }
) {
const token = cookies().get(SESSION_COOKIE_NAME)?.value;
const requestPath = params.path.join("/");
const target = `${buildBackendUrl(`/${requestPath}`)}${request.nextUrl.search}`;
const headers = new Headers();
const contentType = request.headers.get("content-type");
if (contentType) {
headers.set("Content-Type", contentType);
}
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const method = request.method;
const init: RequestInit = {
method,
headers,
cache: "no-store"
};
if (!["GET", "HEAD"].includes(method)) {
init.body = await request.arrayBuffer();
}
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");
responseHeaders.delete("transfer-encoding");
return new NextResponse(response.body, {
status: response.status,
headers: responseHeaders
});
}
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
return proxyRequest(request, params);
}
export async function POST(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
return proxyRequest(request, params);
}
export async function PUT(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
return proxyRequest(request, params);
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
return proxyRequest(request, params);
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
return proxyRequest(request, params);
}

47
web/src/app/globals.css Normal file
View File

@@ -0,0 +1,47 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 210 25% 98%;
--foreground: 220 35% 12%;
--card: 0 0% 100%;
--card-foreground: 220 35% 12%;
--primary: 214 78% 34%;
--primary-foreground: 210 40% 98%;
--secondary: 210 24% 94%;
--secondary-foreground: 220 35% 18%;
--muted: 210 20% 96%;
--muted-foreground: 220 12% 42%;
--accent: 38 85% 92%;
--accent-foreground: 220 35% 18%;
--destructive: 0 76% 52%;
--destructive-foreground: 210 40% 98%;
--success: 154 63% 35%;
--success-foreground: 155 80% 96%;
--warning: 32 88% 45%;
--warning-foreground: 36 100% 96%;
--border: 214 24% 88%;
--input: 214 24% 88%;
--ring: 214 78% 34%;
--radius: 1.25rem;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
background-image:
radial-gradient(circle at top left, rgba(99, 142, 214, 0.1), transparent 22%),
linear-gradient(180deg, rgba(250, 252, 255, 0.98), rgba(245, 247, 250, 0.98));
font-family:
"Space Grotesk",
"Noto Sans SC",
"PingFang SC",
"Microsoft YaHei",
sans-serif;
}
}

27
web/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { Toaster } from "sonner";
import { QueryProvider } from "@/components/providers/query-provider";
import "@/app/globals.css";
export const metadata: Metadata = {
title: "QQuiz Web",
description: "QQuiz Next.js frontend migration scaffold"
};
export default function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body>
<QueryProvider>
{children}
<Toaster richColors position="top-right" />
</QueryProvider>
</body>
</html>
);
}

7
web/src/app/loading.tsx Normal file
View File

@@ -0,0 +1,7 @@
export default function Loading() {
return (
<main className="flex min-h-screen items-center justify-center">
<div className="rounded-full border-4 border-slate-200 border-t-slate-950 p-6 animate-spin" />
</main>
);
}

24
web/src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,24 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function NotFound() {
return (
<main className="flex min-h-screen items-center justify-center p-6">
<Card className="max-w-lg border-slate-200/70 bg-white/90">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm leading-6 text-slate-600">
Next.js 访
</p>
<Button asChild>
<Link href="/dashboard"></Link>
</Button>
</CardContent>
</Card>
</main>
);
}

7
web/src/app/page.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
import { readSessionToken } from "@/lib/auth/session";
export default function IndexPage() {
redirect(readSessionToken() ? "/dashboard" : "/login");
}

View File

@@ -0,0 +1,186 @@
"use client";
import { useState } from "react";
import { Loader2, Save } from "lucide-react";
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 { browserApi } from "@/lib/api/browser";
import { SystemConfigResponse } from "@/lib/types";
export function SettingsPanel({
initialConfig
}: {
initialConfig: SystemConfigResponse;
}) {
const [config, setConfig] = useState(initialConfig);
const [saving, setSaving] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
try {
const payload = await browserApi<SystemConfigResponse>("/admin/config", {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(config)
});
setConfig(payload);
toast.success("设置已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
}
return (
<form className="grid gap-6 xl:grid-cols-2" onSubmit={handleSubmit}>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<label className="flex items-center justify-between gap-4 text-sm text-slate-700">
<span></span>
<input
checked={config.allow_registration}
className="h-4 w-4"
onChange={(event) =>
setConfig((current) => ({
...current,
allow_registration: event.target.checked
}))
}
type="checkbox"
/>
</label>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">MB</label>
<Input
type="number"
value={config.max_upload_size_mb}
onChange={(event) =>
setConfig((current) => ({
...current,
max_upload_size_mb: Number(event.target.value || 0)
}))
}
min={1}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700"></label>
<Input
type="number"
value={config.max_daily_uploads}
onChange={(event) =>
setConfig((current) => ({
...current,
max_daily_uploads: Number(event.target.value || 0)
}))
}
min={1}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">AI </label>
<select
className="flex h-11 w-full rounded-2xl border border-input bg-background px-4 py-2 text-sm"
value={config.ai_provider}
onChange={(event) =>
setConfig((current) => ({
...current,
ai_provider: event.target.value
}))
}
>
<option value="gemini">Gemini</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="qwen">Qwen</option>
</select>
</div>
</CardContent>
</Card>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">OpenAI Base URL</label>
<Input
value={config.openai_base_url || ""}
onChange={(event) =>
setConfig((current) => ({ ...current, openai_base_url: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">OpenAI API Key</label>
<Input
type="password"
value={config.openai_api_key || ""}
onChange={(event) =>
setConfig((current) => ({ ...current, openai_api_key: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Gemini </label>
<Input
value={config.gemini_model || ""}
onChange={(event) =>
setConfig((current) => ({ ...current, gemini_model: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">OpenAI </label>
<Input
value={config.openai_model || ""}
onChange={(event) =>
setConfig((current) => ({ ...current, openai_model: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Anthropic </label>
<Input
value={config.anthropic_model || ""}
onChange={(event) =>
setConfig((current) => ({ ...current, anthropic_model: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Qwen </label>
<Input
value={config.qwen_model || ""}
onChange={(event) =>
setConfig((current) => ({ ...current, qwen_model: event.target.value }))
}
/>
</div>
</CardContent>
</Card>
<div className="xl:col-span-2">
<Button disabled={saving} type="submit">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,358 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Search, Shield, Trash2, UserPlus } from "lucide-react";
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 { PaginationControls } from "@/components/ui/pagination-controls";
import { browserApi } from "@/lib/api/browser";
import { formatDate } from "@/lib/formatters";
import { AdminUserSummary } from "@/lib/types";
type EditingState = {
id: number | null;
username: string;
password: string;
isAdmin: boolean;
};
export function UserManagementPanel({
initialPage,
initialSearch,
initialUsers,
initialTotal,
pageSize
}: {
initialPage: number;
initialSearch: string;
initialUsers: AdminUserSummary[];
initialTotal: number;
pageSize: number;
}) {
const router = useRouter();
const [search, setSearch] = useState(initialSearch);
const [users, setUsers] = useState(initialUsers);
const [total, setTotal] = useState(initialTotal);
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [editing, setEditing] = useState<EditingState>({
id: null,
username: "",
password: "",
isAdmin: false
});
const isCreateMode = editing.id === null;
const title = isCreateMode ? "创建用户" : "编辑用户";
const activeAdminCount = useMemo(
() => users.filter((user) => user.is_admin).length,
[users]
);
useEffect(() => {
setSearch(initialSearch);
setUsers(initialUsers);
setTotal(initialTotal);
}, [initialSearch, initialUsers, initialTotal]);
function buildAdminUrl(nextSearch: string, nextPage: number) {
const params = new URLSearchParams(window.location.search);
const normalizedSearch = nextSearch.trim();
if (nextPage <= 1) {
params.delete("page");
} else {
params.set("page", String(nextPage));
}
if (normalizedSearch) {
params.set("search", normalizedSearch);
} else {
params.delete("search");
}
const query = params.toString();
return query ? `/admin?${query}` : "/admin";
}
function startCreate() {
setEditing({
id: null,
username: "",
password: "",
isAdmin: false
});
}
function startEdit(user: AdminUserSummary) {
setEditing({
id: user.id,
username: user.username,
password: "",
isAdmin: user.is_admin
});
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
try {
if (isCreateMode) {
await browserApi("/admin/users", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
username: editing.username,
password: editing.password,
is_admin: editing.isAdmin
})
});
toast.success("用户已创建");
} else {
const updatePayload: Record<string, unknown> = {
username: editing.username,
is_admin: editing.isAdmin
};
if (editing.password) {
updatePayload.password = editing.password;
}
await browserApi(`/admin/users/${editing.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(updatePayload)
});
toast.success("用户已更新");
}
startCreate();
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setSubmitting(false);
}
}
async function handleDelete(user: AdminUserSummary) {
if (!window.confirm(`确认删除用户 ${user.username}`)) {
return;
}
setDeletingId(user.id);
try {
await browserApi(`/admin/users/${user.id}`, {
method: "DELETE"
});
toast.success("用户已删除");
if (editing.id === user.id) {
startCreate();
}
if (users.length === 1 && initialPage > 1) {
router.push(buildAdminUrl(search, initialPage - 1));
} else {
router.refresh();
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
} finally {
setDeletingId(null);
}
}
async function handleResetPassword(user: AdminUserSummary) {
const nextPassword = window.prompt(`${user.username} 设置新密码`, "");
if (!nextPassword) {
return;
}
try {
await browserApi(`/admin/users/${user.id}/reset-password`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
new_password: nextPassword
})
});
toast.success("密码已重置");
} catch (error) {
toast.error(error instanceof Error ? error.message : "重置失败");
}
}
function handleSearch(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
router.push(buildAdminUrl(search, 1));
}
return (
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<Card className="border-slate-200/70 bg-white/92">
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit}>
<Input
placeholder="用户名"
value={editing.username}
onChange={(event) =>
setEditing((current) => ({ ...current, username: event.target.value }))
}
required
/>
<Input
type="password"
placeholder={isCreateMode ? "密码" : "留空则不修改密码"}
value={editing.password}
onChange={(event) =>
setEditing((current) => ({ ...current, password: event.target.value }))
}
required={isCreateMode}
minLength={6}
/>
<label className="flex items-center gap-3 text-sm text-slate-700">
<input
checked={editing.isAdmin}
className="h-4 w-4 rounded border-slate-300"
onChange={(event) =>
setEditing((current) => ({ ...current, isAdmin: event.target.checked }))
}
type="checkbox"
/>
</label>
<div className="flex gap-2">
<Button className="flex-1" disabled={submitting} type="submit">
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isCreateMode ? (
<UserPlus className="h-4 w-4" />
) : (
<Shield className="h-4 w-4" />
)}
{isCreateMode ? "创建" : "保存"}
</Button>
{!isCreateMode ? (
<Button onClick={startCreate} type="button" variant="outline">
</Button>
) : null}
</div>
</form>
</CardContent>
</Card>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader className="space-y-4">
<div className="flex items-center justify-between gap-3">
<CardTitle></CardTitle>
<div className="text-sm text-slate-500">
{total} / {activeAdminCount}
</div>
</div>
<form className="flex flex-col gap-3 md:flex-row" onSubmit={handleSearch}>
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
className="pl-9"
placeholder="搜索用户名"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<Button type="submit" variant="outline">
<Search className="h-4 w-4" />
</Button>
</form>
</CardHeader>
<CardContent>
<div className="overflow-hidden rounded-2xl border border-slate-200">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium text-right"></th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-t border-slate-200">
<td className="px-4 py-3 font-medium text-slate-900">{user.username}</td>
<td className="px-4 py-3 text-slate-600">
{user.is_admin ? "管理员" : "普通用户"}
</td>
<td className="px-4 py-3 text-slate-600">{user.exam_count}</td>
<td className="px-4 py-3 text-slate-600">{user.mistake_count}</td>
<td className="px-4 py-3 text-slate-600">{formatDate(user.created_at)}</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button onClick={() => startEdit(user)} size="sm" type="button" variant="outline">
</Button>
<Button
onClick={() => handleResetPassword(user)}
size="sm"
type="button"
variant="outline"
>
</Button>
<Button
aria-label={`删除 ${user.username}`}
disabled={deletingId === user.id}
onClick={() => handleDelete(user)}
size="icon"
type="button"
variant="ghost"
>
{deletingId === user.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
</td>
</tr>
))}
{users.length === 0 ? (
<tr>
<td className="px-4 py-8 text-center text-slate-500" colSpan={6}>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<PaginationControls
className="mt-4 rounded-2xl border border-slate-200"
page={initialPage}
pageSize={pageSize}
total={total}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { BookMarked, LayoutDashboard, Settings, Shield, SquareStack, Target, XCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
const baseNavigation = [
{ href: "/dashboard", label: "总览", icon: LayoutDashboard },
{ href: "/exams", label: "题库", icon: SquareStack },
{ href: "/questions", label: "题目", icon: BookMarked },
{ href: "/mistakes", label: "错题", icon: XCircle },
{ href: "/mistake-quiz", label: "错题练习", icon: Target }
];
const adminNavigation = [
{ href: "/admin", label: "管理", icon: Shield },
{ href: "/admin/settings", label: "系统设置", icon: Settings }
];
export function AppSidebar({ isAdmin }: { isAdmin: boolean }) {
const pathname = usePathname();
const navigation = isAdmin
? [...baseNavigation, ...adminNavigation]
: baseNavigation;
const activeHref =
navigation
.slice()
.sort((a, b) => b.href.length - a.href.length)
.find(
(item) =>
pathname === item.href ||
(item.href !== "/dashboard" && pathname.startsWith(`${item.href}/`))
)?.href || "";
return (
<aside className="hidden h-screen w-[280px] shrink-0 flex-col border-r border-slate-200 bg-white/80 px-5 py-6 backdrop-blur xl:flex">
<div className="space-y-4">
<Badge variant="outline" className="w-fit border-slate-300 text-slate-600">
QQuiz Web
</Badge>
<div>
<h2 className="text-xl font-semibold text-slate-950">QQuiz</h2>
<p className="mt-2 text-sm leading-6 text-slate-600"></p>
</div>
</div>
<Separator className="my-6" />
<nav className="space-y-2">
{navigation.map((item) => {
const Icon = item.icon;
const active = item.href === activeHref;
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-2xl px-4 py-3 text-sm font-medium transition-colors",
active
? "bg-primary text-white shadow-sm"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950"
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</nav>
</aside>
);
}

View File

@@ -0,0 +1,65 @@
import { ArrowRight, CheckCircle2 } from "lucide-react";
import Link from "next/link";
import { PageHeader } from "@/components/app-shell/page-header";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
export function FeaturePlaceholder({
eyebrow,
title,
description,
bullets,
ctaHref = "/dashboard",
ctaLabel = "返回首页"
}: {
eyebrow: string;
title: string;
description?: string;
bullets: string[];
ctaHref?: string;
ctaLabel?: string;
}) {
return (
<div className="space-y-8">
<PageHeader eyebrow={eyebrow} title={title} description={description} />
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_280px]">
<Card className="border-slate-200/70 bg-white/90">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{bullets.map((bullet) => (
<div key={bullet} className="flex items-start gap-3 rounded-2xl bg-slate-50 p-4">
<CheckCircle2 className="mt-0.5 h-5 w-5 text-emerald-600" />
<p className="text-sm leading-6 text-slate-700">{bullet}</p>
</div>
))}
</CardContent>
</Card>
<Card className="border-slate-900/10 bg-slate-950 text-white">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<Button asChild variant="secondary" className="w-full bg-white text-slate-950">
<Link href={ctaHref}>
{ctaLabel}
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
import { useRouter } from "next/navigation";
import { LogOut } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
const response = await fetch("/frontend-api/auth/logout", {
method: "POST"
});
if (!response.ok) {
toast.error("退出失败");
return;
}
toast.success("已退出登录");
router.push("/login");
router.refresh();
}
return (
<Button onClick={handleLogout} variant="outline">
<LogOut className="h-4 w-4" />
退
</Button>
);
}

View File

@@ -0,0 +1,27 @@
import { Badge } from "@/components/ui/badge";
export function PageHeader({
eyebrow,
title,
description
}: {
eyebrow?: string;
title: string;
description?: string;
}) {
return (
<div className="space-y-4">
{eyebrow ? <Badge variant="outline">{eyebrow}</Badge> : null}
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight text-slate-950 md:text-4xl">
{title}
</h1>
{description ? (
<p className="max-w-3xl text-sm leading-7 text-slate-600 md:text-base">
{description}
</p>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { LucideIcon } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
export function StatCard({
icon: Icon,
label,
value,
detail
}: {
icon: LucideIcon;
label: string;
value: string;
detail?: string;
}) {
return (
<Card className="border-white/70 bg-white/90">
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="rounded-2xl bg-slate-950 p-3 text-white">
<Icon className="h-5 w-5" />
</div>
<div>
<CardDescription>{label}</CardDescription>
<CardTitle className="text-2xl">{value}</CardTitle>
</div>
</div>
</CardHeader>
{detail ? <CardContent className="text-sm text-slate-600">{detail}</CardContent> : null}
</Card>
);
}

View File

@@ -0,0 +1,15 @@
import { Badge } from "@/components/ui/badge";
import { getExamStatusLabel } from "@/lib/formatters";
export function StatusBadge({ status }: { status: string }) {
const variant =
status === "ready"
? "success"
: status === "failed"
? "destructive"
: status === "processing"
? "warning"
: "outline";
return <Badge variant={variant}>{getExamStatusLabel(status)}</Badge>;
}

View File

@@ -0,0 +1,232 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { AlertCircle, FileText, Loader2, Play, RefreshCw, Upload } from "lucide-react";
import { toast } from "sonner";
import { StatusBadge } from "@/components/app-shell/status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { browserApi } from "@/lib/api/browser";
import { formatDate } from "@/lib/formatters";
import { ExamSummary, ExamUploadResponse, ProgressEvent } from "@/lib/types";
export function ExamDetailClient({
initialExam
}: {
initialExam: ExamSummary;
}) {
const router = useRouter();
const eventSourceRef = useRef<EventSource | null>(null);
const [exam, setExam] = useState(initialExam);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState<ProgressEvent | null>(null);
const isProcessing = exam.status === "processing";
useEffect(() => {
if (!isProcessing) {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
return;
}
const source = new EventSource(`/frontend-api/exams/${exam.id}/progress`);
eventSourceRef.current = source;
source.onmessage = (event) => {
const payload = JSON.parse(event.data) as ProgressEvent;
setProgress(payload);
if (payload.status === "completed") {
toast.success(payload.message);
source.close();
eventSourceRef.current = null;
reloadExam();
}
if (payload.status === "failed") {
toast.error(payload.message);
source.close();
eventSourceRef.current = null;
reloadExam();
}
};
source.onerror = () => {
source.close();
eventSourceRef.current = null;
};
return () => {
source.close();
eventSourceRef.current = null;
};
}, [isProcessing, exam.id]);
async function reloadExam() {
try {
const payload = await browserApi<ExamSummary>(`/exams/${exam.id}`, {
method: "GET"
});
setExam(payload);
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "刷新失败");
}
}
async function handleUpload(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedFile) {
toast.error("请选择文件");
return;
}
const formData = new FormData();
formData.append("file", selectedFile);
setUploading(true);
try {
const payload = await browserApi<ExamUploadResponse>(`/exams/${exam.id}/append`, {
method: "POST",
body: formData
});
setExam((current) => ({ ...current, status: payload.status as ExamSummary["status"] }));
setProgress(null);
setSelectedFile(null);
toast.success("文档已提交");
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传失败");
} finally {
setUploading(false);
}
}
const progressValue = useMemo(() => {
if (isProcessing) {
return Math.round(Number(progress?.progress || 0));
}
if (exam.total_questions <= 0) {
return 0;
}
return Math.round((exam.current_index / exam.total_questions) * 100);
}, [exam.current_index, exam.total_questions, isProcessing, progress]);
return (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card className="border-slate-200/70 bg-white/92">
<CardHeader className="flex flex-row items-start justify-between">
<div className="space-y-2">
<CardTitle className="text-2xl">{exam.title}</CardTitle>
<StatusBadge status={exam.status} />
</div>
<div className="flex gap-2">
<Button asChild variant="outline">
<Link href={`/questions?examId=${exam.id}`}></Link>
</Button>
{exam.total_questions > 0 ? (
<Button asChild>
<Link href={`/quiz/${exam.id}`}></Link>
</Button>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<div className="rounded-2xl bg-slate-50 p-4">
<div className="text-sm text-slate-500"></div>
<div className="mt-2 text-2xl font-semibold text-slate-900">
{exam.total_questions}
</div>
</div>
<div className="rounded-2xl bg-slate-50 p-4">
<div className="text-sm text-slate-500"></div>
<div className="mt-2 text-2xl font-semibold text-slate-900">
{exam.current_index}
</div>
</div>
<div className="rounded-2xl bg-slate-50 p-4">
<div className="text-sm text-slate-500"></div>
<div className="mt-2 text-2xl font-semibold text-slate-900">
{Math.max(0, exam.total_questions - exam.current_index)}
</div>
</div>
<div className="rounded-2xl bg-slate-50 p-4">
<div className="text-sm text-slate-500"></div>
<div className="mt-2 text-2xl font-semibold text-slate-900">
{progressValue}%
</div>
</div>
</div>
<div className="space-y-2">
<div className="h-3 w-full overflow-hidden rounded-full bg-slate-200">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${progressValue}%` }}
/>
</div>
{progress ? (
<div className="text-sm text-slate-600">{progress.message}</div>
) : null}
</div>
<div className="grid gap-4 md:grid-cols-2 text-sm text-slate-600">
<div>{formatDate(exam.created_at)}</div>
<div>{formatDate(exam.updated_at)}</div>
</div>
{exam.status === "failed" ? (
<div className="flex items-start gap-3 rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
<AlertCircle className="mt-0.5 h-5 w-5" />
</div>
) : null}
</CardContent>
</Card>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleUpload}>
<Input
type="file"
accept=".txt,.pdf,.doc,.docx,.xlsx,.xls"
onChange={(event) => setSelectedFile(event.target.files?.[0] || null)}
required
/>
<Button className="w-full" disabled={uploading || isProcessing} type="submit">
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isProcessing ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{isProcessing ? "处理中" : "上传"}
</Button>
</form>
<div className="mt-6 space-y-3 text-sm text-slate-600">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4" />
TXT / PDF / DOC / DOCX / XLSX / XLS
</div>
<div></div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,219 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Loader2, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { StatusBadge } from "@/components/app-shell/status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { PaginationControls } from "@/components/ui/pagination-controls";
import { browserApi } from "@/lib/api/browser";
import { formatDate, formatRelativeTime } from "@/lib/formatters";
import { ExamSummary } from "@/lib/types";
export function ExamsPageClient({
initialExams,
initialTotal,
page,
pageSize
}: {
initialExams: ExamSummary[];
initialTotal: number;
page: number;
pageSize: number;
}) {
const router = useRouter();
const [title, setTitle] = useState("");
const [file, setFile] = useState<File | null>(null);
const [creating, setCreating] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [exams, setExams] = useState(initialExams);
const [total, setTotal] = useState(initialTotal);
useEffect(() => {
setExams(initialExams);
setTotal(initialTotal);
}, [initialExams, initialTotal]);
function goToPage(targetPage: number) {
const params = new URLSearchParams(window.location.search);
if (targetPage <= 1) {
params.delete("page");
} else {
params.set("page", String(targetPage));
}
const query = params.toString();
router.push(query ? `/exams?${query}` : "/exams");
}
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!file) {
toast.error("请选择文件");
return;
}
const formData = new FormData();
formData.append("title", title);
formData.append("file", file);
formData.append("is_random", "false");
setCreating(true);
try {
const response = await browserApi<{ exam_id: number }>("/exams/create", {
method: "POST",
body: formData
});
toast.success("题库已创建");
setTitle("");
setFile(null);
router.push(`/exams/${response.exam_id}`);
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建失败");
} finally {
setCreating(false);
}
}
async function handleDelete(examId: number) {
if (!window.confirm("确认删除这个题库?")) {
return;
}
setDeletingId(examId);
try {
await browserApi<void>(`/exams/${examId}`, {
method: "DELETE"
});
setExams((current) => current.filter((exam) => exam.id !== examId));
setTotal((current) => Math.max(0, current - 1));
toast.success("题库已删除");
if (exams.length === 1 && page > 1) {
goToPage(page - 1);
} else {
router.refresh();
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
} finally {
setDeletingId(null);
}
}
return (
<div className="grid gap-6 xl:grid-cols-[340px_minmax(0,1fr)]">
<Card className="border-slate-200/70 bg-white/92">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleCreate}>
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="题库名称"
required
/>
<Input
type="file"
accept=".txt,.pdf,.doc,.docx,.xlsx,.xls"
onChange={(event) => setFile(event.target.files?.[0] || null)}
required
/>
<Button className="w-full" disabled={creating} type="submit">
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{creating ? "创建中" : "创建"}
</Button>
</form>
</CardContent>
</Card>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader>
<div className="flex items-center justify-between gap-3">
<CardTitle></CardTitle>
<div className="text-sm text-slate-500">{total} </div>
</div>
</CardHeader>
<CardContent>
{exams.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-slate-200">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium text-right"></th>
</tr>
</thead>
<tbody>
{exams.map((exam) => (
<tr key={exam.id} className="border-t border-slate-200">
<td className="px-4 py-3">
<Link className="font-medium text-slate-900 hover:underline" href={`/exams/${exam.id}`}>
{exam.title}
</Link>
<div className="mt-1 text-xs text-slate-500">{formatDate(exam.created_at)}</div>
</td>
<td className="px-4 py-3">
<StatusBadge status={exam.status} />
</td>
<td className="px-4 py-3 text-slate-600">
{exam.current_index}/{exam.total_questions}
</td>
<td className="px-4 py-3 text-slate-600">
{formatRelativeTime(exam.updated_at)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button asChild size="sm" variant="outline">
<Link href={`/exams/${exam.id}`}></Link>
</Button>
<Button
aria-label={`删除 ${exam.title}`}
disabled={deletingId === exam.id}
onClick={() => handleDelete(exam.id)}
size="icon"
type="button"
variant="ghost"
>
{deletingId === exam.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<PaginationControls
className="mt-4 rounded-2xl border border-slate-200"
page={page}
pageSize={pageSize}
total={total}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,143 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PaginationControls } from "@/components/ui/pagination-controls";
import { browserApi } from "@/lib/api/browser";
import { formatDate, getQuestionTypeLabel } from "@/lib/formatters";
import { MistakeListResponse } from "@/lib/types";
type MistakeItem = MistakeListResponse["mistakes"][number];
export function MistakeListClient({
initialMistakes,
initialTotal,
page,
pageSize
}: {
initialMistakes: MistakeItem[];
initialTotal: number;
page: number;
pageSize: number;
}) {
const router = useRouter();
const [mistakes, setMistakes] = useState(initialMistakes);
const [total, setTotal] = useState(initialTotal);
const [deletingId, setDeletingId] = useState<number | null>(null);
useEffect(() => {
setMistakes(initialMistakes);
setTotal(initialTotal);
}, [initialMistakes, initialTotal]);
function goToPage(targetPage: number) {
const params = new URLSearchParams(window.location.search);
if (targetPage <= 1) {
params.delete("page");
} else {
params.set("page", String(targetPage));
}
const query = params.toString();
router.push(query ? `/mistakes?${query}` : "/mistakes");
}
async function handleDelete(mistake: MistakeItem) {
setDeletingId(mistake.id);
try {
await browserApi<void>(`/mistakes/${mistake.id}`, {
method: "DELETE"
});
setMistakes((current) => current.filter((item) => item.id !== mistake.id));
setTotal((current) => Math.max(0, current - 1));
toast.success("已移除");
if (mistakes.length === 1 && page > 1) {
goToPage(page - 1);
} else {
router.refresh();
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
} finally {
setDeletingId(null);
}
}
return (
<Card className="border-slate-200/70 bg-white/92">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle></CardTitle>
<div className="text-sm text-slate-500">{total} </div>
</CardHeader>
<CardContent>
{mistakes.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-slate-200">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium text-right"></th>
</tr>
</thead>
<tbody>
{mistakes.map((mistake) => (
<tr key={mistake.id} className="border-t border-slate-200">
<td className="px-4 py-3 text-slate-700">
<div className="line-clamp-2 max-w-3xl">{mistake.question.content}</div>
</td>
<td className="px-4 py-3 text-slate-600">
{getQuestionTypeLabel(mistake.question.type)}
</td>
<td className="px-4 py-3 text-slate-600">
<div className="line-clamp-1 max-w-xs">{mistake.question.answer}</div>
</td>
<td className="px-4 py-3 text-slate-600">
{formatDate(mistake.created_at)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end">
<Button
aria-label={`删除错题 ${mistake.id}`}
disabled={deletingId === mistake.id}
onClick={() => handleDelete(mistake)}
size="icon"
type="button"
variant="ghost"
>
{deletingId === mistake.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<PaginationControls
className="mt-4 rounded-2xl border border-slate-200"
page={page}
pageSize={pageSize}
total={total}
/>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,267 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowLeft, ArrowRight, Check, Loader2, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { browserApi } from "@/lib/api/browser";
import { AnswerCheckResponse, MistakeListResponse } from "@/lib/types";
import { getQuestionTypeLabel } from "@/lib/formatters";
type MistakeItem = MistakeListResponse["mistakes"][number];
export function MistakePracticeClient() {
const router = useRouter();
const searchParams = useSearchParams();
const [mistakes, setMistakes] = useState<MistakeItem[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<AnswerCheckResponse | null>(null);
const [userAnswer, setUserAnswer] = useState("");
const [multipleAnswers, setMultipleAnswers] = useState<string[]>([]);
useEffect(() => {
void loadMistakes();
}, []);
async function loadMistakes() {
setLoading(true);
try {
const payload = await browserApi<MistakeListResponse>("/mistakes", {
method: "GET",
query: {
skip: 0,
limit: 1000
}
});
let nextMistakes = payload.mistakes;
if (searchParams.get("mode") === "random") {
nextMistakes = [...payload.mistakes].sort(() => Math.random() - 0.5);
}
nextMistakes = nextMistakes.map((item) => {
if (item.question.type === "judge" && (!item.question.options || item.question.options.length === 0)) {
item.question.options = ["A. 正确", "B. 错误"];
}
return item;
});
setMistakes(nextMistakes);
setCurrentIndex(0);
setResult(null);
setUserAnswer("");
setMultipleAnswers([]);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载失败");
} finally {
setLoading(false);
}
}
const currentMistake = mistakes[currentIndex] || null;
const question = currentMistake?.question || null;
const progressText = useMemo(
() => (mistakes.length ? `${currentIndex + 1} / ${mistakes.length}` : "0 / 0"),
[currentIndex, mistakes.length]
);
async function handleSubmit() {
if (!question) {
return;
}
let answer = userAnswer;
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 payload = await browserApi<AnswerCheckResponse>("/questions/check", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
question_id: question.id,
user_answer: answer
})
});
setResult(payload);
} catch (error) {
toast.error(error instanceof Error ? error.message : "提交失败");
} finally {
setSubmitting(false);
}
}
async function handleRemove() {
if (!currentMistake) {
return;
}
try {
await browserApi<void>(`/mistakes/${currentMistake.id}`, {
method: "DELETE"
});
const nextList = mistakes.filter((item) => item.id !== currentMistake.id);
setMistakes(nextList);
setCurrentIndex((current) => Math.max(0, Math.min(current, nextList.length - 1)));
setResult(null);
setUserAnswer("");
setMultipleAnswers([]);
toast.success("已移除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "移除失败");
}
}
function handleNext() {
if (currentIndex < mistakes.length - 1) {
setCurrentIndex((current) => current + 1);
setResult(null);
setUserAnswer("");
setMultipleAnswers([]);
return;
}
toast.success("已完成");
router.push("/mistakes");
}
if (loading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!question) {
return (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-10 text-center text-sm text-slate-500">
</div>
);
}
return (
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center justify-between">
<Button onClick={() => router.push("/mistakes")} type="button" variant="outline">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="text-sm text-slate-600">{progressText}</div>
</div>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader className="flex flex-row items-start justify-between">
<div className="space-y-2">
<CardTitle>{question.content}</CardTitle>
<div className="text-sm text-slate-500">{getQuestionTypeLabel(question.type)}</div>
</div>
<Button onClick={handleRemove} size="sm" type="button" variant="outline">
<Trash2 className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-4">
{question.options?.length ? (
<div className="space-y-3">
{question.options.map((option) => {
const letter = option.charAt(0);
const selected =
question.type === "multiple"
? multipleAnswers.includes(letter)
: userAnswer === letter;
return (
<button
key={option}
className={`w-full rounded-2xl border px-4 py-3 text-left text-sm transition ${
selected
? "border-primary bg-blue-50 text-slate-950"
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300"
}`}
disabled={Boolean(result)}
onClick={() => {
if (result) {
return;
}
if (question.type === "multiple") {
setMultipleAnswers((current) =>
current.includes(letter)
? current.filter((item) => item !== letter)
: [...current, letter]
);
} else {
setUserAnswer(letter);
}
}}
type="button"
>
{option}
</button>
);
})}
</div>
) : null}
{question.type === "short" ? (
<textarea
className="min-h-36 w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm outline-none ring-0 focus:border-primary"
onChange={(event) => setUserAnswer(event.target.value)}
placeholder="输入答案"
value={userAnswer}
/>
) : null}
{!result ? (
<Button className="w-full" disabled={submitting} onClick={handleSubmit} type="button">
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
</Button>
) : (
<div className={`rounded-2xl border p-4 ${result.correct ? "border-emerald-200 bg-emerald-50" : "border-red-200 bg-red-50"}`}>
<div className="flex items-center gap-2 font-medium">
{result.correct ? <Check className="h-4 w-4 text-emerald-600" /> : <X className="h-4 w-4 text-red-600" />}
{result.correct ? "回答正确" : "回答错误"}
</div>
{!result.correct ? (
<div className="mt-3 text-sm text-slate-700">
{result.correct_answer}
</div>
) : null}
{result.analysis ? (
<div className="mt-3 text-sm text-slate-600">{result.analysis}</div>
) : null}
{result.ai_feedback ? (
<div className="mt-3 text-sm text-slate-600">{result.ai_feedback}</div>
) : null}
<Button className="mt-4 w-full" onClick={handleNext} type="button">
{currentIndex < mistakes.length - 1 ? "下一题" : "完成"}
<ArrowRight className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,291 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowLeft, ArrowRight, BookmarkPlus, BookmarkX, Check, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { browserApi } from "@/lib/api/browser";
import { AnswerCheckResponse, ExamSummary, QuestionDetail } from "@/lib/types";
import { getQuestionTypeLabel } from "@/lib/formatters";
export function QuizPlayerClient({
examId
}: {
examId: string;
}) {
const router = useRouter();
const searchParams = useSearchParams();
const [exam, setExam] = useState<ExamSummary | null>(null);
const [question, setQuestion] = useState<QuestionDetail | null>(null);
const [result, setResult] = useState<AnswerCheckResponse | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [inMistakeBook, setInMistakeBook] = useState(false);
const [userAnswer, setUserAnswer] = useState("");
const [multipleAnswers, setMultipleAnswers] = useState<string[]>([]);
useEffect(() => {
void loadQuiz();
}, [examId]);
async function loadQuiz() {
setLoading(true);
try {
if (searchParams.get("reset") === "true") {
await browserApi(`/exams/${examId}/progress`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ current_index: 0 })
});
}
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", {
method: "GET",
query: {
skip: 0,
limit: 1000
}
})
]);
if (questionPayload.type === "judge" && (!questionPayload.options || questionPayload.options.length === 0)) {
questionPayload.options = ["A. 正确", "B. 错误"];
}
setExam(examPayload);
setQuestion(questionPayload);
setResult(null);
setUserAnswer("");
setMultipleAnswers([]);
setInMistakeBook(mistakesPayload.mistakes.some((item) => item.question_id === questionPayload.id));
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载失败");
} finally {
setLoading(false);
}
}
const progressText = useMemo(() => {
if (!exam) {
return "";
}
return `${exam.current_index + 1} / ${exam.total_questions}`;
}, [exam]);
async function handleSubmit() {
if (!question) {
return;
}
let answer = userAnswer;
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 payload = await browserApi<AnswerCheckResponse>("/questions/check", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
question_id: question.id,
user_answer: answer
})
});
setResult(payload);
setInMistakeBook(!payload.correct || inMistakeBook);
} catch (error) {
toast.error(error instanceof Error ? error.message : "提交失败");
} finally {
setSubmitting(false);
}
}
async function handleNext() {
if (!exam) {
return;
}
try {
await browserApi(`/exams/${examId}/progress`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ current_index: exam.current_index + 1 })
});
await loadQuiz();
} catch (error) {
toast.error(error instanceof Error ? error.message : "跳转失败");
}
}
async function handleToggleMistake() {
if (!question) {
return;
}
try {
if (inMistakeBook) {
await browserApi(`/mistakes/question/${question.id}`, {
method: "DELETE"
});
setInMistakeBook(false);
} else {
await browserApi("/mistakes/add", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ question_id: question.id })
});
setInMistakeBook(true);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "操作失败");
}
}
if (loading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!exam || !question) {
return (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-10 text-center text-sm text-slate-500">
</div>
);
}
return (
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center justify-between">
<Button onClick={() => router.push(`/exams/${examId}`)} type="button" variant="outline">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="text-sm text-slate-600">{progressText}</div>
</div>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader className="flex flex-row items-start justify-between">
<div className="space-y-2">
<CardTitle>{question.content}</CardTitle>
<div className="text-sm text-slate-500">{getQuestionTypeLabel(question.type)}</div>
</div>
<Button onClick={handleToggleMistake} size="sm" type="button" variant="outline">
{inMistakeBook ? <BookmarkX className="h-4 w-4" /> : <BookmarkPlus className="h-4 w-4" />}
{inMistakeBook ? "移除错题" : "加入错题"}
</Button>
</CardHeader>
<CardContent className="space-y-4">
{question.options?.length ? (
<div className="space-y-3">
{question.options.map((option) => {
const letter = option.charAt(0);
const selected =
question.type === "multiple"
? multipleAnswers.includes(letter)
: userAnswer === letter;
return (
<button
key={option}
className={`w-full rounded-2xl border px-4 py-3 text-left text-sm transition ${
selected
? "border-primary bg-blue-50 text-slate-950"
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300"
}`}
disabled={Boolean(result)}
onClick={() => {
if (result) {
return;
}
if (question.type === "multiple") {
setMultipleAnswers((current) =>
current.includes(letter)
? current.filter((item) => item !== letter)
: [...current, letter]
);
} else {
setUserAnswer(letter);
}
}}
type="button"
>
{option}
</button>
);
})}
</div>
) : null}
{question.type === "short" ? (
<textarea
className="min-h-36 w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm outline-none ring-0 focus:border-primary"
onChange={(event) => setUserAnswer(event.target.value)}
placeholder="输入答案"
value={userAnswer}
/>
) : null}
{!result ? (
<Button className="w-full" disabled={submitting} onClick={handleSubmit} type="button">
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
</Button>
) : (
<div className={`rounded-2xl border p-4 ${result.correct ? "border-emerald-200 bg-emerald-50" : "border-red-200 bg-red-50"}`}>
<div className="flex items-center gap-2 font-medium">
{result.correct ? <Check className="h-4 w-4 text-emerald-600" /> : <X className="h-4 w-4 text-red-600" />}
{result.correct ? "回答正确" : "回答错误"}
</div>
{!result.correct ? (
<div className="mt-3 text-sm text-slate-700">
{result.correct_answer}
</div>
) : null}
{result.analysis ? (
<div className="mt-3 text-sm text-slate-600">{result.analysis}</div>
) : null}
{result.ai_feedback ? (
<div className="mt-3 text-sm text-slate-600">{result.ai_feedback}</div>
) : null}
<Button className="mt-4 w-full" onClick={handleNext} type="button">
<ArrowRight className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 10_000
}
}
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@@ -0,0 +1,73 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PaginationControls } from "@/components/ui/pagination-controls";
import { getQuestionTypeLabel, formatDate } from "@/lib/formatters";
import { QuestionListItem } from "@/lib/types";
export function QuestionList({
examId,
page,
pageSize,
questions,
total
}: {
examId?: number;
page: number;
pageSize: number;
questions: QuestionListItem[];
total: number;
}) {
return (
<Card className="border-slate-200/70 bg-white/92">
<CardHeader className="flex flex-row items-center justify-between">
<div className="space-y-1">
<CardTitle></CardTitle>
{examId ? <div className="text-sm text-slate-500"> #{examId}</div> : null}
</div>
<div className="text-sm text-slate-500">{total} </div>
</CardHeader>
<CardContent>
{questions.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-slate-200">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{questions.map((question) => (
<tr key={question.id} className="border-t border-slate-200">
<td className="px-4 py-3 text-slate-700">
<div className="line-clamp-2 max-w-3xl">{question.content}</div>
</td>
<td className="px-4 py-3 text-slate-600">
{getQuestionTypeLabel(question.type)}
</td>
<td className="px-4 py-3 text-slate-600">#{question.exam_id}</td>
<td className="px-4 py-3 text-slate-600">
{formatDate(question.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<PaginationControls
className="mt-4 rounded-2xl border border-slate-200"
page={page}
pageSize={pageSize}
total={total}
/>
</CardContent>
</Card>
);
}

Some files were not shown because too many files have changed in this diff Show More