mirror of
https://github.com/handsomezhuzhu/QQuiz.git
synced 2026-02-20 20:10: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