mirror of
https://github.com/handsomezhuzhu/QQuiz.git
synced 2026-02-20 12:00:14 +00:00
🎉 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:
36
.env.example
Normal file
36
.env.example
Normal 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
56
.gitignore
vendored
Normal 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
376
DEPLOYMENT.md
Normal 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
399
PROJECT_STRUCTURE.md
Normal 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
152
README.md
Normal 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
21
backend/.dockerignore
Normal 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
25
backend/Dockerfile
Normal 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
94
backend/alembic.ini
Normal 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
81
backend/alembic/env.py
Normal 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()
|
||||
24
backend/alembic/script.py.mako
Normal file
24
backend/alembic/script.py.mako
Normal 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"}
|
||||
1
backend/alembic/versions/.gitkeep
Normal file
1
backend/alembic/versions/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# This file ensures the versions directory is tracked by git
|
||||
132
backend/database.py
Normal file
132
backend/database.py
Normal 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
84
backend/main.py
Normal 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
134
backend/models.py
Normal 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
18
backend/requirements.txt
Normal 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
|
||||
6
backend/routers/__init__.py
Normal file
6
backend/routers/__init__.py
Normal 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
63
backend/routers/admin.py
Normal 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
130
backend/routers/auth.py
Normal 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
411
backend/routers/exam.py
Normal 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
192
backend/routers/mistake.py
Normal 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
228
backend/routers/question.py
Normal 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
160
backend/schemas.py
Normal 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
|
||||
13
backend/services/__init__.py
Normal file
13
backend/services/__init__.py
Normal 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"
|
||||
]
|
||||
78
backend/services/auth_service.py
Normal file
78
backend/services/auth_service.py
Normal 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
|
||||
121
backend/services/document_parser.py
Normal file
121
backend/services/document_parser.py
Normal 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()
|
||||
216
backend/services/llm_service.py
Normal file
216
backend/services/llm_service.py
Normal 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
83
backend/utils.py
Normal 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
58
docker-compose.yml
Normal 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
9
frontend/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
build
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
.env
|
||||
.env.local
|
||||
4
frontend/.env.example
Normal file
4
frontend/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# Frontend Environment Variables
|
||||
|
||||
# API URL
|
||||
VITE_API_URL=http://localhost:8000
|
||||
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal 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
14
frontend/index.html
Normal 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
44
frontend/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
124
frontend/src/App.jsx
Normal file
124
frontend/src/App.jsx
Normal 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
166
frontend/src/api/client.js
Normal 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
|
||||
142
frontend/src/components/Layout.jsx
Normal file
142
frontend/src/components/Layout.jsx
Normal 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
|
||||
28
frontend/src/components/ProtectedRoute.jsx
Normal file
28
frontend/src/components/ProtectedRoute.jsx
Normal 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
|
||||
}
|
||||
95
frontend/src/context/AuthContext.jsx
Normal file
95
frontend/src/context/AuthContext.jsx
Normal 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
22
frontend/src/index.css
Normal 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
10
frontend/src/index.jsx
Normal 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>
|
||||
)
|
||||
177
frontend/src/pages/AdminSettings.jsx
Normal file
177
frontend/src/pages/AdminSettings.jsx
Normal 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
|
||||
197
frontend/src/pages/Dashboard.jsx
Normal file
197
frontend/src/pages/Dashboard.jsx
Normal 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
|
||||
361
frontend/src/pages/ExamDetail.jsx
Normal file
361
frontend/src/pages/ExamDetail.jsx
Normal 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
|
||||
304
frontend/src/pages/ExamList.jsx
Normal file
304
frontend/src/pages/ExamList.jsx
Normal 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
|
||||
122
frontend/src/pages/Login.jsx
Normal file
122
frontend/src/pages/Login.jsx
Normal 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
|
||||
170
frontend/src/pages/MistakeList.jsx
Normal file
170
frontend/src/pages/MistakeList.jsx
Normal 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
|
||||
397
frontend/src/pages/QuizPlayer.jsx
Normal file
397
frontend/src/pages/QuizPlayer.jsx
Normal 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
|
||||
159
frontend/src/pages/Register.jsx
Normal file
159
frontend/src/pages/Register.jsx
Normal 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
|
||||
117
frontend/src/utils/helpers.js
Normal file
117
frontend/src/utils/helpers.js
Normal 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) + '...'
|
||||
}
|
||||
26
frontend/tailwind.config.js
Normal file
26
frontend/tailwind.config.js
Normal 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
19
frontend/vite.config.js
Normal 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
88
run_local.sh
Normal 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
|
||||
Reference in New Issue
Block a user