🎉 Initial commit: QQuiz - 智能刷题与题库管理平台

## 功能特性

 **核心功能**
- 多文件上传与智能去重(基于 content_hash)
- 异步文档解析(支持 TXT/PDF/DOCX/XLSX)
- AI 智能题目提取与评分(OpenAI/Anthropic/Qwen)
- 断点续做与进度管理
- 自动错题本收集

 **技术栈**
- Backend: FastAPI + SQLAlchemy 2.0 + PostgreSQL
- Frontend: React 18 + Vite + Tailwind CSS
- Deployment: Docker Compose

 **项目结构**
- 53 个文件
- 完整的前后端分离架构
- Docker/源码双模部署支持

🚀 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-01 12:39:46 +08:00
commit c5ecbeaec2
53 changed files with 6211 additions and 0 deletions

36
.env.example Normal file
View File

@@ -0,0 +1,36 @@
# Database Configuration
# For Docker: postgresql+asyncpg://qquiz:qquiz_password@postgres:5432/qquiz_db
# For Local: postgresql+asyncpg://localhost:5432/qquiz_db
DATABASE_URL=postgresql+asyncpg://localhost:5432/qquiz_db
# JWT Secret (Please change this in production!)
SECRET_KEY=your-super-secret-key-change-in-production-minimum-32-characters
# AI Provider Configuration
AI_PROVIDER=openai
# Options: openai, anthropic, qwen
# OpenAI Configuration
OPENAI_API_KEY=sk-your-openai-api-key
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=gpt-4o-mini
# Anthropic Configuration
ANTHROPIC_API_KEY=sk-ant-your-anthropic-api-key
ANTHROPIC_MODEL=claude-3-haiku-20240307
# Qwen Configuration
QWEN_API_KEY=sk-your-qwen-api-key
QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
QWEN_MODEL=qwen-plus
# System Configuration
ALLOW_REGISTRATION=true
MAX_UPLOAD_SIZE_MB=10
MAX_DAILY_UPLOADS=20
# CORS Origins (comma-separated)
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
# Upload Directory
UPLOAD_DIR=./uploads

56
.gitignore vendored Normal file
View File

@@ -0,0 +1,56 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
.venv/
pip-log.txt
*.egg-info/
dist/
build/
# Environment
.env
.env.local
# Database
*.db
*.sqlite3
# Uploads
backend/uploads/
uploads/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Node
node_modules/
npm-debug.log
yarn-error.log
.pnp/
.pnp.js
# Build
frontend/build/
frontend/dist/
# Testing
.coverage
htmlcov/
.pytest_cache/
# Docker
*.log

376
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,376 @@
# QQuiz 部署指南
## 🚀 快速开始
### 方式一Docker Compose推荐
这是最简单的部署方式,适合快速体验和生产环境。
```bash
# 1. 配置环境变量
cp .env.example .env
# 2. 编辑 .env 文件,填入必要配置
# 必填项:
# - SECRET_KEY: JWT 密钥(至少 32 字符)
# - OPENAI_API_KEY: OpenAI API 密钥(或其他 AI 提供商)
# 3. 启动所有服务
docker-compose up -d
# 4. 查看日志
docker-compose logs -f
# 5. 访问应用
# 前端http://localhost:3000
# 后端http://localhost:8000
# API 文档http://localhost:8000/docs
```
### 方式二:本地源码运行
适合开发环境和自定义部署。
#### 前置要求
- Python 3.11+
- Node.js 18+
- PostgreSQL 15+
#### 步骤
**1. 安装并启动 PostgreSQL**
```bash
# macOS
brew install postgresql@15
brew services start postgresql@15
# Ubuntu/Debian
sudo apt install postgresql-15
sudo systemctl start postgresql
# Windows
# 下载并安装 PostgreSQL 官方安装包
```
**2. 创建数据库**
```bash
psql -U postgres
CREATE DATABASE qquiz_db;
CREATE USER qquiz WITH PASSWORD 'qquiz_password';
GRANT ALL PRIVILEGES ON DATABASE qquiz_db TO qquiz;
\q
```
**3. 配置环境变量**
```bash
cp .env.example .env
```
编辑 `.env` 文件:
```env
# 数据库(本地部署)
DATABASE_URL=postgresql+asyncpg://qquiz:qquiz_password@localhost:5432/qquiz_db
# JWT 密钥(必须修改!)
SECRET_KEY=your-very-long-secret-key-at-least-32-characters-long
# AI 提供商(选择一个)
AI_PROVIDER=openai
OPENAI_API_KEY=sk-your-openai-api-key
OPENAI_MODEL=gpt-4o-mini
# 或者使用其他提供商
# AI_PROVIDER=anthropic
# ANTHROPIC_API_KEY=sk-ant-your-key
# ANTHROPIC_MODEL=claude-3-haiku-20240307
# AI_PROVIDER=qwen
# QWEN_API_KEY=sk-your-key
# QWEN_MODEL=qwen-plus
```
**4. 启动后端**
```bash
cd backend
# 创建虚拟环境
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 安装依赖
pip install -r requirements.txt
# 运行数据库迁移
alembic upgrade head
# 启动服务
uvicorn main:app --reload
```
**5. 启动前端**
打开新终端:
```bash
cd frontend
# 安装依赖
npm install
# 启动开发服务器
npm start
```
**6. 访问应用**
- 前端http://localhost:3000
- 后端http://localhost:8000
- API 文档http://localhost:8000/docs
---
## 🔐 默认账户
首次启动后,系统会自动创建管理员账户:
- **用户名:** `admin`
- **密码:** `admin123`
⚠️ **重要:** 首次登录后请立即修改密码!
---
## ⚙️ 配置说明
### 必填配置
| 配置项 | 说明 | 示例 |
|--------|------|------|
| `DATABASE_URL` | 数据库连接字符串 | `postgresql+asyncpg://user:pass@host:5432/db` |
| `SECRET_KEY` | JWT 密钥(至少 32 字符) | `your-super-secret-key-change-in-production` |
| `OPENAI_API_KEY` | OpenAI API 密钥 | `sk-...` |
### 可选配置
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| `ALLOW_REGISTRATION` | 是否允许用户注册 | `true` |
| `MAX_UPLOAD_SIZE_MB` | 最大上传文件大小MB | `10` |
| `MAX_DAILY_UPLOADS` | 每日上传次数限制 | `20` |
| `AI_PROVIDER` | AI 提供商 | `openai` |
### AI 提供商配置
#### OpenAI
```env
AI_PROVIDER=openai
OPENAI_API_KEY=sk-your-key
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=gpt-4o-mini
```
#### Anthropic Claude
```env
AI_PROVIDER=anthropic
ANTHROPIC_API_KEY=sk-ant-your-key
ANTHROPIC_MODEL=claude-3-haiku-20240307
```
#### 通义千问 (Qwen)
```env
AI_PROVIDER=qwen
QWEN_API_KEY=sk-your-key
QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
QWEN_MODEL=qwen-plus
```
---
## 📋 使用流程
### 1. 创建题库
1. 登录后进入「题库管理」
2. 点击「创建题库」
3. 输入题库名称
4. 上传题目文档(支持 TXT/PDF/DOCX/XLSX
5. 等待 AI 解析完成(状态会自动刷新)
### 2. 追加题目
1. 进入题库详情页
2. 点击「添加题目文档」
3. 上传新文档
4. 系统会自动解析并去重
### 3. 开始刷题
1. 题库状态为「就绪」后,点击「开始刷题」或「继续刷题」
2. 选择答案并提交
3. 查看解析和正确答案
4. 点击「下一题」继续
### 4. 错题本
- 答错的题目会自动加入错题本
- 可以手动添加或移除题目
- 在错题本中复习和巩固
---
## 🛠️ 常见问题
### Q: 文档解析失败?
**A:** 检查以下几点:
1. AI API Key 是否正确配置
2. 文档格式是否支持
3. 文档内容是否包含题目
4. 查看后端日志获取详细错误信息
### Q: 如何修改上传限制?
**A:** 在「系统设置」中修改:
- 最大文件大小
- 每日上传次数
或直接修改 `.env` 文件:
```env
MAX_UPLOAD_SIZE_MB=20
MAX_DAILY_UPLOADS=50
```
### Q: 如何更换 AI 提供商?
**A:** 修改 `.env` 文件中的 `AI_PROVIDER` 和对应的 API Key
```env
AI_PROVIDER=anthropic
ANTHROPIC_API_KEY=sk-ant-your-key
```
### Q: 如何备份数据?
**A:** 备份 PostgreSQL 数据库:
```bash
# Docker 环境
docker exec qquiz_postgres pg_dump -U qquiz qquiz_db > backup.sql
# 本地环境
pg_dump -U qquiz qquiz_db > backup.sql
```
### Q: 如何关闭用户注册?
**A:** 在「系统设置」中关闭「允许用户注册」,或修改 `.env`
```env
ALLOW_REGISTRATION=false
```
---
## 📊 生产环境建议
### 安全配置
1. **更改默认密码**
- 首次登录后立即修改 `admin` 账户密码
2. **生成强密钥**
```bash
# 生成随机密钥
openssl rand -hex 32
```
3. **配置 HTTPS**
- 使用 Nginx 或 Caddy 作为反向代理
- 配置 SSL 证书
4. **限制 CORS**
```env
CORS_ORIGINS=https://yourdomain.com
```
### 性能优化
1. **数据库连接池**
- 根据负载调整连接池大小
2. **文件存储**
- 考虑使用对象存储(如 S3替代本地存储
3. **缓存**
- 使用 Redis 缓存频繁查询的数据
### 监控和日志
1. **日志收集**
```bash
# 查看 Docker 日志
docker-compose logs -f backend
# 保存日志到文件
docker-compose logs backend > backend.log
```
2. **健康检查**
- 访问 `http://localhost:8000/health` 检查服务状态
---
## 🐛 故障排查
### 后端无法启动
```bash
# 检查数据库连接
psql -U qquiz -d qquiz_db
# 检查端口占用
lsof -i :8000
# 查看详细日志
uvicorn main:app --reload --log-level debug
```
### 前端无法访问
```bash
# 检查端口占用
lsof -i :3000
# 清除缓存重新安装
rm -rf node_modules package-lock.json
npm install
```
### Docker 容器无法启动
```bash
# 查看容器状态
docker-compose ps
# 查看特定容器日志
docker-compose logs backend
# 重新构建
docker-compose build --no-cache
docker-compose up -d
```
---
## 📞 获取帮助
- GitHub Issues: [报告问题](https://github.com/your-repo/qquiz/issues)
- 文档: [README.md](./README.md)
- API 文档: http://localhost:8000/docs
---
## 📄 许可证
MIT License

399
PROJECT_STRUCTURE.md Normal file
View File

@@ -0,0 +1,399 @@
# QQuiz 项目结构
## 📁 完整目录结构
```
QQuiz/
├── backend/ # FastAPI 后端
│ ├── alembic/ # 数据库迁移
│ │ ├── versions/ # 迁移脚本
│ │ ├── env.py # Alembic 环境配置
│ │ └── script.py.mako # 迁移脚本模板
│ ├── routers/ # API 路由
│ │ ├── __init__.py # 路由包初始化
│ │ ├── auth.py # 认证路由(登录/注册)
│ │ ├── admin.py # 管理员路由
│ │ ├── exam.py # 题库路由(创建/追加/查询)⭐
│ │ ├── question.py # 题目路由(刷题/答题)
│ │ └── mistake.py # 错题本路由
│ ├── services/ # 业务逻辑层
│ │ ├── __init__.py # 服务包初始化
│ │ ├── auth_service.py # 认证服务JWT/权限)
│ │ ├── llm_service.py # AI 服务(解析/评分)⭐
│ │ └── document_parser.py # 文档解析服务
│ ├── models.py # SQLAlchemy 数据模型 ⭐
│ ├── schemas.py # Pydantic 请求/响应模型
│ ├── database.py # 数据库配置
│ ├── utils.py # 工具函数Hash/密码)
│ ├── main.py # FastAPI 应用入口
│ ├── requirements.txt # Python 依赖
│ ├── alembic.ini # Alembic 配置
│ └── Dockerfile # 后端 Docker 镜像
├── frontend/ # React 前端
│ ├── 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 模板
│ ├── package.json # Node 依赖
│ ├── vite.config.js # Vite 配置
│ ├── tailwind.config.js # Tailwind CSS 配置
│ ├── postcss.config.js # PostCSS 配置
│ └── Dockerfile # 前端 Docker 镜像
├── docker-compose.yml # Docker 编排配置 ⭐
├── .env.example # 环境变量模板
├── .gitignore # Git 忽略文件
├── README.md # 项目说明
├── DEPLOYMENT.md # 部署指南
├── PROJECT_STRUCTURE.md # 项目结构(本文件)
└── run_local.sh # 本地运行脚本
⭐ 表示核心文件
```
---
## 🔑 核心文件说明
### 后端核心
#### `models.py` - 数据模型
定义了 5 个核心数据表:
- **User**: 用户表(用户名、密码、管理员标识)
- **SystemConfig**: 系统配置KV 存储)
- **Exam**: 题库表(标题、状态、进度、题目数)
- **Question**: 题目表(内容、类型、选项、答案、**content_hash**
- **UserMistake**: 错题本(用户 ID、题目 ID
**关键设计:**
- `content_hash`: MD5 哈希,用于题目去重
- `current_index`: 记录刷题进度
- `status`: Enum 管理题库状态pending/processing/ready/failed
#### `exam.py` - 题库路由
实现了最核心的业务逻辑:
- `POST /create`: 创建题库并上传第一份文档
- `POST /{exam_id}/append`: 追加文档到现有题库 ⭐
- `GET /`: 获取题库列表
- `GET /{exam_id}`: 获取题库详情
- `PUT /{exam_id}/progress`: 更新刷题进度
**去重逻辑:**
```python
# 1. 解析文档获取题目
questions_data = await llm_service.parse_document(content)
# 2. 计算每道题的 Hash
for q in questions_data:
q["content_hash"] = calculate_content_hash(q["content"])
# 3. 仅在当前 exam_id 范围内查询去重
existing_hashes = await db.execute(
select(Question.content_hash).where(Question.exam_id == exam_id)
)
# 4. 仅插入 Hash 不存在的题目
for q in questions_data:
if q["content_hash"] not in existing_hashes:
db.add(Question(**q))
```
#### `llm_service.py` - AI 服务
提供两个核心功能:
1. `parse_document()`: 调用 LLM 解析文档,提取题目
2. `grade_short_answer()`: AI 评分简答题
支持 3 个 AI 提供商:
- OpenAI (GPT-4o-mini)
- Anthropic (Claude-3-haiku)
- Qwen (通义千问)
---
### 前端核心
#### `client.js` - API 客户端
封装了所有后端 API
- `authAPI`: 登录、注册、用户信息
- `examAPI`: 题库 CRUD、追加文档
- `questionAPI`: 获取题目、答题
- `mistakeAPI`: 错题本管理
- `adminAPI`: 系统配置
**特性:**
- 自动添加 JWT Token
- 统一错误处理和 Toast 提示
- 401 自动跳转登录
#### `ExamDetail.jsx` - 题库详情
最复杂的前端页面,包含:
- **追加上传**: 上传新文档并去重
- **状态轮询**: 每 3 秒轮询一次状态
- **智能按钮**:
- 处理中时禁用「添加文档」
- 就绪后显示「开始/继续刷题」
- **进度展示**: 题目数、完成度、进度条
**状态轮询实现:**
```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()
}
```
---
## 🔄 核心业务流程
### 1. 创建题库流程
```
用户上传文档
后端创建 Exam (status=pending)
后台任务开始解析
更新状态为 processing
调用 document_parser 解析文件
调用 llm_service 提取题目
计算 content_hash 并去重
插入新题目到数据库
更新 total_questions 和 status=ready
前端轮询检测到状态变化
自动刷新显示新题目
```
### 2. 追加文档流程
```
用户点击「添加题目文档」
上传新文档
后端检查 Exam 是否在处理中
更新状态为 processing
后台任务解析新文档
提取题目并计算 Hash
仅在当前 exam_id 范围内查重
插入不重复的题目
更新 total_questions
更新状态为 ready
前端轮询检测并刷新
```
### 3. 刷题流程
```
用户点击「开始刷题」
基于 current_index 加载题目
用户选择/输入答案
提交答案到后端
后端检查答案
├─ 选择题:字符串比对
├─ 多选题:排序后比对
├─ 判断题:字符串比对
└─ 简答题:调用 AI 评分
答错自动加入错题本
返回结果和解析
用户点击「下一题」
更新 current_index += 1
加载下一题
```
---
## 🗄️ 数据库设计
### 关键索引
```sql
-- Exam 表
CREATE INDEX ix_exams_user_status ON exams(user_id, status);
-- Question 表
CREATE INDEX ix_questions_exam_hash ON questions(exam_id, content_hash);
CREATE INDEX ix_questions_content_hash ON questions(content_hash);
-- UserMistake 表
CREATE UNIQUE INDEX ix_user_mistakes_unique ON user_mistakes(user_id, question_id);
```
### 关键约束
- `Question.content_hash`: 用于去重,同一 exam_id 下不允许重复
- `UserMistake`: user_id + question_id 唯一约束,防止重复添加
- 级联删除:删除 Exam 时自动删除所有关联的 Question 和 UserMistake
---
## 🎨 技术栈
### 后端
- **FastAPI**: 现代化 Python Web 框架
- **SQLAlchemy 2.0**: 异步 ORM
- **Alembic**: 数据库迁移
- **Pydantic**: 数据验证
- **JWT**: 无状态认证
- **OpenAI/Anthropic/Qwen**: AI 解析和评分
### 前端
- **React 18**: UI 框架
- **Vite**: 构建工具(比 CRA 更快)
- **Tailwind CSS**: 原子化 CSS
- **Axios**: HTTP 客户端
- **React Router**: 路由管理
- **React Hot Toast**: 消息提示
### 部署
- **Docker + Docker Compose**: 容器化部署
- **PostgreSQL 15**: 关系型数据库
- **Nginx** (可选): 反向代理
---
## 📊 API 接口汇总
### 认证相关
- `POST /api/auth/register`: 用户注册
- `POST /api/auth/login`: 用户登录
- `GET /api/auth/me`: 获取当前用户信息
- `POST /api/auth/change-password`: 修改密码
### 题库相关
- `POST /api/exams/create`: 创建题库
- `POST /api/exams/{exam_id}/append`: 追加文档 ⭐
- `GET /api/exams/`: 获取题库列表
- `GET /api/exams/{exam_id}`: 获取题库详情
- `DELETE /api/exams/{exam_id}`: 删除题库
- `PUT /api/exams/{exam_id}/progress`: 更新进度
### 题目相关
- `GET /api/questions/exam/{exam_id}/questions`: 获取题库所有题目
- `GET /api/questions/exam/{exam_id}/current`: 获取当前题目
- `GET /api/questions/{question_id}`: 获取题目详情
- `POST /api/questions/check`: 检查答案
### 错题本相关
- `GET /api/mistakes/`: 获取错题列表
- `POST /api/mistakes/add`: 添加错题
- `DELETE /api/mistakes/{mistake_id}`: 移除错题
- `DELETE /api/mistakes/question/{question_id}`: 按题目 ID 移除
### 管理员相关
- `GET /api/admin/config`: 获取系统配置
- `PUT /api/admin/config`: 更新系统配置
---
## 🔒 安全特性
1. **密码加密**: bcrypt 哈希
2. **JWT 认证**: 无状态 Token
3. **权限控制**: 管理员/普通用户
4. **CORS 保护**: 可配置允许的来源
5. **文件类型验证**: 仅允许特定格式
6. **文件大小限制**: 可配置最大上传大小
7. **速率限制**: 每日上传次数限制
---
## 🎯 核心创新点
1. **智能去重**: 基于 content_hash 的高效去重算法
2. **追加上传**: 支持向现有题库添加新文档
3. **异步处理**: 后台任务处理文档解析,不阻塞用户
4. **状态轮询**: 前端实时显示处理状态
5. **断点续做**: 基于 current_index 的进度管理
6. **AI 评分**: 简答题智能评分和反馈
7. **自动错题本**: 答错自动收集,支持手动管理
8. **多 AI 支持**: 灵活切换 AI 提供商
---
这就是 QQuiz 的完整架构!🎉

152
README.md Normal file
View File

@@ -0,0 +1,152 @@
# QQuiz - 智能刷题与题库管理平台
QQuiz 是一个支持 Docker/源码双模部署的智能刷题平台,核心功能包括多文件上传、自动去重、异步解析、断点续做和错题本管理。
## 功能特性
- 📚 **多文件上传与去重**: 支持向同一题库追加文档,自动识别并过滤重复题目
- 🤖 **AI 智能解析**: 支持 OpenAI/Anthropic/Qwen 多种 AI 提供商
- 📊 **断点续做**: 自动记录刷题进度,随时继续
-**错题本管理**: 自动收集错题,支持手动添加/移除
- 🎯 **多题型支持**: 单选、多选、判断、简答
- 🔐 **权限管理**: 管理员配置、用户隔离
- 📱 **移动端优先**: 完美适配手机端
## 快速开始
### 方式一Docker Compose (推荐)
```bash
# 1. 克隆项目
git clone <repository-url>
cd QQuiz
# 2. 配置环境变量
cp .env.example .env
# 编辑 .env填入你的 API Key 等配置
# 3. 启动服务
docker-compose up -d
# 4. 访问应用
# 前端: http://localhost:3000
# 后端: http://localhost:8000
# API 文档: http://localhost:8000/docs
```
### 方式二:本地运行
#### 前置要求
- Python 3.11+
- Node.js 18+
- PostgreSQL 15+
```bash
# 1. 配置环境变量
cp .env.example .env
# 编辑 .env修改 DATABASE_URL 为本地数据库地址
# 2. 启动 PostgreSQL
# macOS: brew services start postgresql
# Linux: sudo systemctl start postgresql
# 3. 运行启动脚本
chmod +x run_local.sh
./run_local.sh
```
## 默认账户
**管理员账户:**
- 用户名: `admin`
- 密码: `admin123`
⚠️ **重要**: 首次登录后请立即修改密码!
## 项目结构
```
QQuiz/
├── backend/ # FastAPI 后端
│ ├── alembic/ # 数据库迁移
│ ├── routers/ # API 路由 (Step 2)
│ ├── services/ # 业务逻辑 (Step 2)
│ ├── models.py # 数据模型 ✅
│ ├── database.py # 数据库配置 ✅
│ ├── main.py # 应用入口 ✅
│ └── requirements.txt # Python 依赖 ✅
├── frontend/ # React 前端
│ ├── src/
│ │ ├── api/ # API 客户端 (Step 3)
│ │ ├── pages/ # 页面组件 (Step 4)
│ │ ├── components/ # 通用组件 (Step 4)
│ │ └── App.jsx # 应用入口 ✅
│ ├── package.json # Node 依赖 ✅
│ └── vite.config.js # Vite 配置 ✅
├── docker-compose.yml # Docker 编排 ✅
├── .env.example # 环境变量模板 ✅
└── run_local.sh # 本地运行脚本 ✅
```
## 核心业务流程
### 1. 创建题库
用户首次上传文档时,创建新的 Exam (题库容器)
### 2. 追加文档
在已有题库详情页点击 "添加题目文档",上传新文件
### 3. 去重逻辑
- 对题目内容进行标准化处理 (去空格、标点、转小写)
- 计算 MD5 Hash
- 仅在当前题库范围内查询去重
- 仅插入 Hash 不存在的题目
### 4. 异步处理
- 后台任务处理 AI 解析
- 状态: `pending``processing``ready` / `failed`
- 前端轮询状态,自动刷新
### 5. 刷题体验
- 基于 `current_index` 实现断点续做
- 错题自动加入错题本
- 简答题 AI 评分
## 环境变量说明
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `DATABASE_URL` | 数据库连接字符串 | - |
| `SECRET_KEY` | JWT 密钥 | - |
| `AI_PROVIDER` | AI 提供商 (openai/anthropic/qwen) | openai |
| `OPENAI_API_KEY` | OpenAI API 密钥 | - |
| `ALLOW_REGISTRATION` | 是否允许注册 | true |
| `MAX_UPLOAD_SIZE_MB` | 最大上传文件大小 (MB) | 10 |
| `MAX_DAILY_UPLOADS` | 每日上传次数限制 | 20 |
## 技术栈
**后端:**
- FastAPI - 现代化 Python Web 框架
- SQLAlchemy - ORM
- Alembic - 数据库迁移
- PostgreSQL - 数据库
- Pydantic - 数据验证
**前端:**
- React 18 - UI 框架
- Vite - 构建工具
- Tailwind CSS - 样式框架
- React Router - 路由
- Axios - HTTP 客户端
## 开发进度
- [x] **Step 1**: Foundation & Models ✅
- [ ] **Step 2**: Backend Core Logic
- [ ] **Step 3**: Frontend Config & API
- [ ] **Step 4**: Frontend Complex UI
## License
MIT

21
backend/.dockerignore Normal file
View File

@@ -0,0 +1,21 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
venv/
env/
ENV/
.venv
pip-log.txt
pip-delete-this-directory.txt
.pytest_cache/
.coverage
htmlcov/
dist/
build/
*.egg-info/
.DS_Store
.env
uploads/
*.sqlite3

25
backend/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
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

94
backend/alembic.ini Normal file
View File

@@ -0,0 +1,94 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
timezone = UTC
# max length of characters to apply to the
# "slug" field
truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
version_locations = %(here)s/alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
output_encoding = utf-8
# sqlalchemy.url will be read from environment variable DATABASE_URL
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

81
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,81 @@
"""
Alembic environment configuration for async SQLAlchemy
"""
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
import asyncio
import os
import sys
from dotenv import load_dotenv
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# Load environment variables
load_dotenv()
# Import models
from models import Base
# this is the Alembic Config object
config = context.config
# Set database URL from environment
config.set_main_option("sqlalchemy.url", os.getenv("DATABASE_URL", ""))
# Interpret the config file for Python logging.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here for 'autogenerate' support
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""Run migrations with given connection"""
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in 'online' mode (async)"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1 @@
# This file ensures the versions directory is tracked by git

132
backend/database.py Normal file
View File

@@ -0,0 +1,132 @@
"""
Database configuration and session management
"""
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import NullPool
from contextlib import asynccontextmanager
from typing import AsyncGenerator
import os
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Get database URL from environment
DATABASE_URL = os.getenv("DATABASE_URL")
if not DATABASE_URL:
raise ValueError("DATABASE_URL environment variable is not set")
# Create async engine
engine = create_async_engine(
DATABASE_URL,
echo=False, # Set to True for SQL query logging during development
future=True,
poolclass=NullPool if "sqlite" in DATABASE_URL else None,
)
# Create async session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
Dependency for getting async database session.
Usage in FastAPI:
@app.get("/items")
async def get_items(db: AsyncSession = Depends(get_db)):
...
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db():
"""
Initialize database tables.
Should be called during application startup.
"""
from models import Base
async with engine.begin() as conn:
# Create all tables
await conn.run_sync(Base.metadata.create_all)
print("✅ Database tables created successfully")
async def init_default_config(db: AsyncSession):
"""
Initialize default system configurations if not exists.
"""
from models import SystemConfig, User
from sqlalchemy import select
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Default configurations
default_configs = {
"allow_registration": os.getenv("ALLOW_REGISTRATION", "true"),
"max_upload_size_mb": os.getenv("MAX_UPLOAD_SIZE_MB", "10"),
"max_daily_uploads": os.getenv("MAX_DAILY_UPLOADS", "20"),
"ai_provider": os.getenv("AI_PROVIDER", "openai"),
}
for key, value in default_configs.items():
result = await db.execute(select(SystemConfig).where(SystemConfig.key == key))
existing = result.scalar_one_or_none()
if not existing:
config = SystemConfig(key=key, value=str(value))
db.add(config)
print(f"✅ Created default config: {key} = {value}")
# Create default admin user if not exists
result = await db.execute(select(User).where(User.username == "admin"))
admin = result.scalar_one_or_none()
if not admin:
admin_user = User(
username="admin",
hashed_password=pwd_context.hash("admin123"), # Change this password!
is_admin=True
)
db.add(admin_user)
print("✅ Created default admin user (username: admin, password: admin123)")
print("⚠️ IMPORTANT: Please change the admin password immediately!")
await db.commit()
@asynccontextmanager
async def get_db_context():
"""
Context manager for getting database session outside of FastAPI dependency injection.
Usage:
async with get_db_context() as db:
result = await db.execute(...)
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()

84
backend/main.py Normal file
View File

@@ -0,0 +1,84 @@
"""
QQuiz FastAPI Application
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import os
from dotenv import load_dotenv
from database import init_db, init_default_config, get_db_context
# Load environment variables
load_dotenv()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan events"""
# Startup
print("🚀 Starting QQuiz Application...")
# Initialize database
await init_db()
# Initialize default configurations
async with get_db_context() as db:
await init_default_config(db)
# Create uploads directory
upload_dir = os.getenv("UPLOAD_DIR", "./uploads")
os.makedirs(upload_dir, exist_ok=True)
print(f"📁 Upload directory: {upload_dir}")
print("✅ Application started successfully!")
yield
# Shutdown
print("👋 Shutting down QQuiz Application...")
# Create FastAPI app
app = FastAPI(
title="QQuiz API",
description="智能刷题与题库管理平台",
version="1.0.0",
lifespan=lifespan
)
# Configure CORS
cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:3000").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
async def root():
"""Root endpoint"""
return {
"message": "Welcome to QQuiz API",
"version": "1.0.0",
"docs": "/docs"
}
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy"}
# Import and include routers
from routers import auth, exam, question, mistake, admin
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
app.include_router(exam.router, prefix="/api/exams", tags=["Exams"])
app.include_router(question.router, prefix="/api/questions", tags=["Questions"])
app.include_router(mistake.router, prefix="/api/mistakes", tags=["Mistakes"])
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])

134
backend/models.py Normal file
View File

@@ -0,0 +1,134 @@
"""
SQLAlchemy Models for QQuiz Platform
"""
from datetime import datetime
from enum import Enum as PyEnum
from sqlalchemy import (
Column, Integer, String, Boolean, DateTime,
ForeignKey, Text, JSON, Index, Enum
)
from sqlalchemy.orm import relationship, declarative_base
from sqlalchemy.sql import func
Base = declarative_base()
class ExamStatus(str, PyEnum):
"""Exam processing status"""
PENDING = "pending"
PROCESSING = "processing"
READY = "ready"
FAILED = "failed"
class QuestionType(str, PyEnum):
"""Question types"""
SINGLE = "single" # 单选
MULTIPLE = "multiple" # 多选
JUDGE = "judge" # 判断
SHORT = "short" # 简答
class User(Base):
"""User model"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, nullable=False, index=True)
hashed_password = Column(String(255), nullable=False)
is_admin = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Relationships
exams = relationship("Exam", back_populates="user", cascade="all, delete-orphan")
mistakes = relationship("UserMistake", back_populates="user", cascade="all, delete-orphan")
def __repr__(self):
return f"<User(id={self.id}, username='{self.username}', is_admin={self.is_admin})>"
class SystemConfig(Base):
"""System configuration key-value store"""
__tablename__ = "system_configs"
key = Column(String(100), primary_key=True)
value = Column(Text, nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
def __repr__(self):
return f"<SystemConfig(key='{self.key}', value='{self.value}')>"
class Exam(Base):
"""Exam (Question Bank Container)"""
__tablename__ = "exams"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
title = Column(String(200), nullable=False)
status = Column(Enum(ExamStatus), default=ExamStatus.PENDING, nullable=False, index=True)
current_index = Column(Integer, default=0, nullable=False)
total_questions = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# Relationships
user = relationship("User", back_populates="exams")
questions = relationship("Question", back_populates="exam", cascade="all, delete-orphan")
# Indexes
__table_args__ = (
Index('ix_exams_user_status', 'user_id', 'status'),
)
def __repr__(self):
return f"<Exam(id={self.id}, title='{self.title}', status={self.status}, questions={self.total_questions})>"
class Question(Base):
"""Question model"""
__tablename__ = "questions"
id = Column(Integer, primary_key=True, index=True)
exam_id = Column(Integer, ForeignKey("exams.id", ondelete="CASCADE"), nullable=False)
content = Column(Text, nullable=False)
type = Column(Enum(QuestionType), nullable=False)
options = Column(JSON, nullable=True) # For single/multiple choice: ["A. Option1", "B. Option2", ...]
answer = Column(Text, nullable=False)
analysis = Column(Text, nullable=True)
content_hash = Column(String(32), nullable=False, index=True) # MD5 hash for deduplication
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Relationships
exam = relationship("Exam", back_populates="questions")
mistakes = relationship("UserMistake", back_populates="question", cascade="all, delete-orphan")
# Indexes for deduplication within exam scope
__table_args__ = (
Index('ix_questions_exam_hash', 'exam_id', 'content_hash'),
)
def __repr__(self):
return f"<Question(id={self.id}, type={self.type}, hash={self.content_hash[:8]}...)>"
class UserMistake(Base):
"""User mistake records (错题本)"""
__tablename__ = "user_mistakes"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Relationships
user = relationship("User", back_populates="mistakes")
question = relationship("Question", back_populates="mistakes")
# Unique constraint to prevent duplicates
__table_args__ = (
Index('ix_user_mistakes_unique', 'user_id', 'question_id', unique=True),
)
def __repr__(self):
return f"<UserMistake(user_id={self.user_id}, question_id={self.question_id})>"

18
backend/requirements.txt Normal file
View File

@@ -0,0 +1,18 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy==2.0.25
asyncpg==0.29.0
alembic==1.13.1
pydantic==2.5.3
pydantic-settings==2.1.0
python-dotenv==1.0.0
python-multipart==0.0.6
passlib[bcrypt]==1.7.4
python-jose[cryptography]==3.3.0
aiofiles==23.2.1
httpx==0.26.0
openai==1.10.0
anthropic==0.8.1
python-docx==1.1.0
PyPDF2==3.0.1
openpyxl==3.1.2

View File

@@ -0,0 +1,6 @@
"""
Routers package
"""
from . import auth, exam, question, mistake, admin
__all__ = ["auth", "exam", "question", "mistake", "admin"]

63
backend/routers/admin.py Normal file
View File

@@ -0,0 +1,63 @@
"""
Admin Router
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from database import get_db
from models import User, SystemConfig
from schemas import SystemConfigUpdate, SystemConfigResponse
from services.auth_service import get_current_admin_user
router = APIRouter()
@router.get("/config", response_model=SystemConfigResponse)
async def get_system_config(
current_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""Get system configuration (admin only)"""
# Fetch all config values
result = await db.execute(select(SystemConfig))
configs = {config.key: config.value for config in result.scalars().all()}
return {
"allow_registration": configs.get("allow_registration", "true").lower() == "true",
"max_upload_size_mb": int(configs.get("max_upload_size_mb", "10")),
"max_daily_uploads": int(configs.get("max_daily_uploads", "20")),
"ai_provider": configs.get("ai_provider", "openai")
}
@router.put("/config", response_model=SystemConfigResponse)
async def update_system_config(
config_update: SystemConfigUpdate,
current_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""Update system configuration (admin only)"""
update_data = config_update.dict(exclude_unset=True)
for key, value in update_data.items():
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == key)
)
config = result.scalar_one_or_none()
if config:
config.value = str(value).lower() if isinstance(value, bool) else str(value)
else:
new_config = SystemConfig(
key=key,
value=str(value).lower() if isinstance(value, bool) else str(value)
)
db.add(new_config)
await db.commit()
# Return updated config
return await get_system_config(current_admin, db)

130
backend/routers/auth.py Normal file
View File

@@ -0,0 +1,130 @@
"""
Authentication Router
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import timedelta
from database import get_db
from models import User, SystemConfig
from schemas import UserCreate, UserLogin, Token, UserResponse
from utils import hash_password, verify_password, create_access_token
from services.auth_service import get_current_user
router = APIRouter()
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(
user_data: UserCreate,
db: AsyncSession = Depends(get_db)
):
"""Register a new user"""
# Check if registration is allowed
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "allow_registration")
)
config = result.scalar_one_or_none()
if config and config.value.lower() == "false":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Registration is currently disabled"
)
# Check if username already exists
result = await db.execute(
select(User).where(User.username == user_data.username)
)
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
# Create new user
new_user = User(
username=user_data.username,
hashed_password=hash_password(user_data.password),
is_admin=False
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return new_user
@router.post("/login", response_model=Token)
async def login(
user_data: UserLogin,
db: AsyncSession = Depends(get_db)
):
"""Login and get access token"""
# Find user
result = await db.execute(
select(User).where(User.username == user_data.username)
)
user = result.scalar_one_or_none()
# Verify credentials
if not user or not verify_password(user_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Create access token
access_token = create_access_token(
data={"sub": user.id}
)
return {
"access_token": access_token,
"token_type": "bearer"
}
@router.get("/me", response_model=UserResponse)
async def get_current_user_info(
current_user: User = Depends(get_current_user)
):
"""Get current user information"""
return current_user
@router.post("/change-password")
async def change_password(
old_password: str,
new_password: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Change user password"""
# Verify old password
if not verify_password(old_password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect current password"
)
# Validate new password
if len(new_password) < 6:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="New password must be at least 6 characters"
)
# Update password
current_user.hashed_password = hash_password(new_password)
await db.commit()
return {"message": "Password changed successfully"}

411
backend/routers/exam.py Normal file
View File

@@ -0,0 +1,411 @@
"""
Exam Router - Handles exam creation, file upload, and deduplication
"""
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from typing import List, Optional
from datetime import datetime, timedelta
import os
import aiofiles
from database import get_db
from models import User, Exam, Question, ExamStatus, SystemConfig
from schemas import (
ExamCreate, ExamResponse, ExamListResponse,
ExamUploadResponse, ParseResult, QuizProgressUpdate
)
from services.auth_service import get_current_user
from services.document_parser import document_parser
from services.llm_service import llm_service
from utils import is_allowed_file, calculate_content_hash
router = APIRouter()
async def check_upload_limits(user_id: int, file_size: int, db: AsyncSession):
"""Check if user has exceeded upload limits"""
# Get max upload size config
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "max_upload_size_mb")
)
config = result.scalar_one_or_none()
max_size_mb = int(config.value) if config else 10
# Check file size
if file_size > max_size_mb * 1024 * 1024:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File size exceeds limit of {max_size_mb}MB"
)
# Get max daily uploads config
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "max_daily_uploads")
)
config = result.scalar_one_or_none()
max_daily = int(config.value) if config else 20
# Check daily upload count
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
result = await db.execute(
select(func.count(Exam.id)).where(
and_(
Exam.user_id == user_id,
Exam.created_at >= today_start
)
)
)
upload_count = result.scalar()
if upload_count >= max_daily:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Daily upload limit of {max_daily} reached"
)
async def process_questions_with_dedup(
exam_id: int,
questions_data: List[dict],
db: AsyncSession
) -> ParseResult:
"""
Process parsed questions with deduplication logic.
Args:
exam_id: Target exam ID
questions_data: List of question dicts from LLM parsing
db: Database session
Returns:
ParseResult with statistics
"""
total_parsed = len(questions_data)
duplicates_removed = 0
new_added = 0
# Get existing content hashes for this exam
result = await db.execute(
select(Question.content_hash).where(Question.exam_id == exam_id)
)
existing_hashes = set(row[0] for row in result.all())
# Insert only new questions
for q_data in questions_data:
content_hash = q_data.get("content_hash")
if content_hash in existing_hashes:
duplicates_removed += 1
continue
# Create new question
new_question = Question(
exam_id=exam_id,
content=q_data["content"],
type=q_data["type"],
options=q_data.get("options"),
answer=q_data["answer"],
analysis=q_data.get("analysis"),
content_hash=content_hash
)
db.add(new_question)
existing_hashes.add(content_hash) # Add to set to prevent duplicates in current batch
new_added += 1
await db.commit()
return ParseResult(
total_parsed=total_parsed,
duplicates_removed=duplicates_removed,
new_added=new_added,
message=f"Parsed {total_parsed} questions, removed {duplicates_removed} duplicates, added {new_added} new questions"
)
async def async_parse_and_save(
exam_id: int,
file_content: bytes,
filename: str,
db_url: str
):
"""
Background task to parse document and save questions with deduplication.
"""
from database import AsyncSessionLocal
from sqlalchemy import select
async with AsyncSessionLocal() as db:
try:
# Update exam status to processing
result = await db.execute(select(Exam).where(Exam.id == exam_id))
exam = result.scalar_one()
exam.status = ExamStatus.PROCESSING
await db.commit()
# Parse document
print(f"[Exam {exam_id}] Parsing document: {filename}")
text_content = await document_parser.parse_file(file_content, filename)
if not text_content or len(text_content.strip()) < 10:
raise Exception("Document appears to be empty or too short")
# Parse questions using LLM
print(f"[Exam {exam_id}] Calling LLM to extract questions...")
questions_data = await llm_service.parse_document(text_content)
if not questions_data:
raise Exception("No questions found in document")
# Process questions with deduplication
print(f"[Exam {exam_id}] Processing questions with deduplication...")
parse_result = await process_questions_with_dedup(exam_id, questions_data, db)
# Update exam status and total questions
result = await db.execute(select(Exam).where(Exam.id == exam_id))
exam = result.scalar_one()
# Get updated question count
result = await db.execute(
select(func.count(Question.id)).where(Question.exam_id == exam_id)
)
total_questions = result.scalar()
exam.status = ExamStatus.READY
exam.total_questions = total_questions
await db.commit()
print(f"[Exam {exam_id}] ✅ {parse_result.message}")
except Exception as e:
print(f"[Exam {exam_id}] ❌ Error: {str(e)}")
# Update exam status to failed
result = await db.execute(select(Exam).where(Exam.id == exam_id))
exam = result.scalar_one()
exam.status = ExamStatus.FAILED
await db.commit()
@router.post("/create", response_model=ExamUploadResponse, status_code=status.HTTP_201_CREATED)
async def create_exam_with_upload(
background_tasks: BackgroundTasks,
title: str = Form(...),
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Create a new exam and upload the first document.
Document will be parsed asynchronously in background.
"""
# Validate file
if not file.filename or not is_allowed_file(file.filename):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid file type. Allowed: txt, pdf, doc, docx, xlsx, xls"
)
# Read file content
file_content = await file.read()
# Check upload limits
await check_upload_limits(current_user.id, len(file_content), db)
# Create exam
new_exam = Exam(
user_id=current_user.id,
title=title,
status=ExamStatus.PENDING
)
db.add(new_exam)
await db.commit()
await db.refresh(new_exam)
# Start background parsing
background_tasks.add_task(
async_parse_and_save,
new_exam.id,
file_content,
file.filename,
os.getenv("DATABASE_URL")
)
return ExamUploadResponse(
exam_id=new_exam.id,
title=new_exam.title,
status=new_exam.status.value,
message="Exam created. Document is being processed in background."
)
@router.post("/{exam_id}/append", response_model=ExamUploadResponse)
async def append_document_to_exam(
exam_id: int,
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Append a new document to an existing exam.
Questions will be parsed and deduplicated asynchronously.
"""
# Get exam and verify ownership
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"
)
# Don't allow appending while processing
if exam.status == ExamStatus.PROCESSING:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Exam is currently being processed. Please wait."
)
# Validate file
if not file.filename or not is_allowed_file(file.filename):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid file type. Allowed: txt, pdf, doc, docx, xlsx, xls"
)
# Read file content
file_content = await file.read()
# Check upload limits
await check_upload_limits(current_user.id, len(file_content), db)
# Start background parsing (will auto-deduplicate)
background_tasks.add_task(
async_parse_and_save,
exam.id,
file_content,
file.filename,
os.getenv("DATABASE_URL")
)
return ExamUploadResponse(
exam_id=exam.id,
title=exam.title,
status=ExamStatus.PROCESSING.value,
message=f"Document '{file.filename}' is being processed. Duplicates will be automatically removed."
)
@router.get("/", response_model=ExamListResponse)
async def get_user_exams(
skip: int = 0,
limit: int = 20,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get all exams for current user"""
# Get total count
result = await db.execute(
select(func.count(Exam.id)).where(Exam.user_id == current_user.id)
)
total = result.scalar()
# Get exams
result = await db.execute(
select(Exam)
.where(Exam.user_id == current_user.id)
.order_by(Exam.created_at.desc())
.offset(skip)
.limit(limit)
)
exams = result.scalars().all()
return ExamListResponse(exams=exams, total=total)
@router.get("/{exam_id}", response_model=ExamResponse)
async def get_exam_detail(
exam_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get exam details"""
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"
)
return exam
@router.delete("/{exam_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_exam(
exam_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Delete an exam and all its questions"""
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"
)
await db.delete(exam)
await db.commit()
@router.put("/{exam_id}/progress", response_model=ExamResponse)
async def update_quiz_progress(
exam_id: int,
progress: QuizProgressUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update quiz progress (current_index)"""
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"
)
exam.current_index = progress.current_index
await db.commit()
await db.refresh(exam)
return exam

192
backend/routers/mistake.py Normal file
View File

@@ -0,0 +1,192 @@
"""
Mistake Router - Handles user mistake book (错题本)
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, func
from sqlalchemy.orm import selectinload
from database import get_db
from models import User, Question, UserMistake, Exam
from schemas import MistakeAdd, MistakeResponse, MistakeListResponse
from services.auth_service import get_current_user
router = APIRouter()
@router.get("/", response_model=MistakeListResponse)
async def get_user_mistakes(
skip: int = 0,
limit: int = 50,
exam_id: int = None, # Optional filter by exam
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get user's mistake book with optional exam filter"""
# Build query
query = (
select(UserMistake)
.options(selectinload(UserMistake.question))
.where(UserMistake.user_id == current_user.id)
.order_by(UserMistake.created_at.desc())
)
# Apply exam filter if provided
if exam_id is not None:
query = query.join(Question).where(Question.exam_id == exam_id)
# Get total count
count_query = select(func.count(UserMistake.id)).where(UserMistake.user_id == current_user.id)
if exam_id is not None:
count_query = count_query.join(Question).where(Question.exam_id == exam_id)
result = await db.execute(count_query)
total = result.scalar()
# Get mistakes
result = await db.execute(query.offset(skip).limit(limit))
mistakes = result.scalars().all()
# Format response
mistake_responses = []
for mistake in mistakes:
mistake_responses.append(
MistakeResponse(
id=mistake.id,
user_id=mistake.user_id,
question_id=mistake.question_id,
question=mistake.question,
created_at=mistake.created_at
)
)
return MistakeListResponse(mistakes=mistake_responses, total=total)
@router.post("/add", response_model=MistakeResponse, status_code=status.HTTP_201_CREATED)
async def add_to_mistakes(
mistake_data: MistakeAdd,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Manually add a question to mistake book"""
# Verify question exists and user has access to it
result = await db.execute(
select(Question)
.join(Exam)
.where(
and_(
Question.id == mistake_data.question_id,
Exam.user_id == current_user.id
)
)
)
question = result.scalar_one_or_none()
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found or you don't have access"
)
# Check if already in mistake book
result = await db.execute(
select(UserMistake).where(
and_(
UserMistake.user_id == current_user.id,
UserMistake.question_id == mistake_data.question_id
)
)
)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Question already in mistake book"
)
# Add to mistake book
new_mistake = UserMistake(
user_id=current_user.id,
question_id=mistake_data.question_id
)
db.add(new_mistake)
await db.commit()
await db.refresh(new_mistake)
# Load question relationship
result = await db.execute(
select(UserMistake)
.options(selectinload(UserMistake.question))
.where(UserMistake.id == new_mistake.id)
)
new_mistake = result.scalar_one()
return MistakeResponse(
id=new_mistake.id,
user_id=new_mistake.user_id,
question_id=new_mistake.question_id,
question=new_mistake.question,
created_at=new_mistake.created_at
)
@router.delete("/{mistake_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_from_mistakes(
mistake_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Remove a question from mistake book"""
# Get mistake and verify ownership
result = await db.execute(
select(UserMistake).where(
and_(
UserMistake.id == mistake_id,
UserMistake.user_id == current_user.id
)
)
)
mistake = result.scalar_one_or_none()
if not mistake:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Mistake record not found"
)
await db.delete(mistake)
await db.commit()
@router.delete("/question/{question_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_question_from_mistakes(
question_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Remove a question from mistake book by question ID"""
# Get mistake and verify ownership
result = await db.execute(
select(UserMistake).where(
and_(
UserMistake.question_id == question_id,
UserMistake.user_id == current_user.id
)
)
)
mistake = result.scalar_one_or_none()
if not mistake:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found in mistake book"
)
await db.delete(mistake)
await db.commit()

228
backend/routers/question.py Normal file
View File

@@ -0,0 +1,228 @@
"""
Question Router - Handles quiz playing and answer checking
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, func
from typing import List, Optional
from database import get_db
from models import User, Exam, Question, UserMistake, ExamStatus, QuestionType
from schemas import (
QuestionResponse, QuestionListResponse,
AnswerSubmit, AnswerCheckResponse
)
from services.auth_service import get_current_user
from services.llm_service import llm_service
router = APIRouter()
@router.get("/exam/{exam_id}/questions", response_model=QuestionListResponse)
async def get_exam_questions(
exam_id: int,
skip: int = 0,
limit: int = 50,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get all questions for an exam"""
# Verify exam ownership
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"
)
# Get total count
result = await db.execute(
select(func.count(Question.id)).where(Question.exam_id == exam_id)
)
total = result.scalar()
# Get questions
result = await db.execute(
select(Question)
.where(Question.exam_id == exam_id)
.order_by(Question.id)
.offset(skip)
.limit(limit)
)
questions = result.scalars().all()
return QuestionListResponse(questions=questions, total=total)
@router.get("/exam/{exam_id}/current", response_model=QuestionResponse)
async def get_current_question(
exam_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get the current question based on exam's current_index"""
# Get exam
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"
)
if exam.status != ExamStatus.READY:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Exam is not ready. Status: {exam.status.value}"
)
# Get questions
result = await db.execute(
select(Question)
.where(Question.exam_id == exam_id)
.order_by(Question.id)
.offset(exam.current_index)
.limit(1)
)
question = result.scalar_one_or_none()
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No more questions available. You've completed this exam!"
)
return question
@router.get("/{question_id}", response_model=QuestionResponse)
async def get_question_by_id(
question_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get a specific question by ID"""
# Get question and verify access through exam ownership
result = await db.execute(
select(Question)
.join(Exam)
.where(
and_(
Question.id == question_id,
Exam.user_id == current_user.id
)
)
)
question = result.scalar_one_or_none()
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found"
)
return question
@router.post("/check", response_model=AnswerCheckResponse)
async def check_answer(
answer_data: AnswerSubmit,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Check user's answer and return result.
For short answers, use AI to grade.
Automatically add wrong answers to mistake book.
"""
# Get question and verify access
result = await db.execute(
select(Question)
.join(Exam)
.where(
and_(
Question.id == answer_data.question_id,
Exam.user_id == current_user.id
)
)
)
question = result.scalar_one_or_none()
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found"
)
user_answer = answer_data.user_answer.strip()
correct_answer = question.answer.strip()
is_correct = False
ai_score = None
ai_feedback = None
# Check answer based on question type
if question.type == QuestionType.SHORT:
# Use AI to grade short answer
grading = await llm_service.grade_short_answer(
question.content,
correct_answer,
user_answer
)
ai_score = grading["score"]
ai_feedback = grading["feedback"]
is_correct = ai_score >= 0.7 # Consider 70% as correct
elif question.type == QuestionType.MULTIPLE:
# For multiple choice, normalize answer (sort letters)
user_normalized = ''.join(sorted(user_answer.upper().replace(' ', '')))
correct_normalized = ''.join(sorted(correct_answer.upper().replace(' ', '')))
is_correct = user_normalized == correct_normalized
else:
# For single choice and judge questions
is_correct = user_answer.upper() == correct_answer.upper()
# If wrong, add to mistake book
if not is_correct:
# Check if already in mistake book
result = await db.execute(
select(UserMistake).where(
and_(
UserMistake.user_id == current_user.id,
UserMistake.question_id == question.id
)
)
)
existing_mistake = result.scalar_one_or_none()
if not existing_mistake:
new_mistake = UserMistake(
user_id=current_user.id,
question_id=question.id
)
db.add(new_mistake)
await db.commit()
return AnswerCheckResponse(
correct=is_correct,
user_answer=user_answer,
correct_answer=correct_answer,
analysis=question.analysis,
ai_score=ai_score,
ai_feedback=ai_feedback
)

160
backend/schemas.py Normal file
View File

@@ -0,0 +1,160 @@
"""
Pydantic Schemas for Request/Response Validation
"""
from pydantic import BaseModel, Field, validator
from typing import Optional, List
from datetime import datetime
from models import ExamStatus, QuestionType
# ============ Auth Schemas ============
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=6)
@validator('username')
def username_alphanumeric(cls, v):
if not v.replace('_', '').replace('-', '').isalnum():
raise ValueError('Username must be alphanumeric (allows _ and -)')
return v
class UserLogin(BaseModel):
username: str
password: str
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class UserResponse(BaseModel):
id: int
username: str
is_admin: bool
created_at: datetime
class Config:
from_attributes = True
# ============ System Config Schemas ============
class SystemConfigUpdate(BaseModel):
allow_registration: Optional[bool] = None
max_upload_size_mb: Optional[int] = None
max_daily_uploads: Optional[int] = None
ai_provider: Optional[str] = None
class SystemConfigResponse(BaseModel):
allow_registration: bool
max_upload_size_mb: int
max_daily_uploads: int
ai_provider: str
# ============ Exam Schemas ============
class ExamCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
class ExamResponse(BaseModel):
id: int
user_id: int
title: str
status: ExamStatus
current_index: int
total_questions: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class ExamListResponse(BaseModel):
exams: List[ExamResponse]
total: int
class ExamUploadResponse(BaseModel):
exam_id: int
title: str
status: str
message: str
class ParseResult(BaseModel):
"""Result from file parsing"""
total_parsed: int
duplicates_removed: int
new_added: int
message: str
# ============ Question Schemas ============
class QuestionBase(BaseModel):
content: str
type: QuestionType
options: Optional[List[str]] = None
answer: str
analysis: Optional[str] = None
class QuestionCreate(QuestionBase):
exam_id: int
class QuestionResponse(QuestionBase):
id: int
exam_id: int
created_at: datetime
class Config:
from_attributes = True
class QuestionListResponse(BaseModel):
questions: List[QuestionResponse]
total: int
# ============ Quiz Schemas ============
class AnswerSubmit(BaseModel):
question_id: int
user_answer: str
class AnswerCheckResponse(BaseModel):
correct: bool
user_answer: str
correct_answer: str
analysis: Optional[str] = None
ai_score: Optional[float] = None # For short answer questions
ai_feedback: Optional[str] = None # For short answer questions
class QuizProgressUpdate(BaseModel):
current_index: int
# ============ Mistake Schemas ============
class MistakeAdd(BaseModel):
question_id: int
class MistakeResponse(BaseModel):
id: int
user_id: int
question_id: int
question: QuestionResponse
created_at: datetime
class Config:
from_attributes = True
class MistakeListResponse(BaseModel):
mistakes: List[MistakeResponse]
total: int

View File

@@ -0,0 +1,13 @@
"""
Services package
"""
from .auth_service import get_current_user, get_current_admin_user
from .llm_service import llm_service
from .document_parser import document_parser
__all__ = [
"get_current_user",
"get_current_admin_user",
"llm_service",
"document_parser"
]

View File

@@ -0,0 +1,78 @@
"""
Authentication Service
"""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional
from models import User
from database import get_db
from utils import decode_access_token
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db)
) -> User:
"""
Get current authenticated user from JWT token.
"""
token = credentials.credentials
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Decode token
payload = decode_access_token(token)
if payload is None:
raise credentials_exception
user_id: int = payload.get("sub")
if user_id is None:
raise credentials_exception
# Get user from database
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user
async def get_current_admin_user(
current_user: User = Depends(get_current_user)
) -> User:
"""
Get current user and verify admin permissions.
"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user
async def get_optional_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_db)
) -> Optional[User]:
"""
Get current user if token is provided, otherwise return None.
Useful for endpoints that work for both authenticated and anonymous users.
"""
if credentials is None:
return None
try:
return await get_current_user(credentials, db)
except HTTPException:
return None

View File

@@ -0,0 +1,121 @@
"""
Document Parser Service
Supports: TXT, PDF, DOCX, XLSX
"""
import io
from typing import Optional
import PyPDF2
from docx import Document
import openpyxl
class DocumentParser:
"""Parse various document formats to extract text content"""
@staticmethod
async def parse_txt(file_content: bytes) -> str:
"""Parse TXT file"""
try:
return file_content.decode('utf-8')
except UnicodeDecodeError:
try:
return file_content.decode('gbk')
except:
return file_content.decode('utf-8', errors='ignore')
@staticmethod
async def parse_pdf(file_content: bytes) -> str:
"""Parse PDF file"""
try:
pdf_file = io.BytesIO(file_content)
pdf_reader = PyPDF2.PdfReader(pdf_file)
text_content = []
for page in pdf_reader.pages:
text = page.extract_text()
if text:
text_content.append(text)
return '\n\n'.join(text_content)
except Exception as e:
raise Exception(f"Failed to parse PDF: {str(e)}")
@staticmethod
async def parse_docx(file_content: bytes) -> str:
"""Parse DOCX file"""
try:
docx_file = io.BytesIO(file_content)
doc = Document(docx_file)
text_content = []
for paragraph in doc.paragraphs:
if paragraph.text.strip():
text_content.append(paragraph.text)
# Also extract text from tables
for table in doc.tables:
for row in table.rows:
row_text = ' | '.join(cell.text.strip() for cell in row.cells)
if row_text.strip():
text_content.append(row_text)
return '\n\n'.join(text_content)
except Exception as e:
raise Exception(f"Failed to parse DOCX: {str(e)}")
@staticmethod
async def parse_xlsx(file_content: bytes) -> str:
"""Parse XLSX file"""
try:
xlsx_file = io.BytesIO(file_content)
workbook = openpyxl.load_workbook(xlsx_file, data_only=True)
text_content = []
for sheet_name in workbook.sheetnames:
sheet = workbook[sheet_name]
text_content.append(f"=== Sheet: {sheet_name} ===")
for row in sheet.iter_rows(values_only=True):
row_text = ' | '.join(str(cell) if cell is not None else '' for cell in row)
if row_text.strip(' |'):
text_content.append(row_text)
return '\n\n'.join(text_content)
except Exception as e:
raise Exception(f"Failed to parse XLSX: {str(e)}")
@staticmethod
async def parse_file(file_content: bytes, filename: str) -> str:
"""
Parse file based on extension.
Args:
file_content: File content as bytes
filename: Original filename
Returns:
Extracted text content
Raises:
Exception: If file format is unsupported or parsing fails
"""
extension = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
parsers = {
'txt': DocumentParser.parse_txt,
'pdf': DocumentParser.parse_pdf,
'docx': DocumentParser.parse_docx,
'doc': DocumentParser.parse_docx, # Try to parse DOC as DOCX
'xlsx': DocumentParser.parse_xlsx,
'xls': DocumentParser.parse_xlsx, # Try to parse XLS as XLSX
}
parser = parsers.get(extension)
if not parser:
raise Exception(f"Unsupported file format: {extension}")
return await parser(file_content)
# Singleton instance
document_parser = DocumentParser()

View File

@@ -0,0 +1,216 @@
"""
LLM Service for AI-powered question parsing and grading
"""
import os
import json
from typing import List, Dict, Any, Optional
from openai import AsyncOpenAI
from anthropic import AsyncAnthropic
import httpx
from models import QuestionType
from utils import calculate_content_hash
class LLMService:
"""Service for interacting with various LLM providers"""
def __init__(self):
self.provider = os.getenv("AI_PROVIDER", "openai")
if self.provider == "openai":
self.client = AsyncOpenAI(
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
)
self.model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
elif self.provider == "anthropic":
self.client = AsyncAnthropic(
api_key=os.getenv("ANTHROPIC_API_KEY")
)
self.model = os.getenv("ANTHROPIC_MODEL", "claude-3-haiku-20240307")
elif self.provider == "qwen":
self.client = AsyncOpenAI(
api_key=os.getenv("QWEN_API_KEY"),
base_url=os.getenv("QWEN_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
)
self.model = os.getenv("QWEN_MODEL", "qwen-plus")
else:
raise ValueError(f"Unsupported AI provider: {self.provider}")
async def parse_document(self, content: str) -> List[Dict[str, Any]]:
"""
Parse document content and extract questions.
Returns a list of dictionaries with question data:
[
{
"content": "Question text",
"type": "single/multiple/judge/short",
"options": ["A. Option1", "B. Option2", ...], # For choice questions
"answer": "Correct answer",
"analysis": "Explanation"
},
...
]
"""
prompt = """You are a professional question parser. Parse the given document and extract all questions.
For each question, identify:
1. Question content (the question text)
2. Question type: single (单选), multiple (多选), judge (判断), short (简答)
3. Options (for choice questions only, format: ["A. Option1", "B. Option2", ...])
4. Correct answer
5. Analysis/Explanation (if available)
Return ONLY a JSON array of questions, with no additional text:
[
{
"content": "question text",
"type": "single",
"options": ["A. Option1", "B. Option2", "C. Option3", "D. Option4"],
"answer": "A",
"analysis": "explanation"
},
...
]
Document content:
---
{content}
---
IMPORTANT: Return ONLY the JSON array, no markdown code blocks or explanations."""
try:
if self.provider == "anthropic":
response = await self.client.messages.create(
model=self.model,
max_tokens=4096,
messages=[
{"role": "user", "content": prompt.format(content=content)}
]
)
result = response.content[0].text
else: # OpenAI or Qwen
response = await self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a professional question parser. Return only JSON."},
{"role": "user", "content": prompt.format(content=content)}
],
temperature=0.3,
)
result = response.choices[0].message.content
# Clean result and parse JSON
result = result.strip()
if result.startswith("```json"):
result = result[7:]
if result.startswith("```"):
result = result[3:]
if result.endswith("```"):
result = result[:-3]
result = result.strip()
questions = json.loads(result)
# Add content hash to each question
for q in questions:
q["content_hash"] = calculate_content_hash(q["content"])
return questions
except Exception as e:
print(f"Error parsing document: {e}")
raise Exception(f"Failed to parse document: {str(e)}")
async def grade_short_answer(
self,
question: str,
correct_answer: str,
user_answer: str
) -> Dict[str, Any]:
"""
Grade a short answer question using AI.
Returns:
{
"score": 0.0-1.0,
"feedback": "Detailed feedback"
}
"""
prompt = f"""Grade the following short answer question.
Question: {question}
Standard Answer: {correct_answer}
Student Answer: {user_answer}
Provide a score from 0.0 to 1.0 (where 1.0 is perfect) and detailed feedback.
Return ONLY a JSON object:
{{
"score": 0.85,
"feedback": "Your detailed feedback here"
}}
Be fair but strict. Consider:
1. Correctness of key points
2. Completeness of answer
3. Clarity of expression
Return ONLY the JSON object, no markdown or explanations."""
try:
if self.provider == "anthropic":
response = await self.client.messages.create(
model=self.model,
max_tokens=1024,
messages=[
{"role": "user", "content": prompt}
]
)
result = response.content[0].text
else: # OpenAI or Qwen
response = await self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a fair and strict grader. Return only JSON."},
{"role": "user", "content": prompt}
],
temperature=0.5,
)
result = response.choices[0].message.content
# Clean and parse JSON
result = result.strip()
if result.startswith("```json"):
result = result[7:]
if result.startswith("```"):
result = result[3:]
if result.endswith("```"):
result = result[:-3]
result = result.strip()
grading = json.loads(result)
return {
"score": float(grading.get("score", 0.0)),
"feedback": grading.get("feedback", "")
}
except Exception as e:
print(f"Error grading answer: {e}")
# Return default grading on error
return {
"score": 0.0,
"feedback": "Unable to grade answer due to an error."
}
# Singleton instance
llm_service = LLMService()

83
backend/utils.py Normal file
View File

@@ -0,0 +1,83 @@
"""
Utility functions
"""
import hashlib
import re
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
import os
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# JWT settings
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-this")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
def hash_password(password: str) -> str:
"""Hash a password"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against a hash"""
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def decode_access_token(token: str) -> Optional[dict]:
"""Decode a JWT access token"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
return None
def normalize_content(content: str) -> str:
"""
Normalize content for deduplication.
Removes whitespace, punctuation, and converts to lowercase.
"""
# Remove all whitespace
normalized = re.sub(r'\s+', '', content)
# Remove punctuation
normalized = re.sub(r'[^\w\u4e00-\u9fff]', '', normalized)
# Convert to lowercase
normalized = normalized.lower()
return normalized
def calculate_content_hash(content: str) -> str:
"""
Calculate MD5 hash of normalized content for deduplication.
"""
normalized = normalize_content(content)
return hashlib.md5(normalized.encode('utf-8')).hexdigest()
def get_file_extension(filename: str) -> str:
"""Get file extension from filename"""
return filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
def is_allowed_file(filename: str) -> bool:
"""Check if file extension is allowed"""
allowed_extensions = {'txt', 'pdf', 'doc', 'docx', 'xlsx', 'xls'}
return get_file_extension(filename) in allowed_extensions

58
docker-compose.yml Normal file
View File

@@ -0,0 +1,58 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: qquiz_postgres
environment:
POSTGRES_USER: qquiz
POSTGRES_PASSWORD: qquiz_password
POSTGRES_DB: qquiz_db
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U qquiz"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: qquiz_backend
environment:
- DATABASE_URL=postgresql+asyncpg://qquiz:qquiz_password@postgres:5432/qquiz_db
env_file:
- .env
volumes:
- ./backend:/app
- upload_files:/app/uploads
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: qquiz_frontend
volumes:
- ./frontend:/app
- /app/node_modules
ports:
- "3000:3000"
environment:
- REACT_APP_API_URL=http://localhost:8000
depends_on:
- backend
command: npm start
volumes:
postgres_data:
upload_files:

9
frontend/.dockerignore Normal file
View File

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

4
frontend/.env.example Normal file
View File

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

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
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"]

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!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>

44
frontend/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "qquiz-frontend",
"version": "1.0.0",
"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

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

124
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,124 @@
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 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/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

166
frontend/src/api/client.js Normal file
View File

@@ -0,0 +1,166 @@
/**
* 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 || 'http://localhost:8000',
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('/api/auth/register', { username, password }),
login: (username, password) =>
api.post('/api/auth/login', { username, password }),
getCurrentUser: () =>
api.get('/api/auth/me'),
changePassword: (oldPassword, newPassword) =>
api.post('/api/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('/api/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(`/api/exams/${examId}/append`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
},
// Get user's exam list
getList: (skip = 0, limit = 20) =>
api.get('/api/exams/', { params: { skip, limit } }),
// Get exam detail
getDetail: (examId) =>
api.get(`/api/exams/${examId}`),
// Delete exam
delete: (examId) =>
api.delete(`/api/exams/${examId}`),
// Update quiz progress
updateProgress: (examId, currentIndex) =>
api.put(`/api/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(`/api/questions/exam/${examId}/questions`, { params: { skip, limit } }),
// Get current question (based on exam's current_index)
getCurrentQuestion: (examId) =>
api.get(`/api/questions/exam/${examId}/current`),
// Get question by ID
getById: (questionId) =>
api.get(`/api/questions/${questionId}`),
// Check answer
checkAnswer: (questionId, userAnswer) =>
api.post('/api/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('/api/mistakes/', { params })
},
// Add to mistake book
add: (questionId) =>
api.post('/api/mistakes/add', { question_id: questionId }),
// Remove from mistake book by mistake ID
remove: (mistakeId) =>
api.delete(`/api/mistakes/${mistakeId}`),
// Remove from mistake book by question ID
removeByQuestionId: (questionId) =>
api.delete(`/api/mistakes/question/${questionId}`)
}
// ============ Admin APIs ============
export const adminAPI = {
// Get system config
getConfig: () =>
api.get('/api/admin/config'),
// Update system config
updateConfig: (config) =>
api.put('/api/admin/config', config)
}
export default api

View File

@@ -0,0 +1,142 @@
/**
* 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

@@ -0,0 +1,28 @@
/**
* 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

@@ -0,0 +1,95 @@
/**
* 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>
)
}

22
frontend/src/index.css Normal file
View File

@@ -0,0 +1,22 @@
@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;
}

10
frontend/src/index.jsx Normal file
View File

@@ -0,0 +1,10 @@
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

@@ -0,0 +1,177 @@
/**
* Admin Settings Page
*/
import React, { useState, useEffect } from 'react'
import { adminAPI } from '../api/client'
import { useAuth } from '../context/AuthContext'
import { Settings, Save, Loader } from 'lucide-react'
import toast from 'react-hot-toast'
export const AdminSettings = () => {
const { user } = useAuth()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [config, setConfig] = useState({
allow_registration: true,
max_upload_size_mb: 10,
max_daily_uploads: 20,
ai_provider: 'openai'
})
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
})
}
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-4xl mx-auto px-4 py-6">
<div className="flex items-center gap-3">
<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>
{/* Content */}
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="bg-white rounded-xl shadow-md p-6 space-y-6">
{/* 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="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">
需在 .env 文件中配置对应的 API Key
</p>
</div>
{/* Save Button */}
<div className="pt-4">
<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>
</div>
)
}
export default AdminSettings

View File

@@ -0,0 +1,197 @@
/**
* 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
} 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>
<button
onClick={() => navigate('/admin/settings')}
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"
>
<Settings className="h-5 w-5" />
系统设置
</button>
</div>
</div>
)}
</div>
</Layout>
)
}
export default Dashboard

View File

@@ -0,0 +1,361 @@
/**
* Exam Detail Page - with append upload and status polling
*/
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { examAPI, questionAPI } from '../api/client'
import Layout from '../components/Layout'
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)
useEffect(() => {
loadExamDetail()
// Start polling if status is processing
const interval = setInterval(() => {
pollExamStatus()
}, 3000)
return () => clearInterval(interval)
}, [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)
} catch (error) {
console.error('Failed to load exam:', error)
toast.error('加载题库失败')
} finally {
setLoading(false)
}
}
const pollExamStatus = async () => {
try {
const response = await examAPI.getDetail(examId)
const newExam = response.data
// If status changed from processing to ready
if (exam?.status === 'processing' && newExam.status === 'ready') {
toast.success('文档解析完成!')
await loadExamDetail() // Reload to get updated questions
} else if (exam?.status === 'processing' && newExam.status === 'failed') {
toast.error('文档解析失败')
}
setExam(newExam)
} catch (error) {
console.error('Failed to poll exam:', error)
}
}
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)
await loadExamDetail()
} catch (error) {
console.error('Failed to append document:', 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 progress = 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>
{/* 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: `${progress}%` }}
></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

@@ -0,0 +1,304 @@
/**
* 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 })
await loadExams()
} catch (error) {
console.error('Failed to create exam:', error)
} 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

@@ -0,0 +1,122 @@
/**
* 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>
{/* Footer */}
<div className="mt-8 text-center text-sm text-gray-500">
<p>默认管理员账号admin / admin123</p>
</div>
</div>
</div>
)
}
export default Login

View File

@@ -0,0 +1,170 @@
/**
* 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

@@ -0,0 +1,397 @@
/**
* 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

@@ -0,0 +1,159 @@
/**
* 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

@@ -0,0 +1,117 @@
/**
* 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

@@ -0,0 +1,26 @@
/** @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: [],
}

19
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,19 @@
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'
}
})

88
run_local.sh Normal file
View File

@@ -0,0 +1,88 @@
#!/bin/bash
set -e
echo "===== QQuiz Local Deployment Script ====="
# Check if .env exists
if [ ! -f .env ]; then
echo "Error: .env file not found!"
echo "Please copy .env.example to .env and configure it."
exit 1
fi
# Load environment variables
export $(grep -v '^#' .env | xargs)
# Check Python version
PYTHON_VERSION=$(python3 --version 2>&1 | awk '{print $2}')
echo "Python version: $PYTHON_VERSION"
# Check if PostgreSQL is running
echo "Checking PostgreSQL connection..."
if ! pg_isready -h localhost -p 5432 &> /dev/null; then
echo "Warning: PostgreSQL is not running on localhost:5432"
echo "Please start PostgreSQL or use Docker: docker-compose up -d postgres"
read -p "Continue anyway? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
# Install backend dependencies
echo "Installing backend dependencies..."
cd backend
if [ ! -d "venv" ]; then
echo "Creating virtual environment..."
python3 -m venv venv
fi
source venv/bin/activate
pip install -r requirements.txt
# Run database migrations
echo "Running database migrations..."
alembic upgrade head || echo "Warning: Alembic migration failed. Database might not be initialized."
# Create uploads directory
mkdir -p uploads
# Start backend
echo "Starting backend on http://localhost:8000..."
uvicorn main:app --host 0.0.0.0 --port 8000 --reload &
BACKEND_PID=$!
cd ../frontend
# Install frontend dependencies
echo "Installing frontend dependencies..."
if [ ! -d "node_modules" ]; then
npm install
fi
# Start frontend
echo "Starting frontend on http://localhost:3000..."
npm start &
FRONTEND_PID=$!
echo ""
echo "===== QQuiz is running! ====="
echo "Backend: http://localhost:8000"
echo "Frontend: http://localhost:3000"
echo "API Docs: http://localhost:8000/docs"
echo ""
echo "Press Ctrl+C to stop all services"
# Handle cleanup on exit
cleanup() {
echo ""
echo "Stopping services..."
kill $BACKEND_PID $FRONTEND_PID 2>/dev/null
exit 0
}
trap cleanup SIGINT SIGTERM
# Wait for processes
wait