mirror of
https://github.com/handsomezhuzhu/QQuiz.git
synced 2026-04-18 22:42:53 +00:00
Compare commits
11 Commits
copilot/su
...
9a1a9d3247
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a1a9d3247 | |||
| cab8b3b483 | |||
| 31916e68a6 | |||
| 466fa50aa8 | |||
| 3e4157f021 | |||
| 3d47e568f6 | |||
| e88716b1ea | |||
|
|
4b53e74729 | ||
| 4606407356 | |||
|
|
7d924bb81e | ||
|
|
66f9a64c1c |
@@ -10,6 +10,9 @@ DATABASE_URL=sqlite+aiosqlite:///./qquiz.db
|
|||||||
# JWT Secret (must be at least 32 characters; generate randomly for production)
|
# JWT Secret (must be at least 32 characters; generate randomly for production)
|
||||||
SECRET_KEY=
|
SECRET_KEY=
|
||||||
|
|
||||||
|
# Default admin username (must be at least 3 characters; default: admin)
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
|
||||||
# Default admin password (must be at least 12 characters; generate randomly for production)
|
# Default admin password (must be at least 12 characters; generate randomly for production)
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
|||||||
7
.github/workflows/docker-publish.yml
vendored
7
.github/workflows/docker-publish.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Publish Docker Image
|
name: Build and Publish Single-Container Image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -45,6 +45,7 @@ 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: .
|
||||||
@@ -53,8 +54,8 @@ jobs:
|
|||||||
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=single-container
|
||||||
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 }}"
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -44,8 +44,9 @@ yarn-error.log
|
|||||||
.pnp.js
|
.pnp.js
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
frontend/build/
|
.next/
|
||||||
frontend/dist/
|
web/.next/
|
||||||
|
web/out/
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
.coverage
|
.coverage
|
||||||
|
|||||||
34
AGENTS.md
Normal file
34
AGENTS.md
Normal 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/`.
|
||||||
|
- `web/`: Next.js frontend for active development. Keep route screens under `src/app/` or related route segments, shared UI in `src/components/`, API wrappers in `src/lib/`, and helpers close to their consumers.
|
||||||
|
- `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 the single-container image.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
- `docker compose -f docker-compose-single.yml up -d --build`: start the default single-container deployment with FastAPI proxying the embedded Next.js frontend.
|
||||||
|
- `docker compose up -d --build`: start the split development stack with backend on `:8000` and frontend on `:3000`.
|
||||||
|
- `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 web && npm install && npm run dev`: start the Next.js dev server.
|
||||||
|
- `cd web && 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 and Next.js files use the naming conventions already established in `web/`; preserve route segment and component naming patterns in place.
|
||||||
|
- 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 `web/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.
|
||||||
52
Dockerfile
52
Dockerfile
@@ -1,46 +1,50 @@
|
|||||||
# ==================== 多阶段构建:前后端整合单容器 ====================
|
# ==================== 多阶段构建:单容器运行 FastAPI + Next.js ====================
|
||||||
# Stage 1: 构建前端
|
# Stage 1: 构建 Next.js 前端
|
||||||
FROM node:18-slim AS frontend-builder
|
FROM node:20-slim AS web-builder
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /web
|
||||||
|
|
||||||
# 复制前端依赖文件
|
COPY web/package*.json ./
|
||||||
COPY frontend/package*.json ./
|
|
||||||
|
|
||||||
# 安装依赖
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# 复制前端源代码
|
COPY web/ ./
|
||||||
COPY frontend/ ./
|
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# 构建前端(生成静态文件到 dist 目录)
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: 构建后端并整合前端
|
# Stage 2: 运行 FastAPI + Next.js
|
||||||
FROM python:3.11-slim
|
FROM node:20-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 复制后端依赖文件
|
# 安装 Python 运行时和操作系统依赖
|
||||||
COPY backend/requirements.txt ./
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv libmagic1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# 安装 Python 依赖(使用预编译wheel包,无需gcc)
|
RUN python3 -m venv /opt/venv
|
||||||
RUN pip install -r requirements.txt
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
# 复制后端代码
|
# 安装后端依赖
|
||||||
|
COPY backend/requirements.txt ./backend-requirements.txt
|
||||||
|
RUN python -m pip install --no-cache-dir -r backend-requirements.txt
|
||||||
|
|
||||||
|
# 复制后端代码和启动脚本
|
||||||
COPY backend/ ./
|
COPY backend/ ./
|
||||||
|
COPY scripts/start_single_container.py ./scripts/start_single_container.py
|
||||||
|
|
||||||
# 从前端构建阶段复制静态文件到后端 static 目录
|
# 复制 Next.js standalone 产物
|
||||||
COPY --from=frontend-builder /frontend/build ./static
|
COPY --from=web-builder /web/.next/standalone ./web
|
||||||
|
COPY --from=web-builder /web/.next/static ./web/.next/static
|
||||||
|
|
||||||
# 创建上传目录
|
# 创建上传目录
|
||||||
RUN mkdir -p ./uploads
|
RUN mkdir -p ./uploads
|
||||||
|
|
||||||
# 暴露端口
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# 设置环境变量
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV NEXT_SERVER_URL=http://127.0.0.1:3000
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# 启动命令
|
CMD ["python", "scripts/start_single_container.py"]
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||||
397
README.md
397
README.md
@@ -1,281 +1,220 @@
|
|||||||
# QQuiz - 智能刷题与题库管理平台
|
# QQuiz
|
||||||
|
|
||||||
QQuiz 是一个支持 Docker/源码双模部署的智能刷题平台,核心功能包括多文件上传、自动去重、异步解析、断点续做和错题本管理。
|
QQuiz 是一个用于题库导入、刷题训练和错题管理的全栈应用,支持文档解析、题目去重、断点续做、管理员配置和多模型接入。
|
||||||
|
|
||||||
## 功能特性
|

|
||||||
|
|
||||||
- 📚 **多文件上传与去重**: 支持向同一题库追加文档,自动识别并过滤重复题目
|
## 功能
|
||||||
- 🤖 **AI 智能解析**: 支持 Google Gemini (推荐) / OpenAI / Anthropic / Qwen 多种 AI 提供商
|
|
||||||
- 📄 **原生 PDF 理解**: Gemini 支持直接处理 PDF(最多1000页),完整保留图片、表格、公式等内容
|
- 文档导入:支持 TXT / PDF / DOC / DOCX / XLS / XLSX
|
||||||
- 🎓 **AI 参考答案**: 对于没有提供答案的题目,自动生成 AI 参考答案
|
- 异步解析:后台解析文档并回传进度
|
||||||
- 📊 **断点续做**: 自动记录刷题进度,随时继续
|
- 题目去重:同题库内自动去重
|
||||||
- ❌ **错题本管理**: 自动收集错题,支持手动添加/移除
|
- 刷题与续做:记录当前进度,支持继续作答
|
||||||
- 🎯 **多题型支持**: 单选、多选、判断、简答
|
- 错题本:自动收集错误题目
|
||||||
- 🔐 **权限管理**: 管理员配置、用户隔离
|
- 管理后台:用户管理、系统配置、模型配置
|
||||||
- 📱 **移动端优先**: 完美适配手机端
|
- AI 提供商:Gemini / OpenAI / Anthropic / Qwen
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 使用预构建镜像(最快)
|
QQuiz 默认以单容器形式发布和部署。GitHub Actions 只构建根目录 `Dockerfile` 生成的单容器镜像,README 也以这个路径为主。
|
||||||
|
|
||||||
直接使用 GitHub 自动构建的镜像,无需等待本地构建:
|
### 方式一:直接运行 GitHub Actions 构建好的单容器镜像
|
||||||
|
|
||||||
|
适合只想快速启动,不想先克隆仓库。
|
||||||
|
|
||||||
|
#### 1. 下载环境变量模板
|
||||||
|
|
||||||
|
Linux / macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -L https://raw.githubusercontent.com/handsomezhuzhu/QQuiz/main/.env.example -o .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest `
|
||||||
|
-Uri "https://raw.githubusercontent.com/handsomezhuzhu/QQuiz/main/.env.example" `
|
||||||
|
-OutFile ".env"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 编辑 `.env`
|
||||||
|
|
||||||
|
至少填写以下字段:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SECRET_KEY=replace-with-a-random-32-char-secret
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=replace-with-a-strong-password
|
||||||
|
|
||||||
|
AI_PROVIDER=gemini
|
||||||
|
GEMINI_API_KEY=your-real-gemini-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你不用 Gemini,也可以改成:
|
||||||
|
|
||||||
|
- `AI_PROVIDER=openai` 并填写 `OPENAI_API_KEY`
|
||||||
|
- `AI_PROVIDER=anthropic` 并填写 `ANTHROPIC_API_KEY`
|
||||||
|
- `AI_PROVIDER=qwen` 并填写 `QWEN_API_KEY`
|
||||||
|
|
||||||
|
#### 3. 拉取镜像
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 拉取最新镜像
|
|
||||||
docker pull ghcr.io/handsomezhuzhu/qquiz:latest
|
docker pull ghcr.io/handsomezhuzhu/qquiz:latest
|
||||||
|
```
|
||||||
|
|
||||||
# 2. 配置环境变量
|
#### 4. 创建数据卷
|
||||||
cp .env.example .env
|
|
||||||
# 编辑 .env,填入你的 API Key
|
|
||||||
|
|
||||||
# 3. 运行容器
|
```bash
|
||||||
|
docker volume create qquiz_data
|
||||||
|
docker volume create qquiz_uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. 启动容器
|
||||||
|
|
||||||
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name qquiz \
|
--name qquiz \
|
||||||
-p 8000:8000 \
|
|
||||||
-v $(pwd)/qquiz_data:/app/qquiz_data \
|
|
||||||
--env-file .env \
|
--env-file .env \
|
||||||
|
-e DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db \
|
||||||
|
-e UPLOAD_DIR=/app/uploads \
|
||||||
|
-v qquiz_data:/app/data \
|
||||||
|
-v qquiz_uploads:/app/uploads \
|
||||||
|
-p 8000:8000 \
|
||||||
|
--restart unless-stopped \
|
||||||
ghcr.io/handsomezhuzhu/qquiz:latest
|
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:8000`
|
||||||
|
- API 文档:`http://localhost:8000/docs`
|
||||||
|
|
||||||
### 单容器部署(自行构建)
|
停止:
|
||||||
|
|
||||||
从源码构建,一个容器包含前后端和 SQLite 数据库:
|
```bash
|
||||||
|
docker rm -f qquiz
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式二:从源码启动单容器
|
||||||
|
|
||||||
|
适合需要自行构建镜像或修改代码后再部署。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 配置环境变量(必须提供强密码和密钥)
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 编辑 .env,填入至少 32 位的 SECRET_KEY 和至少 12 位的 ADMIN_PASSWORD(建议使用随机生成值)
|
docker compose -f docker-compose-single.yml up -d --build
|
||||||
|
|
||||||
# 2. 启动服务(未设置强密码/密钥会直接报错终止)
|
|
||||||
SECRET_KEY=$(openssl rand -base64 48) \
|
|
||||||
ADMIN_PASSWORD=$(openssl rand -base64 16) \
|
|
||||||
docker-compose -f docker-compose-single.yml up -d
|
|
||||||
|
|
||||||
# 3. 访问应用: http://localhost:8000
|
|
||||||
# API 文档: http://localhost:8000/docs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 传统部署(3 个容器)
|
访问:
|
||||||
|
|
||||||
前后端分离 + MySQL:
|
- 应用:`http://localhost:8000`
|
||||||
|
- API 文档:`http://localhost:8000/docs`
|
||||||
|
|
||||||
|
### 可选:开发或兼容性部署
|
||||||
|
|
||||||
|
以下方式保留用于开发调试或兼容场景,不再作为默认部署方案:
|
||||||
|
|
||||||
|
#### 前后端分离开发栈
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 启动服务(建议直接在命令行生成强密钥和管理员密码)
|
|
||||||
SECRET_KEY=$(openssl rand -base64 48) \
|
|
||||||
ADMIN_PASSWORD=$(openssl rand -base64 16) \
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# 前端: http://localhost:3000
|
|
||||||
# 后端: http://localhost:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
### 方式二:本地运行
|
|
||||||
|
|
||||||
#### 前置要求
|
|
||||||
- Python 3.11+
|
|
||||||
- Node.js 18+
|
|
||||||
- MySQL 8.0+ 或 Docker (用于运行 MySQL)
|
|
||||||
|
|
||||||
**Linux/macOS 用户:**
|
|
||||||
```bash
|
|
||||||
# 1. 配置环境变量
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 编辑 .env,修改 DATABASE_URL 为本地数据库地址
|
docker compose up -d --build
|
||||||
|
|
||||||
# 2. 启动 MySQL
|
|
||||||
# macOS: brew services start mysql
|
|
||||||
# Linux: sudo systemctl start mysql
|
|
||||||
|
|
||||||
# 3. 运行启动脚本
|
|
||||||
chmod +x scripts/run_local.sh
|
|
||||||
./scripts/run_local.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**MySQL 安装指南:** 详见 [docs/MYSQL_SETUP.md](docs/MYSQL_SETUP.md)
|
访问:
|
||||||
|
|
||||||
## GitHub Actions 自动构建设置
|
- 前端:`http://localhost:3000`
|
||||||
|
- 后端:`http://localhost:8000`
|
||||||
|
|
||||||
如果你 fork 了本项目并想启用自动构建 Docker 镜像功能:
|
#### 分离栈叠加 MySQL
|
||||||
|
|
||||||
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
|
```bash
|
||||||
# 拉取最新镜像
|
cp .env.example .env
|
||||||
docker pull ghcr.io/handsomezhuzhu/qquiz:latest
|
docker compose -f docker-compose.yml -f docker-compose.mysql.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
## 默认账户
|
MySQL 相关说明见 [docs/MYSQL_SETUP.md](docs/MYSQL_SETUP.md)。
|
||||||
|
|
||||||
**管理员账户:**
|
## 本地开发
|
||||||
- 用户名: `admin`
|
|
||||||
- 密码: 取自环境变量 `ADMIN_PASSWORD`(必须至少 12 位,建议随机生成)
|
|
||||||
|
|
||||||
⚠️ **重要**: 在部署前就必须设置强管理员密码;如果需要轮换密码,请更新环境变量后重启服务。
|
### 后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
alembic upgrade head
|
||||||
|
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
当前主前端在 `web/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `web/` 是唯一前端工程,基于 Next.js
|
||||||
|
- 单容器镜像会在同一个容器里运行 FastAPI 和 Next.js,并由 FastAPI 代理前端请求
|
||||||
|
|
||||||
|
## 关键环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `DATABASE_URL` | 数据库连接字符串 |
|
||||||
|
| `SECRET_KEY` | JWT 密钥,至少 32 位 |
|
||||||
|
| `ADMIN_USERNAME` | 默认管理员用户名 |
|
||||||
|
| `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 或兼容网关地址 |
|
||||||
|
| `ANTHROPIC_API_KEY` | Anthropic API Key |
|
||||||
|
| `QWEN_API_KEY` | Qwen API Key |
|
||||||
|
| `ALLOW_REGISTRATION` | 是否允许注册 |
|
||||||
|
| `MAX_UPLOAD_SIZE_MB` | 单次上传大小限制 |
|
||||||
|
| `MAX_DAILY_UPLOADS` | 每日上传次数限制 |
|
||||||
|
|
||||||
|
完整模板见 [`.env.example`](.env.example)。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```text
|
||||||
QQuiz/
|
QQuiz/
|
||||||
├── backend/ # FastAPI 后端
|
├─ backend/ FastAPI 后端
|
||||||
│ ├── alembic/ # 数据库迁移
|
├─ web/ Next.js 前端工程
|
||||||
│ ├── routers/ # API 路由
|
├─ docs/ 文档与截图
|
||||||
│ ├── services/ # 业务逻辑
|
├─ test_data/ 示例题库文件
|
||||||
│ ├── models.py # 数据模型
|
├─ docker-compose-single.yml 单容器部署(默认)
|
||||||
│ ├── database.py # 数据库配置
|
├─ Dockerfile 单容器镜像构建(默认)
|
||||||
│ ├── main.py # 应用入口
|
├─ docker-compose.yml 前后端分离开发/兼容部署
|
||||||
│ └── requirements.txt # Python 依赖
|
└─ docker-compose.mysql.yml MySQL overlay(可选)
|
||||||
├── frontend/ # React 前端
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── api/ # API 客户端
|
|
||||||
│ │ ├── pages/ # 页面组件
|
|
||||||
│ │ ├── components/ # 通用组件
|
|
||||||
│ │ └── App.jsx # 应用入口
|
|
||||||
│ ├── package.json # Node 依赖
|
|
||||||
│ └── vite.config.js # Vite 配置
|
|
||||||
├── scripts/ # 部署和启动脚本
|
|
||||||
│ └── run_local.sh # Linux/macOS 启动脚本
|
|
||||||
├── docs/ # 文档目录
|
|
||||||
│ ├── MYSQL_SETUP.md # MySQL 安装配置指南
|
|
||||||
│ └── PROJECT_STRUCTURE.md # 项目架构详解
|
|
||||||
├── test_data/ # 测试数据
|
|
||||||
│ └── sample_questions.txt # 示例题目
|
|
||||||
├── docker-compose.yml # Docker 编排
|
|
||||||
├── .env.example # 环境变量模板
|
|
||||||
└── README.md # 项目说明
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 核心业务流程
|
|
||||||
|
|
||||||
### 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、SQLAlchemy、Alembic、SQLite / MySQL、httpx
|
||||||
- FastAPI - 现代化 Python Web 框架
|
- 前端:Next.js 14、React 18、TypeScript、Tailwind CSS、TanStack Query
|
||||||
- SQLAlchemy 2.0 - 异步 ORM
|
|
||||||
- Alembic - 数据库迁移
|
|
||||||
- MySQL 8.0 - 数据库
|
|
||||||
- aiomysql - MySQL 异步驱动
|
|
||||||
- Pydantic - 数据验证
|
|
||||||
|
|
||||||
**前端:**
|
## 提交前建议检查
|
||||||
- React 18 - UI 框架
|
|
||||||
- Vite - 构建工具
|
|
||||||
- Tailwind CSS - 样式框架
|
|
||||||
- React Router - 路由
|
|
||||||
- Axios - HTTP 客户端
|
|
||||||
|
|
||||||
## 开发进度
|
```bash
|
||||||
|
cd web && npm run build
|
||||||
|
docker compose -f docker-compose-single.yml build
|
||||||
|
```
|
||||||
|
|
||||||
- [x] **Step 1**: Foundation & Models ✅
|
建议至少手动验证:
|
||||||
- [ ] **Step 2**: Backend Core Logic
|
|
||||||
- [ ] **Step 3**: Frontend Config & API
|
|
||||||
- [ ] **Step 4**: Frontend Complex UI
|
|
||||||
|
|
||||||
## License
|
- 登录 / 退出
|
||||||
|
- 创建题库 / 上传文档 / 查看解析进度
|
||||||
|
- 刷题 / 续做 / 错题加入
|
||||||
|
- 管理员配置
|
||||||
|
- 大数据量列表分页
|
||||||
|
|
||||||
MIT
|
## 开源协议
|
||||||
|
|
||||||
|
本项目采用 [MIT License](LICENSE)。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ async def init_default_config(db: AsyncSession):
|
|||||||
"ai_provider": os.getenv("AI_PROVIDER", "openai"),
|
"ai_provider": os.getenv("AI_PROVIDER", "openai"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Validate admin credentials
|
||||||
|
admin_username = os.getenv("ADMIN_USERNAME", "admin")
|
||||||
|
if not admin_username or len(admin_username) < 3:
|
||||||
|
raise ValueError("ADMIN_USERNAME must be at least 3 characters long")
|
||||||
|
|
||||||
admin_password = os.getenv("ADMIN_PASSWORD")
|
admin_password = os.getenv("ADMIN_PASSWORD")
|
||||||
if not admin_password or len(admin_password) < 12:
|
if not admin_password or len(admin_password) < 12:
|
||||||
raise ValueError("ADMIN_PASSWORD must be set and at least 12 characters long")
|
raise ValueError("ADMIN_PASSWORD must be set and at least 12 characters long")
|
||||||
@@ -99,15 +104,15 @@ async def init_default_config(db: AsyncSession):
|
|||||||
db.add(config)
|
db.add(config)
|
||||||
print(f"✅ Created default config: {key} = {value}")
|
print(f"✅ Created default config: {key} = {value}")
|
||||||
|
|
||||||
# Create default admin user if not exists
|
# Create or update default admin user
|
||||||
result = await db.execute(select(User).where(User.username == "admin"))
|
result = await db.execute(select(User).where(User.username == admin_username))
|
||||||
admin = result.scalar_one_or_none()
|
admin = result.scalar_one_or_none()
|
||||||
|
|
||||||
default_admin_id = admin.id if admin else None
|
default_admin_id = admin.id if admin else None
|
||||||
|
|
||||||
if not admin:
|
if not admin:
|
||||||
admin_user = User(
|
admin_user = User(
|
||||||
username="admin",
|
username=admin_username,
|
||||||
hashed_password=pwd_context.hash(admin_password),
|
hashed_password=pwd_context.hash(admin_password),
|
||||||
is_admin=True
|
is_admin=True
|
||||||
)
|
)
|
||||||
@@ -115,8 +120,12 @@ async def init_default_config(db: AsyncSession):
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(admin_user)
|
await db.refresh(admin_user)
|
||||||
default_admin_id = admin_user.id
|
default_admin_id = admin_user.id
|
||||||
print("✅ Created default admin user (username: admin)")
|
print(f"✅ Created default admin user (username: {admin_username})")
|
||||||
else:
|
else:
|
||||||
|
# Update password if it has changed (verify current password doesn't match)
|
||||||
|
if not pwd_context.verify(admin_password, admin.hashed_password):
|
||||||
|
admin.hashed_password = pwd_context.hash(admin_password)
|
||||||
|
print(f"🔄 Updated default admin password (username: {admin_username})")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
if default_admin_id is not None:
|
if default_admin_id is not None:
|
||||||
|
|||||||
210
backend/main.py
210
backend/main.py
@@ -1,16 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
QQuiz FastAPI Application - 单容器模式(前后端整合)
|
QQuiz FastAPI Application - single-container API and frontend proxy.
|
||||||
"""
|
"""
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
import httpx
|
||||||
from slowapi.errors import RateLimitExceeded
|
from slowapi.errors import RateLimitExceeded
|
||||||
from slowapi.middleware import SlowAPIMiddleware
|
from slowapi.middleware import SlowAPIMiddleware
|
||||||
|
from starlette.background import BackgroundTask
|
||||||
|
|
||||||
from database import init_db, init_default_config, get_db_context
|
from database import init_db, init_default_config, get_db_context
|
||||||
from rate_limit import limiter
|
from rate_limit import limiter
|
||||||
@@ -18,6 +18,22 @@ from rate_limit import limiter
|
|||||||
# Load environment variables
|
# Load environment variables
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
NEXT_SERVER_URL = os.getenv("NEXT_SERVER_URL", "http://127.0.0.1:3000").rstrip("/")
|
||||||
|
INTERNAL_API_URL = os.getenv("INTERNAL_API_URL", "http://127.0.0.1:8000").rstrip("/")
|
||||||
|
SESSION_COOKIE_NAME = "access_token"
|
||||||
|
FRONTEND_PROXY_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||||
|
HOP_BY_HOP_HEADERS = {
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailers",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
"content-length",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
|
async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -32,6 +48,11 @@ async def lifespan(app: FastAPI):
|
|||||||
# Startup
|
# Startup
|
||||||
print("🚀 Starting QQuiz Application...")
|
print("🚀 Starting QQuiz Application...")
|
||||||
|
|
||||||
|
app.state.frontend_client = httpx.AsyncClient(
|
||||||
|
follow_redirects=False,
|
||||||
|
timeout=httpx.Timeout(30.0, connect=5.0),
|
||||||
|
)
|
||||||
|
|
||||||
# Initialize database
|
# Initialize database
|
||||||
await init_db()
|
await init_db()
|
||||||
|
|
||||||
@@ -49,6 +70,7 @@ async def lifespan(app: FastAPI):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
|
await app.state.frontend_client.aclose()
|
||||||
print("👋 Shutting down QQuiz Application...")
|
print("👋 Shutting down QQuiz Application...")
|
||||||
|
|
||||||
|
|
||||||
@@ -89,44 +111,152 @@ app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
|||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""Health check endpoint"""
|
"""Health check endpoint"""
|
||||||
return {"status": "healthy"}
|
|
||||||
|
try:
|
||||||
|
response = await app.state.frontend_client.get(
|
||||||
|
f"{NEXT_SERVER_URL}/login",
|
||||||
|
headers={"Accept-Encoding": "identity"},
|
||||||
|
)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=503,
|
||||||
|
content={
|
||||||
|
"status": "degraded",
|
||||||
|
"api": "healthy",
|
||||||
|
"frontend": "unavailable",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
frontend_status = "healthy" if response.status_code < 500 else "unavailable"
|
||||||
|
if frontend_status != "healthy":
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=503,
|
||||||
|
content={
|
||||||
|
"status": "degraded",
|
||||||
|
"api": "healthy",
|
||||||
|
"frontend": frontend_status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "healthy", "api": "healthy", "frontend": "healthy"}
|
||||||
|
|
||||||
|
|
||||||
# ============ 静态文件服务(前后端整合) ============
|
def build_frontend_target(request: Request, full_path: str) -> str:
|
||||||
|
normalized_path = f"/{full_path}" if full_path else "/"
|
||||||
|
query = request.url.query
|
||||||
|
return f"{NEXT_SERVER_URL}{normalized_path}{f'?{query}' if query else ''}"
|
||||||
|
|
||||||
# 检查静态文件目录是否存在
|
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
|
||||||
if STATIC_DIR.exists():
|
|
||||||
# 挂载静态资源(JS、CSS、图片等)
|
|
||||||
app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="static_assets")
|
|
||||||
|
|
||||||
# 前端应用的所有路由(SPA路由)
|
def build_internal_api_target(request: Request, full_path: str, trailing_slash: bool = False) -> str:
|
||||||
@app.get("/{full_path:path}")
|
normalized_path = full_path.strip("/")
|
||||||
async def serve_frontend(full_path: str):
|
if trailing_slash and normalized_path:
|
||||||
"""
|
normalized_path = f"{normalized_path}/"
|
||||||
服务前端应用
|
query = request.url.query
|
||||||
- API 路由已在上面定义,优先匹配
|
return f"{INTERNAL_API_URL}/api/{normalized_path}{f'?{query}' if query else ''}"
|
||||||
- 其他所有路由返回 index.html(SPA 单页应用)
|
|
||||||
"""
|
|
||||||
index_file = STATIC_DIR / "index.html"
|
|
||||||
if index_file.exists():
|
|
||||||
return FileResponse(index_file)
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"message": "Frontend not built yet",
|
|
||||||
"hint": "Run 'cd frontend && npm run build' to build the frontend"
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
print("⚠️ 静态文件目录不存在,前端功能不可用")
|
|
||||||
print("提示:请先构建前端应用或使用开发模式")
|
|
||||||
|
|
||||||
# 如果没有静态文件,显示 API 信息
|
|
||||||
@app.get("/")
|
def filter_proxy_headers(request: Request) -> dict[str, str]:
|
||||||
async def root():
|
headers = {
|
||||||
"""Root endpoint"""
|
key: value
|
||||||
return {
|
for key, value in request.headers.items()
|
||||||
"message": "Welcome to QQuiz API",
|
if key.lower() not in HOP_BY_HOP_HEADERS and key.lower() != "host"
|
||||||
"version": "1.0.0",
|
}
|
||||||
"docs": "/docs",
|
# Avoid sending compressed payloads through the proxy so response headers stay accurate.
|
||||||
"note": "Frontend not built. Please build frontend or use docker-compose."
|
headers["Accept-Encoding"] = "identity"
|
||||||
}
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def apply_proxy_headers(proxy_response: StreamingResponse, upstream_headers: httpx.Headers) -> None:
|
||||||
|
proxy_response.raw_headers = [
|
||||||
|
(key.encode("latin-1"), value.encode("latin-1"))
|
||||||
|
for key, value in upstream_headers.multi_items()
|
||||||
|
if key.lower() not in HOP_BY_HOP_HEADERS
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.api_route("/frontend-api/proxy/{full_path:path}", methods=FRONTEND_PROXY_METHODS, include_in_schema=False)
|
||||||
|
async def proxy_browser_api(request: Request, full_path: str):
|
||||||
|
"""
|
||||||
|
Serve browser-originated API calls directly from FastAPI in single-container mode.
|
||||||
|
This avoids relying on Next.js route handlers for the /frontend-api/proxy/* namespace.
|
||||||
|
"""
|
||||||
|
target = build_internal_api_target(request, full_path)
|
||||||
|
body = await request.body()
|
||||||
|
client: httpx.AsyncClient = app.state.frontend_client
|
||||||
|
headers = filter_proxy_headers(request)
|
||||||
|
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async def send_request(target_url: str) -> httpx.Response:
|
||||||
|
upstream_request = client.build_request(
|
||||||
|
method=request.method,
|
||||||
|
url=target_url,
|
||||||
|
headers=headers,
|
||||||
|
content=body if body else None,
|
||||||
|
)
|
||||||
|
return await client.send(
|
||||||
|
upstream_request,
|
||||||
|
stream=True,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
upstream_response = await send_request(target)
|
||||||
|
if (
|
||||||
|
request.method in {"GET", "HEAD"}
|
||||||
|
and upstream_response.status_code == 404
|
||||||
|
and full_path
|
||||||
|
and not full_path.endswith("/")
|
||||||
|
):
|
||||||
|
await upstream_response.aclose()
|
||||||
|
upstream_response = await send_request(
|
||||||
|
build_internal_api_target(request, full_path, trailing_slash=True)
|
||||||
|
)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=502,
|
||||||
|
content={"detail": "Backend API is unavailable."},
|
||||||
|
)
|
||||||
|
|
||||||
|
proxy_response = StreamingResponse(
|
||||||
|
upstream_response.aiter_raw(),
|
||||||
|
status_code=upstream_response.status_code,
|
||||||
|
background=BackgroundTask(upstream_response.aclose),
|
||||||
|
)
|
||||||
|
apply_proxy_headers(proxy_response, upstream_response.headers)
|
||||||
|
return proxy_response
|
||||||
|
|
||||||
|
|
||||||
|
@app.api_route("/", methods=FRONTEND_PROXY_METHODS, include_in_schema=False)
|
||||||
|
@app.api_route("/{full_path:path}", methods=FRONTEND_PROXY_METHODS, include_in_schema=False)
|
||||||
|
async def proxy_frontend(request: Request, full_path: str = ""):
|
||||||
|
"""
|
||||||
|
Forward all non-API traffic to the embedded Next.js server.
|
||||||
|
FastAPI keeps ownership of /api/*, /docs, /openapi.json, /redoc and /health.
|
||||||
|
"""
|
||||||
|
target = build_frontend_target(request, full_path)
|
||||||
|
body = await request.body()
|
||||||
|
client: httpx.AsyncClient = app.state.frontend_client
|
||||||
|
|
||||||
|
try:
|
||||||
|
upstream_request = client.build_request(
|
||||||
|
method=request.method,
|
||||||
|
url=target,
|
||||||
|
headers=filter_proxy_headers(request),
|
||||||
|
content=body if body else None,
|
||||||
|
)
|
||||||
|
upstream_response = await client.send(upstream_request, stream=True)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=502,
|
||||||
|
content={"detail": "Frontend server is unavailable."},
|
||||||
|
)
|
||||||
|
|
||||||
|
proxy_response = StreamingResponse(
|
||||||
|
upstream_response.aiter_raw(),
|
||||||
|
status_code=upstream_response.status_code,
|
||||||
|
background=BackgroundTask(upstream_response.aclose),
|
||||||
|
)
|
||||||
|
apply_proxy_headers(proxy_response, upstream_response.headers)
|
||||||
|
return proxy_response
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Authentication Router
|
Authentication Router
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -66,6 +66,7 @@ async def register(
|
|||||||
@router.post("/login", response_model=Token)
|
@router.post("/login", response_model=Token)
|
||||||
@limiter.limit("5/minute")
|
@limiter.limit("5/minute")
|
||||||
async def login(
|
async def login(
|
||||||
|
request: Request,
|
||||||
user_data: UserLogin,
|
user_data: UserLogin,
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
"""
|
"""
|
||||||
Exam Router - Handles exam creation, file upload, and deduplication
|
Exam Router - Handles exam creation, file upload, and deduplication
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, BackgroundTasks
|
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
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import json
|
import json
|
||||||
import magic
|
import magic
|
||||||
|
import random
|
||||||
|
|
||||||
from database import get_db
|
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
|
||||||
@@ -142,9 +143,8 @@ async def generate_ai_reference_answer(
|
|||||||
# Build prompt based on question type
|
# Build prompt based on question type
|
||||||
if question_type in ["single", "multiple"] and options:
|
if question_type in ["single", "multiple"] and options:
|
||||||
options_text = "\n".join(options)
|
options_text = "\n".join(options)
|
||||||
prompt = f"""这是一道{
|
q_type_text = '单选题' if question_type == 'single' else '多选题'
|
||||||
'单选题' if question_type == 'single' else '多选题'
|
prompt = f"""这是一道{q_type_text},但文档中没有提供答案。请根据题目内容,推理出最可能的正确答案。
|
||||||
},但文档中没有提供答案。请根据题目内容,推理出最可能的正确答案。
|
|
||||||
|
|
||||||
题目:{question_content}
|
题目:{question_content}
|
||||||
|
|
||||||
@@ -205,7 +205,8 @@ async def process_questions_with_dedup(
|
|||||||
exam_id: int,
|
exam_id: int,
|
||||||
questions_data: List[dict],
|
questions_data: List[dict],
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
llm_service=None
|
llm_service=None,
|
||||||
|
is_random: bool = False
|
||||||
) -> ParseResult:
|
) -> ParseResult:
|
||||||
"""
|
"""
|
||||||
Process parsed questions with fuzzy deduplication logic.
|
Process parsed questions with fuzzy deduplication logic.
|
||||||
@@ -238,6 +239,11 @@ async def process_questions_with_dedup(
|
|||||||
|
|
||||||
print(f"[Dedup] Checking against {len(existing_questions)} existing questions in database")
|
print(f"[Dedup] Checking against {len(existing_questions)} existing questions in database")
|
||||||
|
|
||||||
|
# Shuffle questions if random mode is enabled
|
||||||
|
if is_random:
|
||||||
|
print(f"[Dedup] Random mode enabled - shuffling {len(questions_data)} questions before saving")
|
||||||
|
random.shuffle(questions_data)
|
||||||
|
|
||||||
# Insert only new questions
|
# Insert only new questions
|
||||||
for q_data in questions_data:
|
for q_data in questions_data:
|
||||||
content_hash = q_data.get("content_hash")
|
content_hash = q_data.get("content_hash")
|
||||||
@@ -313,7 +319,8 @@ async def async_parse_and_save(
|
|||||||
exam_id: int,
|
exam_id: int,
|
||||||
file_content: bytes,
|
file_content: bytes,
|
||||||
filename: str,
|
filename: str,
|
||||||
db_url: str
|
db_url: str,
|
||||||
|
is_random: bool = False
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Background task to parse document and save questions with deduplication.
|
Background task to parse document and save questions with deduplication.
|
||||||
@@ -487,7 +494,7 @@ async def async_parse_and_save(
|
|||||||
))
|
))
|
||||||
|
|
||||||
print(f"[Exam {exam_id}] Processing questions with deduplication...")
|
print(f"[Exam {exam_id}] Processing questions with deduplication...")
|
||||||
parse_result = await process_questions_with_dedup(exam_id, questions_data, db, llm_service)
|
parse_result = await process_questions_with_dedup(exam_id, questions_data, db, llm_service, is_random)
|
||||||
|
|
||||||
# Update exam status and total questions
|
# Update exam status and total questions
|
||||||
result = await db.execute(select(Exam).where(Exam.id == exam_id))
|
result = await db.execute(select(Exam).where(Exam.id == exam_id))
|
||||||
@@ -537,8 +544,10 @@ async def async_parse_and_save(
|
|||||||
@router.post("/create", response_model=ExamUploadResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/create", response_model=ExamUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def create_exam_with_upload(
|
async def create_exam_with_upload(
|
||||||
|
request: Request,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
title: str = Form(...),
|
title: str = Form(...),
|
||||||
|
is_random: bool = Form(False),
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
@@ -573,7 +582,8 @@ async def create_exam_with_upload(
|
|||||||
new_exam.id,
|
new_exam.id,
|
||||||
file_content,
|
file_content,
|
||||||
file.filename,
|
file.filename,
|
||||||
os.getenv("DATABASE_URL")
|
os.getenv("DATABASE_URL"),
|
||||||
|
is_random
|
||||||
)
|
)
|
||||||
|
|
||||||
return ExamUploadResponse(
|
return ExamUploadResponse(
|
||||||
@@ -587,6 +597,7 @@ async def create_exam_with_upload(
|
|||||||
@router.post("/{exam_id}/append", response_model=ExamUploadResponse)
|
@router.post("/{exam_id}/append", response_model=ExamUploadResponse)
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def append_document_to_exam(
|
async def append_document_to_exam(
|
||||||
|
request: Request,
|
||||||
exam_id: int,
|
exam_id: int,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -673,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,
|
||||||
|
|||||||
@@ -19,6 +19,53 @@ from services.config_service import load_llm_config
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=QuestionListResponse)
|
||||||
|
async def get_all_questions(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
exam_id: Optional[int] = None,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get all questions with optional exam filter"""
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
query = select(Question).order_by(Question.id)
|
||||||
|
count_query = select(func.count(Question.id))
|
||||||
|
|
||||||
|
# Apply exam filter if provided
|
||||||
|
if exam_id is not None:
|
||||||
|
# Verify exam ownership/access
|
||||||
|
result = await db.execute(
|
||||||
|
select(Exam).where(
|
||||||
|
and_(Exam.id == exam_id, Exam.user_id == current_user.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
exam = result.scalar_one_or_none()
|
||||||
|
if not exam:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Exam not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
query = query.where(Question.exam_id == exam_id)
|
||||||
|
count_query = count_query.where(Question.exam_id == exam_id)
|
||||||
|
else:
|
||||||
|
# If no exam filter, only show questions from exams owned by user
|
||||||
|
query = query.join(Exam).where(Exam.user_id == current_user.id)
|
||||||
|
count_query = count_query.join(Exam).where(Exam.user_id == current_user.id)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
result = await db.execute(count_query)
|
||||||
|
total = result.scalar()
|
||||||
|
|
||||||
|
# Get questions
|
||||||
|
result = await db.execute(query.offset(skip).limit(limit))
|
||||||
|
questions = result.scalars().all()
|
||||||
|
|
||||||
|
return QuestionListResponse(questions=questions, total=total)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/exam/{exam_id}/questions", response_model=QuestionListResponse)
|
@router.get("/exam/{exam_id}/questions", response_model=QuestionListResponse)
|
||||||
async def get_exam_questions(
|
async def get_exam_questions(
|
||||||
exam_id: int,
|
exam_id: int,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 次
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
# ==================== 单容器部署配置 ====================
|
# ==================== 单容器部署配置 ====================
|
||||||
# 使用方法:docker-compose -f docker-compose-single.yml up -d
|
# 使用方法:docker-compose -f docker-compose-single.yml up -d
|
||||||
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
qquiz:
|
qquiz:
|
||||||
build:
|
build:
|
||||||
@@ -11,40 +9,12 @@ services:
|
|||||||
container_name: qquiz
|
container_name: qquiz
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
# 数据库配置(SQLite 默认)
|
# 数据库配置(SQLite 默认,使用持久化卷)
|
||||||
- DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db
|
- DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db
|
||||||
|
- UPLOAD_DIR=/app/uploads
|
||||||
# JWT 密钥(生产环境必须设置为随机字符串)
|
|
||||||
- SECRET_KEY=${SECRET_KEY:?Set SECRET_KEY to a random string of at least 32 characters}
|
|
||||||
|
|
||||||
# 管理员密码(生产环境必须设置为随机强密码,至少 12 位)
|
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?Set ADMIN_PASSWORD to a strong password of at least 12 characters}
|
|
||||||
|
|
||||||
# AI 提供商配置
|
|
||||||
- AI_PROVIDER=gemini
|
|
||||||
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
|
||||||
- GEMINI_BASE_URL=${GEMINI_BASE_URL:-https://generativelanguage.googleapis.com}
|
|
||||||
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.0-flash-exp}
|
|
||||||
|
|
||||||
# OpenAI 配置(可选)
|
|
||||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
|
||||||
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://api.openai.com/v1}
|
|
||||||
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o-mini}
|
|
||||||
|
|
||||||
# Anthropic 配置(可选)
|
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
|
||||||
- ANTHROPIC_MODEL=${ANTHROPIC_MODEL:-claude-3-haiku-20240307}
|
|
||||||
|
|
||||||
# Qwen 配置(可选)
|
|
||||||
- QWEN_API_KEY=${QWEN_API_KEY:-}
|
|
||||||
- QWEN_BASE_URL=${QWEN_BASE_URL:-https://dashscope.aliyuncs.com/compatible-mode/v1}
|
|
||||||
- QWEN_MODEL=${QWEN_MODEL:-qwen-plus}
|
|
||||||
|
|
||||||
# 系统配置
|
|
||||||
- ALLOW_REGISTRATION=true
|
|
||||||
- MAX_UPLOAD_SIZE_MB=10
|
|
||||||
- MAX_DAILY_UPLOADS=20
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# 持久化数据卷
|
# 持久化数据卷
|
||||||
@@ -54,7 +24,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
|
||||||
@@ -62,4 +32,8 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
qquiz_data:
|
qquiz_data:
|
||||||
|
# Reuse the previous split-stack SQLite volume during migration.
|
||||||
|
name: qquiz_sqlite_data
|
||||||
qquiz_uploads:
|
qquiz_uploads:
|
||||||
|
# Reuse the previous split-stack uploads volume during migration.
|
||||||
|
name: qquiz_upload_files
|
||||||
|
|||||||
29
docker-compose.mysql.yml
Normal file
29
docker-compose.mysql.yml
Normal 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:
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,262 +1,140 @@
|
|||||||
# MySQL 安装与配置指南
|
# MySQL 可选配置指南
|
||||||
|
|
||||||
QQuiz 使用 MySQL 8.0 作为数据库,你可以选择 Docker 部署或本地安装。
|
QQuiz 默认部署路径是单容器 + SQLite。README、根目录 `Dockerfile`、`docker-compose-single.yml` 和 GitHub Actions 发布镜像都围绕这个模式设计。
|
||||||
|
|
||||||
## 方式一:使用 Docker (推荐)
|
只有在你明确需要把数据库独立出去时,才需要 MySQL。常见原因:
|
||||||
|
|
||||||
### 优点
|
- 需要多个应用实例共享同一数据库
|
||||||
- 无需手动安装 MySQL
|
- 已有 MySQL 运维体系
|
||||||
- 自动配置和初始化
|
- 希望把应用容器和数据库生命周期分开
|
||||||
- 隔离环境,不影响系统
|
|
||||||
|
|
||||||
### 使用步骤
|
## 场景一:源码部署时附加 MySQL 容器
|
||||||
|
|
||||||
1. **安装 Docker Desktop**
|
这是当前最直接的 MySQL 用法,适合你已经克隆仓库并接受“应用容器 + MySQL 容器”的可选部署方式。
|
||||||
- 下载地址:https://www.docker.com/products/docker-desktop/
|
|
||||||
- 安装后启动 Docker Desktop
|
|
||||||
|
|
||||||
2. **运行启动脚本**
|
1. 复制环境变量模板:
|
||||||
```bash
|
|
||||||
scripts\fix_and_start.bat
|
|
||||||
```
|
|
||||||
选择 **[1] Use Docker**
|
|
||||||
|
|
||||||
3. **完成!**
|
|
||||||
- Docker 会自动下载 MySQL 镜像
|
|
||||||
- 自动创建数据库和用户
|
|
||||||
- 自动启动服务
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 方式二:本地安装 MySQL
|
|
||||||
|
|
||||||
### 下载 MySQL
|
|
||||||
|
|
||||||
1. 访问 MySQL 官网下载页面:
|
|
||||||
https://dev.mysql.com/downloads/installer/
|
|
||||||
|
|
||||||
2. 选择 **MySQL Installer for Windows**
|
|
||||||
|
|
||||||
3. 下载 `mysql-installer-community-8.0.x.x.msi`
|
|
||||||
|
|
||||||
### 安装步骤
|
|
||||||
|
|
||||||
1. **运行安装程序**
|
|
||||||
- 双击下载的 .msi 文件
|
|
||||||
|
|
||||||
2. **选择安装类型**
|
|
||||||
- 选择 "Developer Default" 或 "Server only"
|
|
||||||
- 点击 Next
|
|
||||||
|
|
||||||
3. **配置 MySQL Server**
|
|
||||||
- **Config Type**: Development Computer
|
|
||||||
- **Port**: 3306 (默认)
|
|
||||||
- **Authentication Method**: 选择 "Use Strong Password Encryption"
|
|
||||||
|
|
||||||
4. **设置 Root 密码**
|
|
||||||
- 输入并记住 root 用户的密码
|
|
||||||
- 建议密码:`root` (开发环境)
|
|
||||||
|
|
||||||
5. **Windows Service 配置**
|
|
||||||
- ✅ Configure MySQL Server as a Windows Service
|
|
||||||
- Service Name: MySQL80
|
|
||||||
- ✅ Start the MySQL Server at System Startup
|
|
||||||
|
|
||||||
6. **完成安装**
|
|
||||||
- 点击 Execute 开始安装
|
|
||||||
- 等待安装完成
|
|
||||||
- 点击 Finish
|
|
||||||
|
|
||||||
### 验证安装
|
|
||||||
|
|
||||||
打开命令提示符,运行:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mysql --version
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
应该显示:`mysql Ver 8.0.x for Win64 on x86_64`
|
Windows PowerShell:
|
||||||
|
|
||||||
### 配置 QQuiz 数据库
|
```powershell
|
||||||
|
Copy-Item .env.example .env
|
||||||
**方式 A:使用脚本自动创建 (推荐)**
|
|
||||||
|
|
||||||
运行:
|
|
||||||
```bash
|
|
||||||
scripts\fix_and_start.bat
|
|
||||||
```
|
|
||||||
选择 **[2] Use Local MySQL**
|
|
||||||
|
|
||||||
**方式 B:手动创建**
|
|
||||||
|
|
||||||
1. 打开 MySQL 命令行客户端:
|
|
||||||
```bash
|
|
||||||
mysql -u root -p
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 输入 root 密码
|
|
||||||
|
|
||||||
3. 创建数据库和用户:
|
|
||||||
```sql
|
|
||||||
CREATE DATABASE qquiz_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
CREATE USER 'qquiz'@'localhost' IDENTIFIED BY 'qquiz_password';
|
|
||||||
GRANT ALL PRIVILEGES ON qquiz_db.* TO 'qquiz'@'localhost';
|
|
||||||
FLUSH PRIVILEGES;
|
|
||||||
EXIT;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 数据库配置说明
|
|
||||||
|
|
||||||
### .env 文件配置
|
|
||||||
|
|
||||||
确保 `.env` 文件中的数据库连接字符串正确:
|
|
||||||
|
|
||||||
**本地 MySQL:**
|
|
||||||
```env
|
|
||||||
DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@localhost:3306/qquiz_db
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker MySQL:**
|
2. 把 `.env` 中的数据库连接改成 MySQL 容器地址:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@mysql:3306/qquiz_db
|
DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@mysql:3306/qquiz_db
|
||||||
```
|
```
|
||||||
|
|
||||||
### 连接参数说明
|
3. 启动应用和 MySQL:
|
||||||
|
|
||||||
- `mysql+aiomysql://` - 使用 aiomysql 异步驱动
|
```bash
|
||||||
- `qquiz` - 数据库用户名
|
docker compose -f docker-compose.yml -f docker-compose.mysql.yml up -d --build
|
||||||
- `qquiz_password` - 数据库密码
|
|
||||||
- `localhost` 或 `mysql` - 数据库主机地址
|
|
||||||
- `3306` - MySQL 默认端口
|
|
||||||
- `qquiz_db` - 数据库名称
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### 1. 端口 3306 被占用
|
|
||||||
|
|
||||||
**错误信息:**
|
|
||||||
```
|
|
||||||
Error: Port 3306 is already in use
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**解决方案:**
|
4. 访问:
|
||||||
- 检查是否已经有 MySQL 运行:`netstat -ano | findstr :3306`
|
|
||||||
- 停止现有的 MySQL 服务
|
|
||||||
- 或修改 `.env` 中的端口号
|
|
||||||
|
|
||||||
### 2. 无法连接到 MySQL
|
- 前端:`http://localhost:3000`
|
||||||
|
- 后端:`http://localhost:8000`
|
||||||
|
|
||||||
**错误信息:**
|
说明:
|
||||||
```
|
|
||||||
Can't connect to MySQL server on 'localhost'
|
|
||||||
```
|
|
||||||
|
|
||||||
**解决方案:**
|
- 这条路径是 MySQL 兼容部署,不是默认发布路径
|
||||||
|
- 默认发布镜像仍然是根目录单容器镜像
|
||||||
|
|
||||||
1. **检查 MySQL 服务是否运行**
|
## 场景二:单容器应用连接外部 MySQL
|
||||||
- 按 Win+R,输入 `services.msc`
|
|
||||||
- 查找 "MySQL80" 服务
|
|
||||||
- 确认状态为 "正在运行"
|
|
||||||
|
|
||||||
2. **启动 MySQL 服务**
|
如果你想继续使用单容器应用镜像,但数据库由外部 MySQL 托管,可以直接让应用容器连接现有数据库。
|
||||||
```bash
|
|
||||||
net start MySQL80
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **检查防火墙设置**
|
### 1. 准备 MySQL 8.0 数据库
|
||||||
- 确保防火墙允许 MySQL 端口 3306
|
|
||||||
|
|
||||||
### 3. 密码验证失败
|
执行以下 SQL 创建数据库和账号:
|
||||||
|
|
||||||
**错误信息:**
|
|
||||||
```
|
|
||||||
Access denied for user 'qquiz'@'localhost'
|
|
||||||
```
|
|
||||||
|
|
||||||
**解决方案:**
|
|
||||||
|
|
||||||
重新创建用户并设置密码:
|
|
||||||
```sql
|
```sql
|
||||||
mysql -u root -p
|
CREATE DATABASE qquiz_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
DROP USER IF EXISTS 'qquiz'@'localhost';
|
CREATE USER 'qquiz'@'%' IDENTIFIED BY 'qquiz_password';
|
||||||
CREATE USER 'qquiz'@'localhost' IDENTIFIED BY 'qquiz_password';
|
GRANT ALL PRIVILEGES ON qquiz_db.* TO 'qquiz'@'%';
|
||||||
GRANT ALL PRIVILEGES ON qquiz_db.* TO 'qquiz'@'localhost';
|
|
||||||
FLUSH PRIVILEGES;
|
FLUSH PRIVILEGES;
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 字符集问题
|
### 2. 修改 `.env`
|
||||||
|
|
||||||
**解决方案:**
|
把 `DATABASE_URL` 改成你的 MySQL 地址,例如:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@mysql.example.com:3306/qquiz_db
|
||||||
|
UPLOAD_DIR=/app/uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 启动单容器镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull ghcr.io/handsomezhuzhu/qquiz:latest
|
||||||
|
|
||||||
|
docker volume create qquiz_uploads
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name qquiz \
|
||||||
|
--env-file .env \
|
||||||
|
-v qquiz_uploads:/app/uploads \
|
||||||
|
-p 8000:8000 \
|
||||||
|
--restart unless-stopped \
|
||||||
|
ghcr.io/handsomezhuzhu/qquiz:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 这里不需要本地 SQLite 数据卷,因为数据库已经外置到 MySQL
|
||||||
|
- 仍然建议保留上传目录卷,避免容器重建后丢失上传文件
|
||||||
|
|
||||||
|
## 本地开发连接 MySQL
|
||||||
|
|
||||||
|
如果你是在本机直接跑后端,`.env` 中可使用本地 MySQL 地址:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@localhost:3306/qquiz_db
|
||||||
|
```
|
||||||
|
|
||||||
|
然后分别启动后端和前端:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 1. 连接不上 MySQL
|
||||||
|
|
||||||
|
检查以下几项:
|
||||||
|
|
||||||
|
- `DATABASE_URL` 中的主机名、端口、用户名和密码是否正确
|
||||||
|
- MySQL 是否允许对应来源地址连接
|
||||||
|
- 3306 端口是否开放
|
||||||
|
|
||||||
|
### 2. 容器里能连,宿主机里不能连
|
||||||
|
|
||||||
|
这是因为容器内部和宿主机访问地址不同:
|
||||||
|
|
||||||
|
- 容器之间互联时通常使用服务名,例如 `mysql`
|
||||||
|
- 宿主机连接本机 MySQL 时通常使用 `localhost`
|
||||||
|
|
||||||
|
### 3. 字符集异常
|
||||||
|
|
||||||
|
建议数据库和表统一使用 `utf8mb4`:
|
||||||
|
|
||||||
确保数据库使用 UTF-8 字符集:
|
|
||||||
```sql
|
```sql
|
||||||
ALTER DATABASE qquiz_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
ALTER DATABASE qquiz_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 管理工具推荐
|
|
||||||
|
|
||||||
### 1. MySQL Workbench (官方)
|
|
||||||
- 下载:https://dev.mysql.com/downloads/workbench/
|
|
||||||
- 功能:可视化数据库管理、SQL 编辑器、备份还原
|
|
||||||
|
|
||||||
### 2. DBeaver (免费开源)
|
|
||||||
- 下载:https://dbeaver.io/download/
|
|
||||||
- 功能:多数据库支持、数据导入导出、ER 图
|
|
||||||
|
|
||||||
### 3. phpMyAdmin (Web 界面)
|
|
||||||
- 适合习惯 Web 界面的用户
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 数据库备份与恢复
|
|
||||||
|
|
||||||
### 备份数据库
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mysqldump -u qquiz -p qquiz_db > backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### 恢复数据库
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mysql -u qquiz -p qquiz_db < backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 切换回 PostgreSQL
|
|
||||||
|
|
||||||
如果需要切换回 PostgreSQL:
|
|
||||||
|
|
||||||
1. 修改 `requirements.txt`:
|
|
||||||
```
|
|
||||||
asyncpg==0.29.0 # 替换 aiomysql
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 修改 `.env`:
|
|
||||||
```
|
|
||||||
DATABASE_URL=postgresql+asyncpg://qquiz:qquiz_password@localhost:5432/qquiz_db
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 修改 `docker-compose.yml`:
|
|
||||||
- 将 `mysql` 服务改回 `postgres`
|
|
||||||
|
|
||||||
4. 重新安装依赖:
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 技术支持
|
|
||||||
|
|
||||||
如遇到其他问题,请:
|
|
||||||
1. 检查 MySQL 错误日志
|
|
||||||
2. 确认防火墙和网络配置
|
|
||||||
3. 查看项目 issues: https://github.com/handsomezhuzhu/QQuiz/issues
|
|
||||||
|
|||||||
86
docs/PLAN.md
Normal file
86
docs/PLAN.md
Normal 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. 页面信息结构:标题、数据、动作优先,减少解释文字
|
||||||
@@ -30,36 +30,17 @@ QQuiz/
|
|||||||
│ ├── alembic.ini # Alembic 配置
|
│ ├── alembic.ini # Alembic 配置
|
||||||
│ └── Dockerfile # 后端 Docker 镜像
|
│ └── Dockerfile # 后端 Docker 镜像
|
||||||
│
|
│
|
||||||
├── frontend/ # React 前端
|
├── web/ # Next.js 前端
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── api/
|
│ │ ├── app/ # App Router 页面、布局、Route Handlers
|
||||||
│ │ │ └── client.js # API 客户端(Axios)⭐
|
│ │ ├── components/ # 共享 UI 组件
|
||||||
│ │ ├── components/
|
│ │ ├── lib/ # API、认证、格式化等公共逻辑
|
||||||
│ │ │ ├── Layout.jsx # 主布局(导航栏)
|
│ │ └── middleware.ts # 登录态守卫
|
||||||
│ │ │ └── ProtectedRoute.jsx # 路由保护
|
|
||||||
│ │ ├── context/
|
|
||||||
│ │ │ └── AuthContext.jsx # 认证上下文
|
|
||||||
│ │ ├── pages/
|
|
||||||
│ │ │ ├── Login.jsx # 登录页
|
|
||||||
│ │ │ ├── Register.jsx # 注册页
|
|
||||||
│ │ │ ├── Dashboard.jsx # 仪表盘
|
|
||||||
│ │ │ ├── ExamList.jsx # 题库列表 ⭐
|
|
||||||
│ │ │ ├── ExamDetail.jsx # 题库详情(追加上传)⭐
|
|
||||||
│ │ │ ├── QuizPlayer.jsx # 刷题核心页面 ⭐
|
|
||||||
│ │ │ ├── MistakeList.jsx # 错题本
|
|
||||||
│ │ │ └── AdminSettings.jsx # 系统设置
|
|
||||||
│ │ ├── utils/
|
|
||||||
│ │ │ └── helpers.js # 工具函数
|
|
||||||
│ │ ├── App.jsx # 应用根组件
|
|
||||||
│ │ ├── index.jsx # 应用入口
|
|
||||||
│ │ └── index.css # 全局样式
|
|
||||||
│ ├── public/
|
|
||||||
│ │ └── index.html # HTML 模板
|
|
||||||
│ ├── package.json # Node 依赖
|
│ ├── package.json # Node 依赖
|
||||||
│ ├── vite.config.js # Vite 配置
|
│ ├── next.config.mjs # Next.js 配置
|
||||||
│ ├── tailwind.config.js # Tailwind CSS 配置
|
│ ├── tailwind.config.ts # Tailwind CSS 配置
|
||||||
│ ├── postcss.config.js # PostCSS 配置
|
│ ├── postcss.config.mjs # PostCSS 配置
|
||||||
│ └── Dockerfile # 前端 Docker 镜像
|
│ └── Dockerfile # 分离部署前端镜像
|
||||||
│
|
│
|
||||||
├── docker-compose.yml # Docker 编排配置 ⭐
|
├── docker-compose.yml # Docker 编排配置 ⭐
|
||||||
├── .env.example # 环境变量模板
|
├── .env.example # 环境变量模板
|
||||||
@@ -133,74 +114,31 @@ for q in questions_data:
|
|||||||
|
|
||||||
### 前端核心
|
### 前端核心
|
||||||
|
|
||||||
#### `client.js` - API 客户端
|
#### `src/lib/api/server.ts` - 服务端 API 访问
|
||||||
封装了所有后端 API:
|
用于 Next Server Components 访问后端:
|
||||||
- `authAPI`: 登录、注册、用户信息
|
- 从 `HttpOnly` Cookie 读取会话令牌
|
||||||
- `examAPI`: 题库 CRUD、追加文档
|
- 直接请求 FastAPI `/api/*`
|
||||||
- `questionAPI`: 获取题目、答题
|
- 401 时自动重定向回登录页
|
||||||
- `mistakeAPI`: 错题本管理
|
|
||||||
- `adminAPI`: 系统配置
|
|
||||||
|
|
||||||
**特性:**
|
#### `src/lib/api/browser.ts` - 浏览器端 API 访问
|
||||||
- 自动添加 JWT Token
|
用于客户端交互:
|
||||||
- 统一错误处理和 Toast 提示
|
- 请求同源 `/frontend-api/proxy/*`
|
||||||
- 401 自动跳转登录
|
- 统一处理错误信息
|
||||||
|
- 默认禁用缓存,保持刷题和后台状态最新
|
||||||
|
|
||||||
#### `ExamDetail.jsx` - 题库详情
|
#### `src/components/exams/exam-detail-client.tsx` - 题库详情
|
||||||
最复杂的前端页面,包含:
|
负责:
|
||||||
- **追加上传**: 上传新文档并去重
|
- 追加上传文档
|
||||||
- **状态轮询**: 每 3 秒轮询一次状态
|
- 展示解析进度
|
||||||
- **智能按钮**:
|
- 通过 `/frontend-api/exams/{examId}/progress` 订阅同源 SSE
|
||||||
- 处理中时禁用「添加文档」
|
- 处理解析完成/失败后的页面刷新
|
||||||
- 就绪后显示「开始/继续刷题」
|
|
||||||
- **进度展示**: 题目数、完成度、进度条
|
|
||||||
|
|
||||||
**状态轮询实现:**
|
#### `src/components/practice/quiz-player-client.tsx` - 刷题核心
|
||||||
```javascript
|
负责:
|
||||||
useEffect(() => {
|
- 加载当前题目
|
||||||
const interval = setInterval(() => {
|
- 提交答案并展示结果
|
||||||
pollExamStatus() // 轮询状态
|
- 推进刷题进度
|
||||||
}, 3000)
|
- 管理简答题与错题练习等交互
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [examId])
|
|
||||||
|
|
||||||
const pollExamStatus = async () => {
|
|
||||||
const newExam = await examAPI.getDetail(examId)
|
|
||||||
|
|
||||||
// 检测状态变化
|
|
||||||
if (exam?.status === 'processing' && newExam.status === 'ready') {
|
|
||||||
toast.success('文档解析完成!')
|
|
||||||
await loadExamDetail() // 重新加载数据
|
|
||||||
}
|
|
||||||
|
|
||||||
setExam(newExam)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `QuizPlayer.jsx` - 刷题核心
|
|
||||||
实现完整的刷题流程:
|
|
||||||
1. 基于 `current_index` 加载当前题目
|
|
||||||
2. 根据题型显示不同的答题界面
|
|
||||||
3. 提交答案并检查(简答题调用 AI 评分)
|
|
||||||
4. 答错自动加入错题本
|
|
||||||
5. 点击下一题自动更新进度
|
|
||||||
|
|
||||||
**断点续做实现:**
|
|
||||||
```javascript
|
|
||||||
// 始终基于 exam.current_index 加载题目
|
|
||||||
const loadCurrentQuestion = async () => {
|
|
||||||
const question = await questionAPI.getCurrentQuestion(examId)
|
|
||||||
// 后端会根据 current_index 返回对应题目
|
|
||||||
}
|
|
||||||
|
|
||||||
// 下一题时更新进度
|
|
||||||
const handleNext = async () => {
|
|
||||||
const newIndex = exam.current_index + 1
|
|
||||||
await examAPI.updateProgress(examId, newIndex)
|
|
||||||
await loadCurrentQuestion()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -323,17 +261,17 @@ CREATE UNIQUE INDEX ix_user_mistakes_unique ON user_mistakes(user_id, question_i
|
|||||||
- **OpenAI/Anthropic/Qwen**: AI 解析和评分
|
- **OpenAI/Anthropic/Qwen**: AI 解析和评分
|
||||||
|
|
||||||
### 前端
|
### 前端
|
||||||
|
- **Next.js 14 App Router**: 前端运行时
|
||||||
- **React 18**: UI 框架
|
- **React 18**: UI 框架
|
||||||
- **Vite**: 构建工具(比 CRA 更快)
|
- **TypeScript**: 类型系统
|
||||||
- **Tailwind CSS**: 原子化 CSS
|
- **Tailwind CSS**: 原子化 CSS
|
||||||
- **Axios**: HTTP 客户端
|
- **TanStack Query**: 客户端缓存和数据同步
|
||||||
- **React Router**: 路由管理
|
- **Route Handlers**: 同源认证与代理层
|
||||||
- **React Hot Toast**: 消息提示
|
|
||||||
|
|
||||||
### 部署
|
### 部署
|
||||||
- **Docker + Docker Compose**: 容器化部署
|
- **Docker + Docker Compose**: 容器化部署
|
||||||
- **PostgreSQL 15**: 关系型数据库
|
- **SQLite / MySQL**: 关系型数据库
|
||||||
- **Nginx** (可选): 反向代理
|
- **FastAPI reverse proxy**: 单容器模式下代理 Next.js
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
92
docs/TASKS.md
Normal file
92
docs/TASKS.md
Normal 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 双栈验证
|
||||||
|
- [ ] 用户管理回归用例
|
||||||
70
docs/audit/architecture.md
Normal file
70
docs/audit/architecture.md
Normal 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: Next.js App Router + TypeScript
|
||||||
|
- Routing: file-system routing + middleware guards
|
||||||
|
- Auth state: `HttpOnly` cookie managed by Next route handlers
|
||||||
|
- API transport: server/client fetch helpers with same-origin proxy routes
|
||||||
|
- Styling: Tailwind CSS + shadcn/ui patterns
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
- `docker-compose.yml`: split development stack
|
||||||
|
- `docker-compose-single.yml`: default single-container deployment
|
||||||
|
- `Dockerfile`: single image running FastAPI + embedded Next.js
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- Single-container deployment is the primary release path
|
||||||
|
- Split deployment remains available for development and compatibility testing
|
||||||
|
- Development and production Compose files must stay explicitly separated
|
||||||
|
|
||||||
|
## Core Constraints
|
||||||
|
|
||||||
|
1. Preserve backend API contracts where possible across frontend changes.
|
||||||
|
2. Keep single-container and split-stack behavior aligned on the same `web/` frontend.
|
||||||
|
3. Fix deployment/documentation drift before treating changes as production-ready.
|
||||||
|
4. Avoid reintroducing duplicate frontend implementations.
|
||||||
|
|
||||||
|
## Immediate Workstreams
|
||||||
|
|
||||||
|
1. Keep single-container delivery using the same `web/` frontend as split deployment.
|
||||||
|
2. Continue moving backend orchestration into typed services.
|
||||||
|
3. Tighten health checks and deployment docs around the embedded Next runtime.
|
||||||
|
4. Cover remaining functional gaps with smoke tests.
|
||||||
86
docs/audit/backend-findings.md
Normal file
86
docs/audit/backend-findings.md
Normal 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.
|
||||||
49
docs/audit/deployment-findings.md
Normal file
49
docs/audit/deployment-findings.md
Normal 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
|
||||||
50
docs/audit/frontend-migration.md
Normal file
50
docs/audit/frontend-migration.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Frontend Cutover Notes
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`web/` is now the only frontend in the repository.
|
||||||
|
|
||||||
|
The previous Vite SPA has been removed so that:
|
||||||
|
|
||||||
|
- split deployment and single-container deployment use the same UI
|
||||||
|
- documentation no longer has to describe two competing frontend stacks
|
||||||
|
- future frontend changes only need to be implemented once
|
||||||
|
|
||||||
|
## Runtime Model
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
- Login goes through Next route handlers under `/frontend-api/auth/*`
|
||||||
|
- Backend JWT is stored in an `HttpOnly` cookie
|
||||||
|
- Browser code never reads the raw token
|
||||||
|
|
||||||
|
### Data
|
||||||
|
|
||||||
|
- Server pages use server-side fetch helpers against FastAPI
|
||||||
|
- Client mutations use browser-side fetch helpers against `/frontend-api/proxy/*`
|
||||||
|
- FastAPI continues to own the public `/api/*` surface
|
||||||
|
|
||||||
|
### Streaming
|
||||||
|
|
||||||
|
- Browser connects to `/frontend-api/exams/{examId}/progress`
|
||||||
|
- The route reads the session cookie and proxies backend SSE
|
||||||
|
- Backend token query parameters stay hidden from the browser
|
||||||
|
|
||||||
|
## Deployment Outcome
|
||||||
|
|
||||||
|
### Split Stack
|
||||||
|
|
||||||
|
- `backend` serves API traffic on `:8000`
|
||||||
|
- `web` serves Next.js on `:3000`
|
||||||
|
|
||||||
|
### Single Container
|
||||||
|
|
||||||
|
- the container runs both FastAPI and Next.js
|
||||||
|
- FastAPI stays on `:8000`
|
||||||
|
- non-API requests are proxied from FastAPI to the embedded Next server
|
||||||
|
|
||||||
|
## Follow-up Expectations
|
||||||
|
|
||||||
|
1. New frontend work lands only in `web/`
|
||||||
|
2. Single-container smoke tests must validate both UI and API paths
|
||||||
|
3. Deployment docs must continue to describe `web/` as the sole frontend
|
||||||
BIN
docs/cover.png
Normal file
BIN
docs/cover.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 371 KiB |
@@ -1,9 +0,0 @@
|
|||||||
node_modules
|
|
||||||
npm-debug.log
|
|
||||||
build
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.dockerignore
|
|
||||||
Dockerfile
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Frontend Environment Variables
|
|
||||||
|
|
||||||
# API URL
|
|
||||||
VITE_API_URL=http://localhost:8000
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
FROM node:18-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
# Copy application code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
# Start development server
|
|
||||||
CMD ["npm", "start"]
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="description" content="QQuiz - 智能刷题与题库管理平台" />
|
|
||||||
<title>QQuiz - 智能刷题平台</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/index.jsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
2928
frontend/package-lock.json
generated
2928
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,45 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "qquiz-frontend",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "QQuiz Frontend - React Application",
|
|
||||||
"private": true,
|
|
||||||
"dependencies": {
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0",
|
|
||||||
"react-router-dom": "^6.21.1",
|
|
||||||
"axios": "^1.6.5",
|
|
||||||
"react-hot-toast": "^2.4.1",
|
|
||||||
"lucide-react": "^0.309.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
|
||||||
"autoprefixer": "^10.4.16",
|
|
||||||
"postcss": "^8.4.33",
|
|
||||||
"tailwindcss": "^3.4.1",
|
|
||||||
"vite": "^5.0.11"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"start": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"eslintConfig": {
|
|
||||||
"extends": [
|
|
||||||
"react-app"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"browserslist": {
|
|
||||||
"production": [
|
|
||||||
">0.2%",
|
|
||||||
"not dead",
|
|
||||||
"not op_mini all"
|
|
||||||
],
|
|
||||||
"development": [
|
|
||||||
"last 1 chrome version",
|
|
||||||
"last 1 firefox version",
|
|
||||||
"last 1 safari version"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
|
||||||
import { Toaster } from 'react-hot-toast'
|
|
||||||
import { AuthProvider } from './context/AuthContext'
|
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
|
||||||
|
|
||||||
// Auth Pages
|
|
||||||
import Login from './pages/Login'
|
|
||||||
import Register from './pages/Register'
|
|
||||||
|
|
||||||
// Main Pages
|
|
||||||
import Dashboard from './pages/Dashboard'
|
|
||||||
import ExamList from './pages/ExamList'
|
|
||||||
import ExamDetail from './pages/ExamDetail'
|
|
||||||
import QuizPlayer from './pages/QuizPlayer'
|
|
||||||
import MistakeList from './pages/MistakeList'
|
|
||||||
|
|
||||||
// Admin Pages
|
|
||||||
import AdminPanel from './pages/AdminPanel'
|
|
||||||
import AdminSettings from './pages/AdminSettings'
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<Router>
|
|
||||||
<AuthProvider>
|
|
||||||
<div className="App">
|
|
||||||
<Toaster
|
|
||||||
position="top-right"
|
|
||||||
toastOptions={{
|
|
||||||
duration: 3000,
|
|
||||||
style: {
|
|
||||||
background: '#363636',
|
|
||||||
color: '#fff',
|
|
||||||
},
|
|
||||||
success: {
|
|
||||||
duration: 3000,
|
|
||||||
iconTheme: {
|
|
||||||
primary: '#10b981',
|
|
||||||
secondary: '#fff',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
duration: 4000,
|
|
||||||
iconTheme: {
|
|
||||||
primary: '#ef4444',
|
|
||||||
secondary: '#fff',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Routes>
|
|
||||||
{/* Public Routes */}
|
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
<Route path="/register" element={<Register />} />
|
|
||||||
|
|
||||||
{/* Protected Routes */}
|
|
||||||
<Route
|
|
||||||
path="/dashboard"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<Dashboard />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
path="/exams"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<ExamList />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
path="/exams/:examId"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<ExamDetail />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
path="/quiz/:examId"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<QuizPlayer />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
path="/mistakes"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<MistakeList />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Admin Only Routes */}
|
|
||||||
<Route
|
|
||||||
path="/admin"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute adminOnly>
|
|
||||||
<AdminPanel />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
path="/admin/settings"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute adminOnly>
|
|
||||||
<AdminSettings />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Default Route */}
|
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
|
||||||
|
|
||||||
{/* 404 */}
|
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
|
||||||
</Routes>
|
|
||||||
</div>
|
|
||||||
</AuthProvider>
|
|
||||||
</Router>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
/**
|
|
||||||
* API Client for QQuiz Backend
|
|
||||||
*/
|
|
||||||
import axios from 'axios'
|
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
|
|
||||||
// Create axios instance
|
|
||||||
const api = axios.create({
|
|
||||||
baseURL: import.meta.env.VITE_API_URL || '/api',
|
|
||||||
timeout: 30000,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Request interceptor - Add auth token
|
|
||||||
api.interceptors.request.use(
|
|
||||||
(config) => {
|
|
||||||
const token = localStorage.getItem('access_token')
|
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Response interceptor - Handle errors
|
|
||||||
api.interceptors.response.use(
|
|
||||||
(response) => response,
|
|
||||||
(error) => {
|
|
||||||
const message = error.response?.data?.detail || 'An error occurred'
|
|
||||||
|
|
||||||
if (error.response?.status === 401) {
|
|
||||||
// Unauthorized - Clear token and redirect to login
|
|
||||||
localStorage.removeItem('access_token')
|
|
||||||
localStorage.removeItem('user')
|
|
||||||
window.location.href = '/login'
|
|
||||||
toast.error('Session expired. Please login again.')
|
|
||||||
} else if (error.response?.status === 403) {
|
|
||||||
toast.error('Permission denied')
|
|
||||||
} else if (error.response?.status === 429) {
|
|
||||||
toast.error(message)
|
|
||||||
} else if (error.response?.status >= 500) {
|
|
||||||
toast.error('Server error. Please try again later.')
|
|
||||||
} else {
|
|
||||||
toast.error(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// ============ Auth APIs ============
|
|
||||||
export const authAPI = {
|
|
||||||
register: (username, password) =>
|
|
||||||
api.post('/auth/register', { username, password }),
|
|
||||||
|
|
||||||
login: (username, password) =>
|
|
||||||
api.post('/auth/login', { username, password }),
|
|
||||||
|
|
||||||
getCurrentUser: () =>
|
|
||||||
api.get('/auth/me'),
|
|
||||||
|
|
||||||
changePassword: (oldPassword, newPassword) =>
|
|
||||||
api.post('/auth/change-password', null, {
|
|
||||||
params: { old_password: oldPassword, new_password: newPassword }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Exam APIs ============
|
|
||||||
export const examAPI = {
|
|
||||||
// Create exam with first document
|
|
||||||
create: (title, file) => {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('title', title)
|
|
||||||
formData.append('file', file)
|
|
||||||
return api.post('/exams/create', formData, {
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
// Append document to existing exam
|
|
||||||
appendDocument: (examId, file) => {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('file', file)
|
|
||||||
return api.post(`/exams/${examId}/append`, formData, {
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
// Get user's exam list
|
|
||||||
getList: (skip = 0, limit = 20) =>
|
|
||||||
api.get('/exams/', { params: { skip, limit } }),
|
|
||||||
|
|
||||||
// Get exam detail
|
|
||||||
getDetail: (examId) =>
|
|
||||||
api.get(`/exams/${examId}`),
|
|
||||||
|
|
||||||
// Delete exam
|
|
||||||
delete: (examId) =>
|
|
||||||
api.delete(`/exams/${examId}`),
|
|
||||||
|
|
||||||
// Update quiz progress
|
|
||||||
updateProgress: (examId, currentIndex) =>
|
|
||||||
api.put(`/exams/${examId}/progress`, { current_index: currentIndex })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Question APIs ============
|
|
||||||
export const questionAPI = {
|
|
||||||
// Get all questions for an exam
|
|
||||||
getExamQuestions: (examId, skip = 0, limit = 50) =>
|
|
||||||
api.get(`/questions/exam/${examId}/questions`, { params: { skip, limit } }),
|
|
||||||
|
|
||||||
// Get current question (based on exam's current_index)
|
|
||||||
getCurrentQuestion: (examId) =>
|
|
||||||
api.get(`/questions/exam/${examId}/current`),
|
|
||||||
|
|
||||||
// Get question by ID
|
|
||||||
getById: (questionId) =>
|
|
||||||
api.get(`/questions/${questionId}`),
|
|
||||||
|
|
||||||
// Check answer
|
|
||||||
checkAnswer: (questionId, userAnswer) =>
|
|
||||||
api.post('/questions/check', {
|
|
||||||
question_id: questionId,
|
|
||||||
user_answer: userAnswer
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Mistake APIs ============
|
|
||||||
export const mistakeAPI = {
|
|
||||||
// Get user's mistake book
|
|
||||||
getList: (skip = 0, limit = 50, examId = null) => {
|
|
||||||
const params = { skip, limit }
|
|
||||||
if (examId) params.exam_id = examId
|
|
||||||
return api.get('/mistakes/', { params })
|
|
||||||
},
|
|
||||||
|
|
||||||
// Add to mistake book
|
|
||||||
add: (questionId) =>
|
|
||||||
api.post('/mistakes/add', { question_id: questionId }),
|
|
||||||
|
|
||||||
// Remove from mistake book by mistake ID
|
|
||||||
remove: (mistakeId) =>
|
|
||||||
api.delete(`/mistakes/${mistakeId}`),
|
|
||||||
|
|
||||||
// Remove from mistake book by question ID
|
|
||||||
removeByQuestionId: (questionId) =>
|
|
||||||
api.delete(`/mistakes/question/${questionId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Admin APIs ============
|
|
||||||
export const adminAPI = {
|
|
||||||
// Config
|
|
||||||
getConfig: () => api.get('/admin/config'),
|
|
||||||
updateConfig: (config) => api.put('/admin/config', config),
|
|
||||||
|
|
||||||
// Users
|
|
||||||
getUsers: (skip = 0, limit = 50, search = null) =>
|
|
||||||
api.get('/admin/users', { params: { skip, limit, search } }),
|
|
||||||
createUser: (username, password, is_admin = false) =>
|
|
||||||
api.post('/admin/users', { username, password, is_admin }),
|
|
||||||
updateUser: (userId, data) =>
|
|
||||||
api.put(`/admin/users/${userId}`, data),
|
|
||||||
deleteUser: (userId) =>
|
|
||||||
api.delete(`/admin/users/${userId}`),
|
|
||||||
|
|
||||||
// Statistics
|
|
||||||
getStatistics: () => api.get('/admin/statistics'),
|
|
||||||
getHealth: () => api.get('/admin/health'),
|
|
||||||
|
|
||||||
// Export
|
|
||||||
exportUsers: () => api.get('/admin/export/users', { responseType: 'blob' }),
|
|
||||||
exportStatistics: () => api.get('/admin/export/statistics', { responseType: 'blob' })
|
|
||||||
}
|
|
||||||
|
|
||||||
export default api
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
/**
|
|
||||||
* Main Layout Component with Navigation
|
|
||||||
*/
|
|
||||||
import React, { useState } from 'react'
|
|
||||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
|
||||||
import { useAuth } from '../context/AuthContext'
|
|
||||||
import {
|
|
||||||
BookOpen,
|
|
||||||
LayoutDashboard,
|
|
||||||
FolderOpen,
|
|
||||||
XCircle,
|
|
||||||
Settings,
|
|
||||||
LogOut,
|
|
||||||
Menu,
|
|
||||||
X
|
|
||||||
} from 'lucide-react'
|
|
||||||
|
|
||||||
export const Layout = ({ children }) => {
|
|
||||||
const { user, logout, isAdmin } = useAuth()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const location = useLocation()
|
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
logout()
|
|
||||||
navigate('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigation = [
|
|
||||||
{ name: '首页', href: '/dashboard', icon: LayoutDashboard },
|
|
||||||
{ name: '题库管理', href: '/exams', icon: FolderOpen },
|
|
||||||
{ name: '错题本', href: '/mistakes', icon: XCircle },
|
|
||||||
]
|
|
||||||
|
|
||||||
if (isAdmin) {
|
|
||||||
navigation.push({ name: '系统设置', href: '/admin/settings', icon: Settings })
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActive = (href) => location.pathname === href
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-100">
|
|
||||||
{/* Mobile Header */}
|
|
||||||
<div className="lg:hidden bg-white shadow-sm">
|
|
||||||
<div className="flex items-center justify-between px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<BookOpen className="h-6 w-6 text-primary-600" />
|
|
||||||
<span className="font-bold text-lg">QQuiz</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
|
||||||
className="p-2 rounded-lg hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
{mobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Menu */}
|
|
||||||
{mobileMenuOpen && (
|
|
||||||
<div className="border-t border-gray-200 px-4 py-3 space-y-2">
|
|
||||||
{navigation.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.name}
|
|
||||||
to={item.href}
|
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
|
||||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
|
|
||||||
isActive(item.href)
|
|
||||||
? 'bg-primary-50 text-primary-600'
|
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<item.icon className="h-5 w-5" />
|
|
||||||
<span>{item.name}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<LogOut className="h-5 w-5" />
|
|
||||||
<span>退出登录</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex">
|
|
||||||
{/* Desktop Sidebar */}
|
|
||||||
<div className="hidden lg:flex lg:flex-col lg:w-64 lg:fixed lg:inset-y-0">
|
|
||||||
<div className="flex flex-col flex-1 bg-white border-r border-gray-200">
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="flex items-center gap-3 px-6 py-6 border-b border-gray-200">
|
|
||||||
<div className="bg-primary-600 p-2 rounded-lg">
|
|
||||||
<BookOpen className="h-6 w-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="font-bold text-lg">QQuiz</h1>
|
|
||||||
<p className="text-xs text-gray-500">{user?.username}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<nav className="flex-1 px-4 py-6 space-y-2">
|
|
||||||
{navigation.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.name}
|
|
||||||
to={item.href}
|
|
||||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
|
|
||||||
isActive(item.href)
|
|
||||||
? 'bg-primary-50 text-primary-600'
|
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<item.icon className="h-5 w-5" />
|
|
||||||
<span>{item.name}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Logout */}
|
|
||||||
<div className="px-4 py-4 border-t border-gray-200">
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors"
|
|
||||||
>
|
|
||||||
<LogOut className="h-5 w-5" />
|
|
||||||
<span>退出登录</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="flex-1 lg:pl-64">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Layout
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
/**
|
|
||||||
* Parsing Progress Component
|
|
||||||
* Displays real-time progress for document parsing
|
|
||||||
*/
|
|
||||||
import React from 'react'
|
|
||||||
import { Loader, CheckCircle, XCircle, FileText, Layers } from 'lucide-react'
|
|
||||||
|
|
||||||
export const ParsingProgress = ({ progress }) => {
|
|
||||||
if (!progress) return null
|
|
||||||
|
|
||||||
const { status, message, progress: percentage, total_chunks, current_chunk, questions_extracted, questions_added, duplicates_removed } = progress
|
|
||||||
|
|
||||||
const getStatusIcon = () => {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed':
|
|
||||||
return <CheckCircle className="h-6 w-6 text-green-500" />
|
|
||||||
case 'failed':
|
|
||||||
return <XCircle className="h-6 w-6 text-red-500" />
|
|
||||||
default:
|
|
||||||
return <Loader className="h-6 w-6 text-primary-500 animate-spin" />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusColor = () => {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed':
|
|
||||||
return 'bg-green-500'
|
|
||||||
case 'failed':
|
|
||||||
return 'bg-red-500'
|
|
||||||
case 'processing_chunk':
|
|
||||||
return 'bg-blue-500'
|
|
||||||
default:
|
|
||||||
return 'bg-primary-500'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
{getStatusIcon()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
{/* Status Message */}
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
{status === 'completed' ? '解析完成' : status === 'failed' ? '解析失败' : '正在解析文档'}
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-4">{message}</p>
|
|
||||||
|
|
||||||
{/* Progress Bar */}
|
|
||||||
{status !== 'completed' && status !== 'failed' && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="flex justify-between text-sm text-gray-600 mb-2">
|
|
||||||
<span>进度</span>
|
|
||||||
<span>{percentage.toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-3 ${getStatusColor()} transition-all duration-300 ease-out`}
|
|
||||||
style={{ width: `${percentage}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Details Grid */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
|
||||||
{total_chunks > 0 && (
|
|
||||||
<div className="bg-blue-50 rounded-lg p-3">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<Layers className="h-4 w-4 text-blue-600" />
|
|
||||||
<span className="text-xs text-blue-600 font-medium">文档拆分</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-lg font-bold text-blue-900">
|
|
||||||
{current_chunk}/{total_chunks}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-blue-600">部分</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{questions_extracted > 0 && (
|
|
||||||
<div className="bg-purple-50 rounded-lg p-3">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<FileText className="h-4 w-4 text-purple-600" />
|
|
||||||
<span className="text-xs text-purple-600 font-medium">已提取</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-lg font-bold text-purple-900">{questions_extracted}</p>
|
|
||||||
<p className="text-xs text-purple-600">题目</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{questions_added > 0 && (
|
|
||||||
<div className="bg-green-50 rounded-lg p-3">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
|
||||||
<span className="text-xs text-green-600 font-medium">已添加</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-lg font-bold text-green-900">{questions_added}</p>
|
|
||||||
<p className="text-xs text-green-600">题目</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{duplicates_removed > 0 && (
|
|
||||||
<div className="bg-orange-50 rounded-lg p-3">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<XCircle className="h-4 w-4 text-orange-600" />
|
|
||||||
<span className="text-xs text-orange-600 font-medium">已去重</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-lg font-bold text-orange-900">{duplicates_removed}</p>
|
|
||||||
<p className="text-xs text-orange-600">题目</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ParsingProgress
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* Protected Route Component
|
|
||||||
*/
|
|
||||||
import React from 'react'
|
|
||||||
import { Navigate } from 'react-router-dom'
|
|
||||||
import { useAuth } from '../context/AuthContext'
|
|
||||||
|
|
||||||
export const ProtectedRoute = ({ children, adminOnly = false }) => {
|
|
||||||
const { user, loading } = useAuth()
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return <Navigate to="/login" replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (adminOnly && !user.is_admin) {
|
|
||||||
return <Navigate to="/dashboard" replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
return children
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
/**
|
|
||||||
* Authentication Context
|
|
||||||
*/
|
|
||||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
|
||||||
import { authAPI } from '../api/client'
|
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
|
|
||||||
const AuthContext = createContext(null)
|
|
||||||
|
|
||||||
export const useAuth = () => {
|
|
||||||
const context = useContext(AuthContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useAuth must be used within AuthProvider')
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AuthProvider = ({ children }) => {
|
|
||||||
const [user, setUser] = useState(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
// Load user from localStorage on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const loadUser = async () => {
|
|
||||||
const token = localStorage.getItem('access_token')
|
|
||||||
if (token) {
|
|
||||||
try {
|
|
||||||
const response = await authAPI.getCurrentUser()
|
|
||||||
setUser(response.data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load user:', error)
|
|
||||||
localStorage.removeItem('access_token')
|
|
||||||
localStorage.removeItem('user')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
loadUser()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const login = async (username, password) => {
|
|
||||||
try {
|
|
||||||
const response = await authAPI.login(username, password)
|
|
||||||
const { access_token } = response.data
|
|
||||||
|
|
||||||
// Save token
|
|
||||||
localStorage.setItem('access_token', access_token)
|
|
||||||
|
|
||||||
// Get user info
|
|
||||||
const userResponse = await authAPI.getCurrentUser()
|
|
||||||
setUser(userResponse.data)
|
|
||||||
|
|
||||||
toast.success('Login successful!')
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login failed:', error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const register = async (username, password) => {
|
|
||||||
try {
|
|
||||||
await authAPI.register(username, password)
|
|
||||||
toast.success('Registration successful! Please login.')
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Registration failed:', error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
localStorage.removeItem('access_token')
|
|
||||||
localStorage.removeItem('user')
|
|
||||||
setUser(null)
|
|
||||||
toast.success('Logged out successfully')
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
user,
|
|
||||||
loading,
|
|
||||||
login,
|
|
||||||
register,
|
|
||||||
logout,
|
|
||||||
isAuthenticated: !!user,
|
|
||||||
isAdmin: user?.is_admin || false
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
|
||||||
sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
|
||||||
monospace;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import ReactDOM from 'react-dom/client'
|
|
||||||
import './index.css'
|
|
||||||
import App from './App'
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>
|
|
||||||
)
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
/**
|
|
||||||
* Admin Panel - 完整的管理员面板
|
|
||||||
*/
|
|
||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { adminAPI } from '../api/client'
|
|
||||||
import { useAuth } from '../context/AuthContext'
|
|
||||||
import {
|
|
||||||
Users, BarChart3, Settings, Trash2, Plus, Search,
|
|
||||||
ArrowLeft, Shield, Activity, Database, Download
|
|
||||||
} from 'lucide-react'
|
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
|
|
||||||
export const AdminPanel = () => {
|
|
||||||
const { user } = useAuth()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const [activeTab, setActiveTab] = useState('stats')
|
|
||||||
|
|
||||||
// 统计数据
|
|
||||||
const [stats, setStats] = useState(null)
|
|
||||||
const [health, setHealth] = useState(null)
|
|
||||||
|
|
||||||
// 用户数据
|
|
||||||
const [users, setUsers] = useState([])
|
|
||||||
const [usersTotal, setUsersTotal] = useState(0)
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
|
||||||
const [newUser, setNewUser] = useState({ username: '', password: '', is_admin: false })
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadStats()
|
|
||||||
loadHealth()
|
|
||||||
loadUsers()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadStats = async () => {
|
|
||||||
try {
|
|
||||||
const res = await adminAPI.getStatistics()
|
|
||||||
setStats(res.data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load statistics:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadHealth = async () => {
|
|
||||||
try {
|
|
||||||
const res = await adminAPI.getHealth()
|
|
||||||
setHealth(res.data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load health:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadUsers = async () => {
|
|
||||||
try {
|
|
||||||
const res = await adminAPI.getUsers(0, 100, searchQuery || null)
|
|
||||||
setUsers(res.data.users)
|
|
||||||
setUsersTotal(res.data.total)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load users:', error)
|
|
||||||
toast.error('加载用户列表失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateUser = async () => {
|
|
||||||
if (!newUser.username || !newUser.password) {
|
|
||||||
toast.error('请填写用户名和密码')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await adminAPI.createUser(newUser.username, newUser.password, newUser.is_admin)
|
|
||||||
toast.success('用户创建成功')
|
|
||||||
setShowCreateModal(false)
|
|
||||||
setNewUser({ username: '', password: '', is_admin: false })
|
|
||||||
loadUsers()
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error.response?.data?.detail || '创建用户失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteUser = async (userId, username) => {
|
|
||||||
if (!confirm(`确定删除用户 ${username}?`)) return
|
|
||||||
try {
|
|
||||||
await adminAPI.deleteUser(userId)
|
|
||||||
toast.success('用户已删除')
|
|
||||||
loadUsers()
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error.response?.data?.detail || '删除失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleExportUsers = async () => {
|
|
||||||
try {
|
|
||||||
const response = await adminAPI.exportUsers()
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]))
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.setAttribute('download', 'users.csv')
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
link.remove()
|
|
||||||
toast.success('导出成功')
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('导出失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-100">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="bg-white shadow">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button onClick={() => navigate(-1)} className="p-2 hover:bg-gray-100 rounded-lg">
|
|
||||||
<ArrowLeft className="h-6 w-6 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
<Shield className="h-8 w-8 text-primary-600" />
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">管理员面板</h1>
|
|
||||||
<p className="text-gray-600">{user?.username}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/admin/settings')}
|
|
||||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Settings className="h-5 w-5" />
|
|
||||||
系统设置
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
|
||||||
<div className="flex gap-4 border-b border-gray-200 mb-6">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('stats')}
|
|
||||||
className={`pb-3 px-4 font-medium border-b-2 transition-colors ${
|
|
||||||
activeTab === 'stats'
|
|
||||||
? 'border-primary-600 text-primary-600'
|
|
||||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<BarChart3 className="h-5 w-5" />
|
|
||||||
系统统计
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('users')}
|
|
||||||
className={`pb-3 px-4 font-medium border-b-2 transition-colors ${
|
|
||||||
activeTab === 'users'
|
|
||||||
? 'border-primary-600 text-primary-600'
|
|
||||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Users className="h-5 w-5" />
|
|
||||||
用户管理
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Tab */}
|
|
||||||
{activeTab === 'stats' && stats && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Overview Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
|
||||||
<div className="bg-white rounded-xl shadow p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-600 text-sm">用户总数</p>
|
|
||||||
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.users?.total || 0}</p>
|
|
||||||
</div>
|
|
||||||
<Users className="h-12 w-12 text-blue-500 opacity-20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl shadow p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-600 text-sm">题库总数</p>
|
|
||||||
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.exams?.total || 0}</p>
|
|
||||||
</div>
|
|
||||||
<Database className="h-12 w-12 text-green-500 opacity-20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl shadow p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-600 text-sm">题目总数</p>
|
|
||||||
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.questions?.total || 0}</p>
|
|
||||||
</div>
|
|
||||||
<Activity className="h-12 w-12 text-purple-500 opacity-20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl shadow p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-600 text-sm">今日活跃</p>
|
|
||||||
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.activity?.today_active_users || 0}</p>
|
|
||||||
</div>
|
|
||||||
<Shield className="h-12 w-12 text-orange-500 opacity-20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* System Health */}
|
|
||||||
{health && (
|
|
||||||
<div className="bg-white rounded-xl shadow p-6">
|
|
||||||
<h3 className="text-lg font-bold text-gray-900 mb-4">系统状态</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-gray-600">状态</span>
|
|
||||||
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm font-medium">
|
|
||||||
{health.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-gray-600">数据库</span>
|
|
||||||
<span className="text-gray-900">{health.system?.database_url || 'SQLite'}</span>
|
|
||||||
</div>
|
|
||||||
{health.database?.size_mb && (
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-gray-600">数据库大小</span>
|
|
||||||
<span className="text-gray-900">{health.database.size_mb} MB</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Users Tab */}
|
|
||||||
{activeTab === 'users' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div className="relative flex-1 max-w-md">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="搜索用户..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && loadUsers()}
|
|
||||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleExportUsers}
|
|
||||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Download className="h-5 w-5" />
|
|
||||||
导出
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Plus className="h-5 w-5" />
|
|
||||||
创建用户
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Users Table */}
|
|
||||||
<div className="bg-white rounded-xl shadow overflow-hidden">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">用户名</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">角色</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">题库数</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">错题数</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">注册时间</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-200">
|
|
||||||
{users.map((u) => (
|
|
||||||
<tr key={u.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-900">{u.id}</td>
|
|
||||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">{u.username}</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
{u.is_admin ? (
|
|
||||||
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs font-medium rounded">管理员</span>
|
|
||||||
) : (
|
|
||||||
<span className="px-2 py-1 bg-gray-100 text-gray-700 text-xs font-medium rounded">普通用户</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-600">{u.exam_count || 0}</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-600">{u.mistake_count || 0}</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-600">
|
|
||||||
{new Date(u.created_at).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteUser(u.id, u.username)}
|
|
||||||
disabled={u.username === 'admin'}
|
|
||||||
className="text-red-600 hover:text-red-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create User Modal */}
|
|
||||||
{showCreateModal && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-xl p-6 w-full max-w-md">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">创建新用户</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">用户名</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newUser.username}
|
|
||||||
onChange={(e) => setNewUser({ ...newUser, username: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">密码</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={newUser.password}
|
|
||||||
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={newUser.is_admin}
|
|
||||||
onChange={(e) => setNewUser({ ...newUser, is_admin: e.target.checked })}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
<label className="text-sm text-gray-700">设为管理员</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 mt-6">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreateModal(false)}
|
|
||||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleCreateUser}
|
|
||||||
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
|
||||||
>
|
|
||||||
创建
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AdminPanel
|
|
||||||
@@ -1,579 +0,0 @@
|
|||||||
/**
|
|
||||||
* Admin Settings Page - Enhanced with API Configuration
|
|
||||||
*/
|
|
||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { adminAPI } from '../api/client'
|
|
||||||
import { useAuth } from '../context/AuthContext'
|
|
||||||
import { Settings, Save, Loader, Key, Link as LinkIcon, Eye, EyeOff, ArrowLeft } from 'lucide-react'
|
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
|
|
||||||
export const AdminSettings = () => {
|
|
||||||
const { user } = useAuth()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [showApiKeys, setShowApiKeys] = useState({
|
|
||||||
openai: false,
|
|
||||||
anthropic: false,
|
|
||||||
qwen: false,
|
|
||||||
gemini: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const [config, setConfig] = useState({
|
|
||||||
allow_registration: true,
|
|
||||||
max_upload_size_mb: 10,
|
|
||||||
max_daily_uploads: 20,
|
|
||||||
ai_provider: 'gemini',
|
|
||||||
// OpenAI
|
|
||||||
openai_api_key: '',
|
|
||||||
openai_base_url: 'https://api.openai.com/v1',
|
|
||||||
openai_model: 'gpt-4o-mini',
|
|
||||||
// Anthropic
|
|
||||||
anthropic_api_key: '',
|
|
||||||
anthropic_model: 'claude-3-haiku-20240307',
|
|
||||||
// Qwen
|
|
||||||
qwen_api_key: '',
|
|
||||||
qwen_base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
|
||||||
qwen_model: 'qwen-plus',
|
|
||||||
// Gemini
|
|
||||||
gemini_api_key: '',
|
|
||||||
gemini_base_url: '',
|
|
||||||
gemini_model: 'gemini-2.0-flash-exp'
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadConfig()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadConfig = async () => {
|
|
||||||
try {
|
|
||||||
const response = await adminAPI.getConfig()
|
|
||||||
setConfig(response.data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load config:', error)
|
|
||||||
toast.error('加载配置失败')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
await adminAPI.updateConfig(config)
|
|
||||||
toast.success('配置保存成功!')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save config:', error)
|
|
||||||
toast.error('保存配置失败')
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleChange = (key, value) => {
|
|
||||||
setConfig({
|
|
||||||
...config,
|
|
||||||
[key]: value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleApiKeyVisibility = (provider) => {
|
|
||||||
setShowApiKeys({
|
|
||||||
...showApiKeys,
|
|
||||||
[provider]: !showApiKeys[provider]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get complete API endpoint URL
|
|
||||||
const getCompleteEndpoint = (provider) => {
|
|
||||||
const endpoints = {
|
|
||||||
openai: '/chat/completions',
|
|
||||||
anthropic: '/messages',
|
|
||||||
qwen: '/chat/completions'
|
|
||||||
}
|
|
||||||
|
|
||||||
let baseUrl = ''
|
|
||||||
if (provider === 'openai') {
|
|
||||||
baseUrl = config.openai_base_url || 'https://api.openai.com/v1'
|
|
||||||
} else if (provider === 'anthropic') {
|
|
||||||
baseUrl = 'https://api.anthropic.com/v1'
|
|
||||||
} else if (provider === 'qwen') {
|
|
||||||
baseUrl = config.qwen_base_url || 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove trailing slash
|
|
||||||
baseUrl = baseUrl.replace(/\/$/, '')
|
|
||||||
|
|
||||||
return `${baseUrl}${endpoints[provider]}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
|
||||||
<Loader className="h-8 w-8 animate-spin text-primary-600" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-100">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="bg-white shadow">
|
|
||||||
<div className="max-w-5xl mx-auto px-4 py-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
|
||||||
title="返回"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-6 w-6 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
<Settings className="h-8 w-8 text-primary-600" />
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">系统设置</h1>
|
|
||||||
<p className="text-gray-600">管理员:{user?.username}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="max-w-5xl mx-auto px-4 py-8 space-y-6">
|
|
||||||
{/* Basic Settings */}
|
|
||||||
<div className="bg-white rounded-xl shadow-md p-6 space-y-6">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">基础设置</h2>
|
|
||||||
|
|
||||||
{/* Allow Registration */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-gray-900">允许用户注册</h3>
|
|
||||||
<p className="text-sm text-gray-500">关闭后新用户无法注册</p>
|
|
||||||
</div>
|
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={config.allow_registration}
|
|
||||||
onChange={(e) => handleChange('allow_registration', e.target.checked)}
|
|
||||||
className="sr-only peer"
|
|
||||||
/>
|
|
||||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Max Upload Size */}
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-900 mb-2">
|
|
||||||
最大上传文件大小 (MB)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="100"
|
|
||||||
value={config.max_upload_size_mb}
|
|
||||||
onChange={(e) => handleChange('max_upload_size_mb', parseInt(e.target.value))}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">建议:5-20 MB</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Max Daily Uploads */}
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-900 mb-2">
|
|
||||||
每日上传次数限制
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="100"
|
|
||||||
value={config.max_daily_uploads}
|
|
||||||
onChange={(e) => handleChange('max_daily_uploads', parseInt(e.target.value))}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">建议:10-50 次</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI Provider */}
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-900 mb-2">
|
|
||||||
AI 提供商
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={config.ai_provider}
|
|
||||||
onChange={(e) => handleChange('ai_provider', e.target.value)}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
|
||||||
>
|
|
||||||
<option value="gemini">Google Gemini (推荐)</option>
|
|
||||||
<option value="openai">OpenAI (GPT)</option>
|
|
||||||
<option value="anthropic">Anthropic (Claude)</option>
|
|
||||||
<option value="qwen">Qwen (通义千问)</option>
|
|
||||||
</select>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
选择后在下方配置对应的 API 密钥。Gemini 支持原生 PDF 解析
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* OpenAI Configuration */}
|
|
||||||
<div className={`bg-white rounded-xl shadow-md p-6 space-y-4 ${config.ai_provider === 'openai' ? 'ring-2 ring-primary-500' : ''}`}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Key className="h-5 w-5 text-green-600" />
|
|
||||||
<h2 className="text-xl font-bold text-gray-900">OpenAI 配置</h2>
|
|
||||||
{config.ai_provider === 'openai' && (
|
|
||||||
<span className="px-2 py-1 text-xs font-medium bg-primary-100 text-primary-700 rounded-full">当前使用</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text-only warning */}
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
|
||||||
<p className="text-sm text-amber-800">
|
|
||||||
⚠️ OpenAI 仅支持文本解析,不支持 PDF 原生理解。PDF 文件将通过文本提取处理,可能丢失格式和图片信息。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* API Key */}
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-900 mb-2">
|
|
||||||
API Key
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type={showApiKeys.openai ? 'text' : 'password'}
|
|
||||||
value={config.openai_api_key || ''}
|
|
||||||
onChange={(e) => handleChange('openai_api_key', e.target.value)}
|
|
||||||
placeholder="sk-proj-..."
|
|
||||||
className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleApiKeyVisibility('openai')}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
{showApiKeys.openai ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">从 https://platform.openai.com/api-keys 获取</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Base URL */}
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-900 mb-2">
|
|
||||||
Base URL
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={config.openai_base_url}
|
|
||||||
onChange={(e) => handleChange('openai_base_url', e.target.value)}
|
|
||||||
placeholder="https://api.openai.com/v1"
|
|
||||||
className="w-full pl-10 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
完整 endpoint: <code className="bg-gray-100 px-2 py-0.5 rounded">{getCompleteEndpoint('openai')}</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Model */}
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-900 mb-2">
|
|
||||||
模型
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
list="openai-models"
|
|
||||||
value={config.openai_model}
|
|
||||||
onChange={(e) => handleChange('openai_model', e.target.value)}
|
|
||||||
placeholder="gpt-4o-mini"
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<datalist id="openai-models">
|
|
||||||
<option value="gpt-4o">gpt-4o (最强)</option>
|
|
||||||
<option value="gpt-4o-mini">gpt-4o-mini (推荐)</option>
|
|
||||||
<option value="gpt-4-turbo">gpt-4-turbo</option>
|
|
||||||
<option value="gpt-3.5-turbo">gpt-3.5-turbo</option>
|
|
||||||
</datalist>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称,或从建议中选择</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Anthropic Configuration */}
|
|
||||||
<div className={`bg-white rounded-xl shadow-md p-6 space-y-4 ${config.ai_provider === 'anthropic' ? 'ring-2 ring-primary-500' : ''}`}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Key className="h-5 w-5 text-orange-600" />
|
|
||||||
<h2 className="text-xl font-bold text-gray-900">Anthropic 配置</h2>
|
|
||||||
{config.ai_provider === 'anthropic' && (
|
|
||||||
<span className="px-2 py-1 text-xs font-medium bg-primary-100 text-primary-700 rounded-full">当前使用</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text-only warning */}
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
|
||||||
<p className="text-sm text-amber-800">
|
|
||||||
⚠️ Anthropic 仅支持文本解析,不支持 PDF 原生理解。PDF 文件将通过文本提取处理,可能丢失格式和图片信息。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* API Key */}
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-900 mb-2">
|
|
||||||
API Key
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type={showApiKeys.anthropic ? 'text' : 'password'}
|
|
||||||
value={config.anthropic_api_key || ''}
|
|
||||||
onChange={(e) => handleChange('anthropic_api_key', e.target.value)}
|
|
||||||
placeholder="sk-ant-..."
|
|
||||||
className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleApiKeyVisibility('anthropic')}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
{showApiKeys.anthropic ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">从 https://console.anthropic.com/settings/keys 获取</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Base URL (fixed for Anthropic) */}
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-900 mb-2">
|
|
||||||
Base URL (固定)
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value="https://api.anthropic.com/v1"
|
|
||||||
disabled
|
|
||||||
className="w-full pl-10 px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
完整 endpoint: <code className="bg-gray-100 px-2 py-0.5 rounded">{getCompleteEndpoint('anthropic')}</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Model */}
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-900 mb-2">
|
|
||||||
模型
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
list="anthropic-models"
|
|
||||||
value={config.anthropic_model}
|
|
||||||
onChange={(e) => handleChange('anthropic_model', e.target.value)}
|
|
||||||
placeholder="claude-3-haiku-20240307"
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<datalist id="anthropic-models">
|
|
||||||
<option value="claude-3-5-sonnet-20241022">claude-3-5-sonnet (最强)</option>
|
|
||||||
<option value="claude-3-haiku-20240307">claude-3-haiku (推荐)</option>
|
|
||||||
<option value="claude-3-opus-20240229">claude-3-opus</option>
|
|
||||||
<option value="claude-3-sonnet-20240229">claude-3-sonnet</option>
|
|
||||||
</datalist>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称,或从建议中选择</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Qwen Configuration */}
|
|
||||||
<div className={`bg-white rounded-xl shadow-md p-6 space-y-4 ${config.ai_provider === 'qwen' ? 'ring-2 ring-primary-500' : ''}`}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Key className="h-5 w-5 text-blue-600" />
|
|
||||||
<h2 className="text-xl font-bold text-gray-900">通义千问 配置</h2>
|
|
||||||
{config.ai_provider === 'qwen' && (
|
|
||||||
<span className="px-2 py-1 text-xs font-medium bg-primary-100 text-primary-700 rounded-full">当前使用</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text-only warning */}
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
|
||||||
<p className="text-sm text-amber-800">
|
|
||||||
⚠️ 通义千问 仅支持文本解析,不支持 PDF 原生理解。PDF 文件将通过文本提取处理,可能丢失格式和图片信息。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* API Key */}
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-900 mb-2">
|
|
||||||
API Key
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type={showApiKeys.qwen ? 'text' : 'password'}
|
|
||||||
value={config.qwen_api_key || ''}
|
|
||||||
onChange={(e) => handleChange('qwen_api_key', e.target.value)}
|
|
||||||
placeholder="sk-..."
|
|
||||||
className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleApiKeyVisibility('qwen')}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
{showApiKeys.qwen ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">从 https://dashscope.console.aliyun.com/apiKey 获取</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Base URL */}
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-900 mb-2">
|
|
||||||
Base URL
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={config.qwen_base_url}
|
|
||||||
onChange={(e) => handleChange('qwen_base_url', e.target.value)}
|
|
||||||
placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
|
||||||
className="w-full pl-10 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
完整 endpoint: <code className="bg-gray-100 px-2 py-0.5 rounded">{getCompleteEndpoint('qwen')}</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Model */}
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-900 mb-2">
|
|
||||||
模型
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
list="qwen-models"
|
|
||||||
value={config.qwen_model}
|
|
||||||
onChange={(e) => handleChange('qwen_model', e.target.value)}
|
|
||||||
placeholder="qwen-plus"
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<datalist id="qwen-models">
|
|
||||||
<option value="qwen-max">qwen-max (最强)</option>
|
|
||||||
<option value="qwen-plus">qwen-plus (推荐)</option>
|
|
||||||
<option value="qwen-turbo">qwen-turbo (快速)</option>
|
|
||||||
<option value="qwen-long">qwen-long (长文本)</option>
|
|
||||||
</datalist>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称,或从建议中选择</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gemini Configuration */}
|
|
||||||
<div className={`bg-white rounded-xl shadow-md p-6 space-y-4 ${config.ai_provider === 'gemini' ? 'ring-2 ring-primary-500' : ''}`}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Key className="h-5 w-5 text-purple-600" />
|
|
||||||
<h2 className="text-xl font-bold text-gray-900">Google Gemini 配置</h2>
|
|
||||||
{config.ai_provider === 'gemini' && (
|
|
||||||
<span className="px-2 py-1 text-xs font-medium bg-primary-100 text-primary-700 rounded-full">当前使用</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PDF support highlight */}
|
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
|
|
||||||
<p className="text-sm text-green-800">
|
|
||||||
✅ Gemini 支持原生 PDF 理解,可直接处理 PDF 文件(最多 1000 页),完整保留图片、表格、公式等内容。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* API Key */}
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-900 mb-2">
|
|
||||||
API Key
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type={showApiKeys.gemini ? 'text' : 'password'}
|
|
||||||
value={config.gemini_api_key || ''}
|
|
||||||
onChange={(e) => handleChange('gemini_api_key', e.target.value)}
|
|
||||||
placeholder="AIza..."
|
|
||||||
className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleApiKeyVisibility('gemini')}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
{showApiKeys.gemini ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">从 https://aistudio.google.com/apikey 获取</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Base URL (optional) */}
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-900 mb-2">
|
|
||||||
Base URL (可选)
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={config.gemini_base_url}
|
|
||||||
onChange={(e) => handleChange('gemini_base_url', e.target.value)}
|
|
||||||
placeholder="https://generativelanguage.googleapis.com(留空使用默认)"
|
|
||||||
className="w-full pl-10 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
可配置自定义代理或中转服务(支持 Key 轮训等)。留空则使用 Google 官方 API
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Model */}
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-900 mb-2">
|
|
||||||
模型
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
list="gemini-models"
|
|
||||||
value={config.gemini_model}
|
|
||||||
onChange={(e) => handleChange('gemini_model', e.target.value)}
|
|
||||||
placeholder="gemini-2.0-flash-exp"
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<datalist id="gemini-models">
|
|
||||||
<option value="gemini-2.0-flash-exp">gemini-2.0-flash-exp (最新,推荐)</option>
|
|
||||||
<option value="gemini-1.5-pro">gemini-1.5-pro (最强)</option>
|
|
||||||
<option value="gemini-1.5-flash">gemini-1.5-flash (快速)</option>
|
|
||||||
<option value="gemini-1.0-pro">gemini-1.0-pro</option>
|
|
||||||
</datalist>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称,或从建议中选择</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save Button */}
|
|
||||||
<div className="bg-white rounded-xl shadow-md p-6">
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<>
|
|
||||||
<Loader className="h-5 w-5 animate-spin" />
|
|
||||||
保存中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Save className="h-5 w-5" />
|
|
||||||
保存所有设置
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AdminSettings
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
/**
|
|
||||||
* Dashboard Page
|
|
||||||
*/
|
|
||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { examAPI, mistakeAPI } from '../api/client'
|
|
||||||
import { useAuth } from '../context/AuthContext'
|
|
||||||
import Layout from '../components/Layout'
|
|
||||||
import {
|
|
||||||
FolderOpen, XCircle, TrendingUp, BookOpen, ArrowRight, Settings, Shield
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { getStatusColor, getStatusText, formatRelativeTime, calculateProgress } from '../utils/helpers'
|
|
||||||
|
|
||||||
export const Dashboard = () => {
|
|
||||||
const { user, isAdmin } = useAuth()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const [stats, setStats] = useState({
|
|
||||||
totalExams: 0,
|
|
||||||
totalQuestions: 0,
|
|
||||||
completedQuestions: 0,
|
|
||||||
mistakeCount: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const [recentExams, setRecentExams] = useState([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadDashboardData()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadDashboardData = async () => {
|
|
||||||
try {
|
|
||||||
const [examsRes, mistakesRes] = await Promise.all([
|
|
||||||
examAPI.getList(0, 5),
|
|
||||||
mistakeAPI.getList(0, 1)
|
|
||||||
])
|
|
||||||
|
|
||||||
const exams = examsRes.data.exams
|
|
||||||
|
|
||||||
// Calculate stats
|
|
||||||
const totalQuestions = exams.reduce((sum, e) => sum + e.total_questions, 0)
|
|
||||||
const completedQuestions = exams.reduce((sum, e) => sum + e.current_index, 0)
|
|
||||||
|
|
||||||
setStats({
|
|
||||||
totalExams: exams.length,
|
|
||||||
totalQuestions,
|
|
||||||
completedQuestions,
|
|
||||||
mistakeCount: mistakesRes.data.total
|
|
||||||
})
|
|
||||||
|
|
||||||
setRecentExams(exams)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load dashboard:', error)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="p-4 md:p-8">
|
|
||||||
{/* Welcome */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
|
||||||
欢迎回来,{user?.username}!
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 mt-1">继续你的学习之旅</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Cards */}
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
|
||||||
<div
|
|
||||||
className="bg-white rounded-xl shadow-sm p-6 cursor-pointer hover:shadow-md transition-shadow"
|
|
||||||
onClick={() => navigate('/exams')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="bg-primary-100 p-2 rounded-lg">
|
|
||||||
<FolderOpen className="h-5 w-5 text-primary-600" />
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl font-bold text-gray-900">{stats.totalExams}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600">题库总数</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="bg-blue-100 p-2 rounded-lg">
|
|
||||||
<BookOpen className="h-5 w-5 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl font-bold text-gray-900">{stats.totalQuestions}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600">题目总数</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="bg-green-100 p-2 rounded-lg">
|
|
||||||
<TrendingUp className="h-5 w-5 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl font-bold text-gray-900">{stats.completedQuestions}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600">已完成</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="bg-white rounded-xl shadow-sm p-6 cursor-pointer hover:shadow-md transition-shadow"
|
|
||||||
onClick={() => navigate('/mistakes')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="bg-red-100 p-2 rounded-lg">
|
|
||||||
<XCircle className="h-5 w-5 text-red-600" />
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl font-bold text-gray-900">{stats.mistakeCount}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600">错题数量</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recent Exams */}
|
|
||||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900">最近的题库</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/exams')}
|
|
||||||
className="text-primary-600 hover:text-primary-700 flex items-center gap-1 text-sm font-medium"
|
|
||||||
>
|
|
||||||
查看全部
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{recentExams.length === 0 ? (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<FolderOpen className="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
|
||||||
<p className="text-gray-500">还没有题库,快去创建一个吧!</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{recentExams.map((exam) => (
|
|
||||||
<div
|
|
||||||
key={exam.id}
|
|
||||||
onClick={() => navigate(`/exams/${exam.id}`)}
|
|
||||||
className="border border-gray-200 rounded-lg p-4 hover:border-primary-300 hover:bg-primary-50 transition-all cursor-pointer"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<h3 className="font-semibold text-gray-900">{exam.title}</h3>
|
|
||||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(exam.status)}`}>
|
|
||||||
{getStatusText(exam.status)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-sm text-gray-600">
|
|
||||||
<span>
|
|
||||||
{exam.current_index} / {exam.total_questions} 题
|
|
||||||
</span>
|
|
||||||
<span>{formatRelativeTime(exam.updated_at)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{exam.total_questions > 0 && (
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-3">
|
|
||||||
<div
|
|
||||||
className="bg-primary-600 h-2 rounded-full transition-all"
|
|
||||||
style={{ width: `${calculateProgress(exam.current_index, exam.total_questions)}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Admin Quick Access */}
|
|
||||||
{isAdmin && (
|
|
||||||
<div className="mt-6 bg-gradient-to-r from-primary-500 to-primary-600 rounded-xl shadow-sm p-6 text-white">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold mb-1">管理员功能</h3>
|
|
||||||
<p className="text-sm text-primary-100">用户管理、系统统计、配置设置</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/admin')}
|
|
||||||
className="bg-white text-primary-600 px-4 py-2 rounded-lg font-medium hover:bg-primary-50 transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Shield className="h-5 w-5" />
|
|
||||||
管理面板
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/admin/settings')}
|
|
||||||
className="bg-white/90 text-primary-600 px-4 py-2 rounded-lg font-medium hover:bg-white transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Settings className="h-5 w-5" />
|
|
||||||
系统设置
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Dashboard
|
|
||||||
@@ -1,411 +0,0 @@
|
|||||||
/**
|
|
||||||
* Exam Detail Page - with real-time parsing progress via SSE
|
|
||||||
*/
|
|
||||||
import React, { useState, useEffect, useRef } from 'react'
|
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
|
||||||
import { examAPI, questionAPI } from '../api/client'
|
|
||||||
import Layout from '../components/Layout'
|
|
||||||
import ParsingProgress from '../components/ParsingProgress'
|
|
||||||
import {
|
|
||||||
ArrowLeft, Upload, Play, Loader, FileText, AlertCircle, RefreshCw
|
|
||||||
} from 'lucide-react'
|
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
import {
|
|
||||||
getStatusColor,
|
|
||||||
getStatusText,
|
|
||||||
formatDate,
|
|
||||||
calculateProgress,
|
|
||||||
isValidFileType,
|
|
||||||
getQuestionTypeText
|
|
||||||
} from '../utils/helpers'
|
|
||||||
|
|
||||||
export const ExamDetail = () => {
|
|
||||||
const { examId } = useParams()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const [exam, setExam] = useState(null)
|
|
||||||
const [questions, setQuestions] = useState([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [uploading, setUploading] = useState(false)
|
|
||||||
const [showUploadModal, setShowUploadModal] = useState(false)
|
|
||||||
const [uploadFile, setUploadFile] = useState(null)
|
|
||||||
const [progress, setProgress] = useState(null)
|
|
||||||
|
|
||||||
const eventSourceRef = useRef(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadExamDetail()
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
return () => {
|
|
||||||
if (eventSourceRef.current) {
|
|
||||||
eventSourceRef.current.close()
|
|
||||||
eventSourceRef.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [examId])
|
|
||||||
|
|
||||||
const loadExamDetail = async () => {
|
|
||||||
try {
|
|
||||||
const [examRes, questionsRes] = await Promise.all([
|
|
||||||
examAPI.getDetail(examId),
|
|
||||||
questionAPI.getExamQuestions(examId, 0, 10) // Load first 10 for preview
|
|
||||||
])
|
|
||||||
|
|
||||||
setExam(examRes.data)
|
|
||||||
setQuestions(questionsRes.data.questions)
|
|
||||||
|
|
||||||
// Connect to SSE if exam is processing
|
|
||||||
if (examRes.data.status === 'processing') {
|
|
||||||
connectSSE()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load exam:', error)
|
|
||||||
toast.error('加载题库失败')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectSSE = () => {
|
|
||||||
// Close existing connection if any
|
|
||||||
if (eventSourceRef.current) {
|
|
||||||
eventSourceRef.current.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[SSE] Connecting to progress stream for exam', examId)
|
|
||||||
|
|
||||||
const token = localStorage.getItem('token')
|
|
||||||
const url = `/api/exams/${examId}/progress?token=${encodeURIComponent(token)}`
|
|
||||||
|
|
||||||
const eventSource = new EventSource(url)
|
|
||||||
eventSourceRef.current = eventSource
|
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const progressData = JSON.parse(event.data)
|
|
||||||
console.log('[SSE] Progress update:', progressData)
|
|
||||||
|
|
||||||
setProgress(progressData)
|
|
||||||
|
|
||||||
// Update exam status if completed or failed
|
|
||||||
if (progressData.status === 'completed') {
|
|
||||||
toast.success(progressData.message)
|
|
||||||
setExam(prev => ({ ...prev, status: 'ready' }))
|
|
||||||
loadExamDetail() // Reload to get updated questions
|
|
||||||
eventSource.close()
|
|
||||||
eventSourceRef.current = null
|
|
||||||
} else if (progressData.status === 'failed') {
|
|
||||||
toast.error(progressData.message)
|
|
||||||
setExam(prev => ({ ...prev, status: 'failed' }))
|
|
||||||
eventSource.close()
|
|
||||||
eventSourceRef.current = null
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[SSE] Failed to parse progress data:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
|
||||||
console.error('[SSE] Connection error:', error)
|
|
||||||
eventSource.close()
|
|
||||||
eventSourceRef.current = null
|
|
||||||
}
|
|
||||||
|
|
||||||
eventSource.onopen = () => {
|
|
||||||
console.log('[SSE] Connection established')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAppendDocument = async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
if (!uploadFile) {
|
|
||||||
toast.error('请选择文件')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValidFileType(uploadFile.name)) {
|
|
||||||
toast.error('不支持的文件类型')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await examAPI.appendDocument(examId, uploadFile)
|
|
||||||
toast.success('文档上传成功,正在解析并去重...')
|
|
||||||
setShowUploadModal(false)
|
|
||||||
setUploadFile(null)
|
|
||||||
setExam(prev => ({ ...prev, status: 'processing' }))
|
|
||||||
|
|
||||||
// Connect to SSE for real-time progress
|
|
||||||
connectSSE()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to append document:', error)
|
|
||||||
toast.error('文档上传失败')
|
|
||||||
} finally {
|
|
||||||
setUploading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleStartQuiz = () => {
|
|
||||||
if (exam.current_index >= exam.total_questions) {
|
|
||||||
if (window.confirm('已经完成所有题目,是否从头开始?')) {
|
|
||||||
navigate(`/quiz/${examId}?reset=true`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
navigate(`/quiz/${examId}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="flex items-center justify-center h-screen">
|
|
||||||
<Loader className="h-8 w-8 animate-spin text-primary-600" />
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!exam) {
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="flex flex-col items-center justify-center h-screen">
|
|
||||||
<AlertCircle className="h-16 w-16 text-gray-300 mb-4" />
|
|
||||||
<p className="text-gray-600">题库不存在</p>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isProcessing = exam.status === 'processing'
|
|
||||||
const isReady = exam.status === 'ready'
|
|
||||||
const isFailed = exam.status === 'failed'
|
|
||||||
const quizProgress = calculateProgress(exam.current_index, exam.total_questions)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="p-4 md:p-8">
|
|
||||||
{/* Back Button */}
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/exams')}
|
|
||||||
className="mb-6 flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-5 w-5" />
|
|
||||||
返回题库列表
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Parsing Progress (only shown when processing) */}
|
|
||||||
{isProcessing && progress && (
|
|
||||||
<ParsingProgress progress={progress} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between mb-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 mb-2">
|
|
||||||
{exam.title}
|
|
||||||
</h1>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`px-3 py-1 text-sm font-medium rounded-full ${getStatusColor(exam.status)}`}>
|
|
||||||
{getStatusText(exam.status)}
|
|
||||||
</span>
|
|
||||||
{isProcessing && (
|
|
||||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
|
||||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
||||||
正在处理中...
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="mt-4 md:mt-0 flex flex-col sm:flex-row gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowUploadModal(true)}
|
|
||||||
disabled={isProcessing}
|
|
||||||
className="bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<Upload className="h-5 w-5" />
|
|
||||||
添加题目文档
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isReady && exam.total_questions > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={handleStartQuiz}
|
|
||||||
className="bg-primary-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<Play className="h-5 w-5" />
|
|
||||||
{exam.current_index > 0 ? '继续刷题' : '开始刷题'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
|
||||||
<p className="text-sm text-gray-600 mb-1">题目总数</p>
|
|
||||||
<p className="text-2xl font-bold text-gray-900">{exam.total_questions}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
|
||||||
<p className="text-sm text-gray-600 mb-1">已完成</p>
|
|
||||||
<p className="text-2xl font-bold text-primary-600">{exam.current_index}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
|
||||||
<p className="text-sm text-gray-600 mb-1">剩余</p>
|
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
|
||||||
{Math.max(0, exam.total_questions - exam.current_index)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
|
||||||
<p className="text-sm text-gray-600 mb-1">完成度</p>
|
|
||||||
<p className="text-2xl font-bold text-green-600">{progress}%</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Bar */}
|
|
||||||
{exam.total_questions > 0 && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
|
||||||
<div
|
|
||||||
className="bg-primary-600 h-3 rounded-full transition-all"
|
|
||||||
style={{ width: `${quizProgress}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="mt-6 pt-6 border-t border-gray-200 text-sm text-gray-600">
|
|
||||||
<p>创建时间:{formatDate(exam.created_at)}</p>
|
|
||||||
<p>最后更新:{formatDate(exam.updated_at)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Failed Status Warning */}
|
|
||||||
{isFailed && (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6 mb-6">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<AlertCircle className="h-6 w-6 text-red-600 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-red-900 mb-1">文档解析失败</h3>
|
|
||||||
<p className="text-sm text-red-700">
|
|
||||||
请检查文档格式是否正确,或尝试重新上传。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Questions Preview */}
|
|
||||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
|
||||||
题目预览 {questions.length > 0 && `(前 ${questions.length} 题)`}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{questions.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<FileText className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
|
||||||
<p className="text-gray-500">
|
|
||||||
{isProcessing ? '正在解析文档,请稍候...' : '暂无题目'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{questions.map((q, index) => (
|
|
||||||
<div key={q.id} className="border border-gray-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="flex-shrink-0 w-8 h-8 bg-primary-100 text-primary-600 rounded-full flex items-center justify-center font-medium">
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-600 rounded">
|
|
||||||
{getQuestionTypeText(q.type)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-900">{q.content}</p>
|
|
||||||
{q.options && q.options.length > 0 && (
|
|
||||||
<ul className="mt-2 space-y-1 text-sm text-gray-600">
|
|
||||||
{q.options.map((opt, i) => (
|
|
||||||
<li key={i}>{opt}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Upload Modal */}
|
|
||||||
{showUploadModal && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
|
||||||
<div className="bg-white rounded-xl max-w-md w-full p-6">
|
|
||||||
<h2 className="text-2xl font-bold mb-4">添加题目文档</h2>
|
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
|
||||||
上传新文档后,系统会自动解析题目并去除重复题目。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleAppendDocument} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
选择文档
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
onChange={(e) => setUploadFile(e.target.files[0])}
|
|
||||||
required
|
|
||||||
accept=".txt,.pdf,.doc,.docx,.xlsx,.xls"
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
支持:TXT, PDF, DOC, DOCX, XLSX, XLS
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setShowUploadModal(false)
|
|
||||||
setUploadFile(null)
|
|
||||||
}}
|
|
||||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={uploading}
|
|
||||||
className="flex-1 bg-primary-600 text-white py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{uploading ? (
|
|
||||||
<>
|
|
||||||
<Loader className="h-5 w-5 animate-spin" />
|
|
||||||
上传中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Upload className="h-5 w-5" />
|
|
||||||
上传
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ExamDetail
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
/**
|
|
||||||
* Exam List Page
|
|
||||||
*/
|
|
||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { examAPI } from '../api/client'
|
|
||||||
import Layout from '../components/Layout'
|
|
||||||
import {
|
|
||||||
Plus, FolderOpen, Loader, AlertCircle, Trash2, Upload
|
|
||||||
} from 'lucide-react'
|
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
import {
|
|
||||||
getStatusColor,
|
|
||||||
getStatusText,
|
|
||||||
formatRelativeTime,
|
|
||||||
calculateProgress,
|
|
||||||
isValidFileType
|
|
||||||
} from '../utils/helpers'
|
|
||||||
|
|
||||||
export const ExamList = () => {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const [exams, setExams] = useState([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
|
||||||
const [creating, setCreating] = useState(false)
|
|
||||||
const [pollInterval, setPollInterval] = useState(null)
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
title: '',
|
|
||||||
file: null
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadExams()
|
|
||||||
|
|
||||||
// Start polling for processing exams
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
checkProcessingExams()
|
|
||||||
}, 3000) // Poll every 3 seconds
|
|
||||||
|
|
||||||
setPollInterval(interval)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (interval) clearInterval(interval)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadExams = async () => {
|
|
||||||
try {
|
|
||||||
const response = await examAPI.getList()
|
|
||||||
setExams(response.data.exams)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load exams:', error)
|
|
||||||
toast.error('加载题库失败')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkProcessingExams = async () => {
|
|
||||||
try {
|
|
||||||
const response = await examAPI.getList()
|
|
||||||
const newExams = response.data.exams
|
|
||||||
|
|
||||||
// Check if any processing exam is now ready
|
|
||||||
const oldProcessing = exams.filter(e => e.status === 'processing')
|
|
||||||
const newReady = newExams.filter(e =>
|
|
||||||
oldProcessing.some(old => old.id === e.id && e.status === 'ready')
|
|
||||||
)
|
|
||||||
|
|
||||||
if (newReady.length > 0) {
|
|
||||||
toast.success(`${newReady.length} 个题库解析完成!`)
|
|
||||||
}
|
|
||||||
|
|
||||||
setExams(newExams)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to poll exams:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreate = async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
if (!formData.file) {
|
|
||||||
toast.error('请选择文件')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValidFileType(formData.file.name)) {
|
|
||||||
toast.error('不支持的文件类型')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setCreating(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await examAPI.create(formData.title, formData.file)
|
|
||||||
toast.success('题库创建成功,正在解析文档...')
|
|
||||||
setShowCreateModal(false)
|
|
||||||
setFormData({ title: '', file: null })
|
|
||||||
|
|
||||||
// 跳转到新创建的试卷详情页
|
|
||||||
if (response.data && response.data.exam_id) {
|
|
||||||
navigate(`/exams/${response.data.exam_id}`)
|
|
||||||
} else {
|
|
||||||
// 如果没有返回 exam_id,刷新列表
|
|
||||||
await loadExams()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create exam:', error)
|
|
||||||
toast.error('创建失败:' + (error.response?.data?.detail || error.message))
|
|
||||||
} finally {
|
|
||||||
setCreating(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (examId) => {
|
|
||||||
if (!window.confirm('确定要删除这个题库吗?删除后无法恢复。')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await examAPI.delete(examId)
|
|
||||||
toast.success('题库已删除')
|
|
||||||
await loadExams()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete exam:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="flex items-center justify-center h-screen">
|
|
||||||
<Loader className="h-8 w-8 animate-spin text-primary-600" />
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="p-4 md:p-8">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">题库管理</h1>
|
|
||||||
<p className="text-gray-600 mt-1">共 {exams.length} 个题库</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
className="mt-4 md:mt-0 bg-primary-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors flex items-center gap-2 justify-center"
|
|
||||||
>
|
|
||||||
<Plus className="h-5 w-5" />
|
|
||||||
创建题库
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Exam Grid */}
|
|
||||||
{exams.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<FolderOpen className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">还没有题库</h3>
|
|
||||||
<p className="text-gray-500 mb-6">创建第一个题库开始刷题吧!</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
className="bg-primary-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors inline-flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Plus className="h-5 w-5" />
|
|
||||||
创建题库
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{exams.map((exam) => (
|
|
||||||
<div
|
|
||||||
key={exam.id}
|
|
||||||
className="bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow p-6"
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 flex-1 pr-2">
|
|
||||||
{exam.title}
|
|
||||||
</h3>
|
|
||||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(exam.status)}`}>
|
|
||||||
{getStatusText(exam.status)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="space-y-2 mb-4">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600">题目数量</span>
|
|
||||||
<span className="font-medium">{exam.total_questions}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600">已完成</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{exam.current_index} / {exam.total_questions}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{exam.total_questions > 0 && (
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-primary-600 h-2 rounded-full transition-all"
|
|
||||||
style={{ width: `${calculateProgress(exam.current_index, exam.total_questions)}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Time */}
|
|
||||||
<p className="text-xs text-gray-500 mb-4">
|
|
||||||
创建于 {formatRelativeTime(exam.created_at)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/exams/${exam.id}`)}
|
|
||||||
className="flex-1 bg-primary-600 text-white py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors"
|
|
||||||
>
|
|
||||||
查看详情
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(exam.id)}
|
|
||||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create Modal */}
|
|
||||||
{showCreateModal && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
|
||||||
<div className="bg-white rounded-xl max-w-md w-full p-6">
|
|
||||||
<h2 className="text-2xl font-bold mb-4">创建新题库</h2>
|
|
||||||
|
|
||||||
<form onSubmit={handleCreate} className="space-y-4">
|
|
||||||
{/* Title */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
题库名称
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.title}
|
|
||||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
|
||||||
required
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
|
||||||
placeholder="例如:数据结构期末复习"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* File */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
上传文档
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
onChange={(e) => setFormData({ ...formData, file: e.target.files[0] })}
|
|
||||||
required
|
|
||||||
accept=".txt,.pdf,.doc,.docx,.xlsx,.xls"
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
支持:TXT, PDF, DOC, DOCX, XLSX, XLS
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Buttons */}
|
|
||||||
<div className="flex gap-3 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setShowCreateModal(false)
|
|
||||||
setFormData({ title: '', file: null })
|
|
||||||
}}
|
|
||||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={creating}
|
|
||||||
className="flex-1 bg-primary-600 text-white py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{creating ? (
|
|
||||||
<>
|
|
||||||
<Loader className="h-5 w-5 animate-spin" />
|
|
||||||
创建中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'创建'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ExamList
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
/**
|
|
||||||
* Login Page
|
|
||||||
*/
|
|
||||||
import React, { useState } from 'react'
|
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
|
||||||
import { useAuth } from '../context/AuthContext'
|
|
||||||
import { BookOpen } from 'lucide-react'
|
|
||||||
|
|
||||||
export const Login = () => {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const { login } = useAuth()
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
username: '',
|
|
||||||
password: ''
|
|
||||||
})
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const success = await login(formData.username, formData.password)
|
|
||||||
if (success) {
|
|
||||||
navigate('/dashboard')
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleChange = (e) => {
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
[e.target.name]: e.target.value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center p-4">
|
|
||||||
<div className="max-w-md w-full">
|
|
||||||
{/* Logo and Title */}
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<div className="flex justify-center mb-4">
|
|
||||||
<div className="bg-primary-600 p-3 rounded-2xl">
|
|
||||||
<BookOpen className="h-10 w-10 text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">QQuiz</h1>
|
|
||||||
<p className="text-gray-600 mt-2">智能刷题与题库管理平台</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Login Form */}
|
|
||||||
<div className="bg-white rounded-2xl shadow-xl p-8">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">登录</h2>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
{/* Username */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
用户名
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="username"
|
|
||||||
value={formData.username}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
|
||||||
placeholder="请输入用户名"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Password */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
密码
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
value={formData.password}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
minLength={6}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
|
||||||
placeholder="请输入密码"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{loading ? '登录中...' : '登录'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Register Link */}
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<p className="text-gray-600">
|
|
||||||
还没有账号?{' '}
|
|
||||||
<Link to="/register" className="text-primary-600 font-medium hover:text-primary-700">
|
|
||||||
立即注册
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Login
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mistake List Page (错题本)
|
|
||||||
*/
|
|
||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
import { mistakeAPI } from '../api/client'
|
|
||||||
import Layout from '../components/Layout'
|
|
||||||
import { XCircle, Loader, Trash2, BookOpen } from 'lucide-react'
|
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
import { getQuestionTypeText, formatRelativeTime } from '../utils/helpers'
|
|
||||||
|
|
||||||
export const MistakeList = () => {
|
|
||||||
const [mistakes, setMistakes] = useState([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [expandedId, setExpandedId] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadMistakes()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadMistakes = async () => {
|
|
||||||
try {
|
|
||||||
const response = await mistakeAPI.getList()
|
|
||||||
setMistakes(response.data.mistakes)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load mistakes:', error)
|
|
||||||
toast.error('加载错题本失败')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemove = async (mistakeId) => {
|
|
||||||
if (!window.confirm('确定要从错题本中移除这道题吗?')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mistakeAPI.remove(mistakeId)
|
|
||||||
toast.success('已移除')
|
|
||||||
await loadMistakes()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to remove mistake:', error)
|
|
||||||
toast.error('移除失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleExpand = (id) => {
|
|
||||||
setExpandedId(expandedId === id ? null : id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="flex items-center justify-center h-screen">
|
|
||||||
<Loader className="h-8 w-8 animate-spin text-primary-600" />
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="p-4 md:p-8">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">错题本</h1>
|
|
||||||
<p className="text-gray-600 mt-1">共 {mistakes.length} 道错题</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Empty State */}
|
|
||||||
{mistakes.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<XCircle className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">错题本是空的</h3>
|
|
||||||
<p className="text-gray-500">继续刷题,错题会自动添加到这里</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{mistakes.map((mistake) => {
|
|
||||||
const q = mistake.question
|
|
||||||
const isExpanded = expandedId === mistake.id
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={mistake.id}
|
|
||||||
className="bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow"
|
|
||||||
>
|
|
||||||
{/* Question Preview */}
|
|
||||||
<div
|
|
||||||
className="p-4 md:p-6 cursor-pointer"
|
|
||||||
onClick={() => toggleExpand(mistake.id)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="flex-shrink-0 w-10 h-10 bg-red-100 text-red-600 rounded-full flex items-center justify-center">
|
|
||||||
<XCircle className="h-5 w-5" />
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-600 rounded">
|
|
||||||
{getQuestionTypeText(q.type)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
{formatRelativeTime(mistake.created_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className={`text-gray-900 ${!isExpanded ? 'line-clamp-2' : ''}`}>
|
|
||||||
{q.content}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
{/* Options */}
|
|
||||||
{q.options && q.options.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{q.options.map((opt, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="p-3 bg-gray-50 rounded-lg text-sm text-gray-700"
|
|
||||||
>
|
|
||||||
{opt}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Answer */}
|
|
||||||
<div className="p-3 bg-green-50 rounded-lg">
|
|
||||||
<p className="text-sm font-medium text-green-900 mb-1">
|
|
||||||
正确答案
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-green-700">{q.answer}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Analysis */}
|
|
||||||
{q.analysis && (
|
|
||||||
<div className="p-3 bg-blue-50 rounded-lg">
|
|
||||||
<p className="text-sm font-medium text-blue-900 mb-1">
|
|
||||||
解析
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-blue-700">{q.analysis}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleRemove(mistake.id)
|
|
||||||
}}
|
|
||||||
className="flex-shrink-0 p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MistakeList
|
|
||||||
@@ -1,397 +0,0 @@
|
|||||||
/**
|
|
||||||
* Quiz Player Page - Core quiz functionality
|
|
||||||
*/
|
|
||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
|
||||||
import { examAPI, questionAPI, mistakeAPI } from '../api/client'
|
|
||||||
import Layout from '../components/Layout'
|
|
||||||
import {
|
|
||||||
ArrowLeft, ArrowRight, Check, X, Loader, BookmarkPlus, BookmarkX, AlertCircle
|
|
||||||
} from 'lucide-react'
|
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
import { getQuestionTypeText } from '../utils/helpers'
|
|
||||||
|
|
||||||
export const QuizPlayer = () => {
|
|
||||||
const { examId } = useParams()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const [searchParams] = useSearchParams()
|
|
||||||
|
|
||||||
const [exam, setExam] = useState(null)
|
|
||||||
const [question, setQuestion] = useState(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
const [userAnswer, setUserAnswer] = useState('')
|
|
||||||
const [multipleAnswers, setMultipleAnswers] = useState([])
|
|
||||||
const [result, setResult] = useState(null)
|
|
||||||
const [inMistakeBook, setInMistakeBook] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadQuiz()
|
|
||||||
}, [examId])
|
|
||||||
|
|
||||||
const loadQuiz = async () => {
|
|
||||||
try {
|
|
||||||
// Check if reset flag is present
|
|
||||||
const shouldReset = searchParams.get('reset') === 'true'
|
|
||||||
if (shouldReset) {
|
|
||||||
await examAPI.updateProgress(examId, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const examRes = await examAPI.getDetail(examId)
|
|
||||||
setExam(examRes.data)
|
|
||||||
|
|
||||||
await loadCurrentQuestion()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load quiz:', error)
|
|
||||||
toast.error('加载题目失败')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadCurrentQuestion = async () => {
|
|
||||||
try {
|
|
||||||
const response = await questionAPI.getCurrentQuestion(examId)
|
|
||||||
setQuestion(response.data)
|
|
||||||
setResult(null)
|
|
||||||
setUserAnswer('')
|
|
||||||
setMultipleAnswers([])
|
|
||||||
await checkIfInMistakeBook(response.data.id)
|
|
||||||
} catch (error) {
|
|
||||||
if (error.response?.status === 404) {
|
|
||||||
toast.success('恭喜!所有题目已完成!')
|
|
||||||
navigate(`/exams/${examId}`)
|
|
||||||
} else {
|
|
||||||
console.error('Failed to load question:', error)
|
|
||||||
toast.error('加载题目失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkIfInMistakeBook = async (questionId) => {
|
|
||||||
try {
|
|
||||||
const response = await mistakeAPI.getList(0, 1000) // TODO: Optimize this
|
|
||||||
const inBook = response.data.mistakes.some(m => m.question_id === questionId)
|
|
||||||
setInMistakeBook(inBook)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to check mistake book:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmitAnswer = async () => {
|
|
||||||
let answer = userAnswer
|
|
||||||
|
|
||||||
// For multiple choice, join selected options
|
|
||||||
if (question.type === 'multiple') {
|
|
||||||
if (multipleAnswers.length === 0) {
|
|
||||||
toast.error('请至少选择一个选项')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
answer = multipleAnswers.sort().join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!answer.trim()) {
|
|
||||||
toast.error('请输入答案')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await questionAPI.checkAnswer(question.id, answer)
|
|
||||||
setResult(response.data)
|
|
||||||
|
|
||||||
if (response.data.correct) {
|
|
||||||
toast.success('回答正确!')
|
|
||||||
} else {
|
|
||||||
toast.error('回答错误')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to check answer:', error)
|
|
||||||
toast.error('提交答案失败')
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNext = async () => {
|
|
||||||
try {
|
|
||||||
const newIndex = exam.current_index + 1
|
|
||||||
await examAPI.updateProgress(examId, newIndex)
|
|
||||||
|
|
||||||
const examRes = await examAPI.getDetail(examId)
|
|
||||||
setExam(examRes.data)
|
|
||||||
|
|
||||||
await loadCurrentQuestion()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to move to next question:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleMistake = async () => {
|
|
||||||
try {
|
|
||||||
if (inMistakeBook) {
|
|
||||||
await mistakeAPI.removeByQuestionId(question.id)
|
|
||||||
setInMistakeBook(false)
|
|
||||||
toast.success('已从错题本移除')
|
|
||||||
} else {
|
|
||||||
await mistakeAPI.add(question.id)
|
|
||||||
setInMistakeBook(true)
|
|
||||||
toast.success('已加入错题本')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to toggle mistake:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMultipleChoice = (option) => {
|
|
||||||
const letter = option.charAt(0)
|
|
||||||
if (multipleAnswers.includes(letter)) {
|
|
||||||
setMultipleAnswers(multipleAnswers.filter(a => a !== letter))
|
|
||||||
} else {
|
|
||||||
setMultipleAnswers([...multipleAnswers, letter])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="flex items-center justify-center h-screen">
|
|
||||||
<Loader className="h-8 w-8 animate-spin text-primary-600" />
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!question) {
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="flex flex-col items-center justify-center h-screen">
|
|
||||||
<AlertCircle className="h-16 w-16 text-gray-300 mb-4" />
|
|
||||||
<p className="text-gray-600">没有更多题目了</p>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="max-w-4xl mx-auto p-4 md:p-8">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/exams/${examId}`)}
|
|
||||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-5 w-5" />
|
|
||||||
返回
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
进度: {exam.current_index + 1} / {exam.total_questions}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Question Card */}
|
|
||||||
<div className="bg-white rounded-xl shadow-md p-6 md:p-8 mb-6">
|
|
||||||
{/* Question Header */}
|
|
||||||
<div className="flex items-start justify-between mb-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="flex-shrink-0 w-10 h-10 bg-primary-100 text-primary-600 rounded-full flex items-center justify-center font-bold">
|
|
||||||
{exam.current_index + 1}
|
|
||||||
</span>
|
|
||||||
<span className="px-3 py-1 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium">
|
|
||||||
{getQuestionTypeText(question.type)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleToggleMistake}
|
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${
|
|
||||||
inMistakeBook
|
|
||||||
? 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200'
|
|
||||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{inMistakeBook ? (
|
|
||||||
<>
|
|
||||||
<BookmarkX className="h-5 w-5" />
|
|
||||||
<span className="hidden sm:inline">移出错题本</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<BookmarkPlus className="h-5 w-5" />
|
|
||||||
<span className="hidden sm:inline">加入错题本</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Question Content */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<p className="text-lg md:text-xl text-gray-900 leading-relaxed">
|
|
||||||
{question.content}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Options (for choice questions) */}
|
|
||||||
{question.options && question.options.length > 0 && (
|
|
||||||
<div className="space-y-3 mb-6">
|
|
||||||
{question.options.map((option, index) => {
|
|
||||||
const letter = option.charAt(0)
|
|
||||||
const isSelected = question.type === 'multiple'
|
|
||||||
? multipleAnswers.includes(letter)
|
|
||||||
: userAnswer === letter
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
onClick={() => {
|
|
||||||
if (!result) {
|
|
||||||
if (question.type === 'multiple') {
|
|
||||||
handleMultipleChoice(option)
|
|
||||||
} else {
|
|
||||||
setUserAnswer(letter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!!result}
|
|
||||||
className={`w-full text-left p-4 rounded-lg border-2 transition-all ${
|
|
||||||
isSelected
|
|
||||||
? 'border-primary-500 bg-primary-50'
|
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
|
||||||
} ${result ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}`}
|
|
||||||
>
|
|
||||||
<span className="text-gray-900">{option}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Short Answer Input */}
|
|
||||||
{question.type === 'short' && !result && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<textarea
|
|
||||||
value={userAnswer}
|
|
||||||
onChange={(e) => setUserAnswer(e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-lg focus:border-primary-500 focus:outline-none"
|
|
||||||
placeholder="请输入你的答案..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Judge Input */}
|
|
||||||
{question.type === 'judge' && !result && (
|
|
||||||
<div className="flex gap-4 mb-6">
|
|
||||||
<button
|
|
||||||
onClick={() => setUserAnswer('A')}
|
|
||||||
className={`flex-1 py-3 rounded-lg border-2 transition-all ${
|
|
||||||
userAnswer === 'A'
|
|
||||||
? 'border-green-500 bg-green-50 text-green-700'
|
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
正确
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setUserAnswer('B')}
|
|
||||||
className={`flex-1 py-3 rounded-lg border-2 transition-all ${
|
|
||||||
userAnswer === 'B'
|
|
||||||
? 'border-red-500 bg-red-50 text-red-700'
|
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
错误
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
{!result && (
|
|
||||||
<button
|
|
||||||
onClick={handleSubmitAnswer}
|
|
||||||
disabled={submitting}
|
|
||||||
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{submitting ? (
|
|
||||||
<>
|
|
||||||
<Loader className="h-5 w-5 animate-spin" />
|
|
||||||
提交中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Check className="h-5 w-5" />
|
|
||||||
提交答案
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Result */}
|
|
||||||
{result && (
|
|
||||||
<div className={`rounded-xl p-6 mb-6 ${
|
|
||||||
result.correct ? 'bg-green-50 border-2 border-green-200' : 'bg-red-50 border-2 border-red-200'
|
|
||||||
}`}>
|
|
||||||
<div className="flex items-start gap-3 mb-4">
|
|
||||||
{result.correct ? (
|
|
||||||
<Check className="h-6 w-6 text-green-600 mt-0.5" />
|
|
||||||
) : (
|
|
||||||
<X className="h-6 w-6 text-red-600 mt-0.5" />
|
|
||||||
)}
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className={`font-bold text-lg mb-2 ${result.correct ? 'text-green-900' : 'text-red-900'}`}>
|
|
||||||
{result.correct ? '回答正确!' : '回答错误'}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{!result.correct && (
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<p className="text-gray-700">
|
|
||||||
<span className="font-medium">你的答案:</span>{result.user_answer}
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700">
|
|
||||||
<span className="font-medium">正确答案:</span>{result.correct_answer}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* AI Score for short answers */}
|
|
||||||
{result.ai_score !== null && result.ai_score !== undefined && (
|
|
||||||
<div className="mt-3 p-3 bg-white rounded-lg">
|
|
||||||
<p className="text-sm font-medium text-gray-700 mb-1">
|
|
||||||
AI 评分:{(result.ai_score * 100).toFixed(0)}%
|
|
||||||
</p>
|
|
||||||
{result.ai_feedback && (
|
|
||||||
<p className="text-sm text-gray-600">{result.ai_feedback}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Analysis */}
|
|
||||||
{result.analysis && (
|
|
||||||
<div className="mt-3 p-3 bg-white rounded-lg">
|
|
||||||
<p className="text-sm font-medium text-gray-700 mb-1">解析:</p>
|
|
||||||
<p className="text-sm text-gray-600">{result.analysis}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Next Button */}
|
|
||||||
<button
|
|
||||||
onClick={handleNext}
|
|
||||||
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
下一题
|
|
||||||
<ArrowRight className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default QuizPlayer
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
/**
|
|
||||||
* Register Page
|
|
||||||
*/
|
|
||||||
import React, { useState } from 'react'
|
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
|
||||||
import { useAuth } from '../context/AuthContext'
|
|
||||||
import { BookOpen } from 'lucide-react'
|
|
||||||
|
|
||||||
export const Register = () => {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const { register } = useAuth()
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
confirmPassword: ''
|
|
||||||
})
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setError('')
|
|
||||||
|
|
||||||
// Validate
|
|
||||||
if (formData.password !== formData.confirmPassword) {
|
|
||||||
setError('两次输入的密码不一致')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.password.length < 6) {
|
|
||||||
setError('密码至少需要 6 位')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const success = await register(formData.username, formData.password)
|
|
||||||
if (success) {
|
|
||||||
navigate('/login')
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleChange = (e) => {
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
[e.target.name]: e.target.value
|
|
||||||
})
|
|
||||||
setError('')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center p-4">
|
|
||||||
<div className="max-w-md w-full">
|
|
||||||
{/* Logo and Title */}
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<div className="flex justify-center mb-4">
|
|
||||||
<div className="bg-primary-600 p-3 rounded-2xl">
|
|
||||||
<BookOpen className="h-10 w-10 text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">QQuiz</h1>
|
|
||||||
<p className="text-gray-600 mt-2">智能刷题与题库管理平台</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Register Form */}
|
|
||||||
<div className="bg-white rounded-2xl shadow-xl p-8">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">注册</h2>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Username */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
用户名
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="username"
|
|
||||||
value={formData.username}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
minLength={3}
|
|
||||||
maxLength={50}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
|
||||||
placeholder="3-50 位字符"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Password */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
密码
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
value={formData.password}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
minLength={6}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
|
||||||
placeholder="至少 6 位"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Confirm Password */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
确认密码
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="confirmPassword"
|
|
||||||
value={formData.confirmPassword}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
minLength={6}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
|
||||||
placeholder="再次输入密码"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{loading ? '注册中...' : '注册'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Login Link */}
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<p className="text-gray-600">
|
|
||||||
已有账号?{' '}
|
|
||||||
<Link to="/login" className="text-primary-600 font-medium hover:text-primary-700">
|
|
||||||
立即登录
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Register
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
/**
|
|
||||||
* Utility Helper Functions
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format date to readable string
|
|
||||||
*/
|
|
||||||
export const formatDate = (dateString) => {
|
|
||||||
const date = new Date(dateString)
|
|
||||||
return new Intl.DateTimeFormat('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
}).format(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format relative time (e.g., "2 days ago")
|
|
||||||
*/
|
|
||||||
export const formatRelativeTime = (dateString) => {
|
|
||||||
const date = new Date(dateString)
|
|
||||||
const now = new Date()
|
|
||||||
const diff = now - date
|
|
||||||
const seconds = Math.floor(diff / 1000)
|
|
||||||
const minutes = Math.floor(seconds / 60)
|
|
||||||
const hours = Math.floor(minutes / 60)
|
|
||||||
const days = Math.floor(hours / 24)
|
|
||||||
|
|
||||||
if (days > 7) {
|
|
||||||
return formatDate(dateString)
|
|
||||||
} else if (days > 0) {
|
|
||||||
return `${days} 天前`
|
|
||||||
} else if (hours > 0) {
|
|
||||||
return `${hours} 小时前`
|
|
||||||
} else if (minutes > 0) {
|
|
||||||
return `${minutes} 分钟前`
|
|
||||||
} else {
|
|
||||||
return '刚刚'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get exam status badge color
|
|
||||||
*/
|
|
||||||
export const getStatusColor = (status) => {
|
|
||||||
const colors = {
|
|
||||||
pending: 'bg-gray-100 text-gray-800',
|
|
||||||
processing: 'bg-blue-100 text-blue-800',
|
|
||||||
ready: 'bg-green-100 text-green-800',
|
|
||||||
failed: 'bg-red-100 text-red-800'
|
|
||||||
}
|
|
||||||
return colors[status] || 'bg-gray-100 text-gray-800'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get exam status text
|
|
||||||
*/
|
|
||||||
export const getStatusText = (status) => {
|
|
||||||
const texts = {
|
|
||||||
pending: '等待中',
|
|
||||||
processing: '处理中',
|
|
||||||
ready: '就绪',
|
|
||||||
failed: '失败'
|
|
||||||
}
|
|
||||||
return texts[status] || status
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get question type text
|
|
||||||
*/
|
|
||||||
export const getQuestionTypeText = (type) => {
|
|
||||||
const texts = {
|
|
||||||
single: '单选题',
|
|
||||||
multiple: '多选题',
|
|
||||||
judge: '判断题',
|
|
||||||
short: '简答题'
|
|
||||||
}
|
|
||||||
return texts[type] || type
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate progress percentage
|
|
||||||
*/
|
|
||||||
export const calculateProgress = (current, total) => {
|
|
||||||
if (total === 0) return 0
|
|
||||||
return Math.round((current / total) * 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate file type
|
|
||||||
*/
|
|
||||||
export const isValidFileType = (filename) => {
|
|
||||||
const allowedExtensions = ['txt', 'pdf', 'doc', 'docx', 'xlsx', 'xls']
|
|
||||||
const extension = filename.split('.').pop().toLowerCase()
|
|
||||||
return allowedExtensions.includes(extension)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format file size
|
|
||||||
*/
|
|
||||||
export const formatFileSize = (bytes) => {
|
|
||||||
if (bytes === 0) return '0 Bytes'
|
|
||||||
const k = 1024
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
||||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Truncate text
|
|
||||||
*/
|
|
||||||
export const truncateText = (text, maxLength = 100) => {
|
|
||||||
if (text.length <= maxLength) return text
|
|
||||||
return text.substring(0, maxLength) + '...'
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
primary: {
|
|
||||||
50: '#eff6ff',
|
|
||||||
100: '#dbeafe',
|
|
||||||
200: '#bfdbfe',
|
|
||||||
300: '#93c5fd',
|
|
||||||
400: '#60a5fa',
|
|
||||||
500: '#3b82f6',
|
|
||||||
600: '#2563eb',
|
|
||||||
700: '#1d4ed8',
|
|
||||||
800: '#1e40af',
|
|
||||||
900: '#1e3a8a',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
server: {
|
|
||||||
host: '0.0.0.0',
|
|
||||||
port: 3000,
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: process.env.REACT_APP_API_URL || 'http://localhost:8000',
|
|
||||||
changeOrigin: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
outDir: 'build'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
225
scripts/smoke_e2e.ps1
Normal file
225
scripts/smoke_e2e.ps1
Normal 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
|
||||||
98
scripts/start_single_container.py
Normal file
98
scripts/start_single_container.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_DIR = "/app"
|
||||||
|
WEB_DIR = "/app/web"
|
||||||
|
|
||||||
|
|
||||||
|
def terminate_process(process: subprocess.Popen | None, label: str) -> None:
|
||||||
|
if process is None or process.poll() is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Stopping {label}...")
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
shared_env = os.environ.copy()
|
||||||
|
shared_env.setdefault("API_BASE_URL", "http://127.0.0.1:8000")
|
||||||
|
shared_env.setdefault("NEXT_SERVER_URL", "http://127.0.0.1:3000")
|
||||||
|
shared_env.setdefault("NEXT_TELEMETRY_DISABLED", "1")
|
||||||
|
|
||||||
|
next_env = shared_env.copy()
|
||||||
|
next_env["NODE_ENV"] = "production"
|
||||||
|
next_env["HOSTNAME"] = "0.0.0.0"
|
||||||
|
next_env["PORT"] = "3000"
|
||||||
|
|
||||||
|
next_process = subprocess.Popen(
|
||||||
|
["node", "server.js"],
|
||||||
|
cwd=WEB_DIR,
|
||||||
|
env=next_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
api_process: subprocess.Popen | None = None
|
||||||
|
|
||||||
|
def shutdown(signum, _frame):
|
||||||
|
print(f"Received signal {signum}, shutting down...")
|
||||||
|
terminate_process(api_process, "FastAPI")
|
||||||
|
terminate_process(next_process, "Next.js")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, shutdown)
|
||||||
|
signal.signal(signal.SIGTERM, shutdown)
|
||||||
|
|
||||||
|
try:
|
||||||
|
migrate_result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||||
|
cwd=ROOT_DIR,
|
||||||
|
env=shared_env,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if migrate_result.returncode != 0:
|
||||||
|
terminate_process(next_process, "Next.js")
|
||||||
|
return migrate_result.returncode
|
||||||
|
|
||||||
|
api_process = subprocess.Popen(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"uvicorn",
|
||||||
|
"main:app",
|
||||||
|
"--host",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--port",
|
||||||
|
"8000",
|
||||||
|
],
|
||||||
|
cwd=ROOT_DIR,
|
||||||
|
env=shared_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
next_returncode = next_process.poll()
|
||||||
|
api_returncode = api_process.poll()
|
||||||
|
|
||||||
|
if next_returncode is not None:
|
||||||
|
terminate_process(api_process, "FastAPI")
|
||||||
|
return next_returncode
|
||||||
|
|
||||||
|
if api_returncode is not None:
|
||||||
|
terminate_process(next_process, "Next.js")
|
||||||
|
return api_returncode
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
finally:
|
||||||
|
terminate_process(api_process, "FastAPI")
|
||||||
|
terminate_process(next_process, "Next.js")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
5
web/.dockerignore
Normal file
5
web/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
npm-debug.log*
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
1
web/.env.example
Normal file
1
web/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
API_BASE_URL=http://localhost:8000
|
||||||
33
web/Dockerfile
Normal file
33
web/Dockerfile
Normal 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"]
|
||||||
14
web/README.md
Normal file
14
web/README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# QQuiz Web
|
||||||
|
|
||||||
|
This directory contains the Next.js frontend for QQuiz.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- App Router application: active
|
||||||
|
- Auth/session proxy routes: active
|
||||||
|
- Single-container deployment target: active
|
||||||
|
- Split-stack frontend: active
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Copy `.env.example` and point `API_BASE_URL` at the FastAPI backend.
|
||||||
18
web/components.json
Normal file
18
web/components.json
Normal 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
5
web/next-env.d.ts
vendored
Normal 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
6
web/next.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: "standalone"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
1817
web/package-lock.json
generated
Normal file
1817
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
web/package.json
Normal file
34
web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {}
|
||||||
},
|
}
|
||||||
}
|
};
|
||||||
44
web/src/app/(app)/admin/page.tsx
Normal file
44
web/src/app/(app)/admin/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
web/src/app/(app)/admin/settings/page.tsx
Normal file
17
web/src/app/(app)/admin/settings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
web/src/app/(app)/dashboard/page.tsx
Normal file
107
web/src/app/(app)/dashboard/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
web/src/app/(app)/exams/[examId]/page.tsx
Normal file
19
web/src/app/(app)/exams/[examId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
web/src/app/(app)/exams/page.tsx
Normal file
28
web/src/app/(app)/exams/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
web/src/app/(app)/layout.tsx
Normal file
29
web/src/app/(app)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
web/src/app/(app)/mistake-quiz/page.tsx
Normal file
11
web/src/app/(app)/mistake-quiz/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
web/src/app/(app)/mistakes/page.tsx
Normal file
28
web/src/app/(app)/mistakes/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
web/src/app/(app)/questions/page.tsx
Normal file
43
web/src/app/(app)/questions/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
web/src/app/(app)/quiz/[examId]/page.tsx
Normal file
15
web/src/app/(app)/quiz/[examId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
web/src/app/(auth)/login/page.tsx
Normal file
120
web/src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"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";
|
||||||
|
import {
|
||||||
|
getResponseErrorMessage,
|
||||||
|
isRecord,
|
||||||
|
readResponsePayload
|
||||||
|
} from "@/lib/api/response";
|
||||||
|
|
||||||
|
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("/frontend-api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await readResponsePayload(response);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(getResponseErrorMessage(payload, "登录失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(payload)) {
|
||||||
|
throw new Error("登录接口返回了无效响应");
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
web/src/app/(auth)/register/page.tsx
Normal file
103
web/src/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"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";
|
||||||
|
import {
|
||||||
|
getResponseErrorMessage,
|
||||||
|
isRecord,
|
||||||
|
readResponsePayload
|
||||||
|
} from "@/lib/api/response";
|
||||||
|
|
||||||
|
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("/frontend-api/proxy/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await readResponsePayload(response);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(getResponseErrorMessage(payload, "注册失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(payload)) {
|
||||||
|
throw new Error("注册接口返回了无效响应");
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
web/src/app/frontend-api/auth/login/route.ts
Normal file
60
web/src/app/frontend-api/auth/login/route.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { SESSION_COOKIE_NAME, buildBackendUrl } from "@/lib/api/config";
|
||||||
|
import {
|
||||||
|
getResponseErrorMessage,
|
||||||
|
isRecord,
|
||||||
|
readResponsePayload
|
||||||
|
} from "@/lib/api/response";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(buildBackendUrl("/auth/login"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: "Backend API is unavailable." },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await readResponsePayload(response);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: getResponseErrorMessage(payload, "登录失败") },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(payload) || typeof payload.access_token !== "string") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: "Backend returned an invalid login response." },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cookies().set({
|
||||||
|
name: SESSION_COOKIE_NAME,
|
||||||
|
value: payload.access_token,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: isSecureRequest,
|
||||||
|
path: "/"
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
17
web/src/app/frontend-api/auth/logout/route.ts
Normal file
17
web/src/app/frontend-api/auth/logout/route.ts
Normal 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();
|
||||||
|
}
|
||||||
57
web/src/app/frontend-api/auth/me/route.ts
Normal file
57
web/src/app/frontend-api/auth/me/route.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
buildBackendUrl
|
||||||
|
} from "@/lib/api/config";
|
||||||
|
import {
|
||||||
|
getResponseErrorMessage,
|
||||||
|
isRecord,
|
||||||
|
readResponsePayload
|
||||||
|
} from "@/lib/api/response";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const token = cookies().get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ detail: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(buildBackendUrl("/auth/me"), {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: "Backend API is unavailable." },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await readResponsePayload(response);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
cookies().delete(SESSION_COOKIE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: getResponseErrorMessage(payload, "获取当前用户失败") },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(payload)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: "Backend returned an invalid auth response." },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(payload, { status: response.status });
|
||||||
|
}
|
||||||
52
web/src/app/frontend-api/exams/[examId]/progress/route.ts
Normal file
52
web/src/app/frontend-api/exams/[examId]/progress/route.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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)}`;
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(target, {
|
||||||
|
headers: {
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache"
|
||||||
|
},
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new NextResponse("Backend API is unavailable.", {
|
||||||
|
status: 502,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/plain; charset=utf-8"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
91
web/src/app/frontend-api/proxy/[...path]/route.ts
Normal file
91
web/src/app/frontend-api/proxy/[...path]/route.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(target, init);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: "Backend API is unavailable." },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
47
web/src/app/globals.css
Normal 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
27
web/src/app/layout.tsx
Normal 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
7
web/src/app/loading.tsx
Normal 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
24
web/src/app/not-found.tsx
Normal 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
7
web/src/app/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { readSessionToken } from "@/lib/auth/session";
|
||||||
|
|
||||||
|
export default function IndexPage() {
|
||||||
|
redirect(readSessionToken() ? "/dashboard" : "/login");
|
||||||
|
}
|
||||||
186
web/src/components/admin/settings-panel.tsx
Normal file
186
web/src/components/admin/settings-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
358
web/src/components/admin/user-management-panel.tsx
Normal file
358
web/src/components/admin/user-management-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
web/src/components/app-shell/app-sidebar.tsx
Normal file
77
web/src/components/app-shell/app-sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
web/src/components/app-shell/feature-placeholder.tsx
Normal file
65
web/src/components/app-shell/feature-placeholder.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
web/src/components/app-shell/logout-button.tsx
Normal file
33
web/src/components/app-shell/logout-button.tsx
Normal 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("/frontend-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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
web/src/components/app-shell/page-header.tsx
Normal file
27
web/src/components/app-shell/page-header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
web/src/components/app-shell/stat-card.tsx
Normal file
38
web/src/components/app-shell/stat-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
web/src/components/app-shell/status-badge.tsx
Normal file
15
web/src/components/app-shell/status-badge.tsx
Normal 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>;
|
||||||
|
}
|
||||||
232
web/src/components/exams/exam-detail-client.tsx
Normal file
232
web/src/components/exams/exam-detail-client.tsx
Normal 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(`/frontend-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>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
web/src/components/exams/exams-page-client.tsx
Normal file
219
web/src/components/exams/exams-page-client.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
web/src/components/mistakes/mistake-list-client.tsx
Normal file
143
web/src/components/mistakes/mistake-list-client.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
web/src/components/practice/mistake-practice-client.tsx
Normal file
267
web/src/components/practice/mistake-practice-client.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
"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", {
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
skip: 0,
|
||||||
|
limit: 1000
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user