完善文档与前端迁移,补充开源协议

This commit is contained in:
2026-04-17 19:48:13 +08:00
parent 466fa50aa8
commit 31916e68a6
94 changed files with 7019 additions and 480 deletions

View File

@@ -52,7 +52,3 @@ CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
# Upload Directory # Upload Directory
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
# ESA Human Verification
VITE_ESA_PREFIX=
VITE_ESA_SCENE_ID=

View File

@@ -1,4 +1,4 @@
name: Build and Publish Docker Image name: Build and Publish Docker Images
on: on:
push: push:
@@ -18,6 +18,15 @@ jobs:
permissions: permissions:
contents: read contents: read
packages: write packages: write
strategy:
matrix:
include:
- image_suffix: backend
context: ./backend
file: ./backend/Dockerfile
- image_suffix: frontend
context: ./web
file: ./web/Dockerfile
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -37,7 +46,7 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-${{ matrix.image_suffix }}
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=semver,pattern={{version}} type=semver,pattern={{version}}
@@ -45,16 +54,17 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image - name: Build and push Docker image
id: build
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: ${{ matrix.context }}
file: ./Dockerfile file: ${{ matrix.file }}
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha 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 platforms: linux/amd64,linux/arm64
- name: Image digest - 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 }}"

3
.gitignore vendored
View File

@@ -46,6 +46,9 @@ yarn-error.log
# Build # Build
frontend/build/ frontend/build/
frontend/dist/ frontend/dist/
.next/
web/.next/
web/out/
# Testing # Testing
.coverage .coverage

34
AGENTS.md Normal file
View File

@@ -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.

21
LICENSE Normal file
View File

@@ -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.

377
README.md
View File

@@ -1,281 +1,186 @@
# QQuiz - 智能刷题与题库管理平台 # QQuiz
QQuiz 是一个支持 Docker/源码双模部署的智能刷题平台,核心功能包括多文件上传、自动去重、异步解析、断点续做错题本管理。 QQuiz 是一个面向题库管理与刷题训练的全栈应用,支持文档导入、异步解析、题目去重、断点续做错题本管理员配置
## 功能特性 ## 界面预览
- 📚 **多文件上传与去重**: 支持向同一题库追加文档,自动识别并过滤重复题目 ![QQuiz 界面截图](docs/cover.png)
- 🤖 **AI 智能解析**: 支持 Google Gemini (推荐) / OpenAI / Anthropic / Qwen 多种 AI 提供商
- 📄 **原生 PDF 理解**: Gemini 支持直接处理 PDF最多1000页完整保留图片、表格、公式等内容 ## 核心能力
- 🎓 **AI 参考答案**: 对于没有提供答案的题目,自动生成 AI 参考答案
- 📊 **断点续做**: 自动记录刷题进度,随时继续 - 多文件导入与题目去重
- **错题本管理**: 自动收集错题,支持手动添加/移除 - 异步解析进度回传
- 🎯 **多题型支持**: 单选、多选、判断、简答 - 单选、多选、判断、简答题统一管理
- 🔐 **权限管理**: 管理员配置、用户隔离 - 刷题进度保存与继续作答
- 📱 **移动端优先**: 完美适配手机端 - 错题本与错题练习
- 管理员用户管理与系统配置
- 支持 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 ```bash
# 1. 拉取最新镜像
docker pull ghcr.io/handsomezhuzhu/qquiz:latest
# 2. 配置环境变量
cp .env.example .env cp .env.example .env
# 编辑 .env填入你的 API Key
# 3. 运行容器 docker compose up -d --build
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
``` ```
**镜像说明:** 访问地址:
- **镜像地址**: `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`
### 单容器部署(自行构建) ### 2. 分离部署 + MySQL
从源码构建,一个容器包含前后端和 SQLite 数据库:
```bash ```bash
# 1. 配置环境变量(必须提供强密码和密钥)
cp .env.example .env cp .env.example .env
# 编辑 .env填入至少 32 位的 SECRET_KEY 和至少 12 位的 ADMIN_PASSWORD建议使用随机生成值
# 2. 启动服务(未设置强密码/密钥会直接报错终止) docker compose -f docker-compose.yml -f docker-compose.mysql.yml up -d --build
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
``` ```
### 传统部署3 个容器) ### 3. 单容器部署
前后端分离 + MySQL 单容器模式会把前端静态资源集成到后端服务中,统一通过 `8000` 提供。
```bash ```bash
# 启动服务(建议直接在命令行生成强密钥和管理员密码) cp .env.example .env
SECRET_KEY=$(openssl rand -base64 48) \
ADMIN_PASSWORD=$(openssl rand -base64 16) \
docker-compose up -d
# 前端: http://localhost:3000 docker compose -f docker-compose-single.yml up -d --build
# 后端: http://localhost:8000
``` ```
### 方式二:本地运行 访问地址:
- 应用:`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+ - Python 3.11+
- Node.js 18+ - 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. 运行启动脚本 更多示例见 [`.env.example`](.env.example)。
chmod +x scripts/run_local.sh
./scripts/run_local.sh
```
**MySQL 安装指南:** 详见 [docs/MYSQL_SETUP.md](docs/MYSQL_SETUP.md) ## 目录结构
## GitHub Actions 自动构建设置 ```text
如果你 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 位,建议随机生成)
⚠️ **重要**: 在部署前就必须设置强管理员密码;如果需要轮换密码,请更新环境变量后重启服务。
## 项目结构
```
QQuiz/ QQuiz/
├─ backend/ # FastAPI 后端 ├─ backend/ FastAPI 后端
├── alembic/ # 数据库迁移 ─ alembic/ 数据库迁移
├── routers/ # API 路由 ─ routers/ API 路由
├── services/ # 业务逻辑 ─ services/ 业务服务
├── models.py # 数据模型 ─ models.py ORM 模型
├── database.py # 数据库配置 ├─ schemas.py Pydantic Schema
├── main.py # 应用入口 ─ main.py 应用入口
│ └── requirements.txt # Python 依赖 ├─ web/ Next.js 前端
├── frontend/ # React 前端 │ ├─ src/app/ App Router 页面与 API Route
├── src/ ─ src/components/ UI 与业务组件
│ ├── api/ # API 客户端 └─ src/lib/ 前端 API、鉴权、工具
├── pages/ # 页面组件 ├─ frontend/ Legacy Vite 前端
├── components/ # 通用组件 ├─ docs/ 部署、审计与截图
└── App.jsx # 应用入口 ├─ test_data/ 示例题库文件
│ ├── package.json # Node 依赖 ├─ docker-compose.yml 前后端分离部署
│ └── vite.config.js # Vite 配置 ├─ docker-compose.mysql.yml MySQL overlay
├── scripts/ # 部署和启动脚本 ├─ docker-compose-single.yml 单容器部署
│ └── run_local.sh # Linux/macOS 启动脚本 └─ Dockerfile 单容器镜像构建
├── docs/ # 文档目录
│ ├── MYSQL_SETUP.md # MySQL 安装配置指南
│ └── PROJECT_STRUCTURE.md # 项目架构详解
├── test_data/ # 测试数据
│ └── sample_questions.txt # 示例题目
├── docker-compose.yml # Docker 编排
├── .env.example # 环境变量模板
└── README.md # 项目说明
``` ```
## 核心业务流程
### 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 - 数据验证
**前端:** - FastAPI
- React 18 - UI 框架 - SQLAlchemy 2.x
- Vite - 构建工具 - Alembic
- Tailwind CSS - 样式框架 - SQLite / MySQL
- React Router - 路由 - httpx
- Axios - HTTP 客户端 - OpenAI / Anthropic SDK
## 开发进度 ### 前端
- [x] **Step 1**: Foundation & Models ✅ - Next.js 14 App Router
- [ ] **Step 2**: Backend Core Logic - React 18
- [ ] **Step 3**: Frontend Config & API - TypeScript
- [ ] **Step 4**: Frontend Complex UI - Tailwind CSS
- TanStack Query
- Radix UI / shadcn 风格组件
## License ## 构建检查
MIT 常用检查命令:
```bash
cd web && npm run build
docker compose build backend frontend
```
仓库当前没有完整自动化测试套件,提交前至少建议手动验证:
- 登录 / 退出
- 创建题库 / 上传文档 / 查看解析进度
- 刷题与错题加入
- 管理员用户管理与系统设置
- 大数据量列表分页
## 开源协议
本项目采用 [MIT License](LICENSE)。

View File

@@ -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 FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# Install system dependencies (gcc for compiling Python packages)
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \ libmagic1 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt . ENV PYTHONUNBUFFERED=1
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code COPY --from=builder /opt/venv /opt/venv
COPY . . COPY . .
# Create uploads directory
RUN mkdir -p uploads RUN mkdir -p uploads
# Expose port
EXPOSE 8000 EXPOSE 8000
# Run database migrations and start server
CMD alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000 CMD alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000

View File

@@ -2,7 +2,7 @@
Admin Router - 完备的管理员功能模块 Admin Router - 完备的管理员功能模块
参考 OpenWebUI 设计 参考 OpenWebUI 设计
""" """
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status, Query
from fastapi.responses import StreamingResponse, FileResponse from fastapi.responses import StreamingResponse, FileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_, desc 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 models import User, SystemConfig, Exam, Question, UserMistake, ExamStatus
from schemas import ( from schemas import (
SystemConfigUpdate, SystemConfigResponse, SystemConfigUpdate, SystemConfigResponse,
UserResponse, UserCreate, UserUpdate, UserListResponse UserResponse, UserCreate, UserUpdate, UserListResponse,
UserPasswordResetRequest, AdminUserSummary
) )
from services.auth_service import get_current_admin_user 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 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) @router.get("/config", response_model=SystemConfigResponse)
async def get_system_config( async def get_system_config(
current_admin: User = Depends(get_current_admin_user), 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) update_data = config_update.dict(exclude_unset=True)
for key, value in update_data.items(): for key, value in update_data.items():
if key.endswith("_api_key") and isinstance(value, str) and "..." in value:
continue
result = await db.execute( result = await db.execute(
select(SystemConfig).where(SystemConfig.key == key) select(SystemConfig).where(SystemConfig.key == key)
) )
@@ -108,9 +117,9 @@ async def update_system_config(
@router.get("/users", response_model=UserListResponse) @router.get("/users", response_model=UserListResponse)
async def get_users( async def get_users(
skip: int = 0, skip: int = Query(0, ge=0),
limit: int = 50, limit: int = Query(50, ge=1, le=100),
search: Optional[str] = None, search: Optional[str] = Query(None, min_length=1, max_length=50),
current_admin: User = Depends(get_current_admin_user), current_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
@@ -121,10 +130,11 @@ async def get_users(
- search: 搜索关键词(用户名) - search: 搜索关键词(用户名)
""" """
query = select(User) query = select(User)
normalized_search = search.strip() if search else None
# 搜索过滤 # 搜索过滤
if search: if normalized_search:
query = query.where(User.username.ilike(f"%{search}%")) query = query.where(User.username.ilike(f"%{normalized_search}%"))
# 统计总数 # 统计总数
count_query = select(func.count()).select_from(query.subquery()) count_query = select(func.count()).select_from(query.subquery())
@@ -136,34 +146,41 @@ async def get_users(
result = await db.execute(query) result = await db.execute(query)
users = result.scalars().all() 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 = [] user_list = []
for user in users: for user in users:
# 统计用户的题库数 user_list.append(
exam_count_query = select(func.count(Exam.id)).where(Exam.user_id == user.id) AdminUserSummary(
exam_result = await db.execute(exam_count_query) id=user.id,
exam_count = exam_result.scalar() 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)
)
)
# 统计用户的错题数 return UserListResponse(users=user_list, total=total, skip=skip, limit=limit)
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
}
@router.post("/users", response_model=UserResponse) @router.post("/users", response_model=UserResponse)
@@ -215,7 +232,19 @@ async def update_user(
detail="User not found" 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) 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: 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" 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) update_data = user_data.dict(exclude_unset=True)
if "password" in update_data: if "password" in update_data:
@@ -238,6 +273,29 @@ async def update_user(
return 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}") @router.delete("/users/{user_id}")
async def delete_user( async def delete_user(
user_id: int, user_id: int,
@@ -255,6 +313,7 @@ async def delete_user(
) )
protected_admin_id = await get_default_admin_id(db) 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: if protected_admin_id and user.id == protected_admin_id:
@@ -270,6 +329,12 @@ async def delete_user(
detail="Cannot delete yourself" 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.delete(user)
await db.commit() await db.commit()

View File

@@ -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 import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, BackgroundTasks, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession 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 typing import List, Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
import os import os
@@ -17,7 +17,7 @@ from database import get_db
from models import User, Exam, Question, ExamStatus, SystemConfig from models import User, Exam, Question, ExamStatus, SystemConfig
from schemas import ( from schemas import (
ExamCreate, ExamResponse, ExamListResponse, ExamCreate, ExamResponse, ExamListResponse,
ExamUploadResponse, ParseResult, QuizProgressUpdate ExamUploadResponse, ParseResult, QuizProgressUpdate, ExamSummaryResponse
) )
from services.auth_service import get_current_user from services.auth_service import get_current_user
from services.document_parser import document_parser from services.document_parser import document_parser
@@ -684,6 +684,57 @@ async def get_user_exams(
return ExamListResponse(exams=exams, total=total) 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) @router.get("/{exam_id}", response_model=ExamResponse)
async def get_exam_detail( async def get_exam_detail(
exam_id: int, exam_id: int,

View File

@@ -38,6 +38,11 @@ class UserLogin(BaseModel):
password: str password: str
class PasswordChangeRequest(BaseModel):
old_password: str = Field(..., min_length=1)
new_password: str = Field(..., min_length=6)
class Token(BaseModel): class Token(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
@@ -53,14 +58,23 @@ class UserResponse(BaseModel):
from_attributes = True from_attributes = True
class AdminUserSummary(UserResponse):
exam_count: int = 0
mistake_count: int = 0
class UserListResponse(BaseModel): class UserListResponse(BaseModel):
"""用户列表响应(包含分页信息)""" """用户列表响应(包含分页信息)"""
users: List[dict] # 包含额外统计信息的用户列表 users: List[AdminUserSummary]
total: int total: int
skip: int skip: int
limit: int limit: int
class UserPasswordResetRequest(BaseModel):
new_password: str = Field(..., min_length=6)
# ============ System Config Schemas ============ # ============ System Config Schemas ============
class SystemConfigUpdate(BaseModel): class SystemConfigUpdate(BaseModel):
allow_registration: Optional[bool] = None allow_registration: Optional[bool] = None
@@ -124,6 +138,15 @@ class ExamListResponse(BaseModel):
total: int 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): class ExamUploadResponse(BaseModel):
exam_id: int exam_id: int
title: str title: str
@@ -140,19 +163,31 @@ class ParseResult(BaseModel):
# ============ Question Schemas ============ # ============ Question Schemas ============
class QuestionBase(BaseModel): class QuestionPublicBase(BaseModel):
content: str content: str
type: QuestionType type: QuestionType
options: Optional[List[str]] = None options: Optional[List[str]] = None
answer: str
analysis: Optional[str] = None analysis: Optional[str] = None
class QuestionBase(QuestionPublicBase):
answer: str
class QuestionCreate(QuestionBase): class QuestionCreate(QuestionBase):
exam_id: int 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 id: int
exam_id: int exam_id: int
created_at: datetime created_at: datetime
@@ -194,7 +229,7 @@ class MistakeResponse(BaseModel):
id: int id: int
user_id: int user_id: int
question_id: int question_id: int
question: QuestionResponse question: QuestionWithAnswerResponse
created_at: datetime created_at: datetime
class Config: class Config:

View File

@@ -15,6 +15,25 @@ from utils import calculate_content_hash
class LLMService: class LLMService:
"""Service for interacting with various LLM providers""" """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): def __init__(self, config: Optional[Dict[str, str]] = None):
""" """
Initialize LLM Service with optional configuration. Initialize LLM Service with optional configuration.
@@ -28,7 +47,10 @@ class LLMService:
if self.provider == "openai": if self.provider == "openai":
api_key = (config or {}).get("openai_api_key") or os.getenv("OPENAI_API_KEY") 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") self.model = (config or {}).get("openai_model") or os.getenv("OPENAI_MODEL", "gpt-4o-mini")
if not api_key: if not api_key:
@@ -37,6 +59,7 @@ class LLMService:
self.client = AsyncOpenAI( self.client = AsyncOpenAI(
api_key=api_key, api_key=api_key,
base_url=base_url, base_url=base_url,
default_headers=self._openai_compat_headers(),
timeout=120.0, # 增加超时时间到 120 秒 timeout=120.0, # 增加超时时间到 120 秒
max_retries=3 # 自动重试 3 次 max_retries=3 # 自动重试 3 次
) )
@@ -69,6 +92,7 @@ class LLMService:
self.client = AsyncOpenAI( self.client = AsyncOpenAI(
api_key=api_key, api_key=api_key,
base_url=base_url, base_url=base_url,
default_headers=self._openai_compat_headers(),
timeout=120.0, # 增加超时时间到 120 秒 timeout=120.0, # 增加超时时间到 120 秒
max_retries=3 # 自动重试 3 次 max_retries=3 # 自动重试 3 次
) )

View File

@@ -16,6 +16,7 @@ services:
environment: environment:
# 数据库配置SQLite 默认,使用持久化卷) # 数据库配置SQLite 默认,使用持久化卷)
- DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db - DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db
- UPLOAD_DIR=/app/uploads
volumes: volumes:
# 持久化数据卷 # 持久化数据卷
@@ -25,7 +26,7 @@ services:
restart: unless-stopped restart: unless-stopped
healthcheck: 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 interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3

29
docker-compose.mysql.yml Normal file
View File

@@ -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:

View File

@@ -1,61 +1,47 @@
services: 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: backend:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: qquiz_backend container_name: qquiz_backend
environment: 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} - 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} - ADMIN_PASSWORD=${ADMIN_PASSWORD:?Set ADMIN_PASSWORD to a strong password of at least 12 characters}
- UPLOAD_DIR=/app/uploads
env_file: env_file:
- .env - .env
volumes: volumes:
- ./backend:/app - sqlite_data:/app/data
- upload_files:/app/uploads - upload_files:/app/uploads
ports: ports:
- "8000:8000" - "8000:8000"
depends_on: healthcheck:
mysql: test: ["CMD", "python", "-c", "import sys, urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5); sys.exit(0)"]
condition: service_healthy interval: 30s
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload timeout: 10s
retries: 5
start_period: 40s
frontend: frontend:
build: build:
context: ./frontend context: ./web
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: qquiz_frontend container_name: qquiz_frontend
volumes:
- ./frontend:/app
- /app/node_modules
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
- VITE_API_URL=/api - API_BASE_URL=http://backend:8000
- REACT_APP_API_URL=http://backend:8000
depends_on: depends_on:
- backend backend:
command: npm start 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: volumes:
mysql_data: sqlite_data:
upload_files: upload_files:

86
docs/PLAN.md Normal file
View File

@@ -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. 页面信息结构:标题、数据、动作优先,减少解释文字

92
docs/TASKS.md Normal file
View File

@@ -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 双栈验证
- [ ] 用户管理回归用例

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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

BIN
docs/cover.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

View File

@@ -6,14 +6,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="QQuiz - 智能刷题与题库管理平台" /> <meta name="description" content="QQuiz - 智能刷题与题库管理平台" />
<title>QQuiz - 智能刷题平台</title> <title>QQuiz - 智能刷题平台</title>
<!-- ESA 人机认证配置 -->
<script>
window.AliyunCaptchaConfig = {
region: "cn",
prefix: "%VITE_ESA_PREFIX%",
};
</script>
<script src="https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js" async></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -4,9 +4,53 @@
import axios from 'axios' import axios from 'axios'
import toast from 'react-hot-toast' 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 // Create axios instance
const api = axios.create({ const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api', baseURL: API_BASE_URL,
timeout: 30000, timeout: 30000,
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -16,8 +60,9 @@ const api = axios.create({
// Request interceptor - Add auth token // Request interceptor - Add auth token
api.interceptors.request.use( api.interceptors.request.use(
(config) => { (config) => {
const token = localStorage.getItem('access_token') const token = getAccessToken()
if (token) { if (token && !isPublicRequest(config)) {
config.headers = config.headers || {}
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
} }
return config return config
@@ -31,19 +76,26 @@ api.interceptors.request.use(
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
const status = error.response?.status
const message = error.response?.data?.detail || 'An error occurred' 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) { if (status === 401 && !isPublicRequest(requestConfig) && hasAuthHeader) {
// Unauthorized - Clear token and redirect to login clearAuthStorage()
localStorage.removeItem('access_token') if (window.location.pathname !== '/login') {
localStorage.removeItem('user') window.location.href = '/login'
window.location.href = '/login' }
toast.error('Session expired. Please login again.') toast.error('Session expired. Please login again.')
} else if (error.response?.status === 403) { } else if (status === 401) {
toast.error('Permission denied')
} else if (error.response?.status === 429) {
toast.error(message) 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.') toast.error('Server error. Please try again later.')
} else { } else {
toast.error(message) toast.error(message)
@@ -56,10 +108,10 @@ api.interceptors.response.use(
// ============ Auth APIs ============ // ============ Auth APIs ============
export const authAPI = { export const authAPI = {
register: (username, password) => register: (username, password) =>
api.post('/auth/register', { username, password }), api.post('/auth/register', { username, password }, { skipAuthHandling: true }),
login: (username, password) => login: (username, password) =>
api.post('/auth/login', { username, password }), api.post('/auth/login', { username, password }, { skipAuthHandling: true }),
getCurrentUser: () => getCurrentUser: () =>
api.get('/auth/me'), api.get('/auth/me'),

View File

@@ -2,7 +2,7 @@
* Authentication Context * Authentication Context
*/ */
import React, { createContext, useContext, useState, useEffect } from 'react' 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' import toast from 'react-hot-toast'
const AuthContext = createContext(null) const AuthContext = createContext(null)
@@ -22,15 +22,14 @@ export const AuthProvider = ({ children }) => {
// Load user from localStorage on mount // Load user from localStorage on mount
useEffect(() => { useEffect(() => {
const loadUser = async () => { const loadUser = async () => {
const token = localStorage.getItem('access_token') const token = localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)
if (token) { if (token) {
try { try {
const response = await authAPI.getCurrentUser() const response = await authAPI.getCurrentUser()
setUser(response.data) setUser(response.data)
} catch (error) { } catch (error) {
console.error('Failed to load user:', error) console.error('Failed to load user:', error)
localStorage.removeItem('access_token') clearAuthStorage()
localStorage.removeItem('user')
} }
} }
setLoading(false) setLoading(false)
@@ -45,7 +44,7 @@ export const AuthProvider = ({ children }) => {
const { access_token } = response.data const { access_token } = response.data
// Save token // Save token
localStorage.setItem('access_token', access_token) localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, access_token)
// Get user info // Get user info
const userResponse = await authAPI.getCurrentUser() const userResponse = await authAPI.getCurrentUser()
@@ -71,8 +70,7 @@ export const AuthProvider = ({ children }) => {
} }
const logout = () => { const logout = () => {
localStorage.removeItem('access_token') clearAuthStorage()
localStorage.removeItem('user')
setUser(null) setUser(null)
toast.success('Logged out successfully') toast.success('Logged out successfully')
} }

View File

@@ -3,7 +3,7 @@
*/ */
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom' 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 ParsingProgress from '../components/ParsingProgress'
import { import {
ArrowLeft, Upload, Play, Loader, FileText, AlertCircle, RefreshCw, ArrowRight ArrowLeft, Upload, Play, Loader, FileText, AlertCircle, RefreshCw, ArrowRight
@@ -51,6 +51,8 @@ export const ExamDetail = () => {
// Connect to SSE if exam is processing // Connect to SSE if exam is processing
if (examRes.data.status === 'processing') { if (examRes.data.status === 'processing') {
connectSSE() connectSSE()
} else {
setProgress(null)
} }
} catch (error) { } catch (error) {
console.error('Failed to load exam:', 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) console.log('[SSE] Connecting to progress stream for exam', examId)
const token = localStorage.getItem('token') const token = getAccessToken()
const url = `/api/exams/${examId}/progress?token=${encodeURIComponent(token)}`
if (!token) {
console.error('[SSE] Missing access token')
return
}
const url = `${buildApiUrl(`/exams/${examId}/progress`)}?token=${encodeURIComponent(token)}`
const eventSource = new EventSource(url) const eventSource = new EventSource(url)
eventSourceRef.current = eventSource eventSourceRef.current = eventSource
@@ -173,6 +181,9 @@ export const ExamDetail = () => {
const isReady = exam.status === 'ready' const isReady = exam.status === 'ready'
const isFailed = exam.status === 'failed' const isFailed = exam.status === 'failed'
const quizProgress = calculateProgress(exam.current_index, exam.total_questions) const quizProgress = calculateProgress(exam.current_index, exam.total_questions)
const completionProgress = isProcessing
? Math.round(Number(progress?.progress ?? 0))
: quizProgress
return ( return (
<> <>
@@ -252,17 +263,17 @@ export const ExamDetail = () => {
</div> </div>
<div className="bg-gray-50 rounded-lg p-4"> <div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-600 mb-1">完成度</p> <p className="text-sm text-gray-600 mb-1">完成度</p>
<p className="text-2xl font-bold text-green-600">{isProcessing ? progress : quizProgress}%</p> <p className="text-2xl font-bold text-green-600">{completionProgress}%</p>
</div> </div>
</div> </div>
{/* Progress Bar */} {/* Progress Bar */}
{exam.total_questions > 0 && ( {(isProcessing || exam.total_questions > 0) && (
<div className="mt-6"> <div className="mt-6">
<div className="w-full bg-gray-200 rounded-full h-3"> <div className="w-full bg-gray-200 rounded-full h-3">
<div <div
className="bg-primary-600 h-3 rounded-full transition-all" className="bg-primary-600 h-3 rounded-full transition-all"
style={{ width: `${quizProgress}%` }} style={{ width: `${completionProgress}%` }}
></div> ></div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
/** /**
* Login Page * Login Page
*/ */
import React, { useState, useEffect } from 'react' import React, { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { BookOpen } from 'lucide-react' import { BookOpen } from 'lucide-react'
@@ -15,110 +15,21 @@ export const Login = () => {
password: '' password: ''
}) })
const [loading, setLoading] = useState(false) 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 const handleSubmit = async (e) => {
// 我们做一个简单的轮询或者依赖 script onload但在 index.html 比较难控制) e.preventDefault()
// 或者直接延迟一下初始化
const timer = setTimeout(initCaptcha, 500);
return () => clearTimeout(timer);
}, []);
const handleLoginSubmit = async (captchaParam) => {
setLoading(true) setLoading(true)
try { 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) const success = await login(formData.username, formData.password)
if (success) { if (success) {
navigate('/dashboard') navigate('/dashboard')
} }
} finally { } finally {
setLoading(false) 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) => { const handleChange = (e) => {
setFormData({ setFormData({
...formData, ...formData,
@@ -144,8 +55,7 @@ export const Login = () => {
<div className="bg-white rounded-2xl shadow-xl p-8"> <div className="bg-white rounded-2xl shadow-xl p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6">登录</h2> <h2 className="text-2xl font-bold text-gray-900 mb-6">登录</h2>
{/* 为了能正确使用 ESA我们将 form 的 onSubmit 移除,改由按钮触发,或者保留 form 但不做提交 */} <form className="space-y-6" onSubmit={handleSubmit}>
<form className="space-y-6" onSubmit={(e) => e.preventDefault()}>
{/* Username */} {/* Username */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
@@ -179,14 +89,8 @@ export const Login = () => {
/> />
</div> </div>
{/* ESA Captcha Container */}
<div id="captcha-element"></div>
{/* Submit Button */}
{/* 绑定 id="login-btn" 供 ESA 使用 */}
<button <button
type="button" type="submit"
id="login-btn"
disabled={loading} disabled={loading}
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >

View File

@@ -2,13 +2,15 @@
* Question Bank Page - View all questions * Question Bank Page - View all questions
*/ */
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { questionAPI } from '../api/client' import { questionAPI } from '../api/client'
import Pagination from '../components/Pagination' import Pagination from '../components/Pagination'
import { FileText, Loader, Search } from 'lucide-react' import { FileText, Loader } from 'lucide-react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { getQuestionTypeText, formatRelativeTime } from '../utils/helpers' import { getQuestionTypeText, formatRelativeTime } from '../utils/helpers'
export const QuestionBank = () => { export const QuestionBank = () => {
const [searchParams] = useSearchParams()
const [questions, setQuestions] = useState([]) const [questions, setQuestions] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState(null) const [expandedId, setExpandedId] = useState(null)
@@ -17,16 +19,23 @@ export const QuestionBank = () => {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [limit, setLimit] = useState(10) const [limit, setLimit] = useState(10)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const examIdParam = searchParams.get('examId')
const examIdFilter = /^\d+$/.test(examIdParam || '') ? Number(examIdParam) : null
useEffect(() => {
setPage(1)
setExpandedId(null)
}, [examIdFilter])
useEffect(() => { useEffect(() => {
loadQuestions() loadQuestions()
}, [page, limit]) }, [page, limit, examIdFilter])
const loadQuestions = async () => { const loadQuestions = async () => {
try { try {
setLoading(true) setLoading(true)
const skip = (page - 1) * limit const skip = (page - 1) * limit
const response = await questionAPI.getAll(skip, limit) const response = await questionAPI.getAll(skip, limit, examIdFilter)
setQuestions(response.data.questions) setQuestions(response.data.questions)
setTotal(response.data.total) setTotal(response.data.total)
} catch (error) { } catch (error) {
@@ -41,6 +50,11 @@ export const QuestionBank = () => {
setExpandedId(expandedId === id ? null : id) setExpandedId(expandedId === id ? null : id)
} }
const title = examIdFilter ? `题库 ${examIdFilter} 题目` : '全站题库'
const subtitle = examIdFilter
? `当前仅显示该题库下的 ${total} 道题目`
: `${total} 道题目`
if (loading && questions.length === 0) { if (loading && questions.length === 0) {
return ( return (
<div className="flex items-center justify-center h-screen"> <div className="flex items-center justify-center h-screen">
@@ -54,8 +68,8 @@ export const QuestionBank = () => {
<div className="p-4 md:p-8"> <div className="p-4 md:p-8">
{/* Header */} {/* Header */}
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">全站题库</h1> <h1 className="text-2xl md:text-3xl font-bold text-gray-900">{title}</h1>
<p className="text-gray-600 mt-1"> {total} 道题目</p> <p className="text-gray-600 mt-1">{subtitle}</p>
</div> </div>
{/* List */} {/* List */}

View File

@@ -9,15 +9,7 @@ export default defineConfig(({ mode }) => {
return { return {
envDir, // Tell Vite to look for .env files in the project root envDir, // Tell Vite to look for .env files in the project root
plugins: [ plugins: [react()],
react(),
{
name: 'html-transform',
transformIndexHtml(html) {
return html.replace(/%VITE_ESA_PREFIX%/g, env.VITE_ESA_PREFIX || '')
},
}
],
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 3000, port: 3000,

225
scripts/smoke_e2e.ps1 Normal file
View File

@@ -0,0 +1,225 @@
param(
[string]$FrontendBase = "http://127.0.0.1:3000",
[string]$AdminUsername = "admin",
[string]$AdminPassword = "AdminTest2026!",
[string]$ProviderBaseUrl = "https://api.openai.com/v1",
[string]$ProviderApiKey = "sk-your-openai-api-key",
[string]$ProviderModel = "gpt-5.4",
[string]$SamplePath = "E:\QQuiz\test_data\sample_questions.txt"
)
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Net.Http
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
Invoke-WebRequest -UseBasicParsing `
-Uri "$FrontendBase/api/auth/login" `
-Method POST `
-WebSession $session `
-ContentType "application/json" `
-Body (@{
username = $AdminUsername
password = $AdminPassword
} | ConvertTo-Json) | Out-Null
Invoke-WebRequest -UseBasicParsing `
-Uri "$FrontendBase/api/proxy/admin/config" `
-Method PUT `
-WebSession $session `
-ContentType "application/json" `
-Body (@{
ai_provider = "openai"
openai_base_url = $ProviderBaseUrl
openai_api_key = $ProviderApiKey
openai_model = $ProviderModel
} | ConvertTo-Json) | Out-Null
$testUsername = "e2e_user"
$testPassword = "E2ETest2026!"
$resetPassword = "E2EPassword2026!"
try {
Invoke-WebRequest -UseBasicParsing `
-Uri "$FrontendBase/api/proxy/admin/users" `
-Method POST `
-WebSession $session `
-ContentType "application/json" `
-Body (@{
username = $testUsername
password = $testPassword
is_admin = $false
} | ConvertTo-Json) | Out-Null
} catch {
if (-not $_.Exception.Response -or $_.Exception.Response.StatusCode.value__ -ne 400) {
throw
}
}
$usersPayload = (
Invoke-WebRequest -UseBasicParsing `
-Uri "$FrontendBase/api/proxy/admin/users?skip=0&limit=100" `
-WebSession $session
).Content | ConvertFrom-Json
$testUser = $usersPayload.users | Where-Object { $_.username -eq $testUsername } | Select-Object -First 1
if (-not $testUser) {
throw "Failed to find $testUsername."
}
Invoke-WebRequest -UseBasicParsing `
-Uri "$FrontendBase/api/proxy/admin/users/$($testUser.id)" `
-Method PUT `
-WebSession $session `
-ContentType "application/json" `
-Body (@{
username = $testUsername
is_admin = $false
} | ConvertTo-Json) | Out-Null
Invoke-WebRequest -UseBasicParsing `
-Uri "$FrontendBase/api/proxy/admin/users/$($testUser.id)/reset-password" `
-Method POST `
-WebSession $session `
-ContentType "application/json" `
-Body (@{
new_password = $resetPassword
} | ConvertTo-Json) | Out-Null
$client = [System.Net.Http.HttpClient]::new()
$tokenCookie = $session.Cookies.GetCookies($FrontendBase) | Where-Object { $_.Name -eq "access_token" } | Select-Object -First 1
if (-not $tokenCookie) {
throw "Login cookie not found."
}
$client.DefaultRequestHeaders.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new("Bearer", $tokenCookie.Value)
$apiHeaders = @{
Authorization = "Bearer $($tokenCookie.Value)"
}
$multipart = [System.Net.Http.MultipartFormDataContent]::new()
$multipart.Add([System.Net.Http.StringContent]::new("E2E Full Flow Exam"), "title")
$multipart.Add([System.Net.Http.StringContent]::new("false"), "is_random")
$bytes = [System.IO.File]::ReadAllBytes($SamplePath)
$fileContent = [System.Net.Http.ByteArrayContent]::new($bytes)
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse("text/plain")
$multipart.Add($fileContent, "file", [System.IO.Path]::GetFileName($SamplePath))
$createResponse = $client.PostAsync("http://127.0.0.1:8000/api/exams/create", $multipart).Result
$createBody = $createResponse.Content.ReadAsStringAsync().Result
if ($createResponse.StatusCode.value__ -ne 201) {
throw "Exam create failed: $createBody"
}
$createPayload = $createBody | ConvertFrom-Json
$examId = $createPayload.exam_id
$deadline = (Get-Date).AddMinutes(4)
$exam = $null
while ((Get-Date) -lt $deadline) {
$exam = (
Invoke-WebRequest -UseBasicParsing `
-Uri "http://127.0.0.1:8000/api/exams/$examId" `
-Headers $apiHeaders
).Content | ConvertFrom-Json
if ($exam.status -eq "ready" -or $exam.status -eq "failed") {
break
}
Start-Sleep -Seconds 5
}
if (-not $exam) {
throw "Exam polling returned no data."
}
if ($exam.status -ne "ready") {
throw "Exam parsing failed or timed out. Final status: $($exam.status)"
}
$questionsPayload = (
Invoke-WebRequest -UseBasicParsing `
-Uri "http://127.0.0.1:8000/api/questions/?exam_id=$examId&skip=0&limit=10" `
-Headers $apiHeaders
).Content | ConvertFrom-Json
if ($questionsPayload.total -lt 1) {
throw "Question list returned no questions."
}
$currentQuestion = (
Invoke-WebRequest -UseBasicParsing `
-Uri "http://127.0.0.1:8000/api/questions/exam/$examId/current" `
-Headers $apiHeaders
).Content | ConvertFrom-Json
$checkPayload = (
Invoke-WebRequest -UseBasicParsing `
-Uri "http://127.0.0.1:8000/api/questions/check" `
-Method POST `
-Headers $apiHeaders `
-ContentType "application/json" `
-Body (@{
question_id = $currentQuestion.id
user_answer = "Z"
} | ConvertTo-Json)
).Content | ConvertFrom-Json
if ($checkPayload.correct -eq $true) {
throw "Expected the forced wrong answer to be incorrect."
}
$mistakesPayload = (
Invoke-WebRequest -UseBasicParsing `
-Uri "http://127.0.0.1:8000/api/mistakes/?skip=0&limit=50" `
-Headers $apiHeaders
).Content | ConvertFrom-Json
if ($mistakesPayload.total -lt 1) {
throw "Mistake list did not record the wrong answer."
}
Invoke-WebRequest -UseBasicParsing `
-Uri "http://127.0.0.1:8000/api/exams/$examId/progress" `
-Method PUT `
-Headers $apiHeaders `
-ContentType "application/json" `
-Body '{"current_index":1}' | Out-Null
$summaryPayload = (
Invoke-WebRequest -UseBasicParsing `
-Uri "http://127.0.0.1:8000/api/exams/summary" `
-Headers $apiHeaders
).Content | ConvertFrom-Json
if ($summaryPayload.total_exams -lt 1) {
throw "Exam summary endpoint returned invalid totals."
}
$testSession = New-Object Microsoft.PowerShell.Commands.WebRequestSession
Invoke-WebRequest -UseBasicParsing `
-Uri "$FrontendBase/api/auth/login" `
-Method POST `
-WebSession $testSession `
-ContentType "application/json" `
-Body (@{
username = $testUsername
password = $resetPassword
} | ConvertTo-Json) | Out-Null
$me = (
Invoke-WebRequest -UseBasicParsing `
-Uri "$FrontendBase/api/auth/me" `
-WebSession $testSession
).Content | ConvertFrom-Json
[pscustomobject]@{
exam_id = $examId
exam_status = $exam.status
total_questions = $exam.total_questions
users_total = $usersPayload.total
mistakes_total = $mistakesPayload.total
summary_total_exams = $summaryPayload.total_exams
test_user = $me.username
test_user_is_admin = $me.is_admin
} | ConvertTo-Json -Depth 4

5
web/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.next
npm-debug.log*
.env
.env.*

1
web/.env.example Normal file
View File

@@ -0,0 +1 @@
API_BASE_URL=http://localhost:8000

33
web/Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

15
web/README.md Normal file
View File

@@ -0,0 +1,15 @@
# QQuiz Web
This directory contains the new Next.js frontend scaffold for the QQuiz
refactor.
## Status
- App Router skeleton: added
- Auth/session proxy routes: added
- Legacy Vite frontend replacement: in progress
- shadcn/ui component foundation: added
## Environment
Copy `.env.example` and point `API_BASE_URL` at the FastAPI backend.

18
web/components.json Normal file
View File

@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
}
}

5
web/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

6
web/next.config.mjs Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone"
};
export default nextConfig;

1817
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
web/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "qquiz-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@tanstack/react-query": "^5.51.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.462.0",
"next": "^14.2.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2"
},
"devDependencies": {
"@types/node": "^22.7.4",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.2"
}
}

6
web/postcss.config.mjs Normal file
View File

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

View File

@@ -0,0 +1,44 @@
import { PageHeader } from "@/components/app-shell/page-header";
import { UserManagementPanel } from "@/components/admin/user-management-panel";
import { requireAdminUser } from "@/lib/auth/guards";
import { serverApi } from "@/lib/api/server";
import { DEFAULT_PAGE_SIZE, getOffset, parsePositiveInt } from "@/lib/pagination";
import { UserListResponse } from "@/lib/types";
export default async function AdminPage({
searchParams
}: {
searchParams?: {
page?: string | string[];
search?: string | string[];
};
}) {
await requireAdminUser();
const page = parsePositiveInt(searchParams?.page);
const search = Array.isArray(searchParams?.search)
? searchParams?.search[0]
: searchParams?.search;
const query = new URLSearchParams({
skip: String(getOffset(page, DEFAULT_PAGE_SIZE)),
limit: String(DEFAULT_PAGE_SIZE)
});
if (search?.trim()) {
query.set("search", search.trim());
}
const data = await serverApi<UserListResponse>(`/admin/users?${query.toString()}`);
return (
<div className="space-y-8">
<PageHeader eyebrow="Admin" title="用户管理" />
<UserManagementPanel
initialPage={page}
initialSearch={search?.trim() || ""}
initialTotal={data.total}
initialUsers={data.users}
pageSize={DEFAULT_PAGE_SIZE}
/>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { SettingsPanel } from "@/components/admin/settings-panel";
import { PageHeader } from "@/components/app-shell/page-header";
import { requireAdminUser } from "@/lib/auth/guards";
import { serverApi } from "@/lib/api/server";
import { SystemConfigResponse } from "@/lib/types";
export default async function AdminSettingsPage() {
await requireAdminUser();
const config = await serverApi<SystemConfigResponse>("/admin/config");
return (
<div className="space-y-8">
<PageHeader eyebrow="Settings" title="系统设置" />
<SettingsPanel initialConfig={config} />
</div>
);
}

View File

@@ -0,0 +1,107 @@
import Link from "next/link";
import { BookOpen, FolderOpen, Shield, TriangleAlert } from "lucide-react";
import { PageHeader } from "@/components/app-shell/page-header";
import { StatCard } from "@/components/app-shell/stat-card";
import { StatusBadge } from "@/components/app-shell/status-badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatRelativeTime } from "@/lib/formatters";
import { requireCurrentUser } from "@/lib/auth/guards";
import { serverApi } from "@/lib/api/server";
import {
AdminStatisticsResponse,
ExamListResponse,
ExamSummaryStats,
MistakeListResponse
} from "@/lib/types";
export default async function DashboardPage() {
const currentUser = await requireCurrentUser();
const [exams, summary, mistakes, stats] = await Promise.all([
serverApi<ExamListResponse>("/exams/?skip=0&limit=5"),
serverApi<ExamSummaryStats>("/exams/summary"),
serverApi<MistakeListResponse>("/mistakes/?skip=0&limit=1"),
currentUser.is_admin
? serverApi<AdminStatisticsResponse>("/admin/statistics")
: Promise.resolve(null)
]);
return (
<div className="space-y-8">
<PageHeader eyebrow="Dashboard" title={`你好,${currentUser.username}`} />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard
icon={FolderOpen}
label="题库"
value={String(summary.total_exams)}
detail={`${summary.ready_exams} 就绪 / ${summary.processing_exams} 处理中`}
/>
<StatCard
icon={BookOpen}
label="题目"
value={String(summary.total_questions)}
detail={`已完成 ${summary.completed_questions}`}
/>
<StatCard
icon={TriangleAlert}
label="错题"
value={String(mistakes.total)}
detail={mistakes.total > 0 ? "待复习" : "暂无错题"}
/>
<StatCard
icon={Shield}
label="角色"
value={currentUser.is_admin ? "管理员" : "用户"}
detail={currentUser.is_admin && stats ? `全站 ${stats.users.total} 用户` : undefined}
/>
</div>
<Card className="border-slate-200/70 bg-white/90">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{exams.exams.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-slate-200">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{exams.exams.map((exam) => (
<tr key={exam.id} className="border-t border-slate-200">
<td className="px-4 py-3">
<Link className="font-medium text-slate-900 hover:underline" href={`/exams/${exam.id}`}>
{exam.title}
</Link>
</td>
<td className="px-4 py-3">
<StatusBadge status={exam.status} />
</td>
<td className="px-4 py-3 text-slate-600">
{exam.current_index}/{exam.total_questions}
</td>
<td className="px-4 py-3 text-slate-600">
{formatRelativeTime(exam.updated_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { ExamDetailClient } from "@/components/exams/exam-detail-client";
import { PageHeader } from "@/components/app-shell/page-header";
import { serverApi } from "@/lib/api/server";
import { ExamSummary } from "@/lib/types";
export default async function ExamDetailPage({
params
}: {
params: { examId: string };
}) {
const exam = await serverApi<ExamSummary>(`/exams/${params.examId}`);
return (
<div className="space-y-8">
<PageHeader eyebrow={`Exam #${params.examId}`} title="题库详情" />
<ExamDetailClient initialExam={exam} />
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { PageHeader } from "@/components/app-shell/page-header";
import { ExamsPageClient } from "@/components/exams/exams-page-client";
import { serverApi } from "@/lib/api/server";
import { DEFAULT_PAGE_SIZE, getOffset, parsePositiveInt } from "@/lib/pagination";
import { ExamListResponse } from "@/lib/types";
export default async function ExamsPage({
searchParams
}: {
searchParams?: { page?: string | string[] };
}) {
const page = parsePositiveInt(searchParams?.page);
const data = await serverApi<ExamListResponse>(
`/exams/?skip=${getOffset(page, DEFAULT_PAGE_SIZE)}&limit=${DEFAULT_PAGE_SIZE}`
);
return (
<div className="space-y-8">
<PageHeader eyebrow="Exams" title="题库" />
<ExamsPageClient
initialExams={data.exams}
initialTotal={data.total}
page={page}
pageSize={DEFAULT_PAGE_SIZE}
/>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { AppSidebar } from "@/components/app-shell/app-sidebar";
import { LogoutButton } from "@/components/app-shell/logout-button";
import { requireCurrentUser } from "@/lib/auth/guards";
export default async function AppLayout({
children
}: {
children: React.ReactNode;
}) {
const currentUser = await requireCurrentUser();
return (
<div className="min-h-screen xl:flex">
<AppSidebar isAdmin={currentUser.is_admin} />
<div className="min-h-screen flex-1">
<div className="flex items-center justify-between border-b border-slate-200/80 bg-white/70 px-6 py-4 backdrop-blur">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">QQuiz</p>
<p className="text-sm text-slate-600">
{currentUser.username} · {currentUser.is_admin ? "管理员" : "普通用户"}
</p>
</div>
<LogoutButton />
</div>
<main className="container py-8">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { PageHeader } from "@/components/app-shell/page-header";
import { MistakePracticeClient } from "@/components/practice/mistake-practice-client";
export default function MistakeQuizPage() {
return (
<div className="space-y-8">
<PageHeader eyebrow="Mistake Practice" title="错题练习" />
<MistakePracticeClient />
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { MistakeListClient } from "@/components/mistakes/mistake-list-client";
import { PageHeader } from "@/components/app-shell/page-header";
import { serverApi } from "@/lib/api/server";
import { DEFAULT_PAGE_SIZE, getOffset, parsePositiveInt } from "@/lib/pagination";
import { MistakeListResponse } from "@/lib/types";
export default async function MistakesPage({
searchParams
}: {
searchParams?: { page?: string | string[] };
}) {
const page = parsePositiveInt(searchParams?.page);
const data = await serverApi<MistakeListResponse>(
`/mistakes/?skip=${getOffset(page, DEFAULT_PAGE_SIZE)}&limit=${DEFAULT_PAGE_SIZE}`
);
return (
<div className="space-y-8">
<PageHeader eyebrow="Mistakes" title="错题" />
<MistakeListClient
initialMistakes={data.mistakes}
initialTotal={data.total}
page={page}
pageSize={DEFAULT_PAGE_SIZE}
/>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { PageHeader } from "@/components/app-shell/page-header";
import { QuestionList } from "@/components/questions/question-list";
import { serverApi } from "@/lib/api/server";
import {
DEFAULT_PAGE_SIZE,
getOffset,
parseOptionalPositiveInt,
parsePositiveInt
} from "@/lib/pagination";
import { QuestionListResponse } from "@/lib/types";
export default async function QuestionsPage({
searchParams
}: {
searchParams?: {
page?: string | string[];
examId?: string | string[];
};
}) {
const page = parsePositiveInt(searchParams?.page);
const examId = parseOptionalPositiveInt(searchParams?.examId);
const examFilter = examId ? `&exam_id=${examId}` : "";
const data = await serverApi<QuestionListResponse>(
`/questions/?skip=${getOffset(page, DEFAULT_PAGE_SIZE)}&limit=${DEFAULT_PAGE_SIZE}${examFilter}`
);
return (
<div className="space-y-8">
<PageHeader
eyebrow="Questions"
title="题目"
description={examId ? `当前仅显示题库 #${examId} 的题目。` : undefined}
/>
<QuestionList
examId={examId}
page={page}
pageSize={DEFAULT_PAGE_SIZE}
questions={data.questions}
total={data.total}
/>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { PageHeader } from "@/components/app-shell/page-header";
import { QuizPlayerClient } from "@/components/practice/quiz-player-client";
export default function QuizPage({
params
}: {
params: { examId: string };
}) {
return (
<div className="space-y-8">
<PageHeader eyebrow={`Quiz #${params.examId}`} title="刷题" />
<QuizPlayerClient examId={params.examId} />
</div>
);
}

View File

@@ -0,0 +1,111 @@
"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { ArrowRight, ShieldCheck } 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 LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
try {
const response = await fetch("/api/auth/login", {
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(searchParams.get("next") || "/dashboard");
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "登录失败");
} finally {
setLoading(false);
}
}
return (
<main className="relative flex min-h-screen items-center justify-center overflow-hidden p-6">
<div className="absolute inset-0 bg-brand-grid bg-[size:34px_34px] opacity-40" />
<div className="relative grid w-full max-w-5xl gap-6 lg:grid-cols-[1.1fr_480px]">
<Card className="hidden border-slate-900/10 bg-slate-950 text-white lg:block">
<CardHeader className="pb-4">
<div className="flex items-center gap-3 text-sm uppercase tracking-[0.2em] text-slate-300">
<ShieldCheck className="h-4 w-4" />
QQuiz Web
</div>
<CardTitle className="text-4xl leading-tight"></CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm leading-7 text-slate-300">
<p>使</p>
</CardContent>
</Card>
<Card className="border-white/80 bg-white/92">
<CardHeader>
<CardTitle className="text-3xl"></CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700"></label>
<Input
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="请输入用户名"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700"></label>
<Input
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
required
/>
</div>
<Button className="w-full" disabled={loading} type="submit">
{loading ? "登录中..." : "登录"}
<ArrowRight className="h-4 w-4" />
</Button>
<p className="text-sm text-slate-600">
<Link className="ml-2 font-medium text-primary underline-offset-4 hover:underline" href="/register">
</Link>
</p>
</form>
</CardContent>
</Card>
</div>
</main>
);
}

View File

@@ -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<HTMLFormElement>) {
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 (
<main className="flex min-h-screen items-center justify-center p-6">
<Card className="w-full max-w-lg border-white/80 bg-white/92">
<CardHeader>
<CardTitle className="text-3xl"></CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700"></label>
<Input
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="3-50 位字母、数字、_ 或 -"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700"></label>
<Input
autoComplete="new-password"
type="password"
minLength={6}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少 6 位"
required
/>
</div>
<Button className="w-full" disabled={loading} type="submit">
{loading ? "提交中..." : "注册"}
<ArrowRight className="h-4 w-4" />
</Button>
<p className="text-sm text-slate-600">
<Link className="ml-2 font-medium text-primary underline-offset-4 hover:underline" href="/login">
</Link>
</p>
</form>
</CardContent>
</Card>
</main>
);
}

View File

@@ -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 });
}

View File

@@ -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();
}

View File

@@ -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 });
}

View File

@@ -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"
}
});
}

View File

@@ -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);
}

47
web/src/app/globals.css Normal file
View File

@@ -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;
}
}

27
web/src/app/layout.tsx Normal file
View File

@@ -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 (
<html lang="zh-CN">
<body>
<QueryProvider>
{children}
<Toaster richColors position="top-right" />
</QueryProvider>
</body>
</html>
);
}

7
web/src/app/loading.tsx Normal file
View File

@@ -0,0 +1,7 @@
export default function Loading() {
return (
<main className="flex min-h-screen items-center justify-center">
<div className="rounded-full border-4 border-slate-200 border-t-slate-950 p-6 animate-spin" />
</main>
);
}

24
web/src/app/not-found.tsx Normal file
View File

@@ -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 (
<main className="flex min-h-screen items-center justify-center p-6">
<Card className="max-w-lg border-slate-200/70 bg-white/90">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm leading-6 text-slate-600">
Next.js 访
</p>
<Button asChild>
<Link href="/dashboard"></Link>
</Button>
</CardContent>
</Card>
</main>
);
}

7
web/src/app/page.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
import { readSessionToken } from "@/lib/auth/session";
export default function IndexPage() {
redirect(readSessionToken() ? "/dashboard" : "/login");
}

View File

@@ -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<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
try {
const payload = await browserApi<SystemConfigResponse>("/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 (
<form className="grid gap-6 xl:grid-cols-2" onSubmit={handleSubmit}>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<label className="flex items-center justify-between gap-4 text-sm text-slate-700">
<span></span>
<input
checked={config.allow_registration}
className="h-4 w-4"
onChange={(event) =>
setConfig((current) => ({
...current,
allow_registration: event.target.checked
}))
}
type="checkbox"
/>
</label>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">MB</label>
<Input
type="number"
value={config.max_upload_size_mb}
onChange={(event) =>
setConfig((current) => ({
...current,
max_upload_size_mb: Number(event.target.value || 0)
}))
}
min={1}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700"></label>
<Input
type="number"
value={config.max_daily_uploads}
onChange={(event) =>
setConfig((current) => ({
...current,
max_daily_uploads: Number(event.target.value || 0)
}))
}
min={1}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">AI </label>
<select
className="flex h-11 w-full rounded-2xl border border-input bg-background px-4 py-2 text-sm"
value={config.ai_provider}
onChange={(event) =>
setConfig((current) => ({
...current,
ai_provider: event.target.value
}))
}
>
<option value="gemini">Gemini</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="qwen">Qwen</option>
</select>
</div>
</CardContent>
</Card>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">OpenAI Base URL</label>
<Input
value={config.openai_base_url || ""}
onChange={(event) =>
setConfig((current) => ({ ...current, openai_base_url: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">OpenAI API Key</label>
<Input
type="password"
value={config.openai_api_key || ""}
onChange={(event) =>
setConfig((current) => ({ ...current, openai_api_key: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Gemini </label>
<Input
value={config.gemini_model || ""}
onChange={(event) =>
setConfig((current) => ({ ...current, gemini_model: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">OpenAI </label>
<Input
value={config.openai_model || ""}
onChange={(event) =>
setConfig((current) => ({ ...current, openai_model: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Anthropic </label>
<Input
value={config.anthropic_model || ""}
onChange={(event) =>
setConfig((current) => ({ ...current, anthropic_model: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Qwen </label>
<Input
value={config.qwen_model || ""}
onChange={(event) =>
setConfig((current) => ({ ...current, qwen_model: event.target.value }))
}
/>
</div>
</CardContent>
</Card>
<div className="xl:col-span-2">
<Button disabled={saving} type="submit">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
</Button>
</div>
</form>
);
}

View File

@@ -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<number | null>(null);
const [editing, setEditing] = useState<EditingState>({
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<HTMLFormElement>) {
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<string, unknown> = {
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<HTMLFormElement>) {
event.preventDefault();
router.push(buildAdminUrl(search, 1));
}
return (
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<Card className="border-slate-200/70 bg-white/92">
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit}>
<Input
placeholder="用户名"
value={editing.username}
onChange={(event) =>
setEditing((current) => ({ ...current, username: event.target.value }))
}
required
/>
<Input
type="password"
placeholder={isCreateMode ? "密码" : "留空则不修改密码"}
value={editing.password}
onChange={(event) =>
setEditing((current) => ({ ...current, password: event.target.value }))
}
required={isCreateMode}
minLength={6}
/>
<label className="flex items-center gap-3 text-sm text-slate-700">
<input
checked={editing.isAdmin}
className="h-4 w-4 rounded border-slate-300"
onChange={(event) =>
setEditing((current) => ({ ...current, isAdmin: event.target.checked }))
}
type="checkbox"
/>
</label>
<div className="flex gap-2">
<Button className="flex-1" disabled={submitting} type="submit">
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isCreateMode ? (
<UserPlus className="h-4 w-4" />
) : (
<Shield className="h-4 w-4" />
)}
{isCreateMode ? "创建" : "保存"}
</Button>
{!isCreateMode ? (
<Button onClick={startCreate} type="button" variant="outline">
</Button>
) : null}
</div>
</form>
</CardContent>
</Card>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader className="space-y-4">
<div className="flex items-center justify-between gap-3">
<CardTitle></CardTitle>
<div className="text-sm text-slate-500">
{total} / {activeAdminCount}
</div>
</div>
<form className="flex flex-col gap-3 md:flex-row" onSubmit={handleSearch}>
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
className="pl-9"
placeholder="搜索用户名"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<Button type="submit" variant="outline">
<Search className="h-4 w-4" />
</Button>
</form>
</CardHeader>
<CardContent>
<div className="overflow-hidden rounded-2xl border border-slate-200">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium text-right"></th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-t border-slate-200">
<td className="px-4 py-3 font-medium text-slate-900">{user.username}</td>
<td className="px-4 py-3 text-slate-600">
{user.is_admin ? "管理员" : "普通用户"}
</td>
<td className="px-4 py-3 text-slate-600">{user.exam_count}</td>
<td className="px-4 py-3 text-slate-600">{user.mistake_count}</td>
<td className="px-4 py-3 text-slate-600">{formatDate(user.created_at)}</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button onClick={() => startEdit(user)} size="sm" type="button" variant="outline">
</Button>
<Button
onClick={() => handleResetPassword(user)}
size="sm"
type="button"
variant="outline"
>
</Button>
<Button
aria-label={`删除 ${user.username}`}
disabled={deletingId === user.id}
onClick={() => handleDelete(user)}
size="icon"
type="button"
variant="ghost"
>
{deletingId === user.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
</td>
</tr>
))}
{users.length === 0 ? (
<tr>
<td className="px-4 py-8 text-center text-slate-500" colSpan={6}>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<PaginationControls
className="mt-4 rounded-2xl border border-slate-200"
page={initialPage}
pageSize={pageSize}
total={total}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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 (
<aside className="hidden h-screen w-[280px] shrink-0 flex-col border-r border-slate-200 bg-white/80 px-5 py-6 backdrop-blur xl:flex">
<div className="space-y-4">
<Badge variant="outline" className="w-fit border-slate-300 text-slate-600">
QQuiz Web
</Badge>
<div>
<h2 className="text-xl font-semibold text-slate-950">QQuiz</h2>
<p className="mt-2 text-sm leading-6 text-slate-600"></p>
</div>
</div>
<Separator className="my-6" />
<nav className="space-y-2">
{navigation.map((item) => {
const Icon = item.icon;
const active = item.href === activeHref;
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-2xl px-4 py-3 text-sm font-medium transition-colors",
active
? "bg-primary text-white shadow-sm"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950"
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</nav>
</aside>
);
}

View File

@@ -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 (
<div className="space-y-8">
<PageHeader eyebrow={eyebrow} title={title} description={description} />
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_280px]">
<Card className="border-slate-200/70 bg-white/90">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{bullets.map((bullet) => (
<div key={bullet} className="flex items-start gap-3 rounded-2xl bg-slate-50 p-4">
<CheckCircle2 className="mt-0.5 h-5 w-5 text-emerald-600" />
<p className="text-sm leading-6 text-slate-700">{bullet}</p>
</div>
))}
</CardContent>
</Card>
<Card className="border-slate-900/10 bg-slate-950 text-white">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<Button asChild variant="secondary" className="w-full bg-white text-slate-950">
<Link href={ctaHref}>
{ctaLabel}
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -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 (
<Button onClick={handleLogout} variant="outline">
<LogOut className="h-4 w-4" />
退
</Button>
);
}

View File

@@ -0,0 +1,27 @@
import { Badge } from "@/components/ui/badge";
export function PageHeader({
eyebrow,
title,
description
}: {
eyebrow?: string;
title: string;
description?: string;
}) {
return (
<div className="space-y-4">
{eyebrow ? <Badge variant="outline">{eyebrow}</Badge> : null}
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight text-slate-950 md:text-4xl">
{title}
</h1>
{description ? (
<p className="max-w-3xl text-sm leading-7 text-slate-600 md:text-base">
{description}
</p>
) : null}
</div>
</div>
);
}

View File

@@ -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 (
<Card className="border-white/70 bg-white/90">
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="rounded-2xl bg-slate-950 p-3 text-white">
<Icon className="h-5 w-5" />
</div>
<div>
<CardDescription>{label}</CardDescription>
<CardTitle className="text-2xl">{value}</CardTitle>
</div>
</div>
</CardHeader>
{detail ? <CardContent className="text-sm text-slate-600">{detail}</CardContent> : null}
</Card>
);
}

View File

@@ -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 <Badge variant={variant}>{getExamStatusLabel(status)}</Badge>;
}

View File

@@ -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<EventSource | null>(null);
const [exam, setExam] = useState(initialExam);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState<ProgressEvent | null>(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<ExamSummary>(`/exams/${exam.id}`, {
method: "GET"
});
setExam(payload);
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "刷新失败");
}
}
async function handleUpload(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedFile) {
toast.error("请选择文件");
return;
}
const formData = new FormData();
formData.append("file", selectedFile);
setUploading(true);
try {
const payload = await browserApi<ExamUploadResponse>(`/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 (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card className="border-slate-200/70 bg-white/92">
<CardHeader className="flex flex-row items-start justify-between">
<div className="space-y-2">
<CardTitle className="text-2xl">{exam.title}</CardTitle>
<StatusBadge status={exam.status} />
</div>
<div className="flex gap-2">
<Button asChild variant="outline">
<Link href={`/questions?examId=${exam.id}`}></Link>
</Button>
{exam.total_questions > 0 ? (
<Button asChild>
<Link href={`/quiz/${exam.id}`}></Link>
</Button>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<div className="rounded-2xl bg-slate-50 p-4">
<div className="text-sm text-slate-500"></div>
<div className="mt-2 text-2xl font-semibold text-slate-900">
{exam.total_questions}
</div>
</div>
<div className="rounded-2xl bg-slate-50 p-4">
<div className="text-sm text-slate-500"></div>
<div className="mt-2 text-2xl font-semibold text-slate-900">
{exam.current_index}
</div>
</div>
<div className="rounded-2xl bg-slate-50 p-4">
<div className="text-sm text-slate-500"></div>
<div className="mt-2 text-2xl font-semibold text-slate-900">
{Math.max(0, exam.total_questions - exam.current_index)}
</div>
</div>
<div className="rounded-2xl bg-slate-50 p-4">
<div className="text-sm text-slate-500"></div>
<div className="mt-2 text-2xl font-semibold text-slate-900">
{progressValue}%
</div>
</div>
</div>
<div className="space-y-2">
<div className="h-3 w-full overflow-hidden rounded-full bg-slate-200">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${progressValue}%` }}
/>
</div>
{progress ? (
<div className="text-sm text-slate-600">{progress.message}</div>
) : null}
</div>
<div className="grid gap-4 md:grid-cols-2 text-sm text-slate-600">
<div>{formatDate(exam.created_at)}</div>
<div>{formatDate(exam.updated_at)}</div>
</div>
{exam.status === "failed" ? (
<div className="flex items-start gap-3 rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
<AlertCircle className="mt-0.5 h-5 w-5" />
</div>
) : null}
</CardContent>
</Card>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleUpload}>
<Input
type="file"
accept=".txt,.pdf,.doc,.docx,.xlsx,.xls"
onChange={(event) => setSelectedFile(event.target.files?.[0] || null)}
required
/>
<Button className="w-full" disabled={uploading || isProcessing} type="submit">
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isProcessing ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{isProcessing ? "处理中" : "上传"}
</Button>
</form>
<div className="mt-6 space-y-3 text-sm text-slate-600">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4" />
TXT / PDF / DOC / DOCX / XLSX / XLS
</div>
<div></div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<File | null>(null);
const [creating, setCreating] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(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<HTMLFormElement>) {
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<void>(`/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 (
<div className="grid gap-6 xl:grid-cols-[340px_minmax(0,1fr)]">
<Card className="border-slate-200/70 bg-white/92">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleCreate}>
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="题库名称"
required
/>
<Input
type="file"
accept=".txt,.pdf,.doc,.docx,.xlsx,.xls"
onChange={(event) => setFile(event.target.files?.[0] || null)}
required
/>
<Button className="w-full" disabled={creating} type="submit">
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{creating ? "创建中" : "创建"}
</Button>
</form>
</CardContent>
</Card>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader>
<div className="flex items-center justify-between gap-3">
<CardTitle></CardTitle>
<div className="text-sm text-slate-500">{total} </div>
</div>
</CardHeader>
<CardContent>
{exams.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-slate-200">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium text-right"></th>
</tr>
</thead>
<tbody>
{exams.map((exam) => (
<tr key={exam.id} className="border-t border-slate-200">
<td className="px-4 py-3">
<Link className="font-medium text-slate-900 hover:underline" href={`/exams/${exam.id}`}>
{exam.title}
</Link>
<div className="mt-1 text-xs text-slate-500">{formatDate(exam.created_at)}</div>
</td>
<td className="px-4 py-3">
<StatusBadge status={exam.status} />
</td>
<td className="px-4 py-3 text-slate-600">
{exam.current_index}/{exam.total_questions}
</td>
<td className="px-4 py-3 text-slate-600">
{formatRelativeTime(exam.updated_at)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button asChild size="sm" variant="outline">
<Link href={`/exams/${exam.id}`}></Link>
</Button>
<Button
aria-label={`删除 ${exam.title}`}
disabled={deletingId === exam.id}
onClick={() => handleDelete(exam.id)}
size="icon"
type="button"
variant="ghost"
>
{deletingId === exam.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<PaginationControls
className="mt-4 rounded-2xl border border-slate-200"
page={page}
pageSize={pageSize}
total={total}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<number | null>(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<void>(`/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 (
<Card className="border-slate-200/70 bg-white/92">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle></CardTitle>
<div className="text-sm text-slate-500">{total} </div>
</CardHeader>
<CardContent>
{mistakes.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-slate-200">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium text-right"></th>
</tr>
</thead>
<tbody>
{mistakes.map((mistake) => (
<tr key={mistake.id} className="border-t border-slate-200">
<td className="px-4 py-3 text-slate-700">
<div className="line-clamp-2 max-w-3xl">{mistake.question.content}</div>
</td>
<td className="px-4 py-3 text-slate-600">
{getQuestionTypeLabel(mistake.question.type)}
</td>
<td className="px-4 py-3 text-slate-600">
<div className="line-clamp-1 max-w-xs">{mistake.question.answer}</div>
</td>
<td className="px-4 py-3 text-slate-600">
{formatDate(mistake.created_at)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end">
<Button
aria-label={`删除错题 ${mistake.id}`}
disabled={deletingId === mistake.id}
onClick={() => handleDelete(mistake)}
size="icon"
type="button"
variant="ghost"
>
{deletingId === mistake.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<PaginationControls
className="mt-4 rounded-2xl border border-slate-200"
page={page}
pageSize={pageSize}
total={total}
/>
</CardContent>
</Card>
);
}

View File

@@ -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<MistakeItem[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<AnswerCheckResponse | null>(null);
const [userAnswer, setUserAnswer] = useState("");
const [multipleAnswers, setMultipleAnswers] = useState<string[]>([]);
useEffect(() => {
void loadMistakes();
}, []);
async function loadMistakes() {
setLoading(true);
try {
const payload = await browserApi<MistakeListResponse>("/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<AnswerCheckResponse>("/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<void>(`/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 (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!question) {
return (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-10 text-center text-sm text-slate-500">
</div>
);
}
return (
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center justify-between">
<Button onClick={() => router.push("/mistakes")} type="button" variant="outline">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="text-sm text-slate-600">{progressText}</div>
</div>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader className="flex flex-row items-start justify-between">
<div className="space-y-2">
<CardTitle>{question.content}</CardTitle>
<div className="text-sm text-slate-500">{getQuestionTypeLabel(question.type)}</div>
</div>
<Button onClick={handleRemove} size="sm" type="button" variant="outline">
<Trash2 className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-4">
{question.options?.length ? (
<div className="space-y-3">
{question.options.map((option) => {
const letter = option.charAt(0);
const selected =
question.type === "multiple"
? multipleAnswers.includes(letter)
: userAnswer === letter;
return (
<button
key={option}
className={`w-full rounded-2xl border px-4 py-3 text-left text-sm transition ${
selected
? "border-primary bg-blue-50 text-slate-950"
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300"
}`}
disabled={Boolean(result)}
onClick={() => {
if (result) {
return;
}
if (question.type === "multiple") {
setMultipleAnswers((current) =>
current.includes(letter)
? current.filter((item) => item !== letter)
: [...current, letter]
);
} else {
setUserAnswer(letter);
}
}}
type="button"
>
{option}
</button>
);
})}
</div>
) : null}
{question.type === "short" ? (
<textarea
className="min-h-36 w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm outline-none ring-0 focus:border-primary"
onChange={(event) => setUserAnswer(event.target.value)}
placeholder="输入答案"
value={userAnswer}
/>
) : null}
{!result ? (
<Button className="w-full" disabled={submitting} onClick={handleSubmit} type="button">
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
</Button>
) : (
<div className={`rounded-2xl border p-4 ${result.correct ? "border-emerald-200 bg-emerald-50" : "border-red-200 bg-red-50"}`}>
<div className="flex items-center gap-2 font-medium">
{result.correct ? <Check className="h-4 w-4 text-emerald-600" /> : <X className="h-4 w-4 text-red-600" />}
{result.correct ? "回答正确" : "回答错误"}
</div>
{!result.correct ? (
<div className="mt-3 text-sm text-slate-700">
{result.correct_answer}
</div>
) : null}
{result.analysis ? (
<div className="mt-3 text-sm text-slate-600">{result.analysis}</div>
) : null}
{result.ai_feedback ? (
<div className="mt-3 text-sm text-slate-600">{result.ai_feedback}</div>
) : null}
<Button className="mt-4 w-full" onClick={handleNext} type="button">
{currentIndex < mistakes.length - 1 ? "下一题" : "完成"}
<ArrowRight className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,287 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowLeft, ArrowRight, BookmarkPlus, BookmarkX, Check, Loader2, 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, ExamSummary, QuestionDetail } from "@/lib/types";
import { getQuestionTypeLabel } from "@/lib/formatters";
export function QuizPlayerClient({
examId
}: {
examId: string;
}) {
const router = useRouter();
const searchParams = useSearchParams();
const [exam, setExam] = useState<ExamSummary | null>(null);
const [question, setQuestion] = useState<QuestionDetail | null>(null);
const [result, setResult] = useState<AnswerCheckResponse | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [inMistakeBook, setInMistakeBook] = useState(false);
const [userAnswer, setUserAnswer] = useState("");
const [multipleAnswers, setMultipleAnswers] = useState<string[]>([]);
useEffect(() => {
void loadQuiz();
}, [examId]);
async function loadQuiz() {
setLoading(true);
try {
if (searchParams.get("reset") === "true") {
await browserApi(`/exams/${examId}/progress`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ current_index: 0 })
});
}
const [examPayload, questionPayload, mistakesPayload] = await Promise.all([
browserApi<ExamSummary>(`/exams/${examId}`, { method: "GET" }),
browserApi<QuestionDetail>(`/questions/exam/${examId}/current`, { method: "GET" }),
browserApi<{ mistakes: Array<{ question_id: number }> }>("/mistakes/?skip=0&limit=1000", {
method: "GET"
})
]);
if (questionPayload.type === "judge" && (!questionPayload.options || questionPayload.options.length === 0)) {
questionPayload.options = ["A. 正确", "B. 错误"];
}
setExam(examPayload);
setQuestion(questionPayload);
setResult(null);
setUserAnswer("");
setMultipleAnswers([]);
setInMistakeBook(mistakesPayload.mistakes.some((item) => item.question_id === questionPayload.id));
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载失败");
} finally {
setLoading(false);
}
}
const progressText = useMemo(() => {
if (!exam) {
return "";
}
return `${exam.current_index + 1} / ${exam.total_questions}`;
}, [exam]);
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<AnswerCheckResponse>("/questions/check", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
question_id: question.id,
user_answer: answer
})
});
setResult(payload);
setInMistakeBook(!payload.correct || inMistakeBook);
} catch (error) {
toast.error(error instanceof Error ? error.message : "提交失败");
} finally {
setSubmitting(false);
}
}
async function handleNext() {
if (!exam) {
return;
}
try {
await browserApi(`/exams/${examId}/progress`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ current_index: exam.current_index + 1 })
});
await loadQuiz();
} catch (error) {
toast.error(error instanceof Error ? error.message : "跳转失败");
}
}
async function handleToggleMistake() {
if (!question) {
return;
}
try {
if (inMistakeBook) {
await browserApi(`/mistakes/question/${question.id}`, {
method: "DELETE"
});
setInMistakeBook(false);
} else {
await browserApi("/mistakes/add", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ question_id: question.id })
});
setInMistakeBook(true);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "操作失败");
}
}
if (loading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!exam || !question) {
return (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-10 text-center text-sm text-slate-500">
</div>
);
}
return (
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center justify-between">
<Button onClick={() => router.push(`/exams/${examId}`)} type="button" variant="outline">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="text-sm text-slate-600">{progressText}</div>
</div>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader className="flex flex-row items-start justify-between">
<div className="space-y-2">
<CardTitle>{question.content}</CardTitle>
<div className="text-sm text-slate-500">{getQuestionTypeLabel(question.type)}</div>
</div>
<Button onClick={handleToggleMistake} size="sm" type="button" variant="outline">
{inMistakeBook ? <BookmarkX className="h-4 w-4" /> : <BookmarkPlus className="h-4 w-4" />}
{inMistakeBook ? "移除错题" : "加入错题"}
</Button>
</CardHeader>
<CardContent className="space-y-4">
{question.options?.length ? (
<div className="space-y-3">
{question.options.map((option) => {
const letter = option.charAt(0);
const selected =
question.type === "multiple"
? multipleAnswers.includes(letter)
: userAnswer === letter;
return (
<button
key={option}
className={`w-full rounded-2xl border px-4 py-3 text-left text-sm transition ${
selected
? "border-primary bg-blue-50 text-slate-950"
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300"
}`}
disabled={Boolean(result)}
onClick={() => {
if (result) {
return;
}
if (question.type === "multiple") {
setMultipleAnswers((current) =>
current.includes(letter)
? current.filter((item) => item !== letter)
: [...current, letter]
);
} else {
setUserAnswer(letter);
}
}}
type="button"
>
{option}
</button>
);
})}
</div>
) : null}
{question.type === "short" ? (
<textarea
className="min-h-36 w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm outline-none ring-0 focus:border-primary"
onChange={(event) => setUserAnswer(event.target.value)}
placeholder="输入答案"
value={userAnswer}
/>
) : null}
{!result ? (
<Button className="w-full" disabled={submitting} onClick={handleSubmit} type="button">
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
</Button>
) : (
<div className={`rounded-2xl border p-4 ${result.correct ? "border-emerald-200 bg-emerald-50" : "border-red-200 bg-red-50"}`}>
<div className="flex items-center gap-2 font-medium">
{result.correct ? <Check className="h-4 w-4 text-emerald-600" /> : <X className="h-4 w-4 text-red-600" />}
{result.correct ? "回答正确" : "回答错误"}
</div>
{!result.correct ? (
<div className="mt-3 text-sm text-slate-700">
{result.correct_answer}
</div>
) : null}
{result.analysis ? (
<div className="mt-3 text-sm text-slate-600">{result.analysis}</div>
) : null}
{result.ai_feedback ? (
<div className="mt-3 text-sm text-slate-600">{result.ai_feedback}</div>
) : null}
<Button className="mt-4 w-full" onClick={handleNext} type="button">
<ArrowRight className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 10_000
}
}
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@@ -0,0 +1,73 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PaginationControls } from "@/components/ui/pagination-controls";
import { getQuestionTypeLabel, formatDate } from "@/lib/formatters";
import { QuestionListItem } from "@/lib/types";
export function QuestionList({
examId,
page,
pageSize,
questions,
total
}: {
examId?: number;
page: number;
pageSize: number;
questions: QuestionListItem[];
total: number;
}) {
return (
<Card className="border-slate-200/70 bg-white/92">
<CardHeader className="flex flex-row items-center justify-between">
<div className="space-y-1">
<CardTitle></CardTitle>
{examId ? <div className="text-sm text-slate-500"> #{examId}</div> : null}
</div>
<div className="text-sm text-slate-500">{total} </div>
</CardHeader>
<CardContent>
{questions.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-slate-200">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{questions.map((question) => (
<tr key={question.id} className="border-t border-slate-200">
<td className="px-4 py-3 text-slate-700">
<div className="line-clamp-2 max-w-3xl">{question.content}</div>
</td>
<td className="px-4 py-3 text-slate-600">
{getQuestionTypeLabel(question.type)}
</td>
<td className="px-4 py-3 text-slate-600">#{question.exam_id}</td>
<td className="px-4 py-3 text-slate-600">
{formatDate(question.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<PaginationControls
className="mt-4 rounded-2xl border border-slate-200"
page={page}
pageSize={pageSize}
total={total}
/>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,33 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-primary/12 text-primary",
secondary: "border-transparent bg-secondary text-secondary-foreground",
outline: "border-border text-foreground",
success: "border-transparent bg-success/15 text-success",
warning: "border-transparent bg-warning/20 text-warning",
destructive: "border-transparent bg-destructive/15 text-destructive"
}
},
defaultVariants: {
variant: "default"
}
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,54 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-sm hover:brightness-110",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline:
"border border-border bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground"
},
size: {
default: "h-11 px-5 py-2",
sm: "h-9 px-4",
lg: "h-12 px-6",
icon: "h-10 w-10"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,68 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-[28px] border border-border/60 bg-card/95 text-card-foreground shadow-panel backdrop-blur",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-xl font-semibold tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-11 w-full rounded-2xl border border-input bg-background px-4 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,138 @@
"use client";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import type { ReactNode } from "react";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function getVisiblePages(currentPage: number, totalPages: number) {
const pages = new Set<number>([1, totalPages, currentPage]);
for (let page = currentPage - 1; page <= currentPage + 1; page += 1) {
if (page > 1 && page < totalPages) {
pages.add(page);
}
}
return Array.from(pages).sort((a, b) => a - b);
}
function PageButton({
href,
children,
disabled,
active = false,
className
}: {
href: string;
children: ReactNode;
disabled?: boolean;
active?: boolean;
className?: string;
}) {
if (disabled) {
return (
<Button className={className} disabled size="sm" type="button" variant="outline">
{children}
</Button>
);
}
return (
<Button
asChild
className={className}
size="sm"
type="button"
variant={active ? "default" : "outline"}
>
<Link href={href}>{children}</Link>
</Button>
);
}
export function PaginationControls({
page,
pageSize,
total,
className
}: {
page: number;
pageSize: number;
total: number;
className?: string;
}) {
const pathname = usePathname();
const searchParams = useSearchParams();
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
if (total <= pageSize) {
return null;
}
const rangeStart = (currentPage - 1) * pageSize + 1;
const rangeEnd = Math.min(total, currentPage * pageSize);
const visiblePages = getVisiblePages(currentPage, totalPages);
function buildHref(targetPage: number) {
const params = new URLSearchParams(searchParams?.toString());
if (targetPage <= 1) {
params.delete("page");
} else {
params.set("page", String(targetPage));
}
const query = params.toString();
return query ? `${pathname}?${query}` : pathname;
}
return (
<div
className={cn(
"flex flex-col gap-3 border-t border-slate-200 px-4 py-4 text-sm text-slate-600 md:flex-row md:items-center md:justify-between",
className
)}
>
<div>
{rangeStart}-{rangeEnd} {total}
</div>
<div className="flex flex-wrap items-center gap-2">
<PageButton disabled={currentPage <= 1} href={buildHref(1)}>
<ChevronsLeft className="h-4 w-4" />
</PageButton>
<PageButton disabled={currentPage <= 1} href={buildHref(currentPage - 1)}>
<ChevronLeft className="h-4 w-4" />
</PageButton>
{visiblePages.map((visiblePage, index) => {
const previousPage = visiblePages[index - 1];
const showGap = previousPage && visiblePage - previousPage > 1;
return (
<div key={visiblePage} className="flex items-center gap-2">
{showGap ? <span className="px-1 text-slate-400">...</span> : null}
<PageButton
active={visiblePage === currentPage}
href={buildHref(visiblePage)}
>
{visiblePage}
</PageButton>
</div>
);
})}
<PageButton disabled={currentPage >= totalPages} href={buildHref(currentPage + 1)}>
<ChevronRight className="h-4 w-4" />
</PageButton>
<PageButton disabled={currentPage >= totalPages} href={buildHref(totalPages)}>
<ChevronsRight className="h-4 w-4" />
</PageButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@@ -0,0 +1,57 @@
import { buildProxyUrl } from "@/lib/api/config";
type BrowserApiOptions = Omit<RequestInit, "body"> & {
body?: BodyInit | null;
query?: Record<string, string | number | boolean | null | undefined>;
};
function buildSearchParams(
query?: BrowserApiOptions["query"]
): URLSearchParams | undefined {
if (!query) {
return undefined;
}
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
params.set(key, String(value));
}
});
return params;
}
export async function browserApi<T>(
path: string,
options: BrowserApiOptions = {}
): Promise<T> {
const { query, headers, ...init } = options;
const response = await fetch(buildProxyUrl(path, buildSearchParams(query)), {
...init,
headers: {
...(headers || {})
},
credentials: "same-origin",
cache: "no-store"
});
if (!response.ok) {
const fallback = `Request failed with status ${response.status}`;
try {
const data = await response.json();
throw new Error(data?.detail || fallback);
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error(fallback);
}
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}

23
web/src/lib/api/config.ts Normal file
View File

@@ -0,0 +1,23 @@
export const SESSION_COOKIE_NAME = "access_token";
export function getApiBaseUrl() {
return process.env.API_BASE_URL || "http://localhost:8000";
}
export function buildBackendUrl(path: string) {
const trimmedBase = getApiBaseUrl().replace(/\/$/, "");
const normalizedPath = path.startsWith("/api/")
? path
: `/api/${path.replace(/^\//, "")}`;
return `${trimmedBase}${normalizedPath}`;
}
export function buildProxyUrl(path: string, search?: URLSearchParams) {
const normalizedPath = path.replace(/^\/+/, "");
const query = search?.toString();
return query
? `/api/proxy/${normalizedPath}?${query}`
: `/api/proxy/${normalizedPath}`;
}

50
web/src/lib/api/server.ts Normal file
View File

@@ -0,0 +1,50 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import {
SESSION_COOKIE_NAME,
buildBackendUrl
} from "@/lib/api/config";
type ServerApiOptions = RequestInit & {
next?: { revalidate?: number };
};
export async function serverApi<T>(
path: string,
init: ServerApiOptions = {}
): Promise<T> {
const token = cookies().get(SESSION_COOKIE_NAME)?.value;
const response = await fetch(buildBackendUrl(path), {
...init,
cache: init.cache || "no-store",
headers: {
...(init.headers || {}),
...(token ? { Authorization: `Bearer ${token}` } : {})
}
});
if (response.status === 401) {
redirect("/login");
}
if (!response.ok) {
const fallback = `Request failed with status ${response.status}`;
try {
const data = await response.json();
throw new Error(data?.detail || fallback);
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error(fallback);
}
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}

View File

@@ -0,0 +1,21 @@
import { redirect } from "next/navigation";
import { serverApi } from "@/lib/api/server";
import { AuthUser } from "@/lib/types";
export async function requireCurrentUser() {
try {
return await serverApi<AuthUser>("/auth/me");
} catch (_error) {
redirect("/login");
}
}
export async function requireAdminUser() {
const user = await requireCurrentUser();
if (!user.is_admin) {
redirect("/dashboard");
}
return user;
}

View File

@@ -0,0 +1,17 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { SESSION_COOKIE_NAME } from "@/lib/api/config";
export function readSessionToken() {
return cookies().get(SESSION_COOKIE_NAME)?.value || null;
}
export function requireSessionToken() {
const token = readSessionToken();
if (!token) {
redirect("/login");
}
return token;
}

50
web/src/lib/formatters.ts Normal file
View File

@@ -0,0 +1,50 @@
export function formatDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(new Date(value));
}
export function formatRelativeTime(value: string) {
const date = new Date(value);
const diff = Date.now() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (days > 0) {
return `${days} 天前`;
}
if (hours > 0) {
return `${hours} 小时前`;
}
if (minutes > 0) {
return `${minutes} 分钟前`;
}
return "刚刚";
}
export function getExamStatusLabel(status: string) {
const map: Record<string, string> = {
pending: "等待中",
processing: "处理中",
ready: "就绪",
failed: "失败"
};
return map[status] || status;
}
export function getQuestionTypeLabel(type: string) {
const map: Record<string, string> = {
single: "单选",
multiple: "多选",
judge: "判断",
short: "简答"
};
return map[type] || type;
}

41
web/src/lib/pagination.ts Normal file
View File

@@ -0,0 +1,41 @@
export const DEFAULT_PAGE_SIZE = 20;
export type SearchParamValue = string | string[] | undefined;
export function parsePositiveInt(
value: SearchParamValue,
fallback = 1
): number {
const raw = Array.isArray(value) ? value[0] : value;
const parsed = Number(raw);
if (!raw || !Number.isFinite(parsed) || parsed < 1) {
return fallback;
}
return Math.floor(parsed);
}
export function parseOptionalPositiveInt(
value: SearchParamValue
): number | undefined {
const raw = Array.isArray(value) ? value[0] : value;
if (!raw) {
return undefined;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 1) {
return undefined;
}
return Math.floor(parsed);
}
export function getOffset(page: number, pageSize = DEFAULT_PAGE_SIZE) {
return Math.max(0, page - 1) * pageSize;
}
export function getTotalPages(total: number, pageSize = DEFAULT_PAGE_SIZE) {
return Math.max(1, Math.ceil(total / pageSize));
}

149
web/src/lib/types.ts Normal file
View File

@@ -0,0 +1,149 @@
export interface AuthUser {
id: number;
username: string;
is_admin: boolean;
created_at: string;
}
export interface ExamSummary {
id: number;
user_id: number;
title: string;
status: "pending" | "processing" | "ready" | "failed";
current_index: number;
total_questions: number;
created_at: string;
updated_at: string;
}
export interface ExamListResponse {
exams: ExamSummary[];
total: number;
}
export interface ExamSummaryStats {
total_exams: number;
total_questions: number;
completed_questions: number;
processing_exams: number;
ready_exams: number;
failed_exams: number;
}
export interface QuestionListItem {
id: number;
exam_id: number;
content: string;
type: "single" | "multiple" | "judge" | "short";
options?: string[] | null;
analysis?: string | null;
created_at: string;
}
export interface QuestionDetail extends QuestionListItem {}
export interface QuestionListResponse {
questions: QuestionListItem[];
total: number;
}
export interface AnswerCheckResponse {
correct: boolean;
user_answer: string;
correct_answer: string;
analysis?: string | null;
ai_score?: number | null;
ai_feedback?: string | null;
}
export interface ExamUploadResponse {
exam_id: number;
title: string;
status: string;
message: string;
}
export interface ProgressEvent {
exam_id: number;
status: string;
message: string;
progress: number;
total_chunks: number;
current_chunk: number;
questions_extracted: number;
questions_added: number;
duplicates_removed: number;
timestamp: string;
}
export interface MistakeListResponse {
mistakes: Array<{
id: number;
user_id: number;
question_id: number;
created_at: string;
question: {
id: number;
exam_id: number;
content: string;
type: "single" | "multiple" | "judge" | "short";
options?: string[] | null;
answer: string;
analysis?: string | null;
created_at: string;
};
}>;
total: number;
}
export interface AdminUserSummary extends AuthUser {
exam_count: number;
mistake_count: number;
}
export interface UserListResponse {
users: AdminUserSummary[];
total: number;
skip: number;
limit: number;
}
export interface AdminStatisticsResponse {
users: {
total: number;
admins: number;
regular_users: number;
};
exams: {
total: number;
today_uploads: number;
by_status: Record<string, number>;
upload_trend: Array<{ date: string; count: number }>;
};
questions: {
total: number;
by_type: Record<string, number>;
};
activity: {
today_active_users: number;
today_uploads: number;
};
}
export interface SystemConfigResponse {
allow_registration: boolean;
max_upload_size_mb: number;
max_daily_uploads: number;
ai_provider: string;
openai_api_key?: string | null;
openai_base_url?: string | null;
openai_model?: string | null;
anthropic_api_key?: string | null;
anthropic_model?: string | null;
qwen_api_key?: string | null;
qwen_base_url?: string | null;
qwen_model?: string | null;
gemini_api_key?: string | null;
gemini_base_url?: string | null;
gemini_model?: string | null;
}

6
web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

31
web/src/middleware.ts Normal file
View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { SESSION_COOKIE_NAME } from "@/lib/api/config";
const protectedPrefixes = [
"/dashboard",
"/exams",
"/quiz",
"/mistakes",
"/mistake-quiz",
"/questions",
"/admin"
];
export function middleware(request: NextRequest) {
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value;
const { pathname } = request.nextUrl;
if (protectedPrefixes.some((prefix) => pathname.startsWith(prefix)) && !token) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"]
};

72
web/tailwind.config.ts Normal file
View File

@@ -0,0 +1,72 @@
import type { Config } from "tailwindcss";
import animate from "tailwindcss-animate";
const config: Config = {
darkMode: ["class"],
content: ["./src/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: "1.5rem",
screens: {
"2xl": "1400px"
}
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))"
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))"
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))"
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))"
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))"
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))"
},
success: {
DEFAULT: "hsl(var(--success))",
foreground: "hsl(var(--success-foreground))"
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))"
}
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)"
},
boxShadow: {
panel: "0 24px 60px rgba(20, 32, 56, 0.14)"
},
backgroundImage: {
"brand-grid":
"linear-gradient(to right, rgba(17, 24, 39, 0.04) 1px, transparent 1px), linear-gradient(to bottom, rgba(17, 24, 39, 0.04) 1px, transparent 1px)"
}
}
},
plugins: [animate]
};
export default config;

25
web/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}