commit c5ecbeaec290f37623032c45991d97f740d66f37 Author: handsomezhuzhu <2658601135@qq.com> Date: Mon Dec 1 12:39:46 2025 +0800 🎉 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e14a26e --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..872baf7 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..ae34971 --- /dev/null +++ b/DEPLOYMENT.md @@ -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 diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md new file mode 100644 index 0000000..1ba77f1 --- /dev/null +++ b/PROJECT_STRUCTURE.md @@ -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 的完整架构!🎉 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6509b3 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# QQuiz - 智能刷题与题库管理平台 + +QQuiz 是一个支持 Docker/源码双模部署的智能刷题平台,核心功能包括多文件上传、自动去重、异步解析、断点续做和错题本管理。 + +## 功能特性 + +- 📚 **多文件上传与去重**: 支持向同一题库追加文档,自动识别并过滤重复题目 +- 🤖 **AI 智能解析**: 支持 OpenAI/Anthropic/Qwen 多种 AI 提供商 +- 📊 **断点续做**: 自动记录刷题进度,随时继续 +- ❌ **错题本管理**: 自动收集错题,支持手动添加/移除 +- 🎯 **多题型支持**: 单选、多选、判断、简答 +- 🔐 **权限管理**: 管理员配置、用户隔离 +- 📱 **移动端优先**: 完美适配手机端 + +## 快速开始 + +### 方式一:Docker Compose (推荐) + +```bash +# 1. 克隆项目 +git clone +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 diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..6a6e349 --- /dev/null +++ b/backend/.dockerignore @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..766dc8f --- /dev/null +++ b/backend/Dockerfile @@ -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 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..acd21c1 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..f4f2ee9 --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/.gitkeep b/backend/alembic/versions/.gitkeep new file mode 100644 index 0000000..ce87c06 --- /dev/null +++ b/backend/alembic/versions/.gitkeep @@ -0,0 +1 @@ +# This file ensures the versions directory is tracked by git diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..115baf1 --- /dev/null +++ b/backend/database.py @@ -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() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..53d8093 --- /dev/null +++ b/backend/main.py @@ -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"]) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..e15e26b --- /dev/null +++ b/backend/models.py @@ -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"" + + +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"" + + +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"" + + +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"" + + +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"" diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..7d0b251 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..39babd0 --- /dev/null +++ b/backend/routers/__init__.py @@ -0,0 +1,6 @@ +""" +Routers package +""" +from . import auth, exam, question, mistake, admin + +__all__ = ["auth", "exam", "question", "mistake", "admin"] diff --git a/backend/routers/admin.py b/backend/routers/admin.py new file mode 100644 index 0000000..ca0caef --- /dev/null +++ b/backend/routers/admin.py @@ -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) diff --git a/backend/routers/auth.py b/backend/routers/auth.py new file mode 100644 index 0000000..1051e3f --- /dev/null +++ b/backend/routers/auth.py @@ -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"} diff --git a/backend/routers/exam.py b/backend/routers/exam.py new file mode 100644 index 0000000..2594d68 --- /dev/null +++ b/backend/routers/exam.py @@ -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 diff --git a/backend/routers/mistake.py b/backend/routers/mistake.py new file mode 100644 index 0000000..ac7ba9d --- /dev/null +++ b/backend/routers/mistake.py @@ -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() diff --git a/backend/routers/question.py b/backend/routers/question.py new file mode 100644 index 0000000..869b98d --- /dev/null +++ b/backend/routers/question.py @@ -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 + ) diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..4481532 --- /dev/null +++ b/backend/schemas.py @@ -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 diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..6a401ea --- /dev/null +++ b/backend/services/__init__.py @@ -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" +] diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py new file mode 100644 index 0000000..41f8082 --- /dev/null +++ b/backend/services/auth_service.py @@ -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 diff --git a/backend/services/document_parser.py b/backend/services/document_parser.py new file mode 100644 index 0000000..3a7bacc --- /dev/null +++ b/backend/services/document_parser.py @@ -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() diff --git a/backend/services/llm_service.py b/backend/services/llm_service.py new file mode 100644 index 0000000..2ebe2be --- /dev/null +++ b/backend/services/llm_service.py @@ -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() diff --git a/backend/utils.py b/backend/utils.py new file mode 100644 index 0000000..74f37c4 --- /dev/null +++ b/backend/utils.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6d01d6e --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..c0bf172 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,9 @@ +node_modules +npm-debug.log +build +.git +.gitignore +.dockerignore +Dockerfile +.env +.env.local diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..ae54091 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,4 @@ +# Frontend Environment Variables + +# API URL +VITE_API_URL=http://localhost:8000 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..a53da8e --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4182b3c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + QQuiz - 智能刷题平台 + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..139512b --- /dev/null +++ b/frontend/package.json @@ -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" + ] + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..db27afb --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( + + +
+ + + + {/* Public Routes */} + } /> + } /> + + {/* Protected Routes */} + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + {/* Admin Only Routes */} + + + + } + /> + + {/* Default Route */} + } /> + + {/* 404 */} + } /> + +
+
+
+ ) +} + +export default App diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..2c249da --- /dev/null +++ b/frontend/src/api/client.js @@ -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 diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx new file mode 100644 index 0000000..f40bd1f --- /dev/null +++ b/frontend/src/components/Layout.jsx @@ -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 ( +
+ {/* Mobile Header */} +
+
+
+ + QQuiz +
+ +
+ + {/* Mobile Menu */} + {mobileMenuOpen && ( +
+ {navigation.map((item) => ( + 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.name} + + ))} + +
+ )} +
+ +
+ {/* Desktop Sidebar */} +
+
+ {/* Logo */} +
+
+ +
+
+

QQuiz

+

{user?.username}

+
+
+ + {/* Navigation */} + + + {/* Logout */} +
+ +
+
+
+ + {/* Main Content */} +
+ {children} +
+
+
+ ) +} + +export default Layout diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..512520f --- /dev/null +++ b/frontend/src/components/ProtectedRoute.jsx @@ -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 ( +
+
+
+ ) + } + + if (!user) { + return + } + + if (adminOnly && !user.is_admin) { + return + } + + return children +} diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000..e3ab9d7 --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -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 ( + + {children} + + ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..8970c1b --- /dev/null +++ b/frontend/src/index.css @@ -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; +} diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx new file mode 100644 index 0000000..995053a --- /dev/null +++ b/frontend/src/index.jsx @@ -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( + + + +) diff --git a/frontend/src/pages/AdminSettings.jsx b/frontend/src/pages/AdminSettings.jsx new file mode 100644 index 0000000..f7397bf --- /dev/null +++ b/frontend/src/pages/AdminSettings.jsx @@ -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 ( +
+ +
+ ) + } + + return ( +
+ {/* Header */} +
+
+
+ +
+

系统设置

+

管理员:{user?.username}

+
+
+
+
+ + {/* Content */} +
+
+ {/* Allow Registration */} +
+
+

允许用户注册

+

关闭后新用户无法注册

+
+ +
+ + {/* Max Upload Size */} +
+ + 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" + /> +

建议:5-20 MB

+
+ + {/* Max Daily Uploads */} +
+ + 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" + /> +

建议:10-50 次

+
+ + {/* AI Provider */} +
+ + +

+ 需在 .env 文件中配置对应的 API Key +

+
+ + {/* Save Button */} +
+ +
+
+
+
+ ) +} + +export default AdminSettings diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..6442fa3 --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -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 ( + +
+ {/* Welcome */} +
+

+ 欢迎回来,{user?.username}! +

+

继续你的学习之旅

+
+ + {/* Stats Cards */} +
+
navigate('/exams')} + > +
+
+ +
+ {stats.totalExams} +
+

题库总数

+
+ +
+
+
+ +
+ {stats.totalQuestions} +
+

题目总数

+
+ +
+
+
+ +
+ {stats.completedQuestions} +
+

已完成

+
+ +
navigate('/mistakes')} + > +
+
+ +
+ {stats.mistakeCount} +
+

错题数量

+
+
+ + {/* Recent Exams */} +
+
+

最近的题库

+ +
+ + {recentExams.length === 0 ? ( +
+ +

还没有题库,快去创建一个吧!

+
+ ) : ( +
+ {recentExams.map((exam) => ( +
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" + > +
+

{exam.title}

+ + {getStatusText(exam.status)} + +
+ +
+ + {exam.current_index} / {exam.total_questions} 题 + + {formatRelativeTime(exam.updated_at)} +
+ + {exam.total_questions > 0 && ( +
+
+
+ )} +
+ ))} +
+ )} +
+ + {/* Admin Quick Access */} + {isAdmin && ( +
+
+
+

管理员功能

+

配置系统设置

+
+ +
+
+ )} +
+
+ ) +} + +export default Dashboard diff --git a/frontend/src/pages/ExamDetail.jsx b/frontend/src/pages/ExamDetail.jsx new file mode 100644 index 0000000..6c53f08 --- /dev/null +++ b/frontend/src/pages/ExamDetail.jsx @@ -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 ( + +
+ +
+
+ ) + } + + if (!exam) { + return ( + +
+ +

题库不存在

+
+
+ ) + } + + 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 ( + +
+ {/* Back Button */} + + + {/* Header */} +
+
+
+

+ {exam.title} +

+
+ + {getStatusText(exam.status)} + + {isProcessing && ( + + + 正在处理中... + + )} +
+
+ + {/* Actions */} +
+ + + {isReady && exam.total_questions > 0 && ( + + )} +
+
+ + {/* Stats */} +
+
+

题目总数

+

{exam.total_questions}

+
+
+

已完成

+

{exam.current_index}

+
+
+

剩余

+

+ {Math.max(0, exam.total_questions - exam.current_index)} +

+
+
+

完成度

+

{progress}%

+
+
+ + {/* Progress Bar */} + {exam.total_questions > 0 && ( +
+
+
+
+
+ )} + + {/* Info */} +
+

创建时间:{formatDate(exam.created_at)}

+

最后更新:{formatDate(exam.updated_at)}

+
+
+ + {/* Failed Status Warning */} + {isFailed && ( +
+
+ +
+

文档解析失败

+

+ 请检查文档格式是否正确,或尝试重新上传。 +

+
+
+
+ )} + + {/* Questions Preview */} +
+

+ 题目预览 {questions.length > 0 && `(前 ${questions.length} 题)`} +

+ + {questions.length === 0 ? ( +
+ +

+ {isProcessing ? '正在解析文档,请稍候...' : '暂无题目'} +

+
+ ) : ( +
+ {questions.map((q, index) => ( +
+
+ + {index + 1} + +
+
+ + {getQuestionTypeText(q.type)} + +
+

{q.content}

+ {q.options && q.options.length > 0 && ( +
    + {q.options.map((opt, i) => ( +
  • {opt}
  • + ))} +
+ )} +
+
+
+ ))} +
+ )} +
+
+ + {/* Upload Modal */} + {showUploadModal && ( +
+
+

添加题目文档

+

+ 上传新文档后,系统会自动解析题目并去除重复题目。 +

+ +
+
+ + 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" + /> +

+ 支持:TXT, PDF, DOC, DOCX, XLSX, XLS +

+
+ +
+ + +
+
+
+
+ )} +
+ ) +} + +export default ExamDetail diff --git a/frontend/src/pages/ExamList.jsx b/frontend/src/pages/ExamList.jsx new file mode 100644 index 0000000..7c62c30 --- /dev/null +++ b/frontend/src/pages/ExamList.jsx @@ -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 ( + +
+ +
+
+ ) + } + + return ( + +
+ {/* Header */} +
+
+

题库管理

+

共 {exams.length} 个题库

+
+ +
+ + {/* Exam Grid */} + {exams.length === 0 ? ( +
+ +

还没有题库

+

创建第一个题库开始刷题吧!

+ +
+ ) : ( +
+ {exams.map((exam) => ( +
+ {/* Header */} +
+

+ {exam.title} +

+ + {getStatusText(exam.status)} + +
+ + {/* Stats */} +
+
+ 题目数量 + {exam.total_questions} +
+
+ 已完成 + + {exam.current_index} / {exam.total_questions} + +
+ {exam.total_questions > 0 && ( +
+
+
+ )} +
+ + {/* Time */} +

+ 创建于 {formatRelativeTime(exam.created_at)} +

+ + {/* Actions */} +
+ + +
+
+ ))} +
+ )} +
+ + {/* Create Modal */} + {showCreateModal && ( +
+
+

创建新题库

+ +
+ {/* Title */} +
+ + 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="例如:数据结构期末复习" + /> +
+ + {/* File */} +
+ + 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" + /> +

+ 支持:TXT, PDF, DOC, DOCX, XLSX, XLS +

+
+ + {/* Buttons */} +
+ + +
+
+
+
+ )} +
+ ) +} + +export default ExamList diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..c41d06b --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -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 ( +
+
+ {/* Logo and Title */} +
+
+
+ +
+
+

QQuiz

+

智能刷题与题库管理平台

+
+ + {/* Login Form */} +
+

登录

+ +
+ {/* Username */} +
+ + +
+ + {/* Password */} +
+ + +
+ + {/* Submit Button */} + +
+ + {/* Register Link */} +
+

+ 还没有账号?{' '} + + 立即注册 + +

+
+
+ + {/* Footer */} +
+

默认管理员账号:admin / admin123

+
+
+
+ ) +} + +export default Login diff --git a/frontend/src/pages/MistakeList.jsx b/frontend/src/pages/MistakeList.jsx new file mode 100644 index 0000000..adf27b6 --- /dev/null +++ b/frontend/src/pages/MistakeList.jsx @@ -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 ( + +
+ +
+
+ ) + } + + return ( + +
+ {/* Header */} +
+

错题本

+

共 {mistakes.length} 道错题

+
+ + {/* Empty State */} + {mistakes.length === 0 ? ( +
+ +

错题本是空的

+

继续刷题,错题会自动添加到这里

+
+ ) : ( +
+ {mistakes.map((mistake) => { + const q = mistake.question + const isExpanded = expandedId === mistake.id + + return ( +
+ {/* Question Preview */} +
toggleExpand(mistake.id)} + > +
+ + + + +
+
+ + {getQuestionTypeText(q.type)} + + + {formatRelativeTime(mistake.created_at)} + +
+ +

+ {q.content} +

+ + {isExpanded && ( +
+ {/* Options */} + {q.options && q.options.length > 0 && ( +
+ {q.options.map((opt, i) => ( +
+ {opt} +
+ ))} +
+ )} + + {/* Answer */} +
+

+ 正确答案 +

+

{q.answer}

+
+ + {/* Analysis */} + {q.analysis && ( +
+

+ 解析 +

+

{q.analysis}

+
+ )} +
+ )} +
+ + +
+
+
+ ) + })} +
+ )} +
+
+ ) +} + +export default MistakeList diff --git a/frontend/src/pages/QuizPlayer.jsx b/frontend/src/pages/QuizPlayer.jsx new file mode 100644 index 0000000..825cf19 --- /dev/null +++ b/frontend/src/pages/QuizPlayer.jsx @@ -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 ( + +
+ +
+
+ ) + } + + if (!question) { + return ( + +
+ +

没有更多题目了

+
+
+ ) + } + + return ( + +
+ {/* Header */} +
+ + +
+ 进度: {exam.current_index + 1} / {exam.total_questions} +
+
+ + {/* Question Card */} +
+ {/* Question Header */} +
+
+ + {exam.current_index + 1} + + + {getQuestionTypeText(question.type)} + +
+ + +
+ + {/* Question Content */} +
+

+ {question.content} +

+
+ + {/* Options (for choice questions) */} + {question.options && question.options.length > 0 && ( +
+ {question.options.map((option, index) => { + const letter = option.charAt(0) + const isSelected = question.type === 'multiple' + ? multipleAnswers.includes(letter) + : userAnswer === letter + + return ( + + ) + })} +
+ )} + + {/* Short Answer Input */} + {question.type === 'short' && !result && ( +
+