mirror of
https://github.com/handsomezhuzhu/QQuiz.git
synced 2026-04-18 22:42:53 +00:00
Compare commits
3 Commits
466fa50aa8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a1a9d3247 | |||
| cab8b3b483 | |||
| 31916e68a6 |
@@ -52,7 +52,3 @@ CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
|
||||
# Upload Directory
|
||||
UPLOAD_DIR=./uploads
|
||||
|
||||
# ESA Human Verification
|
||||
VITE_ESA_PREFIX=
|
||||
VITE_ESA_SCENE_ID=
|
||||
|
||||
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:
|
||||
push:
|
||||
@@ -45,6 +45,7 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
@@ -53,8 +54,8 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max,scope=single-container
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Image digest
|
||||
run: echo "Image pushed with digest ${{ steps.build-and-push.outputs.digest }}"
|
||||
run: echo "Image pushed with digest ${{ steps.build.outputs.digest }}"
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -44,8 +44,9 @@ yarn-error.log
|
||||
.pnp.js
|
||||
|
||||
# Build
|
||||
frontend/build/
|
||||
frontend/dist/
|
||||
.next/
|
||||
web/.next/
|
||||
web/out/
|
||||
|
||||
# Testing
|
||||
.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.
|
||||
51
Dockerfile
51
Dockerfile
@@ -1,51 +1,50 @@
|
||||
# ==================== 多阶段构建:前后端整合单容器 ====================
|
||||
# Stage 1: 构建前端
|
||||
FROM node:18-slim AS frontend-builder
|
||||
# ==================== 多阶段构建:单容器运行 FastAPI + Next.js ====================
|
||||
# Stage 1: 构建 Next.js 前端
|
||||
FROM node:20-slim AS web-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
WORKDIR /web
|
||||
|
||||
# 复制前端依赖文件
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# 安装依赖
|
||||
COPY web/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# 复制前端源代码
|
||||
COPY frontend/ ./
|
||||
COPY web/ ./
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# 构建前端(生成静态文件到 dist 目录)
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: 构建后端并整合前端
|
||||
FROM python:3.11-slim
|
||||
# Stage 2: 运行 FastAPI + Next.js
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装操作系统依赖(python-magic 需要 libmagic)
|
||||
# 安装 Python 运行时和操作系统依赖
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libmagic1 \
|
||||
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv libmagic1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 复制后端依赖文件
|
||||
COPY backend/requirements.txt ./
|
||||
RUN python3 -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# 安装 Python 依赖(使用预编译wheel包,无需gcc)
|
||||
RUN pip install -r requirements.txt
|
||||
# 安装后端依赖
|
||||
COPY backend/requirements.txt ./backend-requirements.txt
|
||||
RUN python -m pip install --no-cache-dir -r backend-requirements.txt
|
||||
|
||||
# 复制后端代码
|
||||
# 复制后端代码和启动脚本
|
||||
COPY backend/ ./
|
||||
COPY scripts/start_single_container.py ./scripts/start_single_container.py
|
||||
|
||||
# 从前端构建阶段复制静态文件到后端 static 目录
|
||||
COPY --from=frontend-builder /frontend/build ./static
|
||||
# 复制 Next.js standalone 产物
|
||||
COPY --from=web-builder /web/.next/standalone ./web
|
||||
COPY --from=web-builder /web/.next/static ./web/.next/static
|
||||
|
||||
# 创建上传目录
|
||||
RUN mkdir -p ./uploads
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000
|
||||
|
||||
# 设置环境变量
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV NEXT_SERVER_URL=http://127.0.0.1:3000
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# 启动命令
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["python", "scripts/start_single_container.py"]
|
||||
|
||||
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页),完整保留图片、表格、公式等内容
|
||||
- 🎓 **AI 参考答案**: 对于没有提供答案的题目,自动生成 AI 参考答案
|
||||
- 📊 **断点续做**: 自动记录刷题进度,随时继续
|
||||
- ❌ **错题本管理**: 自动收集错题,支持手动添加/移除
|
||||
- 🎯 **多题型支持**: 单选、多选、判断、简答
|
||||
- 🔐 **权限管理**: 管理员配置、用户隔离
|
||||
- 📱 **移动端优先**: 完美适配手机端
|
||||
## 功能
|
||||
|
||||
- 文档导入:支持 TXT / PDF / DOC / DOCX / XLS / XLSX
|
||||
- 异步解析:后台解析文档并回传进度
|
||||
- 题目去重:同题库内自动去重
|
||||
- 刷题与续做:记录当前进度,支持继续作答
|
||||
- 错题本:自动收集错误题目
|
||||
- 管理后台:用户管理、系统配置、模型配置
|
||||
- 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
|
||||
# 1. 拉取最新镜像
|
||||
docker pull ghcr.io/handsomezhuzhu/qquiz:latest
|
||||
```
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
# 编辑 .env,填入你的 API Key
|
||||
#### 4. 创建数据卷
|
||||
|
||||
# 3. 运行容器
|
||||
```bash
|
||||
docker volume create qquiz_data
|
||||
docker volume create qquiz_uploads
|
||||
```
|
||||
|
||||
#### 5. 启动容器
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name qquiz \
|
||||
-p 8000:8000 \
|
||||
-v $(pwd)/qquiz_data:/app/qquiz_data \
|
||||
--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
|
||||
|
||||
# 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
|
||||
# 1. 配置环境变量(必须提供强密码和密钥)
|
||||
cp .env.example .env
|
||||
# 编辑 .env,填入至少 32 位的 SECRET_KEY 和至少 12 位的 ADMIN_PASSWORD(建议使用随机生成值)
|
||||
|
||||
# 2. 启动服务(未设置强密码/密钥会直接报错终止)
|
||||
SECRET_KEY=$(openssl rand -base64 48) \
|
||||
ADMIN_PASSWORD=$(openssl rand -base64 16) \
|
||||
docker-compose -f docker-compose-single.yml up -d
|
||||
|
||||
# 3. 访问应用: http://localhost:8000
|
||||
# API 文档: http://localhost:8000/docs
|
||||
docker compose -f docker-compose-single.yml up -d --build
|
||||
```
|
||||
|
||||
### 传统部署(3 个容器)
|
||||
访问:
|
||||
|
||||
前后端分离 + MySQL:
|
||||
- 应用:`http://localhost:8000`
|
||||
- API 文档:`http://localhost:8000/docs`
|
||||
|
||||
### 可选:开发或兼容性部署
|
||||
|
||||
以下方式保留用于开发调试或兼容场景,不再作为默认部署方案:
|
||||
|
||||
#### 前后端分离开发栈
|
||||
|
||||
```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
|
||||
# 编辑 .env,修改 DATABASE_URL 为本地数据库地址
|
||||
|
||||
# 2. 启动 MySQL
|
||||
# macOS: brew services start mysql
|
||||
# Linux: sudo systemctl start mysql
|
||||
|
||||
# 3. 运行启动脚本
|
||||
chmod +x scripts/run_local.sh
|
||||
./scripts/run_local.sh
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
**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
|
||||
# 拉取最新镜像
|
||||
docker pull ghcr.io/handsomezhuzhu/qquiz:latest
|
||||
cp .env.example .env
|
||||
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/
|
||||
├── backend/ # FastAPI 后端
|
||||
│ ├── alembic/ # 数据库迁移
|
||||
│ ├── routers/ # API 路由
|
||||
│ ├── services/ # 业务逻辑
|
||||
│ ├── models.py # 数据模型
|
||||
│ ├── database.py # 数据库配置
|
||||
│ ├── main.py # 应用入口
|
||||
│ └── requirements.txt # Python 依赖
|
||||
├── frontend/ # React 前端
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API 客户端
|
||||
│ │ ├── pages/ # 页面组件
|
||||
│ │ ├── components/ # 通用组件
|
||||
│ │ └── App.jsx # 应用入口
|
||||
│ ├── package.json # Node 依赖
|
||||
│ └── vite.config.js # Vite 配置
|
||||
├── scripts/ # 部署和启动脚本
|
||||
│ └── run_local.sh # Linux/macOS 启动脚本
|
||||
├── docs/ # 文档目录
|
||||
│ ├── MYSQL_SETUP.md # MySQL 安装配置指南
|
||||
│ └── PROJECT_STRUCTURE.md # 项目架构详解
|
||||
├── test_data/ # 测试数据
|
||||
│ └── sample_questions.txt # 示例题目
|
||||
├── docker-compose.yml # Docker 编排
|
||||
├── .env.example # 环境变量模板
|
||||
└── README.md # 项目说明
|
||||
├─ backend/ FastAPI 后端
|
||||
├─ web/ Next.js 前端工程
|
||||
├─ docs/ 文档与截图
|
||||
├─ test_data/ 示例题库文件
|
||||
├─ docker-compose-single.yml 单容器部署(默认)
|
||||
├─ Dockerfile 单容器镜像构建(默认)
|
||||
├─ docker-compose.yml 前后端分离开发/兼容部署
|
||||
└─ docker-compose.mysql.yml MySQL overlay(可选)
|
||||
```
|
||||
|
||||
## 核心业务流程
|
||||
|
||||
### 1. 创建题库
|
||||
用户首次上传文档时,创建新的 Exam (题库容器)
|
||||
|
||||
### 2. 追加文档
|
||||
在已有题库详情页点击 "添加题目文档",上传新文件
|
||||
|
||||
### 3. 去重逻辑
|
||||
- 对题目内容进行标准化处理 (去空格、标点、转小写)
|
||||
- 计算 MD5 Hash
|
||||
- 仅在当前题库范围内查询去重
|
||||
- 仅插入 Hash 不存在的题目
|
||||
|
||||
### 4. 异步处理
|
||||
- 后台任务处理 AI 解析
|
||||
- 状态: `pending` → `processing` → `ready` / `failed`
|
||||
- 前端轮询状态,自动刷新
|
||||
|
||||
### 5. 刷题体验
|
||||
- 基于 `current_index` 实现断点续做
|
||||
- 错题自动加入错题本
|
||||
- 简答题 AI 评分
|
||||
|
||||
## 环境变量说明
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `DATABASE_URL` | 数据库连接字符串 | - |
|
||||
| `SECRET_KEY` | JWT 密钥(必须至少 32 位随机字符串) | - |
|
||||
| `ADMIN_PASSWORD` | 默认管理员密码(必须至少 12 位,建议随机生成) | - |
|
||||
| `AI_PROVIDER` | AI 提供商 (gemini/openai/anthropic/qwen) | gemini |
|
||||
| `GEMINI_API_KEY` | Google Gemini API 密钥 | - |
|
||||
| `GEMINI_BASE_URL` | Gemini API 地址(可选,支持代理) | https://generativelanguage.googleapis.com |
|
||||
| `GEMINI_MODEL` | Gemini 模型 | gemini-2.0-flash-exp |
|
||||
| `OPENAI_API_KEY` | OpenAI API 密钥 | - |
|
||||
| `OPENAI_BASE_URL` | OpenAI API 地址 | https://api.openai.com/v1 |
|
||||
| `OPENAI_MODEL` | OpenAI 模型 | gpt-4o-mini |
|
||||
| `ANTHROPIC_API_KEY` | Anthropic API 密钥 | - |
|
||||
| `ANTHROPIC_MODEL` | Anthropic 模型 | claude-3-haiku-20240307 |
|
||||
| `QWEN_API_KEY` | 通义千问 API 密钥 | - |
|
||||
| `QWEN_BASE_URL` | 通义千问 API 地址 | https://dashscope.aliyuncs.com/compatible-mode/v1 |
|
||||
| `QWEN_MODEL` | 通义千问模型 | qwen-plus |
|
||||
| `ALLOW_REGISTRATION` | 是否允许注册 | true |
|
||||
| `MAX_UPLOAD_SIZE_MB` | 最大上传文件大小 (MB) | 10 |
|
||||
| `MAX_DAILY_UPLOADS` | 每日上传次数限制 | 20 |
|
||||
|
||||
### AI 提供商对比
|
||||
|
||||
| 提供商 | PDF 原生支持 | 文本解析 | 推荐度 | 说明 |
|
||||
|--------|--------------|----------|--------|------|
|
||||
| **Google Gemini** | ✅ | ✅ | ⭐⭐⭐⭐⭐ | 支持原生 PDF(最多1000页),保留图片、表格、公式 |
|
||||
| OpenAI | ❌ | ✅ | ⭐⭐⭐⭐ | 仅文本提取,PDF 会丢失格式和图片 |
|
||||
| Anthropic | ❌ | ✅ | ⭐⭐⭐⭐ | 仅文本提取,PDF 会丢失格式和图片 |
|
||||
| Qwen (通义千问) | ❌ | ✅ | ⭐⭐⭐ | 仅文本提取,PDF 会丢失格式和图片 |
|
||||
|
||||
**推荐使用 Gemini**:如果你的题库包含 PDF 文件(特别是含有图片、公式、表格的学科试卷),强烈推荐使用 Gemini。
|
||||
|
||||
### 如何获取 API Key
|
||||
|
||||
- **Google Gemini**: https://aistudio.google.com/apikey (免费额度充足)
|
||||
- **OpenAI**: https://platform.openai.com/api-keys
|
||||
- **Anthropic**: https://console.anthropic.com/settings/keys
|
||||
- **Qwen (通义千问)**: https://dashscope.console.aliyun.com/apiKey
|
||||
|
||||
### AI 配置方式
|
||||
|
||||
QQuiz 支持两种配置方式:
|
||||
|
||||
1. **环境变量配置** (`.env` 文件):适合 Docker 部署和开发环境
|
||||
2. **数据库配置** (管理员后台):适合生产环境,支持在线修改,无需重启服务
|
||||
|
||||
**推荐流程**:首次部署使用环境变量,部署成功后通过管理员后台修改配置。
|
||||
|
||||
**Gemini 自定义代理**: 如果需要使用 Key 轮训服务或代理,可以在管理员后台配置 `GEMINI_BASE_URL`,支持自定义 Gemini API 地址。
|
||||
|
||||
## 技术栈
|
||||
|
||||
**后端:**
|
||||
- FastAPI - 现代化 Python Web 框架
|
||||
- SQLAlchemy 2.0 - 异步 ORM
|
||||
- Alembic - 数据库迁移
|
||||
- MySQL 8.0 - 数据库
|
||||
- aiomysql - MySQL 异步驱动
|
||||
- Pydantic - 数据验证
|
||||
- 后端:FastAPI、SQLAlchemy、Alembic、SQLite / MySQL、httpx
|
||||
- 前端:Next.js 14、React 18、TypeScript、Tailwind CSS、TanStack Query
|
||||
|
||||
**前端:**
|
||||
- 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
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies (gcc for compiling Python packages)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libmagic1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Copy application code
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
COPY . .
|
||||
|
||||
# Create uploads directory
|
||||
RUN mkdir -p uploads
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run database migrations and start server
|
||||
CMD alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
208
backend/main.py
208
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.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from contextlib import asynccontextmanager
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
import httpx
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from database import init_db, init_default_config, get_db_context
|
||||
from rate_limit import limiter
|
||||
@@ -18,6 +18,22 @@ from rate_limit import limiter
|
||||
# Load environment variables
|
||||
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):
|
||||
return JSONResponse(
|
||||
@@ -32,6 +48,11 @@ async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
print("🚀 Starting QQuiz Application...")
|
||||
|
||||
app.state.frontend_client = httpx.AsyncClient(
|
||||
follow_redirects=False,
|
||||
timeout=httpx.Timeout(30.0, connect=5.0),
|
||||
)
|
||||
|
||||
# Initialize database
|
||||
await init_db()
|
||||
|
||||
@@ -49,6 +70,7 @@ async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
await app.state.frontend_client.aclose()
|
||||
print("👋 Shutting down QQuiz Application...")
|
||||
|
||||
|
||||
@@ -89,44 +111,152 @@ app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""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路由)
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_frontend(full_path: str):
|
||||
"""
|
||||
服务前端应用
|
||||
- API 路由已在上面定义,优先匹配
|
||||
- 其他所有路由返回 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"
|
||||
def build_internal_api_target(request: Request, full_path: str, trailing_slash: bool = False) -> str:
|
||||
normalized_path = full_path.strip("/")
|
||||
if trailing_slash and normalized_path:
|
||||
normalized_path = f"{normalized_path}/"
|
||||
query = request.url.query
|
||||
return f"{INTERNAL_API_URL}/api/{normalized_path}{f'?{query}' if query else ''}"
|
||||
|
||||
|
||||
def filter_proxy_headers(request: Request) -> dict[str, str]:
|
||||
headers = {
|
||||
key: value
|
||||
for key, value in request.headers.items()
|
||||
if key.lower() not in HOP_BY_HOP_HEADERS and key.lower() != "host"
|
||||
}
|
||||
else:
|
||||
print("⚠️ 静态文件目录不存在,前端功能不可用")
|
||||
print("提示:请先构建前端应用或使用开发模式")
|
||||
# Avoid sending compressed payloads through the proxy so response headers stay accurate.
|
||||
headers["Accept-Encoding"] = "identity"
|
||||
return headers
|
||||
|
||||
# 如果没有静态文件,显示 API 信息
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"message": "Welcome to QQuiz API",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"note": "Frontend not built. Please build frontend or use docker-compose."
|
||||
}
|
||||
|
||||
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 - 完备的管理员功能模块
|
||||
参考 OpenWebUI 设计
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from fastapi.responses import StreamingResponse, FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, or_, desc
|
||||
@@ -16,7 +16,8 @@ from database import get_db, engine
|
||||
from models import User, SystemConfig, Exam, Question, UserMistake, ExamStatus
|
||||
from schemas import (
|
||||
SystemConfigUpdate, SystemConfigResponse,
|
||||
UserResponse, UserCreate, UserUpdate, UserListResponse
|
||||
UserResponse, UserCreate, UserUpdate, UserListResponse,
|
||||
UserPasswordResetRequest, AdminUserSummary
|
||||
)
|
||||
from services.auth_service import get_current_admin_user
|
||||
|
||||
@@ -36,6 +37,11 @@ async def get_default_admin_id(db: AsyncSession) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
async def get_admin_count(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.count(User.id)).where(User.is_admin == True))
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
@router.get("/config", response_model=SystemConfigResponse)
|
||||
async def get_system_config(
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
@@ -84,6 +90,9 @@ async def update_system_config(
|
||||
update_data = config_update.dict(exclude_unset=True)
|
||||
|
||||
for key, value in update_data.items():
|
||||
if key.endswith("_api_key") and isinstance(value, str) and "..." in value:
|
||||
continue
|
||||
|
||||
result = await db.execute(
|
||||
select(SystemConfig).where(SystemConfig.key == key)
|
||||
)
|
||||
@@ -108,9 +117,9 @@ async def update_system_config(
|
||||
|
||||
@router.get("/users", response_model=UserListResponse)
|
||||
async def get_users(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
search: Optional[str] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
search: Optional[str] = Query(None, min_length=1, max_length=50),
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
@@ -121,10 +130,11 @@ async def get_users(
|
||||
- search: 搜索关键词(用户名)
|
||||
"""
|
||||
query = select(User)
|
||||
normalized_search = search.strip() if search else None
|
||||
|
||||
# 搜索过滤
|
||||
if search:
|
||||
query = query.where(User.username.ilike(f"%{search}%"))
|
||||
if normalized_search:
|
||||
query = query.where(User.username.ilike(f"%{normalized_search}%"))
|
||||
|
||||
# 统计总数
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
@@ -136,34 +146,41 @@ async def get_users(
|
||||
result = await db.execute(query)
|
||||
users = result.scalars().all()
|
||||
|
||||
# 为每个用户添加统计信息
|
||||
user_ids = [user.id for user in users]
|
||||
exam_count_map = {}
|
||||
mistake_count_map = {}
|
||||
|
||||
if user_ids:
|
||||
exam_count_result = await db.execute(
|
||||
select(Exam.user_id, func.count(Exam.id))
|
||||
.where(Exam.user_id.in_(user_ids))
|
||||
.group_by(Exam.user_id)
|
||||
)
|
||||
exam_count_map = {user_id: count for user_id, count in exam_count_result.all()}
|
||||
|
||||
mistake_count_result = await db.execute(
|
||||
select(UserMistake.user_id, func.count(UserMistake.id))
|
||||
.where(UserMistake.user_id.in_(user_ids))
|
||||
.group_by(UserMistake.user_id)
|
||||
)
|
||||
mistake_count_map = {
|
||||
user_id: count for user_id, count in mistake_count_result.all()
|
||||
}
|
||||
|
||||
user_list = []
|
||||
for user in users:
|
||||
# 统计用户的题库数
|
||||
exam_count_query = select(func.count(Exam.id)).where(Exam.user_id == user.id)
|
||||
exam_result = await db.execute(exam_count_query)
|
||||
exam_count = exam_result.scalar()
|
||||
user_list.append(
|
||||
AdminUserSummary(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
is_admin=user.is_admin,
|
||||
created_at=user.created_at,
|
||||
exam_count=exam_count_map.get(user.id, 0),
|
||||
mistake_count=mistake_count_map.get(user.id, 0)
|
||||
)
|
||||
)
|
||||
|
||||
# 统计用户的错题数
|
||||
mistake_count_query = select(func.count(UserMistake.id)).where(UserMistake.user_id == user.id)
|
||||
mistake_result = await db.execute(mistake_count_query)
|
||||
mistake_count = mistake_result.scalar()
|
||||
|
||||
user_list.append({
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"is_admin": user.is_admin,
|
||||
"created_at": user.created_at,
|
||||
"exam_count": exam_count,
|
||||
"mistake_count": mistake_count
|
||||
})
|
||||
|
||||
return {
|
||||
"users": user_list,
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit
|
||||
}
|
||||
return UserListResponse(users=user_list, total=total, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.post("/users", response_model=UserResponse)
|
||||
@@ -215,7 +232,19 @@ async def update_user(
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
if user_data.username and user_data.username != user.username:
|
||||
existing_user_result = await db.execute(
|
||||
select(User).where(User.username == user_data.username)
|
||||
)
|
||||
existing_user = existing_user_result.scalar_one_or_none()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already exists"
|
||||
)
|
||||
|
||||
protected_admin_id = await get_default_admin_id(db)
|
||||
admin_count = await get_admin_count(db)
|
||||
|
||||
# 不允许修改默认管理员的管理员状态
|
||||
if protected_admin_id and user.id == protected_admin_id and user_data.is_admin is not None:
|
||||
@@ -224,6 +253,12 @@ async def update_user(
|
||||
detail="Cannot modify default admin user's admin status"
|
||||
)
|
||||
|
||||
if user.is_admin and user_data.is_admin is False and admin_count <= 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="At least one admin user must remain"
|
||||
)
|
||||
|
||||
# 更新字段
|
||||
update_data = user_data.dict(exclude_unset=True)
|
||||
if "password" in update_data:
|
||||
@@ -238,6 +273,29 @@ async def update_user(
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/reset-password")
|
||||
async def reset_user_password(
|
||||
user_id: int,
|
||||
payload: UserPasswordResetRequest,
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""重置用户密码(仅管理员)"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
user.hashed_password = pwd_context.hash(payload.new_password)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Password reset successfully"}
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
@@ -255,6 +313,7 @@ async def delete_user(
|
||||
)
|
||||
|
||||
protected_admin_id = await get_default_admin_id(db)
|
||||
admin_count = await get_admin_count(db)
|
||||
|
||||
# 不允许删除默认管理员
|
||||
if protected_admin_id and user.id == protected_admin_id:
|
||||
@@ -270,6 +329,12 @@ async def delete_user(
|
||||
detail="Cannot delete yourself"
|
||||
)
|
||||
|
||||
if user.is_admin and admin_count <= 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="At least one admin user must remain"
|
||||
)
|
||||
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Exam Router - Handles exam creation, file upload, and deduplication
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, BackgroundTasks, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy import select, func, and_, case
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
@@ -17,7 +17,7 @@ from database import get_db
|
||||
from models import User, Exam, Question, ExamStatus, SystemConfig
|
||||
from schemas import (
|
||||
ExamCreate, ExamResponse, ExamListResponse,
|
||||
ExamUploadResponse, ParseResult, QuizProgressUpdate
|
||||
ExamUploadResponse, ParseResult, QuizProgressUpdate, ExamSummaryResponse
|
||||
)
|
||||
from services.auth_service import get_current_user
|
||||
from services.document_parser import document_parser
|
||||
@@ -684,6 +684,57 @@ async def get_user_exams(
|
||||
return ExamListResponse(exams=exams, total=total)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=ExamSummaryResponse)
|
||||
async def get_exam_summary(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get aggregated exam statistics for current user."""
|
||||
|
||||
summary_query = select(
|
||||
func.count(Exam.id),
|
||||
func.coalesce(func.sum(Exam.total_questions), 0),
|
||||
func.coalesce(func.sum(Exam.current_index), 0),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case((Exam.status == ExamStatus.PROCESSING, 1), else_=0)
|
||||
),
|
||||
0
|
||||
),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case((Exam.status == ExamStatus.READY, 1), else_=0)
|
||||
),
|
||||
0
|
||||
),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case((Exam.status == ExamStatus.FAILED, 1), else_=0)
|
||||
),
|
||||
0
|
||||
)
|
||||
).where(Exam.user_id == current_user.id)
|
||||
|
||||
result = await db.execute(summary_query)
|
||||
(
|
||||
total_exams,
|
||||
total_questions,
|
||||
completed_questions,
|
||||
processing_exams,
|
||||
ready_exams,
|
||||
failed_exams
|
||||
) = result.one()
|
||||
|
||||
return ExamSummaryResponse(
|
||||
total_exams=total_exams or 0,
|
||||
total_questions=total_questions or 0,
|
||||
completed_questions=completed_questions or 0,
|
||||
processing_exams=processing_exams or 0,
|
||||
ready_exams=ready_exams or 0,
|
||||
failed_exams=failed_exams or 0
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{exam_id}", response_model=ExamResponse)
|
||||
async def get_exam_detail(
|
||||
exam_id: int,
|
||||
|
||||
@@ -38,6 +38,11 @@ class UserLogin(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
old_password: str = Field(..., min_length=1)
|
||||
new_password: str = Field(..., min_length=6)
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
@@ -53,14 +58,23 @@ class UserResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminUserSummary(UserResponse):
|
||||
exam_count: int = 0
|
||||
mistake_count: int = 0
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
"""用户列表响应(包含分页信息)"""
|
||||
users: List[dict] # 包含额外统计信息的用户列表
|
||||
users: List[AdminUserSummary]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
class UserPasswordResetRequest(BaseModel):
|
||||
new_password: str = Field(..., min_length=6)
|
||||
|
||||
|
||||
# ============ System Config Schemas ============
|
||||
class SystemConfigUpdate(BaseModel):
|
||||
allow_registration: Optional[bool] = None
|
||||
@@ -124,6 +138,15 @@ class ExamListResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class ExamSummaryResponse(BaseModel):
|
||||
total_exams: int
|
||||
total_questions: int
|
||||
completed_questions: int
|
||||
processing_exams: int
|
||||
ready_exams: int
|
||||
failed_exams: int
|
||||
|
||||
|
||||
class ExamUploadResponse(BaseModel):
|
||||
exam_id: int
|
||||
title: str
|
||||
@@ -140,19 +163,31 @@ class ParseResult(BaseModel):
|
||||
|
||||
|
||||
# ============ Question Schemas ============
|
||||
class QuestionBase(BaseModel):
|
||||
class QuestionPublicBase(BaseModel):
|
||||
content: str
|
||||
type: QuestionType
|
||||
options: Optional[List[str]] = None
|
||||
answer: str
|
||||
analysis: Optional[str] = None
|
||||
|
||||
|
||||
class QuestionBase(QuestionPublicBase):
|
||||
answer: str
|
||||
|
||||
|
||||
class QuestionCreate(QuestionBase):
|
||||
exam_id: int
|
||||
|
||||
|
||||
class QuestionResponse(QuestionBase):
|
||||
class QuestionResponse(QuestionPublicBase):
|
||||
id: int
|
||||
exam_id: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class QuestionWithAnswerResponse(QuestionBase):
|
||||
id: int
|
||||
exam_id: int
|
||||
created_at: datetime
|
||||
@@ -194,7 +229,7 @@ class MistakeResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
question_id: int
|
||||
question: QuestionResponse
|
||||
question: QuestionWithAnswerResponse
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -15,6 +15,25 @@ from utils import calculate_content_hash
|
||||
class LLMService:
|
||||
"""Service for interacting with various LLM providers"""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_openai_base_url(base_url: Optional[str], default: str) -> str:
|
||||
normalized = (base_url or default).rstrip("/")
|
||||
if normalized.endswith("/v1"):
|
||||
return normalized
|
||||
|
||||
if normalized.count("/") <= 2:
|
||||
return f"{normalized}/v1"
|
||||
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _openai_compat_headers() -> Dict[str, str]:
|
||||
"""
|
||||
Some OpenAI-compatible gateways block the default OpenAI SDK user agent.
|
||||
Use a neutral UA so requests behave like a generic HTTP client.
|
||||
"""
|
||||
return {"User-Agent": "QQuiz/1.0"}
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, str]] = None):
|
||||
"""
|
||||
Initialize LLM Service with optional configuration.
|
||||
@@ -28,7 +47,10 @@ class LLMService:
|
||||
|
||||
if self.provider == "openai":
|
||||
api_key = (config or {}).get("openai_api_key") or os.getenv("OPENAI_API_KEY")
|
||||
base_url = (config or {}).get("openai_base_url") or os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
||||
base_url = self._normalize_openai_base_url(
|
||||
(config or {}).get("openai_base_url") or os.getenv("OPENAI_BASE_URL"),
|
||||
"https://api.openai.com/v1"
|
||||
)
|
||||
self.model = (config or {}).get("openai_model") or os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
||||
|
||||
if not api_key:
|
||||
@@ -37,6 +59,7 @@ class LLMService:
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
default_headers=self._openai_compat_headers(),
|
||||
timeout=120.0, # 增加超时时间到 120 秒
|
||||
max_retries=3 # 自动重试 3 次
|
||||
)
|
||||
@@ -69,6 +92,7 @@ class LLMService:
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
default_headers=self._openai_compat_headers(),
|
||||
timeout=120.0, # 增加超时时间到 120 秒
|
||||
max_retries=3 # 自动重试 3 次
|
||||
)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# ==================== 单容器部署配置 ====================
|
||||
# 使用方法:docker-compose -f docker-compose-single.yml up -d
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
qquiz:
|
||||
build:
|
||||
@@ -16,6 +14,7 @@ services:
|
||||
environment:
|
||||
# 数据库配置(SQLite 默认,使用持久化卷)
|
||||
- DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db
|
||||
- UPLOAD_DIR=/app/uploads
|
||||
|
||||
volumes:
|
||||
# 持久化数据卷
|
||||
@@ -25,7 +24,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
test: ["CMD", "python", "-c", "import sys, urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5); sys.exit(0)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -33,4 +32,8 @@ services:
|
||||
|
||||
volumes:
|
||||
qquiz_data:
|
||||
# Reuse the previous split-stack SQLite volume during migration.
|
||||
name: qquiz_sqlite_data
|
||||
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:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: qquiz_mysql
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root_password
|
||||
MYSQL_DATABASE: qquiz_db
|
||||
MYSQL_USER: qquiz
|
||||
MYSQL_PASSWORD: qquiz_password
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
ports:
|
||||
- "3306:3306"
|
||||
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "qquiz", "-pqquiz_password"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: qquiz_backend
|
||||
environment:
|
||||
- DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@mysql:3306/qquiz_db
|
||||
- DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db
|
||||
- SECRET_KEY=${SECRET_KEY:?Set SECRET_KEY to a random string of at least 32 characters}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?Set ADMIN_PASSWORD to a strong password of at least 12 characters}
|
||||
- UPLOAD_DIR=/app/uploads
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- sqlite_data:/app/data
|
||||
- upload_files:/app/uploads
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import sys, urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5); sys.exit(0)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 40s
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
context: ./web
|
||||
dockerfile: Dockerfile
|
||||
container_name: qquiz_frontend
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- VITE_API_URL=/api
|
||||
- REACT_APP_API_URL=http://backend:8000
|
||||
- API_BASE_URL=http://backend:8000
|
||||
depends_on:
|
||||
- backend
|
||||
command: npm start
|
||||
backend:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/login').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
sqlite_data:
|
||||
upload_files:
|
||||
|
||||
@@ -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**
|
||||
- 下载地址:https://www.docker.com/products/docker-desktop/
|
||||
- 安装后启动 Docker Desktop
|
||||
这是当前最直接的 MySQL 用法,适合你已经克隆仓库并接受“应用容器 + MySQL 容器”的可选部署方式。
|
||||
|
||||
2. **运行启动脚本**
|
||||
```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
|
||||
|
||||
### 验证安装
|
||||
|
||||
打开命令提示符,运行:
|
||||
1. 复制环境变量模板:
|
||||
|
||||
```bash
|
||||
mysql --version
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
应该显示:`mysql Ver 8.0.x for Win64 on x86_64`
|
||||
Windows PowerShell:
|
||||
|
||||
### 配置 QQuiz 数据库
|
||||
|
||||
**方式 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
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
```
|
||||
|
||||
**Docker MySQL:**
|
||||
2. 把 `.env` 中的数据库连接改成 MySQL 容器地址:
|
||||
|
||||
```env
|
||||
DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@mysql:3306/qquiz_db
|
||||
```
|
||||
|
||||
### 连接参数说明
|
||||
3. 启动应用和 MySQL:
|
||||
|
||||
- `mysql+aiomysql://` - 使用 aiomysql 异步驱动
|
||||
- `qquiz` - 数据库用户名
|
||||
- `qquiz_password` - 数据库密码
|
||||
- `localhost` 或 `mysql` - 数据库主机地址
|
||||
- `3306` - MySQL 默认端口
|
||||
- `qquiz_db` - 数据库名称
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 端口 3306 被占用
|
||||
|
||||
**错误信息:**
|
||||
```
|
||||
Error: Port 3306 is already in use
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.mysql.yml up -d --build
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
- 检查是否已经有 MySQL 运行:`netstat -ano | findstr :3306`
|
||||
- 停止现有的 MySQL 服务
|
||||
- 或修改 `.env` 中的端口号
|
||||
4. 访问:
|
||||
|
||||
### 2. 无法连接到 MySQL
|
||||
- 前端:`http://localhost:3000`
|
||||
- 后端:`http://localhost:8000`
|
||||
|
||||
**错误信息:**
|
||||
```
|
||||
Can't connect to MySQL server on 'localhost'
|
||||
```
|
||||
说明:
|
||||
|
||||
**解决方案:**
|
||||
- 这条路径是 MySQL 兼容部署,不是默认发布路径
|
||||
- 默认发布镜像仍然是根目录单容器镜像
|
||||
|
||||
1. **检查 MySQL 服务是否运行**
|
||||
- 按 Win+R,输入 `services.msc`
|
||||
- 查找 "MySQL80" 服务
|
||||
- 确认状态为 "正在运行"
|
||||
## 场景二:单容器应用连接外部 MySQL
|
||||
|
||||
2. **启动 MySQL 服务**
|
||||
```bash
|
||||
net start MySQL80
|
||||
```
|
||||
如果你想继续使用单容器应用镜像,但数据库由外部 MySQL 托管,可以直接让应用容器连接现有数据库。
|
||||
|
||||
3. **检查防火墙设置**
|
||||
- 确保防火墙允许 MySQL 端口 3306
|
||||
### 1. 准备 MySQL 8.0 数据库
|
||||
|
||||
### 3. 密码验证失败
|
||||
执行以下 SQL 创建数据库和账号:
|
||||
|
||||
**错误信息:**
|
||||
```
|
||||
Access denied for user 'qquiz'@'localhost'
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
|
||||
重新创建用户并设置密码:
|
||||
```sql
|
||||
mysql -u root -p
|
||||
DROP USER IF EXISTS 'qquiz'@'localhost';
|
||||
CREATE USER 'qquiz'@'localhost' IDENTIFIED BY 'qquiz_password';
|
||||
GRANT ALL PRIVILEGES ON qquiz_db.* TO 'qquiz'@'localhost';
|
||||
CREATE DATABASE qquiz_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'qquiz'@'%' IDENTIFIED BY 'qquiz_password';
|
||||
GRANT ALL PRIVILEGES ON qquiz_db.* TO 'qquiz'@'%';
|
||||
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
|
||||
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 配置
|
||||
│ └── Dockerfile # 后端 Docker 镜像
|
||||
│
|
||||
├── frontend/ # React 前端
|
||||
├── web/ # Next.js 前端
|
||||
│ ├── src/
|
||||
│ │ ├── api/
|
||||
│ │ │ └── client.js # API 客户端(Axios)⭐
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── Layout.jsx # 主布局(导航栏)
|
||||
│ │ │ └── ProtectedRoute.jsx # 路由保护
|
||||
│ │ ├── context/
|
||||
│ │ │ └── AuthContext.jsx # 认证上下文
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── Login.jsx # 登录页
|
||||
│ │ │ ├── Register.jsx # 注册页
|
||||
│ │ │ ├── Dashboard.jsx # 仪表盘
|
||||
│ │ │ ├── ExamList.jsx # 题库列表 ⭐
|
||||
│ │ │ ├── ExamDetail.jsx # 题库详情(追加上传)⭐
|
||||
│ │ │ ├── QuizPlayer.jsx # 刷题核心页面 ⭐
|
||||
│ │ │ ├── MistakeList.jsx # 错题本
|
||||
│ │ │ └── AdminSettings.jsx # 系统设置
|
||||
│ │ ├── utils/
|
||||
│ │ │ └── helpers.js # 工具函数
|
||||
│ │ ├── App.jsx # 应用根组件
|
||||
│ │ ├── index.jsx # 应用入口
|
||||
│ │ └── index.css # 全局样式
|
||||
│ ├── public/
|
||||
│ │ └── index.html # HTML 模板
|
||||
│ │ ├── app/ # App Router 页面、布局、Route Handlers
|
||||
│ │ ├── components/ # 共享 UI 组件
|
||||
│ │ ├── lib/ # API、认证、格式化等公共逻辑
|
||||
│ │ └── middleware.ts # 登录态守卫
|
||||
│ ├── package.json # Node 依赖
|
||||
│ ├── vite.config.js # Vite 配置
|
||||
│ ├── tailwind.config.js # Tailwind CSS 配置
|
||||
│ ├── postcss.config.js # PostCSS 配置
|
||||
│ └── Dockerfile # 前端 Docker 镜像
|
||||
│ ├── next.config.mjs # Next.js 配置
|
||||
│ ├── tailwind.config.ts # Tailwind CSS 配置
|
||||
│ ├── postcss.config.mjs # PostCSS 配置
|
||||
│ └── Dockerfile # 分离部署前端镜像
|
||||
│
|
||||
├── docker-compose.yml # Docker 编排配置 ⭐
|
||||
├── .env.example # 环境变量模板
|
||||
@@ -133,74 +114,31 @@ for q in questions_data:
|
||||
|
||||
### 前端核心
|
||||
|
||||
#### `client.js` - API 客户端
|
||||
封装了所有后端 API:
|
||||
- `authAPI`: 登录、注册、用户信息
|
||||
- `examAPI`: 题库 CRUD、追加文档
|
||||
- `questionAPI`: 获取题目、答题
|
||||
- `mistakeAPI`: 错题本管理
|
||||
- `adminAPI`: 系统配置
|
||||
#### `src/lib/api/server.ts` - 服务端 API 访问
|
||||
用于 Next Server Components 访问后端:
|
||||
- 从 `HttpOnly` Cookie 读取会话令牌
|
||||
- 直接请求 FastAPI `/api/*`
|
||||
- 401 时自动重定向回登录页
|
||||
|
||||
**特性:**
|
||||
- 自动添加 JWT Token
|
||||
- 统一错误处理和 Toast 提示
|
||||
- 401 自动跳转登录
|
||||
#### `src/lib/api/browser.ts` - 浏览器端 API 访问
|
||||
用于客户端交互:
|
||||
- 请求同源 `/frontend-api/proxy/*`
|
||||
- 统一处理错误信息
|
||||
- 默认禁用缓存,保持刷题和后台状态最新
|
||||
|
||||
#### `ExamDetail.jsx` - 题库详情
|
||||
最复杂的前端页面,包含:
|
||||
- **追加上传**: 上传新文档并去重
|
||||
- **状态轮询**: 每 3 秒轮询一次状态
|
||||
- **智能按钮**:
|
||||
- 处理中时禁用「添加文档」
|
||||
- 就绪后显示「开始/继续刷题」
|
||||
- **进度展示**: 题目数、完成度、进度条
|
||||
#### `src/components/exams/exam-detail-client.tsx` - 题库详情
|
||||
负责:
|
||||
- 追加上传文档
|
||||
- 展示解析进度
|
||||
- 通过 `/frontend-api/exams/{examId}/progress` 订阅同源 SSE
|
||||
- 处理解析完成/失败后的页面刷新
|
||||
|
||||
**状态轮询实现:**
|
||||
```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()
|
||||
}
|
||||
```
|
||||
#### `src/components/practice/quiz-player-client.tsx` - 刷题核心
|
||||
负责:
|
||||
- 加载当前题目
|
||||
- 提交答案并展示结果
|
||||
- 推进刷题进度
|
||||
- 管理简答题与错题练习等交互
|
||||
|
||||
---
|
||||
|
||||
@@ -323,17 +261,17 @@ CREATE UNIQUE INDEX ix_user_mistakes_unique ON user_mistakes(user_id, question_i
|
||||
- **OpenAI/Anthropic/Qwen**: AI 解析和评分
|
||||
|
||||
### 前端
|
||||
- **Next.js 14 App Router**: 前端运行时
|
||||
- **React 18**: UI 框架
|
||||
- **Vite**: 构建工具(比 CRA 更快)
|
||||
- **TypeScript**: 类型系统
|
||||
- **Tailwind CSS**: 原子化 CSS
|
||||
- **Axios**: HTTP 客户端
|
||||
- **React Router**: 路由管理
|
||||
- **React Hot Toast**: 消息提示
|
||||
- **TanStack Query**: 客户端缓存和数据同步
|
||||
- **Route Handlers**: 同源认证与代理层
|
||||
|
||||
### 部署
|
||||
- **Docker + Docker Compose**: 容器化部署
|
||||
- **PostgreSQL 15**: 关系型数据库
|
||||
- **Nginx** (可选): 反向代理
|
||||
- **SQLite / MySQL**: 关系型数据库
|
||||
- **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,22 +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>
|
||||
<!-- ESA 人机认证配置 -->
|
||||
<script>
|
||||
window.AliyunCaptchaConfig = {
|
||||
region: "cn",
|
||||
prefix: "%VITE_ESA_PREFIX%",
|
||||
};
|
||||
</script>
|
||||
<script src="https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js" async></script>
|
||||
</head>
|
||||
<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,101 +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'
|
||||
import Layout from './components/Layout'
|
||||
|
||||
// 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'
|
||||
import MistakePlayer from './pages/MistakePlayer'
|
||||
import QuestionBank from './pages/QuestionBank'
|
||||
|
||||
// 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 with Layout */}
|
||||
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/exams" element={<ExamList />} />
|
||||
<Route path="/exams/:examId" element={<ExamDetail />} />
|
||||
<Route path="/quiz/:examId" element={<QuizPlayer />} />
|
||||
<Route path="/mistakes" element={<MistakeList />} />
|
||||
<Route path="/mistake-quiz" element={<MistakePlayer />} />
|
||||
<Route path="/questions" element={<QuestionBank />} />
|
||||
|
||||
{/* Admin Only Routes */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute adminOnly>
|
||||
<AdminPanel />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/settings"
|
||||
element={
|
||||
<ProtectedRoute adminOnly>
|
||||
<AdminSettings />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* 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,188 +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, isRandom = false) => {
|
||||
const formData = new FormData()
|
||||
formData.append('title', title)
|
||||
formData.append('file', file)
|
||||
formData.append('is_random', isRandom)
|
||||
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 (Question Bank)
|
||||
getAll: (skip = 0, limit = 50, examId = null) => {
|
||||
const params = { skip, limit }
|
||||
if (examId) params.exam_id = examId
|
||||
return api.get('/questions/', { params })
|
||||
},
|
||||
|
||||
// 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, Outlet } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import {
|
||||
BookOpen,
|
||||
LayoutDashboard,
|
||||
FolderOpen,
|
||||
XCircle,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Shield
|
||||
} from 'lucide-react'
|
||||
|
||||
export const Layout = () => {
|
||||
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', icon: Shield })
|
||||
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">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
@@ -1,87 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react'
|
||||
|
||||
const Pagination = ({
|
||||
currentPage,
|
||||
totalItems,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
pageSizeOptions = [10, 20, 50, 100]
|
||||
}) => {
|
||||
const totalPages = Math.ceil(totalItems / pageSize)
|
||||
const [inputPage, setInputPage] = useState(currentPage)
|
||||
|
||||
useEffect(() => {
|
||||
setInputPage(currentPage)
|
||||
}, [currentPage])
|
||||
|
||||
const handlePageSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
let page = parseInt(inputPage)
|
||||
if (isNaN(page)) page = 1
|
||||
if (page < 1) page = 1
|
||||
if (page > totalPages) page = totalPages
|
||||
onPageChange(page)
|
||||
setInputPage(page)
|
||||
}
|
||||
|
||||
if (totalItems === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 py-4 border-t border-gray-100 mt-4">
|
||||
{/* Info */}
|
||||
<div className="text-sm text-gray-500">
|
||||
显示 {Math.min((currentPage - 1) * pageSize + 1, totalItems)} - {Math.min(currentPage * pageSize, totalItems)} 共 {totalItems} 条
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
{/* Page Size Selector */}
|
||||
<div className="relative group">
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
className="appearance-none bg-white border border-gray-300 text-gray-700 py-2 pl-3 pr-8 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent cursor-pointer hover:border-gray-400 transition-colors"
|
||||
>
|
||||
{pageSizeOptions.map(size => (
|
||||
<option key={size} value={size}>{size} 条/页</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Manual Input */}
|
||||
<form onSubmit={handlePageSubmit} className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={inputPage}
|
||||
onChange={(e) => setInputPage(e.target.value)}
|
||||
className="w-12 text-center py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent mx-1"
|
||||
/>
|
||||
<span className="text-gray-500 text-sm mx-1">/ {totalPages}</span>
|
||||
</form>
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Pagination
|
||||
@@ -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,356 +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="p-4 md:p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">管理员面板</h1>
|
||||
<p className="text-gray-600 mt-1">系统统计与用户管理</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-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" />
|
||||
<span className="hidden md:inline">创建用户</span>
|
||||
<span className="md:hidden">新建</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white rounded-xl shadow overflow-hidden overflow-x-auto">
|
||||
<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,562 +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="p-4 md:p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">系统设置</h1>
|
||||
<p className="text-gray-600 mt-1">配置系统参数与 AI 接口</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="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,187 +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 {
|
||||
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 (
|
||||
<>
|
||||
<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 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => navigate('/questions')}
|
||||
>
|
||||
<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>
|
||||
<span className="text-xs text-gray-500 mt-1 block text-right">
|
||||
{calculateProgress(exam.current_index, exam.total_questions)}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
@@ -1,376 +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 ParsingProgress from '../components/ParsingProgress'
|
||||
import {
|
||||
ArrowLeft, Upload, Play, Loader, FileText, AlertCircle, RefreshCw, ArrowRight
|
||||
} 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 [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 = await examAPI.getDetail(examId)
|
||||
setExam(examRes.data)
|
||||
|
||||
// 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 (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<Loader className="h-8 w-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!exam) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<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">{isProcessing ? progress : quizProgress}%</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>
|
||||
)}
|
||||
|
||||
{/* View All Questions Link */}
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-sm p-6 cursor-pointer hover:shadow-md transition-shadow flex items-center justify-between group"
|
||||
onClick={() => navigate(`/questions?examId=${examId}`)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-blue-100 p-3 rounded-full text-blue-600 group-hover:bg-blue-600 group-hover:text-white transition-colors">
|
||||
<FileText className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900">查看题库所有题目</h2>
|
||||
<p className="text-gray-600">浏览、搜索和查看该题库中的所有题目详情</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-100 p-2 rounded-full text-gray-400 group-hover:bg-blue-100 group-hover:text-blue-600 transition-colors">
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExamDetail
|
||||
@@ -1,342 +0,0 @@
|
||||
/**
|
||||
* Exam List Page
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { examAPI } from '../api/client'
|
||||
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,
|
||||
isRandom: false
|
||||
})
|
||||
|
||||
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, formData.isRandom)
|
||||
toast.success('题库创建成功,正在解析文档...')
|
||||
setShowCreateModal(false)
|
||||
setFormData({ title: '', file: null, isRandom: false })
|
||||
|
||||
// 跳转到新创建的试卷详情页
|
||||
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 (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<Loader className="h-8 w-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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-4 py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors flex items-center gap-2 justify-center text-sm md:text-base"
|
||||
>
|
||||
<Plus className="h-4 w-4 md:h-5 md:w-5" />
|
||||
<span className="hidden md:inline">创建题库</span>
|
||||
<span className="md:hidden">新建</span>
|
||||
</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>
|
||||
|
||||
{/* Order Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
题目顺序
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
checked={!formData.isRandom}
|
||||
onChange={() => setFormData({ ...formData, isRandom: false })}
|
||||
className="text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">顺序(按文档原序)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
checked={formData.isRandom}
|
||||
onChange={() => setFormData({ ...formData, isRandom: true })}
|
||||
className="text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">乱序(随机打乱)</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
注意:创建后题目顺序将固定,无法再次更改。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false)
|
||||
setShowCreateModal(false)
|
||||
setFormData({ title: '', file: null, isRandom: false })
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExamList
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* Login Page
|
||||
*/
|
||||
import React, { useState, useEffect } 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 [captchaInstance, setCaptchaInstance] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 确保 window.initAliyunCaptcha 存在且 DOM 元素已渲染
|
||||
const initCaptcha = () => {
|
||||
if (window.initAliyunCaptcha && document.getElementById('captcha-element')) {
|
||||
try {
|
||||
window.initAliyunCaptcha({
|
||||
SceneId: import.meta.env.VITE_ESA_SCENE_ID, // 从环境变量读取场景ID
|
||||
mode: "popup", // 弹出式
|
||||
element: "#captcha-element", // 渲染验证码的元素
|
||||
button: "#login-btn", // 触发验证码的按钮ID
|
||||
success: async function (captchaVerifyParam) {
|
||||
// 验证成功后的回调
|
||||
// 这里我们获取到了验证参数,虽然文档说要发给后端,
|
||||
// 但 ESA 边缘拦截其实是在请求发出时检查 Cookie/Header
|
||||
// 对于“一点即过”或“滑块”,SDK 会自动处理验证逻辑
|
||||
// 这里的 verifiedParam 是用来回传给服务端做二次校验的
|
||||
// 由于我们此时还没有登录逻辑,我们可以在这里直接提交表单
|
||||
// 即把 verifyParam 存下来,或者直接调用 login
|
||||
|
||||
// 注意:由于是 form 的 onSubmit 触发,这里我们其实是在 form 提交被阻止(preventDefault)后
|
||||
// 由用户点击按钮触发了验证码,验证码成功后再执行真正的登录
|
||||
// 但 React 的 form 处理通常是 onSubmit
|
||||
// 我们可以让按钮类型为 button 而不是 submit,点击触发验证码
|
||||
// 验证码成功后手动调用 handleSubmit 的逻辑
|
||||
|
||||
console.log('Captcha Success:', captchaVerifyParam);
|
||||
handleLoginSubmit(captchaVerifyParam);
|
||||
},
|
||||
fail: function (result) {
|
||||
console.error('Captcha Failed:', result);
|
||||
},
|
||||
getInstance: function (instance) {
|
||||
setCaptchaInstance(instance);
|
||||
},
|
||||
slideStyle: {
|
||||
width: 360,
|
||||
height: 40,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Captcha init error:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 如果脚本还没加载完,可能需要等待。为了简单起见,且我们在 index.html 加了 async
|
||||
// 我们做一个简单的轮询或者依赖 script onload(但在 index.html 比较难控制)
|
||||
// 或者直接延迟一下初始化
|
||||
const timer = setTimeout(initCaptcha, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleLoginSubmit = async (captchaParam) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 这里的 login 可能需要改造以接受验证码参数,或者利用 fetch 的拦截器
|
||||
// 如果是 ESA 边缘拦截,通常它会看请求里带不带特定的 Header/Cookie
|
||||
// 文档示例里是手动 fetch 并且带上了 header: 'captcha-Verify-param'
|
||||
// 暂时我们假设 login 函数内部不需要显式传参(通过 ESA 自动拦截),或者 ESA 需要 headers
|
||||
// 为了安全,建议把 captchaParam 传给 login,让 login 放到 headers 里
|
||||
// 但现在我们先维持原样,或者您可以把 captchaParam 放到 sessionStorage 里由 axios 拦截器读取
|
||||
|
||||
// 注意:上面的 success 回调里我们直接调用了这个,说明验证通过了
|
||||
const success = await login(formData.username, formData.password)
|
||||
if (success) {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if(captchaInstance) captchaInstance.refresh(); // 失败或完成后刷新验证码
|
||||
}
|
||||
}
|
||||
|
||||
// 这里的 handleSubmit 变成只是触发验证码(如果也没通过验证的话)
|
||||
// 但 ESA 示例是绑定 button,点击 button 直接出验证码
|
||||
// 所以我们可以把 type="submit" 变成 type="button" 且 id="login-btn"
|
||||
const handlePreSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
// 此时不需要做任何事,因为按钮被 ESA 接管了,点击会自动弹窗
|
||||
// 只有验证成功了才会走 success -> handleLoginSubmit
|
||||
// 但是!如果没填用户名密码怎么办?
|
||||
// 最好在点击前校验表单。
|
||||
// ESA 的 button 参数会劫持点击事件。
|
||||
// 我们可以不绑定 button 参数,而是手动验证表单后,调用 captchaInstance.show() (如果是无痕或弹窗)
|
||||
// 官方文档说绑定 button 是“触发验证码弹窗或无痕验证的元素”
|
||||
// 如果我们保留 form submit,拦截它,如果表单有效,则手动 captchaInstance.show() (如果 SDK 支持)
|
||||
// 文档说“无痕模式首次验证不支持 show/hide”。
|
||||
// 咱们还是按官方推荐绑定 button,但是这会导致校验逻辑变复杂
|
||||
|
||||
// 简化方案:为了不破坏现有逻辑,我们不绑定 button ?
|
||||
// 不,必须绑定。那我们把“登录”按钮作为触发器。
|
||||
// 可是如果不填表单直接点登录 -> 验证码 -> 成功 -> 提交空表单 -> 报错。流程不太对。
|
||||
|
||||
// 更好的流程:
|
||||
// 用户填表 -> 点击登录 -> 校验表单 -> (有效) -> 弹出验证码 -> (成功) -> 提交后端
|
||||
|
||||
// 我们可以做一个不可见的 button 绑定给 ESA,验证表单通过后,用代码模拟点击这个 button?
|
||||
// 或者直接用 id="login-btn" 绑定当前的登录按钮,
|
||||
// 但是在 success 回调里检查 formData 是否为空?
|
||||
}
|
||||
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[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>
|
||||
|
||||
{/* 为了能正确使用 ESA,我们将 form 的 onSubmit 移除,改由按钮触发,或者保留 form 但不做提交 */}
|
||||
<form className="space-y-6" onSubmit={(e) => e.preventDefault()}>
|
||||
{/* 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>
|
||||
|
||||
{/* ESA Captcha Container */}
|
||||
<div id="captcha-element"></div>
|
||||
|
||||
{/* Submit Button */}
|
||||
{/* 绑定 id="login-btn" 供 ESA 使用 */}
|
||||
<button
|
||||
type="button"
|
||||
id="login-btn"
|
||||
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,242 +0,0 @@
|
||||
/**
|
||||
* Mistake List Page (错题本)
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { mistakeAPI } from '../api/client'
|
||||
import Pagination from '../components/Pagination'
|
||||
import { XCircle, Loader, Trash2, BookOpen, Play, ChevronRight } 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)
|
||||
const [showModeModal, setShowModeModal] = useState(false)
|
||||
|
||||
// Pagination
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit, setLimit] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
loadMistakes()
|
||||
}, [page, limit])
|
||||
|
||||
const loadMistakes = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const skip = (page - 1) * limit
|
||||
const response = await mistakeAPI.getList(skip, limit)
|
||||
setMistakes(response.data.mistakes)
|
||||
setTotal(response.data.total)
|
||||
} 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 (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<Loader className="h-8 w-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-4 md:p-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">错题本</h1>
|
||||
<p className="text-gray-600 mt-1">共 {total} 道错题</p>
|
||||
</div>
|
||||
|
||||
{mistakes.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowModeModal(true)}
|
||||
className="bg-primary-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-primary-700 transition-colors flex items-center justify-center gap-2 text-sm md:text-base"
|
||||
>
|
||||
<Play className="h-4 w-4 md:h-5 md:w-5" />
|
||||
<span className="hidden md:inline">开始刷错题</span>
|
||||
<span className="md:hidden">刷题</span>
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalItems={total}
|
||||
pageSize={limit}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(newLimit) => {
|
||||
setLimit(newLimit)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mode Selection Modal */}
|
||||
{showModeModal && (
|
||||
<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-sm w-full p-6">
|
||||
<h2 className="text-xl font-bold mb-4 text-center">选择刷题模式</h2>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => navigate('/mistake-quiz?mode=sequential')}
|
||||
className="w-full p-4 border-2 border-primary-100 bg-primary-50 rounded-xl hover:bg-primary-100 transition-colors flex items-center justify-between group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<p className="font-bold text-primary-900">顺序刷题</p>
|
||||
<p className="text-sm text-primary-700">按照加入错题本的时间顺序</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-primary-400 group-hover:text-primary-600" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/mistake-quiz?mode=random')}
|
||||
className="w-full p-4 border-2 border-purple-100 bg-purple-50 rounded-xl hover:bg-purple-100 transition-colors flex items-center justify-between group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<p className="font-bold text-purple-900">随机刷题</p>
|
||||
<p className="text-sm text-purple-700">打乱顺序进行练习</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-purple-400 group-hover:text-purple-600" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModeModal(false)}
|
||||
className="mt-4 w-full py-2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MistakeList
|
||||
@@ -1,412 +0,0 @@
|
||||
/**
|
||||
* Mistake Player Page - Re-do wrong questions
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { mistakeAPI, questionAPI } from '../api/client'
|
||||
import {
|
||||
ArrowLeft, ArrowRight, Check, X, Loader, Trash2, AlertCircle
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { getQuestionTypeText } from '../utils/helpers'
|
||||
|
||||
export const MistakePlayer = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const searchParams = new URLSearchParams(location.search)
|
||||
const mode = searchParams.get('mode') || 'sequential'
|
||||
|
||||
console.log('MistakePlayer mounted, mode:', mode)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [mistake, setMistake] = useState(null)
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [randomMistakes, setRandomMistakes] = useState([]) // Store full mistake objects
|
||||
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [userAnswer, setUserAnswer] = useState('')
|
||||
const [multipleAnswers, setMultipleAnswers] = useState([])
|
||||
const [result, setResult] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadMistake()
|
||||
}, [currentIndex, mode])
|
||||
|
||||
const loadMistake = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
let currentMistake = null
|
||||
|
||||
if (mode === 'random') {
|
||||
// Random Mode Logic
|
||||
if (randomMistakes.length === 0) {
|
||||
// First load: fetch all mistakes
|
||||
const response = await mistakeAPI.getList(0, 1000)
|
||||
const allMistakes = response.data.mistakes
|
||||
setTotal(response.data.total)
|
||||
|
||||
if (allMistakes.length > 0) {
|
||||
// Shuffle mistakes
|
||||
const shuffled = [...allMistakes]
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
setRandomMistakes(shuffled)
|
||||
currentMistake = shuffled[0]
|
||||
}
|
||||
} else {
|
||||
// Subsequent loads: use stored mistakes
|
||||
if (currentIndex < randomMistakes.length) {
|
||||
currentMistake = randomMistakes[currentIndex]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Sequential Mode Logic
|
||||
const response = await mistakeAPI.getList(currentIndex, 1)
|
||||
setTotal(response.data.total)
|
||||
if (response.data.mistakes.length > 0) {
|
||||
currentMistake = response.data.mistakes[0]
|
||||
}
|
||||
}
|
||||
|
||||
if (currentMistake) {
|
||||
// Ensure options exist for judge type
|
||||
if (currentMistake.question.type === 'judge' && (!currentMistake.question.options || currentMistake.question.options.length === 0)) {
|
||||
currentMistake.question.options = ['A. 正确', 'B. 错误']
|
||||
}
|
||||
setMistake(currentMistake)
|
||||
console.log('Mistake loaded:', currentMistake)
|
||||
setResult(null)
|
||||
setUserAnswer('')
|
||||
setMultipleAnswers([])
|
||||
} else {
|
||||
setMistake(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load mistake:', error)
|
||||
toast.error('加载错题失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
console.log('Loading finished')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitAnswer = async () => {
|
||||
let answer = userAnswer
|
||||
|
||||
if (mistake.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(mistake.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 = () => {
|
||||
if (currentIndex < total - 1) {
|
||||
setCurrentIndex(prev => prev + 1)
|
||||
} else {
|
||||
toast.success('已完成所有错题!')
|
||||
navigate('/mistakes')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!window.confirm('确定要从错题本中移除这道题吗?')) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await mistakeAPI.remove(mistake.id)
|
||||
toast.success('已移除')
|
||||
// Reload current index (which will now be the next item or empty)
|
||||
// If we remove the last item, we need to go back one step or show empty
|
||||
if (mode === 'random') {
|
||||
// Remove from random list
|
||||
const newRandomList = randomMistakes.filter(m => m.id !== mistake.id)
|
||||
setRandomMistakes(newRandomList)
|
||||
setTotal(newRandomList.length)
|
||||
|
||||
if (currentIndex >= newRandomList.length && newRandomList.length > 0) {
|
||||
setCurrentIndex(prev => prev - 1)
|
||||
} else if (newRandomList.length === 0) {
|
||||
setMistake(null)
|
||||
} else {
|
||||
// Force reload with new list
|
||||
const nextMistake = newRandomList[currentIndex]
|
||||
if (nextMistake.question.type === 'judge' && (!nextMistake.question.options || nextMistake.question.options.length === 0)) {
|
||||
nextMistake.question.options = ['A. 正确', 'B. 错误']
|
||||
}
|
||||
setMistake(nextMistake)
|
||||
setResult(null)
|
||||
setUserAnswer('')
|
||||
setMultipleAnswers([])
|
||||
}
|
||||
} else {
|
||||
if (currentIndex >= total - 1 && total > 1) {
|
||||
setCurrentIndex(prev => prev - 1)
|
||||
} else {
|
||||
loadMistake()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to remove mistake:', error)
|
||||
toast.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 && !mistake) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<Loader className="h-8 w-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!mistake) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh]">
|
||||
<AlertCircle className="h-16 w-16 text-gray-300 mb-4" />
|
||||
<p className="text-gray-600">错题本为空</p>
|
||||
<button
|
||||
onClick={() => navigate('/mistakes')}
|
||||
className="mt-4 text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
返回错题列表
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const question = mistake.question
|
||||
|
||||
if (!question) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh]">
|
||||
<AlertCircle className="h-16 w-16 text-red-300 mb-4" />
|
||||
<p className="text-gray-600">题目数据缺失</p>
|
||||
<button
|
||||
onClick={() => navigate('/mistakes')}
|
||||
className="mt-4 text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
返回错题列表
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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('/mistakes')}
|
||||
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">
|
||||
进度: {currentIndex + 1} / {total}
|
||||
</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-red-100 text-red-600 rounded-full flex items-center justify-center font-bold">
|
||||
{currentIndex + 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={handleRemove}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
||||
>
|
||||
<Trash2 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 */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
{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"
|
||||
>
|
||||
{currentIndex < total - 1 ? '下一题' : '完成复习'}
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MistakePlayer
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* Question Bank Page - View all questions
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { questionAPI } from '../api/client'
|
||||
import Pagination from '../components/Pagination'
|
||||
import { FileText, Loader, Search } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { getQuestionTypeText, formatRelativeTime } from '../utils/helpers'
|
||||
|
||||
export const QuestionBank = () => {
|
||||
const [questions, setQuestions] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedId, setExpandedId] = useState(null)
|
||||
|
||||
// Pagination
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit, setLimit] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
loadQuestions()
|
||||
}, [page, limit])
|
||||
|
||||
const loadQuestions = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const skip = (page - 1) * limit
|
||||
const response = await questionAPI.getAll(skip, limit)
|
||||
setQuestions(response.data.questions)
|
||||
setTotal(response.data.total)
|
||||
} catch (error) {
|
||||
console.error('Failed to load questions:', error)
|
||||
toast.error('加载题库失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
setExpandedId(expandedId === id ? null : id)
|
||||
}
|
||||
|
||||
if (loading && questions.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<Loader className="h-8 w-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">共 {total} 道题目</p>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="space-y-4">
|
||||
{questions.map((q) => {
|
||||
const isExpanded = expandedId === q.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={q.id}
|
||||
className="bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div
|
||||
className="p-4 md:p-6 cursor-pointer"
|
||||
onClick={() => toggleExpand(q.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-10 h-10 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center">
|
||||
<FileText 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">
|
||||
ID: {q.id}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatRelativeTime(q.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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalItems={total}
|
||||
pageSize={limit}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(newLimit) => {
|
||||
setLimit(newLimit)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuestionBank
|
||||
@@ -1,369 +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 {
|
||||
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)
|
||||
// For judge questions, ensure options exist
|
||||
if (response.data.type === 'judge' && (!response.data.options || response.data.options.length === 0)) {
|
||||
response.data.options = ['A. 正确', 'B. 错误']
|
||||
}
|
||||
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 (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<Loader className="h-8 w-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!question) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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,35 +0,0 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Assume running from frontend directory
|
||||
const envDir = path.resolve(process.cwd(), '..')
|
||||
const env = loadEnv(mode, envDir, '')
|
||||
|
||||
return {
|
||||
envDir, // Tell Vite to look for .env files in the project root
|
||||
plugins: [
|
||||
react(),
|
||||
{
|
||||
name: 'html-transform',
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(/%VITE_ESA_PREFIX%/g, env.VITE_ESA_PREFIX || '')
|
||||
},
|
||||
}
|
||||
],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: env.VITE_API_URL || 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 {
|
||||
plugins: {
|
||||
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