diff --git a/.env.example b/.env.example index 70e2275..250549f 100644 --- a/.env.example +++ b/.env.example @@ -52,7 +52,3 @@ CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 # Upload Directory UPLOAD_DIR=./uploads - -# ESA Human Verification -VITE_ESA_PREFIX= -VITE_ESA_SCENE_ID= diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 57a2c32..e2687f0 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,4 +1,4 @@ -name: Build and Publish Docker Image +name: Build and Publish Docker Images on: push: @@ -18,6 +18,15 @@ jobs: permissions: contents: read packages: write + strategy: + matrix: + include: + - image_suffix: backend + context: ./backend + file: ./backend/Dockerfile + - image_suffix: frontend + context: ./web + file: ./web/Dockerfile steps: - name: Checkout repository @@ -37,7 +46,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-${{ matrix.image_suffix }} tags: | type=ref,event=branch type=semver,pattern={{version}} @@ -45,16 +54,17 @@ jobs: type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image + id: build uses: docker/build-push-action@v5 with: - context: . - file: ./Dockerfile + context: ${{ matrix.context }} + file: ${{ matrix.file }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha - cache-to: type=gha,mode=max + cache-to: type=gha,mode=max,scope=${{ matrix.image_suffix }} platforms: linux/amd64,linux/arm64 - name: Image digest - run: echo "Image pushed with digest ${{ steps.build-and-push.outputs.digest }}" + run: echo "Image pushed with digest ${{ steps.build.outputs.digest }}" diff --git a/.gitignore b/.gitignore index 4f510a9..3d49bab 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,9 @@ yarn-error.log # Build frontend/build/ frontend/dist/ +.next/ +web/.next/ +web/out/ # Testing .coverage diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6c99ff3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `backend/`: FastAPI API. Keep HTTP entrypoints in `routers/`, reusable business logic in `services/`, database definitions in `models.py`, request/response schemas in `schemas.py`, and migrations in `alembic/`. +- `frontend/`: React 18 + Vite client. Put route screens in `src/pages/`, shared UI in `src/components/`, auth state in `src/context/`, API wrappers in `src/api/`, and helpers in `src/utils/`. +- `docs/` holds deployment and architecture notes, `scripts/run_local.sh` bootstraps local Linux/macOS development, `test_data/` contains sample question files, and `.github/workflows/docker-publish.yml` publishes container images. + +## Build, Test, and Development Commands +- `docker compose up -d --build`: start MySQL, backend on `:8000`, and frontend on `:3000`. +- `docker compose -f docker-compose-single.yml up -d --build`: start the single-container SQLite deployment. +- `cd backend && pip install -r requirements.txt && alembic upgrade head && uvicorn main:app --reload --host 0.0.0.0 --port 8000`: run the API locally. +- `cd frontend && npm install && npm run dev`: start the Vite dev server. +- `cd frontend && npm run build`: create a production frontend bundle. + +## Coding Style & Naming Conventions +- Python uses 4-space indentation, `snake_case` for modules/functions, and `PascalCase` for ORM or Pydantic classes. +- React files use `PascalCase.jsx` for pages/components and `camelCase` for state, helpers, and API wrappers. +- Keep route handlers thin: validation in schemas, orchestration in routers, reusable logic in `backend/services/`. +- No formatter or lint script is enforced today, so match surrounding style before making broad formatting changes. + +## Testing Guidelines +- The repository currently has no committed automated test suite or coverage gate. +- Before opening a PR, smoke-test auth, exam creation/upload, parsing progress, quiz playback, mistake review, and admin settings. +- Use `test_data/sample_questions*.txt` for parser and import checks. +- If you add tests, place backend tests under `backend/tests/test_*.py` and frontend tests under `frontend/src/__tests__/`. + +## Commit & Pull Request Guidelines +- Recent history favors short, focused subjects, often imperative and sometimes Chinese, such as `安全修复和管理员账号密码自定义`. +- Keep each commit scoped to one change. PRs should include a summary, affected areas, config or migration notes, linked issues, and UI screenshots or GIFs for frontend changes. + +## Security & Configuration Tips +- Copy `.env.example` to `.env`; never commit real API keys or passwords. +- `SECRET_KEY` must be at least 32 characters, and `ADMIN_PASSWORD` at least 12. +- Update `.env.example` and relevant docs whenever configuration keys or security-sensitive defaults change. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..edd3fe7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 handsomezhuzhu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 76e5e56..8deef42 100644 --- a/README.md +++ b/README.md @@ -1,281 +1,186 @@ -# QQuiz - 智能刷题与题库管理平台 +# QQuiz -QQuiz 是一个支持 Docker/源码双模部署的智能刷题平台,核心功能包括多文件上传、自动去重、异步解析、断点续做和错题本管理。 +QQuiz 是一个面向题库管理与刷题训练的全栈应用,支持文档导入、异步解析、题目去重、断点续做、错题本与管理员配置。 -## 功能特性 +## 界面预览 -- 📚 **多文件上传与去重**: 支持向同一题库追加文档,自动识别并过滤重复题目 -- 🤖 **AI 智能解析**: 支持 Google Gemini (推荐) / OpenAI / Anthropic / Qwen 多种 AI 提供商 -- 📄 **原生 PDF 理解**: Gemini 支持直接处理 PDF(最多1000页),完整保留图片、表格、公式等内容 -- 🎓 **AI 参考答案**: 对于没有提供答案的题目,自动生成 AI 参考答案 -- 📊 **断点续做**: 自动记录刷题进度,随时继续 -- ❌ **错题本管理**: 自动收集错题,支持手动添加/移除 -- 🎯 **多题型支持**: 单选、多选、判断、简答 -- 🔐 **权限管理**: 管理员配置、用户隔离 -- 📱 **移动端优先**: 完美适配手机端 +![QQuiz 界面截图](docs/cover.png) + +## 核心能力 + +- 多文件导入与题目去重 +- 异步解析进度回传 +- 单选、多选、判断、简答题统一管理 +- 刷题进度保存与继续作答 +- 错题本与错题练习 +- 管理员用户管理与系统配置 +- 支持 Gemini / OpenAI / Anthropic / Qwen + +## 当前架构 + +- `backend/`:FastAPI + SQLAlchemy + Alembic +- `web/`:当前主前端,Next.js App Router + TypeScript + Tailwind CSS +- `frontend/`:保留中的 legacy Vite 前端,用于单容器兼容路径 + +说明: + +- 分离部署优先使用 `web/` +- 单容器镜像当前仍复用 `frontend/` 构建静态资源 ## 快速开始 -### 使用预构建镜像(最快) +### 1. 分离部署,推荐 -直接使用 GitHub 自动构建的镜像,无需等待本地构建: +前端运行在 `3000`,后端运行在 `8000`。 ```bash -# 1. 拉取最新镜像 -docker pull ghcr.io/handsomezhuzhu/qquiz:latest - -# 2. 配置环境变量 cp .env.example .env -# 编辑 .env,填入你的 API Key -# 3. 运行容器 -docker run -d \ - --name qquiz \ - -p 8000:8000 \ - -v $(pwd)/qquiz_data:/app/qquiz_data \ - --env-file .env \ - ghcr.io/handsomezhuzhu/qquiz:latest - -# 4. 访问应用: http://localhost:8000 +docker compose up -d --build ``` -**镜像说明:** -- **镜像地址**: `ghcr.io/handsomezhuzhu/qquiz:latest` -- **构建来源**: GitHub Actions 自动构建,每次 push 到 main 分支自动更新 -- **架构支持**: linux/amd64, linux/arm64(支持树莓派、Apple Silicon 等) -- **大小**: 约 400-500MB(包含前后端完整运行环境) -- **标签说明**: - - `latest`: 最新主分支版本 - - `v1.0.0`: 特定版本号(如果有 tag) +访问地址: -**数据持久化:** 使用 `-v` 参数挂载 `qquiz_data` 目录,包含 SQLite 数据库和上传文件,确保数据不会丢失。 +- 前端:`http://localhost:3000` +- 后端:`http://localhost:8000` +- 后端健康检查:`http://localhost:8000/health` -### 单容器部署(自行构建) - -从源码构建,一个容器包含前后端和 SQLite 数据库: +### 2. 分离部署 + MySQL ```bash -# 1. 配置环境变量(必须提供强密码和密钥) cp .env.example .env -# 编辑 .env,填入至少 32 位的 SECRET_KEY 和至少 12 位的 ADMIN_PASSWORD(建议使用随机生成值) -# 2. 启动服务(未设置强密码/密钥会直接报错终止) -SECRET_KEY=$(openssl rand -base64 48) \ -ADMIN_PASSWORD=$(openssl rand -base64 16) \ -docker-compose -f docker-compose-single.yml up -d - -# 3. 访问应用: http://localhost:8000 -# API 文档: http://localhost:8000/docs +docker compose -f docker-compose.yml -f docker-compose.mysql.yml up -d --build ``` -### 传统部署(3 个容器) +### 3. 单容器部署 -前后端分离 + MySQL: +单容器模式会把前端静态资源集成到后端服务中,统一通过 `8000` 提供。 ```bash -# 启动服务(建议直接在命令行生成强密钥和管理员密码) -SECRET_KEY=$(openssl rand -base64 48) \ -ADMIN_PASSWORD=$(openssl rand -base64 16) \ -docker-compose up -d +cp .env.example .env -# 前端: http://localhost:3000 -# 后端: http://localhost:8000 +docker compose -f docker-compose-single.yml up -d --build ``` -### 方式二:本地运行 +访问地址: + +- 应用:`http://localhost:8000` +- API 文档:`http://localhost:8000/docs` + +## 本地开发 + +### 后端 + +```bash +cd backend +pip install -r requirements.txt +alembic upgrade head +uvicorn main:app --reload --host 0.0.0.0 --port 8000 +``` + +### 新前端 + +```bash +cd web +npm install +npm run dev +``` + +### 旧前端 + +仅在兼容或迁移场景下需要: + +```bash +cd frontend +npm install +npm run dev +``` + +## 运行要求 -#### 前置要求 - Python 3.11+ - Node.js 18+ -- MySQL 8.0+ 或 Docker (用于运行 MySQL) +- Docker / Docker Compose -**Linux/macOS 用户:** -```bash -# 1. 配置环境变量 -cp .env.example .env -# 编辑 .env,修改 DATABASE_URL 为本地数据库地址 +## 关键环境变量 -# 2. 启动 MySQL -# macOS: brew services start mysql -# Linux: sudo systemctl start mysql +| 变量 | 说明 | +| --- | --- | +| `DATABASE_URL` | 数据库连接字符串 | +| `SECRET_KEY` | JWT 密钥,至少 32 位 | +| `ADMIN_PASSWORD` | 默认管理员密码,至少 12 位 | +| `AI_PROVIDER` | `gemini` / `openai` / `anthropic` / `qwen` | +| `GEMINI_API_KEY` | Gemini API Key | +| `OPENAI_API_KEY` | OpenAI API Key | +| `OPENAI_BASE_URL` | OpenAI 或兼容网关地址 | +| `ALLOW_REGISTRATION` | 是否允许注册 | +| `MAX_UPLOAD_SIZE_MB` | 单次上传大小限制 | +| `MAX_DAILY_UPLOADS` | 每日上传次数限制 | -# 3. 运行启动脚本 -chmod +x scripts/run_local.sh -./scripts/run_local.sh -``` +更多示例见 [`.env.example`](.env.example)。 -**MySQL 安装指南:** 详见 [docs/MYSQL_SETUP.md](docs/MYSQL_SETUP.md) +## 目录结构 -## GitHub Actions 自动构建设置 - -如果你 fork 了本项目并想启用自动构建 Docker 镜像功能: - -1. **启用 GitHub Actions**: - - 进入你的仓库 Settings → Actions → General - - 确保 "Actions permissions" 设置为 "Allow all actions" - -2. **启用 Packages 写入权限**: - - Settings → Actions → General - - 找到 "Workflow permissions" - - 选择 "Read and write permissions" - - 勾选 "Allow GitHub Actions to create and approve pull requests" - -3. **触发构建**: - - 推送代码到 `main` 分支会自动触发构建 - - 或者创建 tag(如 `v1.0.0`)会构建带版本号的镜像 - - 也可以在 Actions 页面手动触发 "Build and Publish Docker Image" workflow - -4. **查看构建的镜像**: - - 构建完成后,镜像会自动发布到 `ghcr.io/handsomezhuzhu/qquiz` - - 在仓库主页右侧 "Packages" 可以看到已发布的镜像 - - 镜像默认是私有的,如需公开:进入 Package 页面 → Package settings → Change visibility - -**镜像地址:** -```bash -# 拉取最新镜像 -docker pull ghcr.io/handsomezhuzhu/qquiz:latest -``` - -## 默认账户 - -**管理员账户:** -- 用户名: `admin` -- 密码: 取自环境变量 `ADMIN_PASSWORD`(必须至少 12 位,建议随机生成) - -⚠️ **重要**: 在部署前就必须设置强管理员密码;如果需要轮换密码,请更新环境变量后重启服务。 - -## 项目结构 - -``` +```text QQuiz/ -├── backend/ # FastAPI 后端 -│ ├── alembic/ # 数据库迁移 -│ ├── routers/ # API 路由 -│ ├── services/ # 业务逻辑 -│ ├── models.py # 数据模型 -│ ├── database.py # 数据库配置 -│ ├── main.py # 应用入口 -│ └── requirements.txt # Python 依赖 -├── frontend/ # React 前端 -│ ├── src/ -│ │ ├── api/ # API 客户端 -│ │ ├── pages/ # 页面组件 -│ │ ├── components/ # 通用组件 -│ │ └── App.jsx # 应用入口 -│ ├── package.json # Node 依赖 -│ └── vite.config.js # Vite 配置 -├── scripts/ # 部署和启动脚本 -│ └── run_local.sh # Linux/macOS 启动脚本 -├── docs/ # 文档目录 -│ ├── MYSQL_SETUP.md # MySQL 安装配置指南 -│ └── PROJECT_STRUCTURE.md # 项目架构详解 -├── test_data/ # 测试数据 -│ └── sample_questions.txt # 示例题目 -├── docker-compose.yml # Docker 编排 -├── .env.example # 环境变量模板 -└── README.md # 项目说明 +├─ backend/ FastAPI 后端 +│ ├─ alembic/ 数据库迁移 +│ ├─ routers/ API 路由 +│ ├─ services/ 业务服务 +│ ├─ models.py ORM 模型 +│ ├─ schemas.py Pydantic Schema +│ └─ main.py 应用入口 +├─ web/ Next.js 前端 +│ ├─ src/app/ App Router 页面与 API Route +│ ├─ src/components/ UI 与业务组件 +│ └─ src/lib/ 前端 API、鉴权、工具 +├─ frontend/ Legacy Vite 前端 +├─ docs/ 部署、审计与截图 +├─ test_data/ 示例题库文件 +├─ docker-compose.yml 前后端分离部署 +├─ docker-compose.mysql.yml MySQL overlay +├─ docker-compose-single.yml 单容器部署 +└─ Dockerfile 单容器镜像构建 ``` -## 核心业务流程 - -### 1. 创建题库 -用户首次上传文档时,创建新的 Exam (题库容器) - -### 2. 追加文档 -在已有题库详情页点击 "添加题目文档",上传新文件 - -### 3. 去重逻辑 -- 对题目内容进行标准化处理 (去空格、标点、转小写) -- 计算 MD5 Hash -- 仅在当前题库范围内查询去重 -- 仅插入 Hash 不存在的题目 - -### 4. 异步处理 -- 后台任务处理 AI 解析 -- 状态: `pending` → `processing` → `ready` / `failed` -- 前端轮询状态,自动刷新 - -### 5. 刷题体验 -- 基于 `current_index` 实现断点续做 -- 错题自动加入错题本 -- 简答题 AI 评分 - -## 环境变量说明 - -| 变量 | 说明 | 默认值 | -|------|------|--------| -| `DATABASE_URL` | 数据库连接字符串 | - | -| `SECRET_KEY` | JWT 密钥(必须至少 32 位随机字符串) | - | -| `ADMIN_PASSWORD` | 默认管理员密码(必须至少 12 位,建议随机生成) | - | -| `AI_PROVIDER` | AI 提供商 (gemini/openai/anthropic/qwen) | gemini | -| `GEMINI_API_KEY` | Google Gemini API 密钥 | - | -| `GEMINI_BASE_URL` | Gemini API 地址(可选,支持代理) | https://generativelanguage.googleapis.com | -| `GEMINI_MODEL` | Gemini 模型 | gemini-2.0-flash-exp | -| `OPENAI_API_KEY` | OpenAI API 密钥 | - | -| `OPENAI_BASE_URL` | OpenAI API 地址 | https://api.openai.com/v1 | -| `OPENAI_MODEL` | OpenAI 模型 | gpt-4o-mini | -| `ANTHROPIC_API_KEY` | Anthropic API 密钥 | - | -| `ANTHROPIC_MODEL` | Anthropic 模型 | claude-3-haiku-20240307 | -| `QWEN_API_KEY` | 通义千问 API 密钥 | - | -| `QWEN_BASE_URL` | 通义千问 API 地址 | https://dashscope.aliyuncs.com/compatible-mode/v1 | -| `QWEN_MODEL` | 通义千问模型 | qwen-plus | -| `ALLOW_REGISTRATION` | 是否允许注册 | true | -| `MAX_UPLOAD_SIZE_MB` | 最大上传文件大小 (MB) | 10 | -| `MAX_DAILY_UPLOADS` | 每日上传次数限制 | 20 | - -### AI 提供商对比 - -| 提供商 | PDF 原生支持 | 文本解析 | 推荐度 | 说明 | -|--------|--------------|----------|--------|------| -| **Google Gemini** | ✅ | ✅ | ⭐⭐⭐⭐⭐ | 支持原生 PDF(最多1000页),保留图片、表格、公式 | -| OpenAI | ❌ | ✅ | ⭐⭐⭐⭐ | 仅文本提取,PDF 会丢失格式和图片 | -| Anthropic | ❌ | ✅ | ⭐⭐⭐⭐ | 仅文本提取,PDF 会丢失格式和图片 | -| Qwen (通义千问) | ❌ | ✅ | ⭐⭐⭐ | 仅文本提取,PDF 会丢失格式和图片 | - -**推荐使用 Gemini**:如果你的题库包含 PDF 文件(特别是含有图片、公式、表格的学科试卷),强烈推荐使用 Gemini。 - -### 如何获取 API Key - -- **Google Gemini**: https://aistudio.google.com/apikey (免费额度充足) -- **OpenAI**: https://platform.openai.com/api-keys -- **Anthropic**: https://console.anthropic.com/settings/keys -- **Qwen (通义千问)**: https://dashscope.console.aliyun.com/apiKey - -### AI 配置方式 - -QQuiz 支持两种配置方式: - -1. **环境变量配置** (`.env` 文件):适合 Docker 部署和开发环境 -2. **数据库配置** (管理员后台):适合生产环境,支持在线修改,无需重启服务 - -**推荐流程**:首次部署使用环境变量,部署成功后通过管理员后台修改配置。 - -**Gemini 自定义代理**: 如果需要使用 Key 轮训服务或代理,可以在管理员后台配置 `GEMINI_BASE_URL`,支持自定义 Gemini API 地址。 - ## 技术栈 -**后端:** -- FastAPI - 现代化 Python Web 框架 -- SQLAlchemy 2.0 - 异步 ORM -- Alembic - 数据库迁移 -- MySQL 8.0 - 数据库 -- aiomysql - MySQL 异步驱动 -- Pydantic - 数据验证 +### 后端 -**前端:** -- React 18 - UI 框架 -- Vite - 构建工具 -- Tailwind CSS - 样式框架 -- React Router - 路由 -- Axios - HTTP 客户端 +- FastAPI +- SQLAlchemy 2.x +- Alembic +- SQLite / MySQL +- httpx +- OpenAI / Anthropic SDK -## 开发进度 +### 前端 -- [x] **Step 1**: Foundation & Models ✅ -- [ ] **Step 2**: Backend Core Logic -- [ ] **Step 3**: Frontend Config & API -- [ ] **Step 4**: Frontend Complex UI +- Next.js 14 App Router +- React 18 +- TypeScript +- Tailwind CSS +- TanStack Query +- Radix UI / shadcn 风格组件 -## License +## 构建检查 -MIT +常用检查命令: + +```bash +cd web && npm run build +docker compose build backend frontend +``` + +仓库当前没有完整自动化测试套件,提交前至少建议手动验证: + +- 登录 / 退出 +- 创建题库 / 上传文档 / 查看解析进度 +- 刷题与错题加入 +- 管理员用户管理与系统设置 +- 大数据量列表分页 + +## 开源协议 + +本项目采用 [MIT License](LICENSE)。 diff --git a/backend/Dockerfile b/backend/Dockerfile index 01cb0d5..27a876e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,24 +1,35 @@ +FROM python:3.11-slim AS builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libmagic1 \ + && rm -rf /var/lib/apt/lists/* + +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + FROM python:3.11-slim WORKDIR /app -# Install system dependencies (gcc for compiling Python packages) RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc \ + libmagic1 \ && rm -rf /var/lib/apt/lists/* -# Copy requirements and install Python dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +ENV PATH="/opt/venv/bin:$PATH" +ENV PYTHONUNBUFFERED=1 -# Copy application code +COPY --from=builder /opt/venv /opt/venv COPY . . -# Create uploads directory RUN mkdir -p uploads -# Expose port EXPOSE 8000 -# Run database migrations and start server CMD alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000 diff --git a/backend/routers/admin.py b/backend/routers/admin.py index 2bf2b82..81e7a53 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -2,7 +2,7 @@ Admin Router - 完备的管理员功能模块 参考 OpenWebUI 设计 """ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi.responses import StreamingResponse, FileResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, and_, or_, desc @@ -16,7 +16,8 @@ from database import get_db, engine from models import User, SystemConfig, Exam, Question, UserMistake, ExamStatus from schemas import ( SystemConfigUpdate, SystemConfigResponse, - UserResponse, UserCreate, UserUpdate, UserListResponse + UserResponse, UserCreate, UserUpdate, UserListResponse, + UserPasswordResetRequest, AdminUserSummary ) from services.auth_service import get_current_admin_user @@ -36,6 +37,11 @@ async def get_default_admin_id(db: AsyncSession) -> Optional[int]: return None +async def get_admin_count(db: AsyncSession) -> int: + result = await db.execute(select(func.count(User.id)).where(User.is_admin == True)) + return result.scalar() or 0 + + @router.get("/config", response_model=SystemConfigResponse) async def get_system_config( current_admin: User = Depends(get_current_admin_user), @@ -84,6 +90,9 @@ async def update_system_config( update_data = config_update.dict(exclude_unset=True) for key, value in update_data.items(): + if key.endswith("_api_key") and isinstance(value, str) and "..." in value: + continue + result = await db.execute( select(SystemConfig).where(SystemConfig.key == key) ) @@ -108,9 +117,9 @@ async def update_system_config( @router.get("/users", response_model=UserListResponse) async def get_users( - skip: int = 0, - limit: int = 50, - search: Optional[str] = None, + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + search: Optional[str] = Query(None, min_length=1, max_length=50), current_admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_db) ): @@ -121,10 +130,11 @@ async def get_users( - search: 搜索关键词(用户名) """ query = select(User) + normalized_search = search.strip() if search else None # 搜索过滤 - if search: - query = query.where(User.username.ilike(f"%{search}%")) + if normalized_search: + query = query.where(User.username.ilike(f"%{normalized_search}%")) # 统计总数 count_query = select(func.count()).select_from(query.subquery()) @@ -136,34 +146,41 @@ async def get_users( result = await db.execute(query) users = result.scalars().all() - # 为每个用户添加统计信息 + user_ids = [user.id for user in users] + exam_count_map = {} + mistake_count_map = {} + + if user_ids: + exam_count_result = await db.execute( + select(Exam.user_id, func.count(Exam.id)) + .where(Exam.user_id.in_(user_ids)) + .group_by(Exam.user_id) + ) + exam_count_map = {user_id: count for user_id, count in exam_count_result.all()} + + mistake_count_result = await db.execute( + select(UserMistake.user_id, func.count(UserMistake.id)) + .where(UserMistake.user_id.in_(user_ids)) + .group_by(UserMistake.user_id) + ) + mistake_count_map = { + user_id: count for user_id, count in mistake_count_result.all() + } + user_list = [] for user in users: - # 统计用户的题库数 - exam_count_query = select(func.count(Exam.id)).where(Exam.user_id == user.id) - exam_result = await db.execute(exam_count_query) - exam_count = exam_result.scalar() + user_list.append( + AdminUserSummary( + id=user.id, + username=user.username, + is_admin=user.is_admin, + created_at=user.created_at, + exam_count=exam_count_map.get(user.id, 0), + mistake_count=mistake_count_map.get(user.id, 0) + ) + ) - # 统计用户的错题数 - mistake_count_query = select(func.count(UserMistake.id)).where(UserMistake.user_id == user.id) - mistake_result = await db.execute(mistake_count_query) - mistake_count = mistake_result.scalar() - - user_list.append({ - "id": user.id, - "username": user.username, - "is_admin": user.is_admin, - "created_at": user.created_at, - "exam_count": exam_count, - "mistake_count": mistake_count - }) - - return { - "users": user_list, - "total": total, - "skip": skip, - "limit": limit - } + return UserListResponse(users=user_list, total=total, skip=skip, limit=limit) @router.post("/users", response_model=UserResponse) @@ -215,7 +232,19 @@ async def update_user( detail="User not found" ) + if user_data.username and user_data.username != user.username: + existing_user_result = await db.execute( + select(User).where(User.username == user_data.username) + ) + existing_user = existing_user_result.scalar_one_or_none() + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already exists" + ) + protected_admin_id = await get_default_admin_id(db) + admin_count = await get_admin_count(db) # 不允许修改默认管理员的管理员状态 if protected_admin_id and user.id == protected_admin_id and user_data.is_admin is not None: @@ -224,6 +253,12 @@ async def update_user( detail="Cannot modify default admin user's admin status" ) + if user.is_admin and user_data.is_admin is False and admin_count <= 1: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="At least one admin user must remain" + ) + # 更新字段 update_data = user_data.dict(exclude_unset=True) if "password" in update_data: @@ -238,6 +273,29 @@ async def update_user( return user +@router.post("/users/{user_id}/reset-password") +async def reset_user_password( + user_id: int, + payload: UserPasswordResetRequest, + current_admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_db) +): + """重置用户密码(仅管理员)""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + user.hashed_password = pwd_context.hash(payload.new_password) + await db.commit() + + return {"message": "Password reset successfully"} + + @router.delete("/users/{user_id}") async def delete_user( user_id: int, @@ -255,6 +313,7 @@ async def delete_user( ) protected_admin_id = await get_default_admin_id(db) + admin_count = await get_admin_count(db) # 不允许删除默认管理员 if protected_admin_id and user.id == protected_admin_id: @@ -270,6 +329,12 @@ async def delete_user( detail="Cannot delete yourself" ) + if user.is_admin and admin_count <= 1: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="At least one admin user must remain" + ) + await db.delete(user) await db.commit() diff --git a/backend/routers/exam.py b/backend/routers/exam.py index 9223841..b0dac2a 100644 --- a/backend/routers/exam.py +++ b/backend/routers/exam.py @@ -4,7 +4,7 @@ Exam Router - Handles exam creation, file upload, and deduplication from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, BackgroundTasks, Request from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_ +from sqlalchemy import select, func, and_, case from typing import List, Optional from datetime import datetime, timedelta import os @@ -17,7 +17,7 @@ from database import get_db from models import User, Exam, Question, ExamStatus, SystemConfig from schemas import ( ExamCreate, ExamResponse, ExamListResponse, - ExamUploadResponse, ParseResult, QuizProgressUpdate + ExamUploadResponse, ParseResult, QuizProgressUpdate, ExamSummaryResponse ) from services.auth_service import get_current_user from services.document_parser import document_parser @@ -684,6 +684,57 @@ async def get_user_exams( return ExamListResponse(exams=exams, total=total) +@router.get("/summary", response_model=ExamSummaryResponse) +async def get_exam_summary( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get aggregated exam statistics for current user.""" + + summary_query = select( + func.count(Exam.id), + func.coalesce(func.sum(Exam.total_questions), 0), + func.coalesce(func.sum(Exam.current_index), 0), + func.coalesce( + func.sum( + case((Exam.status == ExamStatus.PROCESSING, 1), else_=0) + ), + 0 + ), + func.coalesce( + func.sum( + case((Exam.status == ExamStatus.READY, 1), else_=0) + ), + 0 + ), + func.coalesce( + func.sum( + case((Exam.status == ExamStatus.FAILED, 1), else_=0) + ), + 0 + ) + ).where(Exam.user_id == current_user.id) + + result = await db.execute(summary_query) + ( + total_exams, + total_questions, + completed_questions, + processing_exams, + ready_exams, + failed_exams + ) = result.one() + + return ExamSummaryResponse( + total_exams=total_exams or 0, + total_questions=total_questions or 0, + completed_questions=completed_questions or 0, + processing_exams=processing_exams or 0, + ready_exams=ready_exams or 0, + failed_exams=failed_exams or 0 + ) + + @router.get("/{exam_id}", response_model=ExamResponse) async def get_exam_detail( exam_id: int, diff --git a/backend/schemas.py b/backend/schemas.py index 16f8f74..e4d3abb 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -38,6 +38,11 @@ class UserLogin(BaseModel): password: str +class PasswordChangeRequest(BaseModel): + old_password: str = Field(..., min_length=1) + new_password: str = Field(..., min_length=6) + + class Token(BaseModel): access_token: str token_type: str = "bearer" @@ -53,14 +58,23 @@ class UserResponse(BaseModel): from_attributes = True +class AdminUserSummary(UserResponse): + exam_count: int = 0 + mistake_count: int = 0 + + class UserListResponse(BaseModel): """用户列表响应(包含分页信息)""" - users: List[dict] # 包含额外统计信息的用户列表 + users: List[AdminUserSummary] total: int skip: int limit: int +class UserPasswordResetRequest(BaseModel): + new_password: str = Field(..., min_length=6) + + # ============ System Config Schemas ============ class SystemConfigUpdate(BaseModel): allow_registration: Optional[bool] = None @@ -124,6 +138,15 @@ class ExamListResponse(BaseModel): total: int +class ExamSummaryResponse(BaseModel): + total_exams: int + total_questions: int + completed_questions: int + processing_exams: int + ready_exams: int + failed_exams: int + + class ExamUploadResponse(BaseModel): exam_id: int title: str @@ -140,19 +163,31 @@ class ParseResult(BaseModel): # ============ Question Schemas ============ -class QuestionBase(BaseModel): +class QuestionPublicBase(BaseModel): content: str type: QuestionType options: Optional[List[str]] = None - answer: str analysis: Optional[str] = None +class QuestionBase(QuestionPublicBase): + answer: str + + class QuestionCreate(QuestionBase): exam_id: int -class QuestionResponse(QuestionBase): +class QuestionResponse(QuestionPublicBase): + id: int + exam_id: int + created_at: datetime + + class Config: + from_attributes = True + + +class QuestionWithAnswerResponse(QuestionBase): id: int exam_id: int created_at: datetime @@ -194,7 +229,7 @@ class MistakeResponse(BaseModel): id: int user_id: int question_id: int - question: QuestionResponse + question: QuestionWithAnswerResponse created_at: datetime class Config: diff --git a/backend/services/llm_service.py b/backend/services/llm_service.py index 5554e3d..2411e98 100644 --- a/backend/services/llm_service.py +++ b/backend/services/llm_service.py @@ -15,6 +15,25 @@ from utils import calculate_content_hash class LLMService: """Service for interacting with various LLM providers""" + @staticmethod + def _normalize_openai_base_url(base_url: Optional[str], default: str) -> str: + normalized = (base_url or default).rstrip("/") + if normalized.endswith("/v1"): + return normalized + + if normalized.count("/") <= 2: + return f"{normalized}/v1" + + return normalized + + @staticmethod + def _openai_compat_headers() -> Dict[str, str]: + """ + Some OpenAI-compatible gateways block the default OpenAI SDK user agent. + Use a neutral UA so requests behave like a generic HTTP client. + """ + return {"User-Agent": "QQuiz/1.0"} + def __init__(self, config: Optional[Dict[str, str]] = None): """ Initialize LLM Service with optional configuration. @@ -28,7 +47,10 @@ class LLMService: if self.provider == "openai": api_key = (config or {}).get("openai_api_key") or os.getenv("OPENAI_API_KEY") - base_url = (config or {}).get("openai_base_url") or os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") + base_url = self._normalize_openai_base_url( + (config or {}).get("openai_base_url") or os.getenv("OPENAI_BASE_URL"), + "https://api.openai.com/v1" + ) self.model = (config or {}).get("openai_model") or os.getenv("OPENAI_MODEL", "gpt-4o-mini") if not api_key: @@ -37,6 +59,7 @@ class LLMService: self.client = AsyncOpenAI( api_key=api_key, base_url=base_url, + default_headers=self._openai_compat_headers(), timeout=120.0, # 增加超时时间到 120 秒 max_retries=3 # 自动重试 3 次 ) @@ -69,6 +92,7 @@ class LLMService: self.client = AsyncOpenAI( api_key=api_key, base_url=base_url, + default_headers=self._openai_compat_headers(), timeout=120.0, # 增加超时时间到 120 秒 max_retries=3 # 自动重试 3 次 ) diff --git a/docker-compose-single.yml b/docker-compose-single.yml index 17a096d..799ee1a 100644 --- a/docker-compose-single.yml +++ b/docker-compose-single.yml @@ -16,6 +16,7 @@ services: environment: # 数据库配置(SQLite 默认,使用持久化卷) - DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db + - UPLOAD_DIR=/app/uploads volumes: # 持久化数据卷 @@ -25,7 +26,7 @@ services: restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + test: ["CMD", "python", "-c", "import sys, urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5); sys.exit(0)"] interval: 30s timeout: 10s retries: 3 diff --git a/docker-compose.mysql.yml b/docker-compose.mysql.yml new file mode 100644 index 0000000..08ebb43 --- /dev/null +++ b/docker-compose.mysql.yml @@ -0,0 +1,29 @@ +services: + mysql: + image: mysql:8.0 + container_name: qquiz_mysql + environment: + MYSQL_ROOT_PASSWORD: root_password + MYSQL_DATABASE: qquiz_db + MYSQL_USER: qquiz + MYSQL_PASSWORD: qquiz_password + volumes: + - mysql_data:/var/lib/mysql + ports: + - "3306:3306" + command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "qquiz", "-pqquiz_password"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + environment: + - DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@mysql:3306/qquiz_db + depends_on: + mysql: + condition: service_healthy + +volumes: + mysql_data: diff --git a/docker-compose.yml b/docker-compose.yml index 535a97e..c21d5c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,61 +1,47 @@ services: - mysql: - image: mysql:8.0 - container_name: qquiz_mysql - environment: - MYSQL_ROOT_PASSWORD: root_password - MYSQL_DATABASE: qquiz_db - MYSQL_USER: qquiz - MYSQL_PASSWORD: qquiz_password - volumes: - - mysql_data:/var/lib/mysql - ports: - - "3306:3306" - command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "qquiz", "-pqquiz_password"] - interval: 10s - timeout: 5s - retries: 5 - backend: build: context: ./backend dockerfile: Dockerfile container_name: qquiz_backend environment: - - DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@mysql:3306/qquiz_db + - DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db - SECRET_KEY=${SECRET_KEY:?Set SECRET_KEY to a random string of at least 32 characters} - ADMIN_PASSWORD=${ADMIN_PASSWORD:?Set ADMIN_PASSWORD to a strong password of at least 12 characters} + - UPLOAD_DIR=/app/uploads env_file: - .env volumes: - - ./backend:/app + - sqlite_data:/app/data - upload_files:/app/uploads ports: - "8000:8000" - depends_on: - mysql: - condition: service_healthy - command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload + healthcheck: + test: ["CMD", "python", "-c", "import sys, urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5); sys.exit(0)"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 40s frontend: build: - context: ./frontend + context: ./web dockerfile: Dockerfile container_name: qquiz_frontend - volumes: - - ./frontend:/app - - /app/node_modules ports: - "3000:3000" environment: - - VITE_API_URL=/api - - REACT_APP_API_URL=http://backend:8000 + - API_BASE_URL=http://backend:8000 depends_on: - - backend - command: npm start + backend: + condition: service_started + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/login').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 40s volumes: - mysql_data: + sqlite_data: upload_files: diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..3c11c36 --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,86 @@ +# QQuiz Execution Plan + +更新时间:2026-04-17 + +## 目标 + +把当前项目推进到可以持续开发和稳定验收的状态,重点落实: + +1. 默认 SQLite,兼容 MySQL +2. 前端界面简洁、可直接操作、少说明文字 +3. 用户管理达到可上市产品的基础要求 +4. push 后 GitHub 自动构建镜像 +5. 逐步完成旧 Vite 前端到新 Next 前端的替换 + +## 已完成的代码级工作 + +- 默认 Docker 拓扑已切到 SQLite +- MySQL 兼容拓扑已拆到 `docker-compose.mysql.yml` +- 新前端容器已接入并替换 Docker 默认前端 +- 管理员用户管理已经接入真实接口 +- 管理员设置页已经接入真实配置接口 +- 侧边栏选中态 bug 已修复 +- 新前端色彩已收敛为更简洁的产品风格 + +## 当前代码扫描后的主要问题 + +### 后端 + +1. 数据库迁移体系仍然不完整 +2. `LLMService` 仍存在启动期副作用 +3. 文档解析任务仍依赖进程内后台任务 +4. 题库导入并发与去重约束还没彻底补完 +5. 管理模块还缺用户状态、审计、批量能力 + +### 前端 + +1. Exam detail / Question / Mistake / Quiz 仍是占位或半占位页面 +2. 部分页面仍有旧迁移骨架内容,需要继续清理 +3. 确认类交互还没统一替换为更正式的对话框方案 +4. 视觉层还需要统一列表、表单、状态、分页组件 + +### 部署与文档 + +1. Compose 仍缺 dev/prod 分离 +2. 文档体系仍需把运行方式统一到 SQLite 默认 + MySQL 兼容 +3. CI 只有主干镜像构建,还缺 PR 验证与 smoke test + +## 后续执行顺序 + +### 第一阶段:后端稳定性 + +1. Alembic 基线迁移 +2. 去掉 `create_all` 正式职责 +3. 去掉 LLM import side effect +4. 统一事务边界 +5. 补用户状态字段与审计日志模型 + +### 第二阶段:前端业务页 + +1. 接通 `exams/[examId]` +2. 接通 `questions` +3. 接通 `mistakes` +4. 接通 `quiz/[examId]` +5. 接通 `mistake-quiz` + +### 第三阶段:用户管理产品化 + +1. 用户状态管理 +2. 审计日志 +3. 批量操作 +4. 更完整的密码与安全策略 + +### 第四阶段:工程化 + +1. Compose dev/prod 分离 +2. PR workflow +3. SQLite/MySQL 双栈 smoke +4. 文档统一 + +## 前端视觉要求 + +1. 主色:深蓝,作为动作与选中态 +2. 背景:浅灰蓝,不用大面积高饱和装饰 +3. 卡片:白底、细边框、轻阴影 +4. 状态色:成功绿、警告橙、错误红 +5. 页面信息结构:标题、数据、动作优先,减少解释文字 diff --git a/docs/TASKS.md b/docs/TASKS.md new file mode 100644 index 0000000..bbd5a13 --- /dev/null +++ b/docs/TASKS.md @@ -0,0 +1,92 @@ +# QQuiz Task Checklist + +更新时间:2026-04-17 + +## P0 运行基线 + +- [x] 默认 Docker 拓扑切回 SQLite +- [x] 保留 MySQL 兼容 Compose 覆盖文件 +- [x] 前后端容器可启动并完成最小探活 +- [x] GitHub Actions 改成 push 后自动构建 backend/frontend 镜像 +- [ ] 补开发/生产分离 Compose +- [ ] 补 PR 级别 build/smoke workflow +- [ ] 清理根目录 Docker 文档漂移 + +## P1 后端稳定性 + +- [x] 管理员配置接口忽略打码后的密钥回写 +- [x] 用户列表返回改为强类型 +- [x] 用户列表统计去掉 N+1 查询 +- [x] 最后一个管理员保护 +- [x] 管理员密码重置接口 +- [ ] 去掉启动期 `create_all` 作为正式迁移方式 +- [ ] 建 Alembic 初始迁移 +- [ ] 去掉 `LLMService` import side effect +- [ ] 收敛事务边界 +- [ ] 修 ingestion 并发与唯一约束 +- [ ] 规范健康检查和错误模型 + +## P2 用户管理 + +- [x] 用户搜索 +- [x] 创建用户 +- [x] 编辑用户 +- [x] 重置密码 +- [x] 删除用户 +- [ ] 用户状态字段(启用/禁用/锁定) +- [ ] 审计日志 +- [ ] 批量操作 +- [ ] 密码强度与重置流程优化 +- [ ] 默认管理员保护策略文档化 + +## P3 新前端基础层 + +- [x] Next.js App Router 骨架 +- [x] BFF 登录/登出/`/me` 代理 +- [x] 同源 API 代理 +- [x] SSE 代理入口 +- [x] 移除旧前端 ESA 人机验证 +- [ ] 中间件与服务端守卫完善 +- [ ] 错误页/空状态统一 +- [ ] URL 状态策略统一 + +## P4 页面迁移 + +### 已接入真实数据 + +- [x] Dashboard +- [x] Exams list +- [x] Exam detail +- [x] Questions list +- [x] Mistakes list +- [x] Quiz player +- [x] Mistake quiz +- [x] Admin user management +- [x] Admin settings + +### 待继续 + +- [ ] 上传/进度/失败重试链路 + +## P5 前端视觉与交互 + +- [x] 侧边栏选中态修复 +- [x] 新前端配色收敛为更简洁的产品风格 +- [x] 去掉大段迁移说明文案 +- [ ] 统一表格、表单、按钮、状态徽标 +- [ ] 清理页面中的占位内容 +- [ ] 替换 `window.confirm` 为统一对话框 +- [ ] 移动端布局细化 + +## P6 测试与验收 + +- [x] 旧前端构建通过 +- [x] 新前端构建通过 +- [x] Docker 最小登录链路验证 +- [x] 管理员配置、用户管理、上传解析、题目、错题、刷题链路验证 +- [x] 管理员与普通用户登录验证 +- [x] PowerShell smoke 脚本固化全流程验证 +- [ ] 后端集成测试 +- [ ] 前端 E2E 烟测 +- [ ] SQLite / MySQL 双栈验证 +- [ ] 用户管理回归用例 diff --git a/docs/audit/architecture.md b/docs/audit/architecture.md new file mode 100644 index 0000000..5abbf18 --- /dev/null +++ b/docs/audit/architecture.md @@ -0,0 +1,70 @@ +# QQuiz Architecture Audit + +## Scope + +This document records the current system shape and the approved target +direction for the ongoing refactor. + +Audit date: 2026-04-17 + +## Current Architecture + +### Backend + +- Runtime: FastAPI + SQLAlchemy async +- Database access: direct ORM session injection per request +- Task execution: in-process `BackgroundTasks` +- Progress streaming: in-memory `ProgressService` +- Schema management: mixed `create_all()` and Alembic placeholders + +### Frontend + +- Runtime: React 18 + Vite SPA +- Routing: `react-router-dom` +- Auth state: client-only `localStorage` token + context +- API transport: axios interceptor with browser redirects +- Styling: Tailwind CSS with page-local utility classes + +### Deployment + +- `docker-compose.yml`: development-oriented split stack +- `docker-compose-single.yml`: monolith container with SQLite +- `Dockerfile`: FastAPI serves the built SPA as static assets + +## Target Architecture + +### Backend + +- Keep FastAPI as the system API boundary +- Move heavy router logic into typed services +- Use Alembic as the only schema migration path +- Introduce durable ingestion execution semantics +- Replace implicit transaction patterns with explicit service-level boundaries + +### Frontend + +- New app in `web/` +- Stack: Next.js App Router + TypeScript + Tailwind + shadcn/ui +- Auth: `HttpOnly` session cookie mediated by Next route handlers +- Data fetching: `fetch` wrappers for server/client usage +- Streaming: Next proxy route for exam progress SSE + +### Deployment + +- Split deployment becomes the primary production shape +- Monolith mode remains secondary compatibility mode +- Development and production Compose files must be separated + +## Core Constraints + +1. Do not overwrite existing uncommitted user changes in the legacy frontend. +2. Keep the legacy `frontend/` app available until the new `web/` app reaches functional parity. +3. Preserve backend API contracts where possible during the frontend migration. +4. Fix deployment/documentation drift before treating new frontend work as production-ready. + +## Immediate Workstreams + +1. Remove abandoned ESA captcha wiring from the legacy frontend. +2. Write audit documents and freeze the migration backlog. +3. Scaffold the new `web/` frontend without disturbing the legacy app. +4. Fix first-order deployment issues such as health checks and documented mount paths. diff --git a/docs/audit/backend-findings.md b/docs/audit/backend-findings.md new file mode 100644 index 0000000..f13b814 --- /dev/null +++ b/docs/audit/backend-findings.md @@ -0,0 +1,86 @@ +# Backend Findings + +## Critical Findings + +### Schema lifecycle is unsafe + +- App startup still calls `create_all()` +- Alembic metadata exists but the migration chain is effectively empty +- This prevents controlled upgrades and rollbacks + +Files: + +- `backend/main.py` +- `backend/database.py` +- `backend/alembic/versions/.gitkeep` + +### Parsing tasks are not durable + +- Document ingestion runs inside FastAPI `BackgroundTasks` +- Progress state lives in-process only +- Process restarts or horizontal scaling can strand exams in `pending` or `processing` + +Files: + +- `backend/routers/exam.py` +- `backend/services/progress_service.py` + +### Transaction boundaries are inconsistent + +- `get_db()` performs commit/rollback automatically +- Routers and background tasks also call `commit()` directly +- SSE endpoints keep a database dependency open for long-lived streams + +Files: + +- `backend/database.py` +- `backend/routers/exam.py` + +## High-Priority Bugs + +### Admin config can destroy secrets + +- `GET /api/admin/config` masks API keys +- `PUT /api/admin/config` persists whatever the frontend sends back +- A round-trip save can replace the real secret with the masked placeholder + +Files: + +- `backend/routers/admin.py` + +### LLM service has import-time side effects + +- `LLMService()` is instantiated at module import time +- Missing environment variables can break startup before DB-backed config is loaded + +Files: + +- `backend/services/llm_service.py` + +### Ingestion deduplication is race-prone + +- No unique DB constraint on `(exam_id, content_hash)` +- Multiple append operations can race and insert duplicates + +Files: + +- `backend/models.py` +- `backend/routers/exam.py` + +### Answer checking degrades incorrectly on infra failure + +- Short-answer grading failures are converted into zero scores +- User mistake data can be polluted by provider outages or config errors + +Files: + +- `backend/services/llm_service.py` +- `backend/routers/question.py` + +## Refactor Order + +1. Replace runtime schema creation with Alembic-first migrations. +2. Move ingestion, config, and answer checking into service classes. +3. Introduce explicit transaction boundaries and idempotent ingestion rules. +4. Add durable task execution and real status/error semantics. +5. Add integration tests for config round-trips, ingestion races, and answer normalization. diff --git a/docs/audit/deployment-findings.md b/docs/audit/deployment-findings.md new file mode 100644 index 0000000..c47b0f4 --- /dev/null +++ b/docs/audit/deployment-findings.md @@ -0,0 +1,49 @@ +# Deployment Findings + +## Current Problems + +### Monolith persistence documentation is wrong + +- Existing `docker run` examples mounted the wrong path +- SQLite and upload persistence must target `/app/data` and `/app/uploads` + +### Monolith health check was broken + +- `docker-compose-single.yml` used `curl` +- The image does not guarantee `curl` exists +- The health check has been switched to Python stdlib HTTP probing + +### Split Compose is development-oriented + +- Source mounts are enabled +- Backend runs with `uvicorn --reload` +- Frontend runs a dev server +- This is not a production deployment model + +### Security posture is weak + +- Compose contains hard-coded MySQL credentials +- MySQL is exposed on `3306` +- Environment guidance is inconsistent across README, Compose, and `.env.example` + +## Approved Direction + +1. Treat split deployment as the default production topology. +2. Keep monolith deployment as a compatibility target only. +3. Separate development assets from production assets. +4. Validate all release images with smoke checks before publishing. + +## Backlog + +### Short term + +- Create `compose.dev.yml` and `compose.prod.yml` +- Remove dev-server assumptions from production documentation +- Add backend runtime dependencies explicitly to image builds +- Align README with actual mount paths and health checks + +### Medium term + +- Add PR build, typecheck, lint, and smoke-test workflows +- Publish separate images for API and Next web app +- Document rollback by image tag and Compose profile diff --git a/docs/audit/frontend-migration.md b/docs/audit/frontend-migration.md new file mode 100644 index 0000000..1632258 --- /dev/null +++ b/docs/audit/frontend-migration.md @@ -0,0 +1,70 @@ +# Frontend Migration Plan + +## Decision + +The legacy Vite SPA remains in `frontend/` as a fallback. + +The new frontend is being built in `web/` with: + +- Next.js App Router +- TypeScript +- Tailwind CSS +- shadcn/ui component model + +The abandoned ESA captcha integration has been removed from the legacy login page. + +## Why a Rewrite Instead of an In-Place Port + +The legacy frontend mixes too many browser-only assumptions into core runtime +boundaries: + +- token storage in `localStorage` +- `window.location` redirects inside transport code +- client-only route protection +- SSE token passing in query strings + +Those patterns do not map cleanly onto Next App Router and server-first auth. + +## New Runtime Model + +### Auth + +- Login goes through Next route handlers +- Backend JWT is stored in an `HttpOnly` cookie +- Browser code never reads the raw token + +### Data + +- Server pages use server-side fetch helpers +- Client mutations use browser-side fetch helpers against Next proxy routes +- URL state is used for pagination and filters + +### Streaming + +- Browser connects to a same-origin Next progress route +- The route reads the session cookie and proxies backend SSE +- Backend URL tokens are hidden from the browser + +## Directory Map + +```text +web/ + src/app/ + src/components/ + src/lib/ + src/middleware.ts +``` + +## Migration Order + +1. Auth shell, layouts, middleware, and proxy routes +2. Dashboard, exams list, questions list, and admin overview +3. Exam detail upload and progress streaming +4. Quiz and mistake-practice flows +5. Cutover, smoke testing, and legacy frontend retirement + +## Non-Goals for This First Slice + +- No immediate removal of the legacy `frontend/` +- No backend contract rewrite yet +- No server actions as the primary data mutation layer diff --git a/docs/cover.png b/docs/cover.png new file mode 100644 index 0000000..438d4d5 Binary files /dev/null and b/docs/cover.png differ diff --git a/frontend/index.html b/frontend/index.html index 2915a45..4182b3c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,14 +6,6 @@ QQuiz - 智能刷题平台 - - -
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index ff44b3a..70f2415 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -4,9 +4,53 @@ import axios from 'axios' import toast from 'react-hot-toast' +export const API_BASE_URL = import.meta.env.VITE_API_URL || '/api' +export const AUTH_TOKEN_STORAGE_KEY = 'access_token' + +const AUTH_USER_STORAGE_KEY = 'user' +const PUBLIC_REQUEST_PATHS = ['/auth/login', '/auth/register'] + +const getRequestPath = (config) => { + const url = config?.url || '' + + if (!url) return '' + + if (url.startsWith('http://') || url.startsWith('https://')) { + try { + return new URL(url).pathname + } catch (error) { + return url + } + } + + return url.startsWith('/') ? url : `/${url}` +} + +const isPublicRequest = (config) => { + if (config?.skipAuthHandling === true) { + return true + } + + const path = getRequestPath(config) + return PUBLIC_REQUEST_PATHS.some((publicPath) => path.endsWith(publicPath)) +} + +export const buildApiUrl = (path) => { + const base = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL + const normalizedPath = path.startsWith('/') ? path : `/${path}` + return `${base}${normalizedPath}` +} + +export const getAccessToken = () => localStorage.getItem(AUTH_TOKEN_STORAGE_KEY) + +export const clearAuthStorage = () => { + localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY) + localStorage.removeItem(AUTH_USER_STORAGE_KEY) +} + // Create axios instance const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL || '/api', + baseURL: API_BASE_URL, timeout: 30000, headers: { 'Content-Type': 'application/json' @@ -16,8 +60,9 @@ const api = axios.create({ // Request interceptor - Add auth token api.interceptors.request.use( (config) => { - const token = localStorage.getItem('access_token') - if (token) { + const token = getAccessToken() + if (token && !isPublicRequest(config)) { + config.headers = config.headers || {} config.headers.Authorization = `Bearer ${token}` } return config @@ -31,19 +76,26 @@ api.interceptors.request.use( api.interceptors.response.use( (response) => response, (error) => { + const status = error.response?.status const message = error.response?.data?.detail || 'An error occurred' + const requestConfig = error.config || {} + const hasAuthHeader = Boolean( + requestConfig.headers?.Authorization || requestConfig.headers?.authorization + ) - if (error.response?.status === 401) { - // Unauthorized - Clear token and redirect to login - localStorage.removeItem('access_token') - localStorage.removeItem('user') - window.location.href = '/login' + if (status === 401 && !isPublicRequest(requestConfig) && hasAuthHeader) { + clearAuthStorage() + if (window.location.pathname !== '/login') { + 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) { + } else if (status === 401) { toast.error(message) - } else if (error.response?.status >= 500) { + } else if (status === 403) { + toast.error('Permission denied') + } else if (status === 429) { + toast.error(message) + } else if (status >= 500) { toast.error('Server error. Please try again later.') } else { toast.error(message) @@ -56,10 +108,10 @@ api.interceptors.response.use( // ============ Auth APIs ============ export const authAPI = { register: (username, password) => - api.post('/auth/register', { username, password }), + api.post('/auth/register', { username, password }, { skipAuthHandling: true }), login: (username, password) => - api.post('/auth/login', { username, password }), + api.post('/auth/login', { username, password }, { skipAuthHandling: true }), getCurrentUser: () => api.get('/auth/me'), diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index e3ab9d7..ae38602 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -2,7 +2,7 @@ * Authentication Context */ import React, { createContext, useContext, useState, useEffect } from 'react' -import { authAPI } from '../api/client' +import { authAPI, AUTH_TOKEN_STORAGE_KEY, clearAuthStorage } from '../api/client' import toast from 'react-hot-toast' const AuthContext = createContext(null) @@ -22,15 +22,14 @@ export const AuthProvider = ({ children }) => { // Load user from localStorage on mount useEffect(() => { const loadUser = async () => { - const token = localStorage.getItem('access_token') + const token = localStorage.getItem(AUTH_TOKEN_STORAGE_KEY) 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') + clearAuthStorage() } } setLoading(false) @@ -45,7 +44,7 @@ export const AuthProvider = ({ children }) => { const { access_token } = response.data // Save token - localStorage.setItem('access_token', access_token) + localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, access_token) // Get user info const userResponse = await authAPI.getCurrentUser() @@ -71,8 +70,7 @@ export const AuthProvider = ({ children }) => { } const logout = () => { - localStorage.removeItem('access_token') - localStorage.removeItem('user') + clearAuthStorage() setUser(null) toast.success('Logged out successfully') } diff --git a/frontend/src/pages/ExamDetail.jsx b/frontend/src/pages/ExamDetail.jsx index a175e91..730a1ed 100644 --- a/frontend/src/pages/ExamDetail.jsx +++ b/frontend/src/pages/ExamDetail.jsx @@ -3,7 +3,7 @@ */ import React, { useState, useEffect, useRef } from 'react' import { useParams, useNavigate } from 'react-router-dom' -import { examAPI, questionAPI } from '../api/client' +import { examAPI, buildApiUrl, getAccessToken } from '../api/client' import ParsingProgress from '../components/ParsingProgress' import { ArrowLeft, Upload, Play, Loader, FileText, AlertCircle, RefreshCw, ArrowRight @@ -51,6 +51,8 @@ export const ExamDetail = () => { // Connect to SSE if exam is processing if (examRes.data.status === 'processing') { connectSSE() + } else { + setProgress(null) } } catch (error) { console.error('Failed to load exam:', error) @@ -68,8 +70,14 @@ export const ExamDetail = () => { console.log('[SSE] Connecting to progress stream for exam', examId) - const token = localStorage.getItem('token') - const url = `/api/exams/${examId}/progress?token=${encodeURIComponent(token)}` + const token = getAccessToken() + + if (!token) { + console.error('[SSE] Missing access token') + return + } + + const url = `${buildApiUrl(`/exams/${examId}/progress`)}?token=${encodeURIComponent(token)}` const eventSource = new EventSource(url) eventSourceRef.current = eventSource @@ -173,6 +181,9 @@ export const ExamDetail = () => { const isReady = exam.status === 'ready' const isFailed = exam.status === 'failed' const quizProgress = calculateProgress(exam.current_index, exam.total_questions) + const completionProgress = isProcessing + ? Math.round(Number(progress?.progress ?? 0)) + : quizProgress return ( <> @@ -252,17 +263,17 @@ export const ExamDetail = () => {

完成度

-

{isProcessing ? progress : quizProgress}%

+

{completionProgress}%

{/* Progress Bar */} - {exam.total_questions > 0 && ( + {(isProcessing || exam.total_questions > 0) && (
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index b0e56e3..73fdd71 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -1,7 +1,7 @@ /** * Login Page */ -import React, { useState, useEffect } from 'react' +import React, { useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import { BookOpen } from 'lucide-react' @@ -15,110 +15,21 @@ export const Login = () => { password: '' }) const [loading, setLoading] = useState(false) - const [captchaInstance, setCaptchaInstance] = useState(null) - - useEffect(() => { - // 确保 window.initAliyunCaptcha 存在且 DOM 元素已渲染 - const initCaptcha = () => { - if (window.initAliyunCaptcha && document.getElementById('captcha-element')) { - try { - window.initAliyunCaptcha({ - SceneId: import.meta.env.VITE_ESA_SCENE_ID, // 从环境变量读取场景ID - mode: "popup", // 弹出式 - element: "#captcha-element", // 渲染验证码的元素 - button: "#login-btn", // 触发验证码的按钮ID - success: async function (captchaVerifyParam) { - // 验证成功后的回调 - // 这里我们获取到了验证参数,虽然文档说要发给后端, - // 但 ESA 边缘拦截其实是在请求发出时检查 Cookie/Header - // 对于“一点即过”或“滑块”,SDK 会自动处理验证逻辑 - // 这里的 verifiedParam 是用来回传给服务端做二次校验的 - // 由于我们此时还没有登录逻辑,我们可以在这里直接提交表单 - // 即把 verifyParam 存下来,或者直接调用 login - - // 注意:由于是 form 的 onSubmit 触发,这里我们其实是在 form 提交被阻止(preventDefault)后 - // 由用户点击按钮触发了验证码,验证码成功后再执行真正的登录 - // 但 React 的 form 处理通常是 onSubmit - // 我们可以让按钮类型为 button 而不是 submit,点击触发验证码 - // 验证码成功后手动调用 handleSubmit 的逻辑 - - console.log('Captcha Success:', captchaVerifyParam); - handleLoginSubmit(captchaVerifyParam); - }, - fail: function (result) { - console.error('Captcha Failed:', result); - }, - getInstance: function (instance) { - setCaptchaInstance(instance); - }, - slideStyle: { - width: 360, - height: 40, - } - }); - } catch (error) { - console.error("Captcha init error:", error); - } - } - }; - // 如果脚本还没加载完,可能需要等待。为了简单起见,且我们在 index.html 加了 async - // 我们做一个简单的轮询或者依赖 script onload(但在 index.html 比较难控制) - // 或者直接延迟一下初始化 - const timer = setTimeout(initCaptcha, 500); - return () => clearTimeout(timer); - }, []); + const handleSubmit = async (e) => { + e.preventDefault() - const handleLoginSubmit = async (captchaParam) => { setLoading(true) try { - // 这里的 login 可能需要改造以接受验证码参数,或者利用 fetch 的拦截器 - // 如果是 ESA 边缘拦截,通常它会看请求里带不带特定的 Header/Cookie - // 文档示例里是手动 fetch 并且带上了 header: 'captcha-Verify-param' - // 暂时我们假设 login 函数内部不需要显式传参(通过 ESA 自动拦截),或者 ESA 需要 headers - // 为了安全,建议把 captchaParam 传给 login,让 login 放到 headers 里 - // 但现在我们先维持原样,或者您可以把 captchaParam 放到 sessionStorage 里由 axios 拦截器读取 - - // 注意:上面的 success 回调里我们直接调用了这个,说明验证通过了 const success = await login(formData.username, formData.password) if (success) { navigate('/dashboard') } } finally { setLoading(false) - if(captchaInstance) captchaInstance.refresh(); // 失败或完成后刷新验证码 } } - // 这里的 handleSubmit 变成只是触发验证码(如果也没通过验证的话) - // 但 ESA 示例是绑定 button,点击 button 直接出验证码 - // 所以我们可以把 type="submit" 变成 type="button" 且 id="login-btn" - const handlePreSubmit = (e) => { - e.preventDefault(); - // 此时不需要做任何事,因为按钮被 ESA 接管了,点击会自动弹窗 - // 只有验证成功了才会走 success -> handleLoginSubmit - // 但是!如果没填用户名密码怎么办? - // 最好在点击前校验表单。 - // ESA 的 button 参数会劫持点击事件。 - // 我们可以不绑定 button 参数,而是手动验证表单后,调用 captchaInstance.show() (如果是无痕或弹窗) - // 官方文档说绑定 button 是“触发验证码弹窗或无痕验证的元素” - // 如果我们保留 form submit,拦截它,如果表单有效,则手动 captchaInstance.show() (如果 SDK 支持) - // 文档说“无痕模式首次验证不支持 show/hide”。 - // 咱们还是按官方推荐绑定 button,但是这会导致校验逻辑变复杂 - - // 简化方案:为了不破坏现有逻辑,我们不绑定 button ? - // 不,必须绑定。那我们把“登录”按钮作为触发器。 - // 可是如果不填表单直接点登录 -> 验证码 -> 成功 -> 提交空表单 -> 报错。流程不太对。 - - // 更好的流程: - // 用户填表 -> 点击登录 -> 校验表单 -> (有效) -> 弹出验证码 -> (成功) -> 提交后端 - - // 我们可以做一个不可见的 button 绑定给 ESA,验证表单通过后,用代码模拟点击这个 button? - // 或者直接用 id="login-btn" 绑定当前的登录按钮, - // 但是在 success 回调里检查 formData 是否为空? - } - - const handleChange = (e) => { setFormData({ ...formData, @@ -144,8 +55,7 @@ export const Login = () => {

登录

- {/* 为了能正确使用 ESA,我们将 form 的 onSubmit 移除,改由按钮触发,或者保留 form 但不做提交 */} -
e.preventDefault()}> + {/* Username */}
- {/* ESA Captcha Container */} -
- - {/* Submit Button */} - {/* 绑定 id="login-btn" 供 ESA 使用 */} + +

+ 还没有账号? + + 立即注册 + +

+
+ + +
+ + ); +} diff --git a/web/src/app/(auth)/register/page.tsx b/web/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..76f47ea --- /dev/null +++ b/web/src/app/(auth)/register/page.tsx @@ -0,0 +1,94 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { ArrowRight } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; + +export default function RegisterPage() { + const router = useRouter(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setLoading(true); + + try { + const response = await fetch("/api/proxy/auth/register", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ username, password }) + }); + + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload?.detail || "注册失败"); + } + + toast.success("注册成功,请登录"); + router.push("/login"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "注册失败"); + } finally { + setLoading(false); + } + } + + return ( +
+ + + 创建账户 + + +
+
+ + setUsername(event.target.value)} + placeholder="3-50 位字母、数字、_ 或 -" + required + /> +
+ +
+ + setPassword(event.target.value)} + placeholder="至少 6 位" + required + /> +
+ + + +

+ 已有账号? + + 返回登录 + +

+
+
+
+
+ ); +} diff --git a/web/src/app/api/auth/login/route.ts b/web/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..d07c538 --- /dev/null +++ b/web/src/app/api/auth/login/route.ts @@ -0,0 +1,37 @@ +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; + +import { SESSION_COOKIE_NAME, buildBackendUrl } from "@/lib/api/config"; + +export async function POST(request: NextRequest) { + const body = await request.json(); + const forwardedProto = request.headers.get("x-forwarded-proto"); + const isSecureRequest = + request.nextUrl.protocol === "https:" || forwardedProto === "https"; + + const response = await fetch(buildBackendUrl("/auth/login"), { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(body), + cache: "no-store" + }); + + const payload = await response.json(); + + if (!response.ok) { + return NextResponse.json(payload, { status: response.status }); + } + + cookies().set({ + name: SESSION_COOKIE_NAME, + value: payload.access_token, + httpOnly: true, + sameSite: "lax", + secure: isSecureRequest, + path: "/" + }); + + return NextResponse.json({ ok: true }); +} diff --git a/web/src/app/api/auth/logout/route.ts b/web/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..a986089 --- /dev/null +++ b/web/src/app/api/auth/logout/route.ts @@ -0,0 +1,17 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +import { SESSION_COOKIE_NAME } from "@/lib/api/config"; + +async function clearSession() { + cookies().delete(SESSION_COOKIE_NAME); + return NextResponse.json({ ok: true }); +} + +export async function POST() { + return clearSession(); +} + +export async function GET() { + return clearSession(); +} diff --git a/web/src/app/api/auth/me/route.ts b/web/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..f06f679 --- /dev/null +++ b/web/src/app/api/auth/me/route.ts @@ -0,0 +1,30 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +import { + SESSION_COOKIE_NAME, + buildBackendUrl +} from "@/lib/api/config"; + +export async function GET() { + const token = cookies().get(SESSION_COOKIE_NAME)?.value; + + if (!token) { + return NextResponse.json({ detail: "Unauthorized" }, { status: 401 }); + } + + const response = await fetch(buildBackendUrl("/auth/me"), { + headers: { + Authorization: `Bearer ${token}` + }, + cache: "no-store" + }); + + const payload = await response.json(); + + if (response.status === 401) { + cookies().delete(SESSION_COOKIE_NAME); + } + + return NextResponse.json(payload, { status: response.status }); +} diff --git a/web/src/app/api/exams/[examId]/progress/route.ts b/web/src/app/api/exams/[examId]/progress/route.ts new file mode 100644 index 0000000..61c8990 --- /dev/null +++ b/web/src/app/api/exams/[examId]/progress/route.ts @@ -0,0 +1,42 @@ +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; + +import { + SESSION_COOKIE_NAME, + buildBackendUrl +} from "@/lib/api/config"; + +export async function GET( + _request: NextRequest, + { params }: { params: { examId: string } } +) { + const token = cookies().get(SESSION_COOKIE_NAME)?.value; + if (!token) { + return NextResponse.json({ detail: "Unauthorized" }, { status: 401 }); + } + + const target = `${buildBackendUrl(`/exams/${params.examId}/progress`)}?token=${encodeURIComponent(token)}`; + const response = await fetch(target, { + headers: { + Accept: "text/event-stream", + "Cache-Control": "no-cache" + }, + cache: "no-store" + }); + + if (!response.ok || !response.body) { + const payload = await response.text(); + return new NextResponse(payload || "Failed to open exam progress stream", { + status: response.status + }); + } + + return new NextResponse(response.body, { + status: response.status, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive" + } + }); +} diff --git a/web/src/app/api/proxy/[...path]/route.ts b/web/src/app/api/proxy/[...path]/route.ts new file mode 100644 index 0000000..c889904 --- /dev/null +++ b/web/src/app/api/proxy/[...path]/route.ts @@ -0,0 +1,82 @@ +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; + +import { + SESSION_COOKIE_NAME, + buildBackendUrl +} from "@/lib/api/config"; + +async function proxyRequest( + request: NextRequest, + params: { path: string[] } +) { + const token = cookies().get(SESSION_COOKIE_NAME)?.value; + const requestPath = params.path.join("/"); + const target = `${buildBackendUrl(`/${requestPath}`)}${request.nextUrl.search}`; + + const headers = new Headers(); + const contentType = request.headers.get("content-type"); + if (contentType) { + headers.set("Content-Type", contentType); + } + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + + const method = request.method; + const init: RequestInit = { + method, + headers, + cache: "no-store" + }; + + if (!["GET", "HEAD"].includes(method)) { + init.body = await request.arrayBuffer(); + } + + const response = await fetch(target, init); + const responseHeaders = new Headers(response.headers); + responseHeaders.delete("content-encoding"); + responseHeaders.delete("content-length"); + responseHeaders.delete("transfer-encoding"); + + return new NextResponse(response.body, { + status: response.status, + headers: responseHeaders + }); +} + +export async function GET( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + return proxyRequest(request, params); +} + +export async function POST( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + return proxyRequest(request, params); +} + +export async function PUT( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + return proxyRequest(request, params); +} + +export async function PATCH( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + return proxyRequest(request, params); +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + return proxyRequest(request, params); +} diff --git a/web/src/app/globals.css b/web/src/app/globals.css new file mode 100644 index 0000000..afec177 --- /dev/null +++ b/web/src/app/globals.css @@ -0,0 +1,47 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 210 25% 98%; + --foreground: 220 35% 12%; + --card: 0 0% 100%; + --card-foreground: 220 35% 12%; + --primary: 214 78% 34%; + --primary-foreground: 210 40% 98%; + --secondary: 210 24% 94%; + --secondary-foreground: 220 35% 18%; + --muted: 210 20% 96%; + --muted-foreground: 220 12% 42%; + --accent: 38 85% 92%; + --accent-foreground: 220 35% 18%; + --destructive: 0 76% 52%; + --destructive-foreground: 210 40% 98%; + --success: 154 63% 35%; + --success-foreground: 155 80% 96%; + --warning: 32 88% 45%; + --warning-foreground: 36 100% 96%; + --border: 214 24% 88%; + --input: 214 24% 88%; + --ring: 214 78% 34%; + --radius: 1.25rem; + } + + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground antialiased; + background-image: + radial-gradient(circle at top left, rgba(99, 142, 214, 0.1), transparent 22%), + linear-gradient(180deg, rgba(250, 252, 255, 0.98), rgba(245, 247, 250, 0.98)); + font-family: + "Space Grotesk", + "Noto Sans SC", + "PingFang SC", + "Microsoft YaHei", + sans-serif; + } +} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx new file mode 100644 index 0000000..1e07be5 --- /dev/null +++ b/web/src/app/layout.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from "next"; +import { Toaster } from "sonner"; + +import { QueryProvider } from "@/components/providers/query-provider"; +import "@/app/globals.css"; + +export const metadata: Metadata = { + title: "QQuiz Web", + description: "QQuiz Next.js frontend migration scaffold" +}; + +export default function RootLayout({ + children +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + {children} + + + + + ); +} diff --git a/web/src/app/loading.tsx b/web/src/app/loading.tsx new file mode 100644 index 0000000..80e19ac --- /dev/null +++ b/web/src/app/loading.tsx @@ -0,0 +1,7 @@ +export default function Loading() { + return ( +
+
+
+ ); +} diff --git a/web/src/app/not-found.tsx b/web/src/app/not-found.tsx new file mode 100644 index 0000000..7c418d6 --- /dev/null +++ b/web/src/app/not-found.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function NotFound() { + return ( +
+ + + 页面不存在 + + +

+ 这个路由还没有迁入新的 Next.js 前端,或者你访问了一个不存在的地址。 +

+ +
+
+
+ ); +} diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx new file mode 100644 index 0000000..f046ad8 --- /dev/null +++ b/web/src/app/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from "next/navigation"; + +import { readSessionToken } from "@/lib/auth/session"; + +export default function IndexPage() { + redirect(readSessionToken() ? "/dashboard" : "/login"); +} diff --git a/web/src/components/admin/settings-panel.tsx b/web/src/components/admin/settings-panel.tsx new file mode 100644 index 0000000..9f12b2f --- /dev/null +++ b/web/src/components/admin/settings-panel.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useState } from "react"; +import { Loader2, Save } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { browserApi } from "@/lib/api/browser"; +import { SystemConfigResponse } from "@/lib/types"; + +export function SettingsPanel({ + initialConfig +}: { + initialConfig: SystemConfigResponse; +}) { + const [config, setConfig] = useState(initialConfig); + const [saving, setSaving] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setSaving(true); + + try { + const payload = await browserApi("/admin/config", { + method: "PUT", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(config) + }); + setConfig(payload); + toast.success("设置已保存"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "保存失败"); + } finally { + setSaving(false); + } + } + + return ( +
+ + + 基础设置 + + + + +
+ + + setConfig((current) => ({ + ...current, + max_upload_size_mb: Number(event.target.value || 0) + })) + } + min={1} + /> +
+ +
+ + + setConfig((current) => ({ + ...current, + max_daily_uploads: Number(event.target.value || 0) + })) + } + min={1} + /> +
+ +
+ + +
+
+
+ + + + 模型配置 + + +
+ + + setConfig((current) => ({ ...current, openai_base_url: event.target.value })) + } + /> +
+
+ + + setConfig((current) => ({ ...current, openai_api_key: event.target.value })) + } + /> +
+
+ + + setConfig((current) => ({ ...current, gemini_model: event.target.value })) + } + /> +
+
+ + + setConfig((current) => ({ ...current, openai_model: event.target.value })) + } + /> +
+
+ + + setConfig((current) => ({ ...current, anthropic_model: event.target.value })) + } + /> +
+
+ + + setConfig((current) => ({ ...current, qwen_model: event.target.value })) + } + /> +
+
+
+ +
+ +
+
+ ); +} diff --git a/web/src/components/admin/user-management-panel.tsx b/web/src/components/admin/user-management-panel.tsx new file mode 100644 index 0000000..96f1559 --- /dev/null +++ b/web/src/components/admin/user-management-panel.tsx @@ -0,0 +1,358 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, Search, Shield, Trash2, UserPlus } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { PaginationControls } from "@/components/ui/pagination-controls"; +import { browserApi } from "@/lib/api/browser"; +import { formatDate } from "@/lib/formatters"; +import { AdminUserSummary } from "@/lib/types"; + +type EditingState = { + id: number | null; + username: string; + password: string; + isAdmin: boolean; +}; + +export function UserManagementPanel({ + initialPage, + initialSearch, + initialUsers, + initialTotal, + pageSize +}: { + initialPage: number; + initialSearch: string; + initialUsers: AdminUserSummary[]; + initialTotal: number; + pageSize: number; +}) { + const router = useRouter(); + const [search, setSearch] = useState(initialSearch); + const [users, setUsers] = useState(initialUsers); + const [total, setTotal] = useState(initialTotal); + const [submitting, setSubmitting] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const [editing, setEditing] = useState({ + id: null, + username: "", + password: "", + isAdmin: false + }); + + const isCreateMode = editing.id === null; + const title = isCreateMode ? "创建用户" : "编辑用户"; + + const activeAdminCount = useMemo( + () => users.filter((user) => user.is_admin).length, + [users] + ); + + useEffect(() => { + setSearch(initialSearch); + setUsers(initialUsers); + setTotal(initialTotal); + }, [initialSearch, initialUsers, initialTotal]); + + function buildAdminUrl(nextSearch: string, nextPage: number) { + const params = new URLSearchParams(window.location.search); + const normalizedSearch = nextSearch.trim(); + + if (nextPage <= 1) { + params.delete("page"); + } else { + params.set("page", String(nextPage)); + } + + if (normalizedSearch) { + params.set("search", normalizedSearch); + } else { + params.delete("search"); + } + + const query = params.toString(); + return query ? `/admin?${query}` : "/admin"; + } + + function startCreate() { + setEditing({ + id: null, + username: "", + password: "", + isAdmin: false + }); + } + + function startEdit(user: AdminUserSummary) { + setEditing({ + id: user.id, + username: user.username, + password: "", + isAdmin: user.is_admin + }); + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setSubmitting(true); + + try { + if (isCreateMode) { + await browserApi("/admin/users", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + username: editing.username, + password: editing.password, + is_admin: editing.isAdmin + }) + }); + toast.success("用户已创建"); + } else { + const updatePayload: Record = { + username: editing.username, + is_admin: editing.isAdmin + }; + + if (editing.password) { + updatePayload.password = editing.password; + } + + await browserApi(`/admin/users/${editing.id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(updatePayload) + }); + toast.success("用户已更新"); + } + + startCreate(); + router.refresh(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "保存失败"); + } finally { + setSubmitting(false); + } + } + + async function handleDelete(user: AdminUserSummary) { + if (!window.confirm(`确认删除用户 ${user.username}?`)) { + return; + } + + setDeletingId(user.id); + try { + await browserApi(`/admin/users/${user.id}`, { + method: "DELETE" + }); + toast.success("用户已删除"); + if (editing.id === user.id) { + startCreate(); + } + if (users.length === 1 && initialPage > 1) { + router.push(buildAdminUrl(search, initialPage - 1)); + } else { + router.refresh(); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "删除失败"); + } finally { + setDeletingId(null); + } + } + + async function handleResetPassword(user: AdminUserSummary) { + const nextPassword = window.prompt(`给 ${user.username} 设置新密码`, ""); + if (!nextPassword) { + return; + } + + try { + await browserApi(`/admin/users/${user.id}/reset-password`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + new_password: nextPassword + }) + }); + toast.success("密码已重置"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "重置失败"); + } + } + + function handleSearch(event: React.FormEvent) { + event.preventDefault(); + router.push(buildAdminUrl(search, 1)); + } + + return ( +
+ + + {title} + + +
+ + setEditing((current) => ({ ...current, username: event.target.value })) + } + required + /> + + setEditing((current) => ({ ...current, password: event.target.value })) + } + required={isCreateMode} + minLength={6} + /> + +
+ + {!isCreateMode ? ( + + ) : null} +
+
+
+
+ + + +
+ 用户 +
+ {total} 个用户 / {activeAdminCount} 个管理员 +
+
+
+
+ + setSearch(event.target.value)} + /> +
+ +
+
+ +
+ + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + ))} + {users.length === 0 ? ( + + + + ) : null} + +
用户角色题库错题创建时间操作
{user.username} + {user.is_admin ? "管理员" : "普通用户"} + {user.exam_count}{user.mistake_count}{formatDate(user.created_at)} +
+ + + +
+
+ 暂无用户 +
+
+ + +
+
+
+ ); +} diff --git a/web/src/components/app-shell/app-sidebar.tsx b/web/src/components/app-shell/app-sidebar.tsx new file mode 100644 index 0000000..820b5d5 --- /dev/null +++ b/web/src/components/app-shell/app-sidebar.tsx @@ -0,0 +1,77 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { BookMarked, LayoutDashboard, Settings, Shield, SquareStack, Target, XCircle } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; + +const baseNavigation = [ + { href: "/dashboard", label: "总览", icon: LayoutDashboard }, + { href: "/exams", label: "题库", icon: SquareStack }, + { href: "/questions", label: "题目", icon: BookMarked }, + { href: "/mistakes", label: "错题", icon: XCircle }, + { href: "/mistake-quiz", label: "错题练习", icon: Target } +]; + +const adminNavigation = [ + { href: "/admin", label: "管理", icon: Shield }, + { href: "/admin/settings", label: "系统设置", icon: Settings } +]; + +export function AppSidebar({ isAdmin }: { isAdmin: boolean }) { + const pathname = usePathname(); + const navigation = isAdmin + ? [...baseNavigation, ...adminNavigation] + : baseNavigation; + const activeHref = + navigation + .slice() + .sort((a, b) => b.href.length - a.href.length) + .find( + (item) => + pathname === item.href || + (item.href !== "/dashboard" && pathname.startsWith(`${item.href}/`)) + )?.href || ""; + + return ( + + ); +} diff --git a/web/src/components/app-shell/feature-placeholder.tsx b/web/src/components/app-shell/feature-placeholder.tsx new file mode 100644 index 0000000..77b2ed1 --- /dev/null +++ b/web/src/components/app-shell/feature-placeholder.tsx @@ -0,0 +1,65 @@ +import { ArrowRight, CheckCircle2 } from "lucide-react"; +import Link from "next/link"; + +import { PageHeader } from "@/components/app-shell/page-header"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@/components/ui/card"; + +export function FeaturePlaceholder({ + eyebrow, + title, + description, + bullets, + ctaHref = "/dashboard", + ctaLabel = "返回首页" +}: { + eyebrow: string; + title: string; + description?: string; + bullets: string[]; + ctaHref?: string; + ctaLabel?: string; +}) { + return ( +
+ + +
+ + + 待接入 + 下一步会接真实数据和操作。 + + + {bullets.map((bullet) => ( +
+ +

{bullet}

+
+ ))} +
+
+ + + + 操作 + + + + + +
+
+ ); +} diff --git a/web/src/components/app-shell/logout-button.tsx b/web/src/components/app-shell/logout-button.tsx new file mode 100644 index 0000000..84b5eb9 --- /dev/null +++ b/web/src/components/app-shell/logout-button.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { LogOut } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; + +export function LogoutButton() { + const router = useRouter(); + + async function handleLogout() { + const response = await fetch("/api/auth/logout", { + method: "POST" + }); + + if (!response.ok) { + toast.error("退出失败"); + return; + } + + toast.success("已退出登录"); + router.push("/login"); + router.refresh(); + } + + return ( + + ); +} diff --git a/web/src/components/app-shell/page-header.tsx b/web/src/components/app-shell/page-header.tsx new file mode 100644 index 0000000..a421f87 --- /dev/null +++ b/web/src/components/app-shell/page-header.tsx @@ -0,0 +1,27 @@ +import { Badge } from "@/components/ui/badge"; + +export function PageHeader({ + eyebrow, + title, + description +}: { + eyebrow?: string; + title: string; + description?: string; +}) { + return ( +
+ {eyebrow ? {eyebrow} : null} +
+

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null} +
+
+ ); +} diff --git a/web/src/components/app-shell/stat-card.tsx b/web/src/components/app-shell/stat-card.tsx new file mode 100644 index 0000000..50d2c38 --- /dev/null +++ b/web/src/components/app-shell/stat-card.tsx @@ -0,0 +1,38 @@ +import { LucideIcon } from "lucide-react"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@/components/ui/card"; + +export function StatCard({ + icon: Icon, + label, + value, + detail +}: { + icon: LucideIcon; + label: string; + value: string; + detail?: string; +}) { + return ( + + +
+
+ +
+
+ {label} + {value} +
+
+
+ {detail ? {detail} : null} +
+ ); +} diff --git a/web/src/components/app-shell/status-badge.tsx b/web/src/components/app-shell/status-badge.tsx new file mode 100644 index 0000000..5f13e73 --- /dev/null +++ b/web/src/components/app-shell/status-badge.tsx @@ -0,0 +1,15 @@ +import { Badge } from "@/components/ui/badge"; +import { getExamStatusLabel } from "@/lib/formatters"; + +export function StatusBadge({ status }: { status: string }) { + const variant = + status === "ready" + ? "success" + : status === "failed" + ? "destructive" + : status === "processing" + ? "warning" + : "outline"; + + return {getExamStatusLabel(status)}; +} diff --git a/web/src/components/exams/exam-detail-client.tsx b/web/src/components/exams/exam-detail-client.tsx new file mode 100644 index 0000000..026a179 --- /dev/null +++ b/web/src/components/exams/exam-detail-client.tsx @@ -0,0 +1,232 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { AlertCircle, FileText, Loader2, Play, RefreshCw, Upload } from "lucide-react"; +import { toast } from "sonner"; + +import { StatusBadge } from "@/components/app-shell/status-badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { browserApi } from "@/lib/api/browser"; +import { formatDate } from "@/lib/formatters"; +import { ExamSummary, ExamUploadResponse, ProgressEvent } from "@/lib/types"; + +export function ExamDetailClient({ + initialExam +}: { + initialExam: ExamSummary; +}) { + const router = useRouter(); + const eventSourceRef = useRef(null); + const [exam, setExam] = useState(initialExam); + const [selectedFile, setSelectedFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(null); + + const isProcessing = exam.status === "processing"; + + useEffect(() => { + if (!isProcessing) { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + return; + } + + const source = new EventSource(`/api/exams/${exam.id}/progress`); + eventSourceRef.current = source; + + source.onmessage = (event) => { + const payload = JSON.parse(event.data) as ProgressEvent; + setProgress(payload); + + if (payload.status === "completed") { + toast.success(payload.message); + source.close(); + eventSourceRef.current = null; + reloadExam(); + } + + if (payload.status === "failed") { + toast.error(payload.message); + source.close(); + eventSourceRef.current = null; + reloadExam(); + } + }; + + source.onerror = () => { + source.close(); + eventSourceRef.current = null; + }; + + return () => { + source.close(); + eventSourceRef.current = null; + }; + }, [isProcessing, exam.id]); + + async function reloadExam() { + try { + const payload = await browserApi(`/exams/${exam.id}`, { + method: "GET" + }); + setExam(payload); + router.refresh(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "刷新失败"); + } + } + + async function handleUpload(event: React.FormEvent) { + event.preventDefault(); + if (!selectedFile) { + toast.error("请选择文件"); + return; + } + + const formData = new FormData(); + formData.append("file", selectedFile); + + setUploading(true); + try { + const payload = await browserApi(`/exams/${exam.id}/append`, { + method: "POST", + body: formData + }); + setExam((current) => ({ ...current, status: payload.status as ExamSummary["status"] })); + setProgress(null); + setSelectedFile(null); + toast.success("文档已提交"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "上传失败"); + } finally { + setUploading(false); + } + } + + const progressValue = useMemo(() => { + if (isProcessing) { + return Math.round(Number(progress?.progress || 0)); + } + + if (exam.total_questions <= 0) { + return 0; + } + + return Math.round((exam.current_index / exam.total_questions) * 100); + }, [exam.current_index, exam.total_questions, isProcessing, progress]); + + return ( +
+ + +
+ {exam.title} + +
+
+ + {exam.total_questions > 0 ? ( + + ) : null} +
+
+ +
+
+
题目
+
+ {exam.total_questions} +
+
+
+
已完成
+
+ {exam.current_index} +
+
+
+
剩余
+
+ {Math.max(0, exam.total_questions - exam.current_index)} +
+
+
+
进度
+
+ {progressValue}% +
+
+
+ +
+
+
+
+ {progress ? ( +
{progress.message}
+ ) : null} +
+ +
+
创建时间:{formatDate(exam.created_at)}
+
更新时间:{formatDate(exam.updated_at)}
+
+ + {exam.status === "failed" ? ( +
+ + 解析失败,请重新上传文档。 +
+ ) : null} + + + + + + 追加文档 + + +
+ setSelectedFile(event.target.files?.[0] || null)} + required + /> + +
+ +
+
+ + 支持 TXT / PDF / DOC / DOCX / XLSX / XLS +
+
处理过程中会自动去重。
+
+
+
+
+ ); +} diff --git a/web/src/components/exams/exams-page-client.tsx b/web/src/components/exams/exams-page-client.tsx new file mode 100644 index 0000000..2034792 --- /dev/null +++ b/web/src/components/exams/exams-page-client.tsx @@ -0,0 +1,219 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Loader2, Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { StatusBadge } from "@/components/app-shell/status-badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { PaginationControls } from "@/components/ui/pagination-controls"; +import { browserApi } from "@/lib/api/browser"; +import { formatDate, formatRelativeTime } from "@/lib/formatters"; +import { ExamSummary } from "@/lib/types"; + +export function ExamsPageClient({ + initialExams, + initialTotal, + page, + pageSize +}: { + initialExams: ExamSummary[]; + initialTotal: number; + page: number; + pageSize: number; +}) { + const router = useRouter(); + const [title, setTitle] = useState(""); + const [file, setFile] = useState(null); + const [creating, setCreating] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const [exams, setExams] = useState(initialExams); + const [total, setTotal] = useState(initialTotal); + + useEffect(() => { + setExams(initialExams); + setTotal(initialTotal); + }, [initialExams, initialTotal]); + + function goToPage(targetPage: number) { + const params = new URLSearchParams(window.location.search); + if (targetPage <= 1) { + params.delete("page"); + } else { + params.set("page", String(targetPage)); + } + + const query = params.toString(); + router.push(query ? `/exams?${query}` : "/exams"); + } + + async function handleCreate(event: React.FormEvent) { + event.preventDefault(); + if (!file) { + toast.error("请选择文件"); + return; + } + + const formData = new FormData(); + formData.append("title", title); + formData.append("file", file); + formData.append("is_random", "false"); + + setCreating(true); + try { + const response = await browserApi<{ exam_id: number }>("/exams/create", { + method: "POST", + body: formData + }); + + toast.success("题库已创建"); + setTitle(""); + setFile(null); + router.push(`/exams/${response.exam_id}`); + router.refresh(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "创建失败"); + } finally { + setCreating(false); + } + } + + async function handleDelete(examId: number) { + if (!window.confirm("确认删除这个题库?")) { + return; + } + + setDeletingId(examId); + try { + await browserApi(`/exams/${examId}`, { + method: "DELETE" + }); + + setExams((current) => current.filter((exam) => exam.id !== examId)); + setTotal((current) => Math.max(0, current - 1)); + toast.success("题库已删除"); + if (exams.length === 1 && page > 1) { + goToPage(page - 1); + } else { + router.refresh(); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "删除失败"); + } finally { + setDeletingId(null); + } + } + + return ( +
+ + + 新建题库 + + +
+ setTitle(event.target.value)} + placeholder="题库名称" + required + /> + setFile(event.target.files?.[0] || null)} + required + /> + +
+
+
+ + + +
+ 题库列表 +
{total} 条
+
+
+ + {exams.length === 0 ? ( +
+ 暂无题库 +
+ ) : ( +
+ + + + + + + + + + + + {exams.map((exam) => ( + + + + + + + + ))} + +
名称状态题目更新时间操作
+ + {exam.title} + +
{formatDate(exam.created_at)}
+
+ + + {exam.current_index}/{exam.total_questions} + + {formatRelativeTime(exam.updated_at)} + +
+ + +
+
+
+ )} + + +
+
+
+ ); +} diff --git a/web/src/components/mistakes/mistake-list-client.tsx b/web/src/components/mistakes/mistake-list-client.tsx new file mode 100644 index 0000000..024db06 --- /dev/null +++ b/web/src/components/mistakes/mistake-list-client.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { PaginationControls } from "@/components/ui/pagination-controls"; +import { browserApi } from "@/lib/api/browser"; +import { formatDate, getQuestionTypeLabel } from "@/lib/formatters"; +import { MistakeListResponse } from "@/lib/types"; + +type MistakeItem = MistakeListResponse["mistakes"][number]; + +export function MistakeListClient({ + initialMistakes, + initialTotal, + page, + pageSize +}: { + initialMistakes: MistakeItem[]; + initialTotal: number; + page: number; + pageSize: number; +}) { + const router = useRouter(); + const [mistakes, setMistakes] = useState(initialMistakes); + const [total, setTotal] = useState(initialTotal); + const [deletingId, setDeletingId] = useState(null); + + useEffect(() => { + setMistakes(initialMistakes); + setTotal(initialTotal); + }, [initialMistakes, initialTotal]); + + function goToPage(targetPage: number) { + const params = new URLSearchParams(window.location.search); + if (targetPage <= 1) { + params.delete("page"); + } else { + params.set("page", String(targetPage)); + } + + const query = params.toString(); + router.push(query ? `/mistakes?${query}` : "/mistakes"); + } + + async function handleDelete(mistake: MistakeItem) { + setDeletingId(mistake.id); + try { + await browserApi(`/mistakes/${mistake.id}`, { + method: "DELETE" + }); + setMistakes((current) => current.filter((item) => item.id !== mistake.id)); + setTotal((current) => Math.max(0, current - 1)); + toast.success("已移除"); + if (mistakes.length === 1 && page > 1) { + goToPage(page - 1); + } else { + router.refresh(); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "删除失败"); + } finally { + setDeletingId(null); + } + } + + return ( + + + 错题 +
{total} 条
+
+ + {mistakes.length === 0 ? ( +
+ 暂无错题 +
+ ) : ( +
+ + + + + + + + + + + + {mistakes.map((mistake) => ( + + + + + + + + ))} + +
题目类型答案加入时间操作
+
{mistake.question.content}
+
+ {getQuestionTypeLabel(mistake.question.type)} + +
{mistake.question.answer}
+
+ {formatDate(mistake.created_at)} + +
+ +
+
+
+ )} + + +
+
+ ); +} diff --git a/web/src/components/practice/mistake-practice-client.tsx b/web/src/components/practice/mistake-practice-client.tsx new file mode 100644 index 0000000..2a6185a --- /dev/null +++ b/web/src/components/practice/mistake-practice-client.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { ArrowLeft, ArrowRight, Check, Loader2, Trash2, X } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { browserApi } from "@/lib/api/browser"; +import { AnswerCheckResponse, MistakeListResponse } from "@/lib/types"; +import { getQuestionTypeLabel } from "@/lib/formatters"; + +type MistakeItem = MistakeListResponse["mistakes"][number]; + +export function MistakePracticeClient() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [mistakes, setMistakes] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState(null); + const [userAnswer, setUserAnswer] = useState(""); + const [multipleAnswers, setMultipleAnswers] = useState([]); + + useEffect(() => { + void loadMistakes(); + }, []); + + async function loadMistakes() { + setLoading(true); + try { + const payload = await browserApi("/mistakes/?skip=0&limit=1000", { + method: "GET" + }); + + let nextMistakes = payload.mistakes; + if (searchParams.get("mode") === "random") { + nextMistakes = [...payload.mistakes].sort(() => Math.random() - 0.5); + } + + nextMistakes = nextMistakes.map((item) => { + if (item.question.type === "judge" && (!item.question.options || item.question.options.length === 0)) { + item.question.options = ["A. 正确", "B. 错误"]; + } + return item; + }); + + setMistakes(nextMistakes); + setCurrentIndex(0); + setResult(null); + setUserAnswer(""); + setMultipleAnswers([]); + } catch (error) { + toast.error(error instanceof Error ? error.message : "加载失败"); + } finally { + setLoading(false); + } + } + + const currentMistake = mistakes[currentIndex] || null; + const question = currentMistake?.question || null; + const progressText = useMemo( + () => (mistakes.length ? `${currentIndex + 1} / ${mistakes.length}` : "0 / 0"), + [currentIndex, mistakes.length] + ); + + async function handleSubmit() { + if (!question) { + return; + } + + let answer = userAnswer; + if (question.type === "multiple") { + if (multipleAnswers.length === 0) { + toast.error("请至少选择一个选项"); + return; + } + answer = [...multipleAnswers].sort().join(""); + } + + if (!answer.trim()) { + toast.error("请输入答案"); + return; + } + + setSubmitting(true); + try { + const payload = await browserApi("/questions/check", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + question_id: question.id, + user_answer: answer + }) + }); + setResult(payload); + } catch (error) { + toast.error(error instanceof Error ? error.message : "提交失败"); + } finally { + setSubmitting(false); + } + } + + async function handleRemove() { + if (!currentMistake) { + return; + } + + try { + await browserApi(`/mistakes/${currentMistake.id}`, { + method: "DELETE" + }); + const nextList = mistakes.filter((item) => item.id !== currentMistake.id); + setMistakes(nextList); + setCurrentIndex((current) => Math.max(0, Math.min(current, nextList.length - 1))); + setResult(null); + setUserAnswer(""); + setMultipleAnswers([]); + toast.success("已移除"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "移除失败"); + } + } + + function handleNext() { + if (currentIndex < mistakes.length - 1) { + setCurrentIndex((current) => current + 1); + setResult(null); + setUserAnswer(""); + setMultipleAnswers([]); + return; + } + + toast.success("已完成"); + router.push("/mistakes"); + } + + if (loading) { + return ( +
+ +
+ ); + } + + if (!question) { + return ( +
+ 错题本为空 +
+ ); + } + + return ( +
+
+ +
{progressText}
+
+ + + +
+ {question.content} +
{getQuestionTypeLabel(question.type)}
+
+ +
+ + {question.options?.length ? ( +
+ {question.options.map((option) => { + const letter = option.charAt(0); + const selected = + question.type === "multiple" + ? multipleAnswers.includes(letter) + : userAnswer === letter; + + return ( + + ); + })} +
+ ) : null} + + {question.type === "short" ? ( +