mirror of
https://github.com/handsomezhuzhu/QQuiz.git
synced 2026-04-18 14:32:54 +00:00
完善文档与前端迁移,补充开源协议
This commit is contained in:
@@ -52,7 +52,3 @@ CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
|||||||
|
|
||||||
# Upload Directory
|
# Upload Directory
|
||||||
UPLOAD_DIR=./uploads
|
UPLOAD_DIR=./uploads
|
||||||
|
|
||||||
# ESA Human Verification
|
|
||||||
VITE_ESA_PREFIX=
|
|
||||||
VITE_ESA_SCENE_ID=
|
|
||||||
|
|||||||
22
.github/workflows/docker-publish.yml
vendored
22
.github/workflows/docker-publish.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Publish Docker Image
|
name: Build and Publish Docker Images
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -18,6 +18,15 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- image_suffix: backend
|
||||||
|
context: ./backend
|
||||||
|
file: ./backend/Dockerfile
|
||||||
|
- image_suffix: frontend
|
||||||
|
context: ./web
|
||||||
|
file: ./web/Dockerfile
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -37,7 +46,7 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-${{ matrix.image_suffix }}
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=branch
|
type=ref,event=branch
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
@@ -45,16 +54,17 @@ jobs:
|
|||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
|
id: build
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: ${{ matrix.context }}
|
||||||
file: ./Dockerfile
|
file: ${{ matrix.file }}
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max,scope=${{ matrix.image_suffix }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
- name: Image digest
|
- name: Image digest
|
||||||
run: echo "Image pushed with digest ${{ steps.build-and-push.outputs.digest }}"
|
run: echo "Image pushed with digest ${{ steps.build.outputs.digest }}"
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -46,6 +46,9 @@ yarn-error.log
|
|||||||
# Build
|
# Build
|
||||||
frontend/build/
|
frontend/build/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
.next/
|
||||||
|
web/.next/
|
||||||
|
web/out/
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
.coverage
|
.coverage
|
||||||
|
|||||||
34
AGENTS.md
Normal file
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/`.
|
||||||
|
- `frontend/`: React 18 + Vite client. Put route screens in `src/pages/`, shared UI in `src/components/`, auth state in `src/context/`, API wrappers in `src/api/`, and helpers in `src/utils/`.
|
||||||
|
- `docs/` holds deployment and architecture notes, `scripts/run_local.sh` bootstraps local Linux/macOS development, `test_data/` contains sample question files, and `.github/workflows/docker-publish.yml` publishes container images.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
- `docker compose up -d --build`: start MySQL, backend on `:8000`, and frontend on `:3000`.
|
||||||
|
- `docker compose -f docker-compose-single.yml up -d --build`: start the single-container SQLite deployment.
|
||||||
|
- `cd backend && pip install -r requirements.txt && alembic upgrade head && uvicorn main:app --reload --host 0.0.0.0 --port 8000`: run the API locally.
|
||||||
|
- `cd frontend && npm install && npm run dev`: start the Vite dev server.
|
||||||
|
- `cd frontend && npm run build`: create a production frontend bundle.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
- Python uses 4-space indentation, `snake_case` for modules/functions, and `PascalCase` for ORM or Pydantic classes.
|
||||||
|
- React files use `PascalCase.jsx` for pages/components and `camelCase` for state, helpers, and API wrappers.
|
||||||
|
- Keep route handlers thin: validation in schemas, orchestration in routers, reusable logic in `backend/services/`.
|
||||||
|
- No formatter or lint script is enforced today, so match surrounding style before making broad formatting changes.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
- The repository currently has no committed automated test suite or coverage gate.
|
||||||
|
- Before opening a PR, smoke-test auth, exam creation/upload, parsing progress, quiz playback, mistake review, and admin settings.
|
||||||
|
- Use `test_data/sample_questions*.txt` for parser and import checks.
|
||||||
|
- If you add tests, place backend tests under `backend/tests/test_*.py` and frontend tests under `frontend/src/__tests__/`.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
- Recent history favors short, focused subjects, often imperative and sometimes Chinese, such as `安全修复和管理员账号密码自定义`.
|
||||||
|
- Keep each commit scoped to one change. PRs should include a summary, affected areas, config or migration notes, linked issues, and UI screenshots or GIFs for frontend changes.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
- Copy `.env.example` to `.env`; never commit real API keys or passwords.
|
||||||
|
- `SECRET_KEY` must be at least 32 characters, and `ADMIN_PASSWORD` at least 12.
|
||||||
|
- Update `.env.example` and relevant docs whenever configuration keys or security-sensitive defaults change.
|
||||||
21
LICENSE
Normal file
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.
|
||||||
377
README.md
377
README.md
@@ -1,281 +1,186 @@
|
|||||||
# QQuiz - 智能刷题与题库管理平台
|
# QQuiz
|
||||||
|
|
||||||
QQuiz 是一个支持 Docker/源码双模部署的智能刷题平台,核心功能包括多文件上传、自动去重、异步解析、断点续做和错题本管理。
|
QQuiz 是一个面向题库管理与刷题训练的全栈应用,支持文档导入、异步解析、题目去重、断点续做、错题本与管理员配置。
|
||||||
|
|
||||||
## 功能特性
|
## 界面预览
|
||||||
|
|
||||||
- 📚 **多文件上传与去重**: 支持向同一题库追加文档,自动识别并过滤重复题目
|

|
||||||
- 🤖 **AI 智能解析**: 支持 Google Gemini (推荐) / OpenAI / Anthropic / Qwen 多种 AI 提供商
|
|
||||||
- 📄 **原生 PDF 理解**: Gemini 支持直接处理 PDF(最多1000页),完整保留图片、表格、公式等内容
|
## 核心能力
|
||||||
- 🎓 **AI 参考答案**: 对于没有提供答案的题目,自动生成 AI 参考答案
|
|
||||||
- 📊 **断点续做**: 自动记录刷题进度,随时继续
|
- 多文件导入与题目去重
|
||||||
- ❌ **错题本管理**: 自动收集错题,支持手动添加/移除
|
- 异步解析进度回传
|
||||||
- 🎯 **多题型支持**: 单选、多选、判断、简答
|
- 单选、多选、判断、简答题统一管理
|
||||||
- 🔐 **权限管理**: 管理员配置、用户隔离
|
- 刷题进度保存与继续作答
|
||||||
- 📱 **移动端优先**: 完美适配手机端
|
- 错题本与错题练习
|
||||||
|
- 管理员用户管理与系统配置
|
||||||
|
- 支持 Gemini / OpenAI / Anthropic / Qwen
|
||||||
|
|
||||||
|
## 当前架构
|
||||||
|
|
||||||
|
- `backend/`:FastAPI + SQLAlchemy + Alembic
|
||||||
|
- `web/`:当前主前端,Next.js App Router + TypeScript + Tailwind CSS
|
||||||
|
- `frontend/`:保留中的 legacy Vite 前端,用于单容器兼容路径
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 分离部署优先使用 `web/`
|
||||||
|
- 单容器镜像当前仍复用 `frontend/` 构建静态资源
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 使用预构建镜像(最快)
|
### 1. 分离部署,推荐
|
||||||
|
|
||||||
直接使用 GitHub 自动构建的镜像,无需等待本地构建:
|
前端运行在 `3000`,后端运行在 `8000`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 拉取最新镜像
|
|
||||||
docker pull ghcr.io/handsomezhuzhu/qquiz:latest
|
|
||||||
|
|
||||||
# 2. 配置环境变量
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 编辑 .env,填入你的 API Key
|
|
||||||
|
|
||||||
# 3. 运行容器
|
docker compose up -d --build
|
||||||
docker run -d \
|
|
||||||
--name qquiz \
|
|
||||||
-p 8000:8000 \
|
|
||||||
-v $(pwd)/qquiz_data:/app/qquiz_data \
|
|
||||||
--env-file .env \
|
|
||||||
ghcr.io/handsomezhuzhu/qquiz:latest
|
|
||||||
|
|
||||||
# 4. 访问应用: http://localhost:8000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**镜像说明:**
|
访问地址:
|
||||||
- **镜像地址**: `ghcr.io/handsomezhuzhu/qquiz:latest`
|
|
||||||
- **构建来源**: GitHub Actions 自动构建,每次 push 到 main 分支自动更新
|
|
||||||
- **架构支持**: linux/amd64, linux/arm64(支持树莓派、Apple Silicon 等)
|
|
||||||
- **大小**: 约 400-500MB(包含前后端完整运行环境)
|
|
||||||
- **标签说明**:
|
|
||||||
- `latest`: 最新主分支版本
|
|
||||||
- `v1.0.0`: 特定版本号(如果有 tag)
|
|
||||||
|
|
||||||
**数据持久化:** 使用 `-v` 参数挂载 `qquiz_data` 目录,包含 SQLite 数据库和上传文件,确保数据不会丢失。
|
- 前端:`http://localhost:3000`
|
||||||
|
- 后端:`http://localhost:8000`
|
||||||
|
- 后端健康检查:`http://localhost:8000/health`
|
||||||
|
|
||||||
### 单容器部署(自行构建)
|
### 2. 分离部署 + MySQL
|
||||||
|
|
||||||
从源码构建,一个容器包含前后端和 SQLite 数据库:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 配置环境变量(必须提供强密码和密钥)
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 编辑 .env,填入至少 32 位的 SECRET_KEY 和至少 12 位的 ADMIN_PASSWORD(建议使用随机生成值)
|
|
||||||
|
|
||||||
# 2. 启动服务(未设置强密码/密钥会直接报错终止)
|
docker compose -f docker-compose.yml -f docker-compose.mysql.yml up -d --build
|
||||||
SECRET_KEY=$(openssl rand -base64 48) \
|
|
||||||
ADMIN_PASSWORD=$(openssl rand -base64 16) \
|
|
||||||
docker-compose -f docker-compose-single.yml up -d
|
|
||||||
|
|
||||||
# 3. 访问应用: http://localhost:8000
|
|
||||||
# API 文档: http://localhost:8000/docs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 传统部署(3 个容器)
|
### 3. 单容器部署
|
||||||
|
|
||||||
前后端分离 + MySQL:
|
单容器模式会把前端静态资源集成到后端服务中,统一通过 `8000` 提供。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 启动服务(建议直接在命令行生成强密钥和管理员密码)
|
cp .env.example .env
|
||||||
SECRET_KEY=$(openssl rand -base64 48) \
|
|
||||||
ADMIN_PASSWORD=$(openssl rand -base64 16) \
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# 前端: http://localhost:3000
|
docker compose -f docker-compose-single.yml up -d --build
|
||||||
# 后端: http://localhost:8000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 方式二:本地运行
|
访问地址:
|
||||||
|
|
||||||
|
- 应用:`http://localhost:8000`
|
||||||
|
- API 文档:`http://localhost:8000/docs`
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
alembic upgrade head
|
||||||
|
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 新前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 旧前端
|
||||||
|
|
||||||
|
仅在兼容或迁移场景下需要:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行要求
|
||||||
|
|
||||||
#### 前置要求
|
|
||||||
- Python 3.11+
|
- Python 3.11+
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- MySQL 8.0+ 或 Docker (用于运行 MySQL)
|
- Docker / Docker Compose
|
||||||
|
|
||||||
**Linux/macOS 用户:**
|
## 关键环境变量
|
||||||
```bash
|
|
||||||
# 1. 配置环境变量
|
|
||||||
cp .env.example .env
|
|
||||||
# 编辑 .env,修改 DATABASE_URL 为本地数据库地址
|
|
||||||
|
|
||||||
# 2. 启动 MySQL
|
| 变量 | 说明 |
|
||||||
# macOS: brew services start mysql
|
| --- | --- |
|
||||||
# Linux: sudo systemctl start mysql
|
| `DATABASE_URL` | 数据库连接字符串 |
|
||||||
|
| `SECRET_KEY` | JWT 密钥,至少 32 位 |
|
||||||
|
| `ADMIN_PASSWORD` | 默认管理员密码,至少 12 位 |
|
||||||
|
| `AI_PROVIDER` | `gemini` / `openai` / `anthropic` / `qwen` |
|
||||||
|
| `GEMINI_API_KEY` | Gemini API Key |
|
||||||
|
| `OPENAI_API_KEY` | OpenAI API Key |
|
||||||
|
| `OPENAI_BASE_URL` | OpenAI 或兼容网关地址 |
|
||||||
|
| `ALLOW_REGISTRATION` | 是否允许注册 |
|
||||||
|
| `MAX_UPLOAD_SIZE_MB` | 单次上传大小限制 |
|
||||||
|
| `MAX_DAILY_UPLOADS` | 每日上传次数限制 |
|
||||||
|
|
||||||
# 3. 运行启动脚本
|
更多示例见 [`.env.example`](.env.example)。
|
||||||
chmod +x scripts/run_local.sh
|
|
||||||
./scripts/run_local.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**MySQL 安装指南:** 详见 [docs/MYSQL_SETUP.md](docs/MYSQL_SETUP.md)
|
## 目录结构
|
||||||
|
|
||||||
## GitHub Actions 自动构建设置
|
```text
|
||||||
|
|
||||||
如果你 fork 了本项目并想启用自动构建 Docker 镜像功能:
|
|
||||||
|
|
||||||
1. **启用 GitHub Actions**:
|
|
||||||
- 进入你的仓库 Settings → Actions → General
|
|
||||||
- 确保 "Actions permissions" 设置为 "Allow all actions"
|
|
||||||
|
|
||||||
2. **启用 Packages 写入权限**:
|
|
||||||
- Settings → Actions → General
|
|
||||||
- 找到 "Workflow permissions"
|
|
||||||
- 选择 "Read and write permissions"
|
|
||||||
- 勾选 "Allow GitHub Actions to create and approve pull requests"
|
|
||||||
|
|
||||||
3. **触发构建**:
|
|
||||||
- 推送代码到 `main` 分支会自动触发构建
|
|
||||||
- 或者创建 tag(如 `v1.0.0`)会构建带版本号的镜像
|
|
||||||
- 也可以在 Actions 页面手动触发 "Build and Publish Docker Image" workflow
|
|
||||||
|
|
||||||
4. **查看构建的镜像**:
|
|
||||||
- 构建完成后,镜像会自动发布到 `ghcr.io/handsomezhuzhu/qquiz`
|
|
||||||
- 在仓库主页右侧 "Packages" 可以看到已发布的镜像
|
|
||||||
- 镜像默认是私有的,如需公开:进入 Package 页面 → Package settings → Change visibility
|
|
||||||
|
|
||||||
**镜像地址:**
|
|
||||||
```bash
|
|
||||||
# 拉取最新镜像
|
|
||||||
docker pull ghcr.io/handsomezhuzhu/qquiz:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## 默认账户
|
|
||||||
|
|
||||||
**管理员账户:**
|
|
||||||
- 用户名: `admin`
|
|
||||||
- 密码: 取自环境变量 `ADMIN_PASSWORD`(必须至少 12 位,建议随机生成)
|
|
||||||
|
|
||||||
⚠️ **重要**: 在部署前就必须设置强管理员密码;如果需要轮换密码,请更新环境变量后重启服务。
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
QQuiz/
|
QQuiz/
|
||||||
├── backend/ # FastAPI 后端
|
├─ backend/ FastAPI 后端
|
||||||
│ ├── alembic/ # 数据库迁移
|
│ ├─ alembic/ 数据库迁移
|
||||||
│ ├── routers/ # API 路由
|
│ ├─ routers/ API 路由
|
||||||
│ ├── services/ # 业务逻辑
|
│ ├─ services/ 业务服务
|
||||||
│ ├── models.py # 数据模型
|
│ ├─ models.py ORM 模型
|
||||||
│ ├── database.py # 数据库配置
|
│ ├─ schemas.py Pydantic Schema
|
||||||
│ ├── main.py # 应用入口
|
│ └─ main.py 应用入口
|
||||||
│ └── requirements.txt # Python 依赖
|
├─ web/ Next.js 前端
|
||||||
├── frontend/ # React 前端
|
│ ├─ src/app/ App Router 页面与 API Route
|
||||||
│ ├── src/
|
│ ├─ src/components/ UI 与业务组件
|
||||||
│ │ ├── api/ # API 客户端
|
│ └─ src/lib/ 前端 API、鉴权、工具
|
||||||
│ │ ├── pages/ # 页面组件
|
├─ frontend/ Legacy Vite 前端
|
||||||
│ │ ├── components/ # 通用组件
|
├─ docs/ 部署、审计与截图
|
||||||
│ │ └── App.jsx # 应用入口
|
├─ test_data/ 示例题库文件
|
||||||
│ ├── package.json # Node 依赖
|
├─ docker-compose.yml 前后端分离部署
|
||||||
│ └── vite.config.js # Vite 配置
|
├─ docker-compose.mysql.yml MySQL overlay
|
||||||
├── scripts/ # 部署和启动脚本
|
├─ docker-compose-single.yml 单容器部署
|
||||||
│ └── run_local.sh # Linux/macOS 启动脚本
|
└─ Dockerfile 单容器镜像构建
|
||||||
├── docs/ # 文档目录
|
|
||||||
│ ├── MYSQL_SETUP.md # MySQL 安装配置指南
|
|
||||||
│ └── PROJECT_STRUCTURE.md # 项目架构详解
|
|
||||||
├── test_data/ # 测试数据
|
|
||||||
│ └── sample_questions.txt # 示例题目
|
|
||||||
├── docker-compose.yml # Docker 编排
|
|
||||||
├── .env.example # 环境变量模板
|
|
||||||
└── README.md # 项目说明
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 核心业务流程
|
|
||||||
|
|
||||||
### 1. 创建题库
|
|
||||||
用户首次上传文档时,创建新的 Exam (题库容器)
|
|
||||||
|
|
||||||
### 2. 追加文档
|
|
||||||
在已有题库详情页点击 "添加题目文档",上传新文件
|
|
||||||
|
|
||||||
### 3. 去重逻辑
|
|
||||||
- 对题目内容进行标准化处理 (去空格、标点、转小写)
|
|
||||||
- 计算 MD5 Hash
|
|
||||||
- 仅在当前题库范围内查询去重
|
|
||||||
- 仅插入 Hash 不存在的题目
|
|
||||||
|
|
||||||
### 4. 异步处理
|
|
||||||
- 后台任务处理 AI 解析
|
|
||||||
- 状态: `pending` → `processing` → `ready` / `failed`
|
|
||||||
- 前端轮询状态,自动刷新
|
|
||||||
|
|
||||||
### 5. 刷题体验
|
|
||||||
- 基于 `current_index` 实现断点续做
|
|
||||||
- 错题自动加入错题本
|
|
||||||
- 简答题 AI 评分
|
|
||||||
|
|
||||||
## 环境变量说明
|
|
||||||
|
|
||||||
| 变量 | 说明 | 默认值 |
|
|
||||||
|------|------|--------|
|
|
||||||
| `DATABASE_URL` | 数据库连接字符串 | - |
|
|
||||||
| `SECRET_KEY` | JWT 密钥(必须至少 32 位随机字符串) | - |
|
|
||||||
| `ADMIN_PASSWORD` | 默认管理员密码(必须至少 12 位,建议随机生成) | - |
|
|
||||||
| `AI_PROVIDER` | AI 提供商 (gemini/openai/anthropic/qwen) | gemini |
|
|
||||||
| `GEMINI_API_KEY` | Google Gemini API 密钥 | - |
|
|
||||||
| `GEMINI_BASE_URL` | Gemini API 地址(可选,支持代理) | https://generativelanguage.googleapis.com |
|
|
||||||
| `GEMINI_MODEL` | Gemini 模型 | gemini-2.0-flash-exp |
|
|
||||||
| `OPENAI_API_KEY` | OpenAI API 密钥 | - |
|
|
||||||
| `OPENAI_BASE_URL` | OpenAI API 地址 | https://api.openai.com/v1 |
|
|
||||||
| `OPENAI_MODEL` | OpenAI 模型 | gpt-4o-mini |
|
|
||||||
| `ANTHROPIC_API_KEY` | Anthropic API 密钥 | - |
|
|
||||||
| `ANTHROPIC_MODEL` | Anthropic 模型 | claude-3-haiku-20240307 |
|
|
||||||
| `QWEN_API_KEY` | 通义千问 API 密钥 | - |
|
|
||||||
| `QWEN_BASE_URL` | 通义千问 API 地址 | https://dashscope.aliyuncs.com/compatible-mode/v1 |
|
|
||||||
| `QWEN_MODEL` | 通义千问模型 | qwen-plus |
|
|
||||||
| `ALLOW_REGISTRATION` | 是否允许注册 | true |
|
|
||||||
| `MAX_UPLOAD_SIZE_MB` | 最大上传文件大小 (MB) | 10 |
|
|
||||||
| `MAX_DAILY_UPLOADS` | 每日上传次数限制 | 20 |
|
|
||||||
|
|
||||||
### AI 提供商对比
|
|
||||||
|
|
||||||
| 提供商 | PDF 原生支持 | 文本解析 | 推荐度 | 说明 |
|
|
||||||
|--------|--------------|----------|--------|------|
|
|
||||||
| **Google Gemini** | ✅ | ✅ | ⭐⭐⭐⭐⭐ | 支持原生 PDF(最多1000页),保留图片、表格、公式 |
|
|
||||||
| OpenAI | ❌ | ✅ | ⭐⭐⭐⭐ | 仅文本提取,PDF 会丢失格式和图片 |
|
|
||||||
| Anthropic | ❌ | ✅ | ⭐⭐⭐⭐ | 仅文本提取,PDF 会丢失格式和图片 |
|
|
||||||
| Qwen (通义千问) | ❌ | ✅ | ⭐⭐⭐ | 仅文本提取,PDF 会丢失格式和图片 |
|
|
||||||
|
|
||||||
**推荐使用 Gemini**:如果你的题库包含 PDF 文件(特别是含有图片、公式、表格的学科试卷),强烈推荐使用 Gemini。
|
|
||||||
|
|
||||||
### 如何获取 API Key
|
|
||||||
|
|
||||||
- **Google Gemini**: https://aistudio.google.com/apikey (免费额度充足)
|
|
||||||
- **OpenAI**: https://platform.openai.com/api-keys
|
|
||||||
- **Anthropic**: https://console.anthropic.com/settings/keys
|
|
||||||
- **Qwen (通义千问)**: https://dashscope.console.aliyun.com/apiKey
|
|
||||||
|
|
||||||
### AI 配置方式
|
|
||||||
|
|
||||||
QQuiz 支持两种配置方式:
|
|
||||||
|
|
||||||
1. **环境变量配置** (`.env` 文件):适合 Docker 部署和开发环境
|
|
||||||
2. **数据库配置** (管理员后台):适合生产环境,支持在线修改,无需重启服务
|
|
||||||
|
|
||||||
**推荐流程**:首次部署使用环境变量,部署成功后通过管理员后台修改配置。
|
|
||||||
|
|
||||||
**Gemini 自定义代理**: 如果需要使用 Key 轮训服务或代理,可以在管理员后台配置 `GEMINI_BASE_URL`,支持自定义 Gemini API 地址。
|
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
**后端:**
|
### 后端
|
||||||
- FastAPI - 现代化 Python Web 框架
|
|
||||||
- SQLAlchemy 2.0 - 异步 ORM
|
|
||||||
- Alembic - 数据库迁移
|
|
||||||
- MySQL 8.0 - 数据库
|
|
||||||
- aiomysql - MySQL 异步驱动
|
|
||||||
- Pydantic - 数据验证
|
|
||||||
|
|
||||||
**前端:**
|
- FastAPI
|
||||||
- React 18 - UI 框架
|
- SQLAlchemy 2.x
|
||||||
- Vite - 构建工具
|
- Alembic
|
||||||
- Tailwind CSS - 样式框架
|
- SQLite / MySQL
|
||||||
- React Router - 路由
|
- httpx
|
||||||
- Axios - HTTP 客户端
|
- OpenAI / Anthropic SDK
|
||||||
|
|
||||||
## 开发进度
|
### 前端
|
||||||
|
|
||||||
- [x] **Step 1**: Foundation & Models ✅
|
- Next.js 14 App Router
|
||||||
- [ ] **Step 2**: Backend Core Logic
|
- React 18
|
||||||
- [ ] **Step 3**: Frontend Config & API
|
- TypeScript
|
||||||
- [ ] **Step 4**: Frontend Complex UI
|
- Tailwind CSS
|
||||||
|
- TanStack Query
|
||||||
|
- Radix UI / shadcn 风格组件
|
||||||
|
|
||||||
## License
|
## 构建检查
|
||||||
|
|
||||||
MIT
|
常用检查命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web && npm run build
|
||||||
|
docker compose build backend frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
仓库当前没有完整自动化测试套件,提交前至少建议手动验证:
|
||||||
|
|
||||||
|
- 登录 / 退出
|
||||||
|
- 创建题库 / 上传文档 / 查看解析进度
|
||||||
|
- 刷题与错题加入
|
||||||
|
- 管理员用户管理与系统设置
|
||||||
|
- 大数据量列表分页
|
||||||
|
|
||||||
|
## 开源协议
|
||||||
|
|
||||||
|
本项目采用 [MIT License](LICENSE)。
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
|
FROM python:3.11-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
libmagic1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN python -m venv /opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies (gcc for compiling Python packages)
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
gcc \
|
libmagic1 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements and install Python dependencies
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
COPY requirements.txt .
|
ENV PYTHONUNBUFFERED=1
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy application code
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Create uploads directory
|
|
||||||
RUN mkdir -p uploads
|
RUN mkdir -p uploads
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Run database migrations and start server
|
|
||||||
CMD alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000
|
CMD alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Admin Router - 完备的管理员功能模块
|
Admin Router - 完备的管理员功能模块
|
||||||
参考 OpenWebUI 设计
|
参考 OpenWebUI 设计
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
from fastapi.responses import StreamingResponse, FileResponse
|
from fastapi.responses import StreamingResponse, FileResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, and_, or_, desc
|
from sqlalchemy import select, func, and_, or_, desc
|
||||||
@@ -16,7 +16,8 @@ from database import get_db, engine
|
|||||||
from models import User, SystemConfig, Exam, Question, UserMistake, ExamStatus
|
from models import User, SystemConfig, Exam, Question, UserMistake, ExamStatus
|
||||||
from schemas import (
|
from schemas import (
|
||||||
SystemConfigUpdate, SystemConfigResponse,
|
SystemConfigUpdate, SystemConfigResponse,
|
||||||
UserResponse, UserCreate, UserUpdate, UserListResponse
|
UserResponse, UserCreate, UserUpdate, UserListResponse,
|
||||||
|
UserPasswordResetRequest, AdminUserSummary
|
||||||
)
|
)
|
||||||
from services.auth_service import get_current_admin_user
|
from services.auth_service import get_current_admin_user
|
||||||
|
|
||||||
@@ -36,6 +37,11 @@ async def get_default_admin_id(db: AsyncSession) -> Optional[int]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_admin_count(db: AsyncSession) -> int:
|
||||||
|
result = await db.execute(select(func.count(User.id)).where(User.is_admin == True))
|
||||||
|
return result.scalar() or 0
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", response_model=SystemConfigResponse)
|
@router.get("/config", response_model=SystemConfigResponse)
|
||||||
async def get_system_config(
|
async def get_system_config(
|
||||||
current_admin: User = Depends(get_current_admin_user),
|
current_admin: User = Depends(get_current_admin_user),
|
||||||
@@ -84,6 +90,9 @@ async def update_system_config(
|
|||||||
update_data = config_update.dict(exclude_unset=True)
|
update_data = config_update.dict(exclude_unset=True)
|
||||||
|
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
|
if key.endswith("_api_key") and isinstance(value, str) and "..." in value:
|
||||||
|
continue
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(SystemConfig).where(SystemConfig.key == key)
|
select(SystemConfig).where(SystemConfig.key == key)
|
||||||
)
|
)
|
||||||
@@ -108,9 +117,9 @@ async def update_system_config(
|
|||||||
|
|
||||||
@router.get("/users", response_model=UserListResponse)
|
@router.get("/users", response_model=UserListResponse)
|
||||||
async def get_users(
|
async def get_users(
|
||||||
skip: int = 0,
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = 50,
|
limit: int = Query(50, ge=1, le=100),
|
||||||
search: Optional[str] = None,
|
search: Optional[str] = Query(None, min_length=1, max_length=50),
|
||||||
current_admin: User = Depends(get_current_admin_user),
|
current_admin: User = Depends(get_current_admin_user),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
@@ -121,10 +130,11 @@ async def get_users(
|
|||||||
- search: 搜索关键词(用户名)
|
- search: 搜索关键词(用户名)
|
||||||
"""
|
"""
|
||||||
query = select(User)
|
query = select(User)
|
||||||
|
normalized_search = search.strip() if search else None
|
||||||
|
|
||||||
# 搜索过滤
|
# 搜索过滤
|
||||||
if search:
|
if normalized_search:
|
||||||
query = query.where(User.username.ilike(f"%{search}%"))
|
query = query.where(User.username.ilike(f"%{normalized_search}%"))
|
||||||
|
|
||||||
# 统计总数
|
# 统计总数
|
||||||
count_query = select(func.count()).select_from(query.subquery())
|
count_query = select(func.count()).select_from(query.subquery())
|
||||||
@@ -136,34 +146,41 @@ async def get_users(
|
|||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
users = result.scalars().all()
|
users = result.scalars().all()
|
||||||
|
|
||||||
# 为每个用户添加统计信息
|
user_ids = [user.id for user in users]
|
||||||
|
exam_count_map = {}
|
||||||
|
mistake_count_map = {}
|
||||||
|
|
||||||
|
if user_ids:
|
||||||
|
exam_count_result = await db.execute(
|
||||||
|
select(Exam.user_id, func.count(Exam.id))
|
||||||
|
.where(Exam.user_id.in_(user_ids))
|
||||||
|
.group_by(Exam.user_id)
|
||||||
|
)
|
||||||
|
exam_count_map = {user_id: count for user_id, count in exam_count_result.all()}
|
||||||
|
|
||||||
|
mistake_count_result = await db.execute(
|
||||||
|
select(UserMistake.user_id, func.count(UserMistake.id))
|
||||||
|
.where(UserMistake.user_id.in_(user_ids))
|
||||||
|
.group_by(UserMistake.user_id)
|
||||||
|
)
|
||||||
|
mistake_count_map = {
|
||||||
|
user_id: count for user_id, count in mistake_count_result.all()
|
||||||
|
}
|
||||||
|
|
||||||
user_list = []
|
user_list = []
|
||||||
for user in users:
|
for user in users:
|
||||||
# 统计用户的题库数
|
user_list.append(
|
||||||
exam_count_query = select(func.count(Exam.id)).where(Exam.user_id == user.id)
|
AdminUserSummary(
|
||||||
exam_result = await db.execute(exam_count_query)
|
id=user.id,
|
||||||
exam_count = exam_result.scalar()
|
username=user.username,
|
||||||
|
is_admin=user.is_admin,
|
||||||
|
created_at=user.created_at,
|
||||||
|
exam_count=exam_count_map.get(user.id, 0),
|
||||||
|
mistake_count=mistake_count_map.get(user.id, 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# 统计用户的错题数
|
return UserListResponse(users=user_list, total=total, skip=skip, limit=limit)
|
||||||
mistake_count_query = select(func.count(UserMistake.id)).where(UserMistake.user_id == user.id)
|
|
||||||
mistake_result = await db.execute(mistake_count_query)
|
|
||||||
mistake_count = mistake_result.scalar()
|
|
||||||
|
|
||||||
user_list.append({
|
|
||||||
"id": user.id,
|
|
||||||
"username": user.username,
|
|
||||||
"is_admin": user.is_admin,
|
|
||||||
"created_at": user.created_at,
|
|
||||||
"exam_count": exam_count,
|
|
||||||
"mistake_count": mistake_count
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"users": user_list,
|
|
||||||
"total": total,
|
|
||||||
"skip": skip,
|
|
||||||
"limit": limit
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users", response_model=UserResponse)
|
@router.post("/users", response_model=UserResponse)
|
||||||
@@ -215,7 +232,19 @@ async def update_user(
|
|||||||
detail="User not found"
|
detail="User not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if user_data.username and user_data.username != user.username:
|
||||||
|
existing_user_result = await db.execute(
|
||||||
|
select(User).where(User.username == user_data.username)
|
||||||
|
)
|
||||||
|
existing_user = existing_user_result.scalar_one_or_none()
|
||||||
|
if existing_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Username already exists"
|
||||||
|
)
|
||||||
|
|
||||||
protected_admin_id = await get_default_admin_id(db)
|
protected_admin_id = await get_default_admin_id(db)
|
||||||
|
admin_count = await get_admin_count(db)
|
||||||
|
|
||||||
# 不允许修改默认管理员的管理员状态
|
# 不允许修改默认管理员的管理员状态
|
||||||
if protected_admin_id and user.id == protected_admin_id and user_data.is_admin is not None:
|
if protected_admin_id and user.id == protected_admin_id and user_data.is_admin is not None:
|
||||||
@@ -224,6 +253,12 @@ async def update_user(
|
|||||||
detail="Cannot modify default admin user's admin status"
|
detail="Cannot modify default admin user's admin status"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if user.is_admin and user_data.is_admin is False and admin_count <= 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="At least one admin user must remain"
|
||||||
|
)
|
||||||
|
|
||||||
# 更新字段
|
# 更新字段
|
||||||
update_data = user_data.dict(exclude_unset=True)
|
update_data = user_data.dict(exclude_unset=True)
|
||||||
if "password" in update_data:
|
if "password" in update_data:
|
||||||
@@ -238,6 +273,29 @@ async def update_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/reset-password")
|
||||||
|
async def reset_user_password(
|
||||||
|
user_id: int,
|
||||||
|
payload: UserPasswordResetRequest,
|
||||||
|
current_admin: User = Depends(get_current_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""重置用户密码(仅管理员)"""
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
user.hashed_password = pwd_context.hash(payload.new_password)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"message": "Password reset successfully"}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/users/{user_id}")
|
@router.delete("/users/{user_id}")
|
||||||
async def delete_user(
|
async def delete_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -255,6 +313,7 @@ async def delete_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
protected_admin_id = await get_default_admin_id(db)
|
protected_admin_id = await get_default_admin_id(db)
|
||||||
|
admin_count = await get_admin_count(db)
|
||||||
|
|
||||||
# 不允许删除默认管理员
|
# 不允许删除默认管理员
|
||||||
if protected_admin_id and user.id == protected_admin_id:
|
if protected_admin_id and user.id == protected_admin_id:
|
||||||
@@ -270,6 +329,12 @@ async def delete_user(
|
|||||||
detail="Cannot delete yourself"
|
detail="Cannot delete yourself"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if user.is_admin and admin_count <= 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="At least one admin user must remain"
|
||||||
|
)
|
||||||
|
|
||||||
await db.delete(user)
|
await db.delete(user)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Exam Router - Handles exam creation, file upload, and deduplication
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, BackgroundTasks, Request
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, BackgroundTasks, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, and_
|
from sqlalchemy import select, func, and_, case
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import os
|
import os
|
||||||
@@ -17,7 +17,7 @@ from database import get_db
|
|||||||
from models import User, Exam, Question, ExamStatus, SystemConfig
|
from models import User, Exam, Question, ExamStatus, SystemConfig
|
||||||
from schemas import (
|
from schemas import (
|
||||||
ExamCreate, ExamResponse, ExamListResponse,
|
ExamCreate, ExamResponse, ExamListResponse,
|
||||||
ExamUploadResponse, ParseResult, QuizProgressUpdate
|
ExamUploadResponse, ParseResult, QuizProgressUpdate, ExamSummaryResponse
|
||||||
)
|
)
|
||||||
from services.auth_service import get_current_user
|
from services.auth_service import get_current_user
|
||||||
from services.document_parser import document_parser
|
from services.document_parser import document_parser
|
||||||
@@ -684,6 +684,57 @@ async def get_user_exams(
|
|||||||
return ExamListResponse(exams=exams, total=total)
|
return ExamListResponse(exams=exams, total=total)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=ExamSummaryResponse)
|
||||||
|
async def get_exam_summary(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get aggregated exam statistics for current user."""
|
||||||
|
|
||||||
|
summary_query = select(
|
||||||
|
func.count(Exam.id),
|
||||||
|
func.coalesce(func.sum(Exam.total_questions), 0),
|
||||||
|
func.coalesce(func.sum(Exam.current_index), 0),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
case((Exam.status == ExamStatus.PROCESSING, 1), else_=0)
|
||||||
|
),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
case((Exam.status == ExamStatus.READY, 1), else_=0)
|
||||||
|
),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
case((Exam.status == ExamStatus.FAILED, 1), else_=0)
|
||||||
|
),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
).where(Exam.user_id == current_user.id)
|
||||||
|
|
||||||
|
result = await db.execute(summary_query)
|
||||||
|
(
|
||||||
|
total_exams,
|
||||||
|
total_questions,
|
||||||
|
completed_questions,
|
||||||
|
processing_exams,
|
||||||
|
ready_exams,
|
||||||
|
failed_exams
|
||||||
|
) = result.one()
|
||||||
|
|
||||||
|
return ExamSummaryResponse(
|
||||||
|
total_exams=total_exams or 0,
|
||||||
|
total_questions=total_questions or 0,
|
||||||
|
completed_questions=completed_questions or 0,
|
||||||
|
processing_exams=processing_exams or 0,
|
||||||
|
ready_exams=ready_exams or 0,
|
||||||
|
failed_exams=failed_exams or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{exam_id}", response_model=ExamResponse)
|
@router.get("/{exam_id}", response_model=ExamResponse)
|
||||||
async def get_exam_detail(
|
async def get_exam_detail(
|
||||||
exam_id: int,
|
exam_id: int,
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ class UserLogin(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChangeRequest(BaseModel):
|
||||||
|
old_password: str = Field(..., min_length=1)
|
||||||
|
new_password: str = Field(..., min_length=6)
|
||||||
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
@@ -53,14 +58,23 @@ class UserResponse(BaseModel):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserSummary(UserResponse):
|
||||||
|
exam_count: int = 0
|
||||||
|
mistake_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
class UserListResponse(BaseModel):
|
class UserListResponse(BaseModel):
|
||||||
"""用户列表响应(包含分页信息)"""
|
"""用户列表响应(包含分页信息)"""
|
||||||
users: List[dict] # 包含额外统计信息的用户列表
|
users: List[AdminUserSummary]
|
||||||
total: int
|
total: int
|
||||||
skip: int
|
skip: int
|
||||||
limit: int
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
class UserPasswordResetRequest(BaseModel):
|
||||||
|
new_password: str = Field(..., min_length=6)
|
||||||
|
|
||||||
|
|
||||||
# ============ System Config Schemas ============
|
# ============ System Config Schemas ============
|
||||||
class SystemConfigUpdate(BaseModel):
|
class SystemConfigUpdate(BaseModel):
|
||||||
allow_registration: Optional[bool] = None
|
allow_registration: Optional[bool] = None
|
||||||
@@ -124,6 +138,15 @@ class ExamListResponse(BaseModel):
|
|||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class ExamSummaryResponse(BaseModel):
|
||||||
|
total_exams: int
|
||||||
|
total_questions: int
|
||||||
|
completed_questions: int
|
||||||
|
processing_exams: int
|
||||||
|
ready_exams: int
|
||||||
|
failed_exams: int
|
||||||
|
|
||||||
|
|
||||||
class ExamUploadResponse(BaseModel):
|
class ExamUploadResponse(BaseModel):
|
||||||
exam_id: int
|
exam_id: int
|
||||||
title: str
|
title: str
|
||||||
@@ -140,19 +163,31 @@ class ParseResult(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# ============ Question Schemas ============
|
# ============ Question Schemas ============
|
||||||
class QuestionBase(BaseModel):
|
class QuestionPublicBase(BaseModel):
|
||||||
content: str
|
content: str
|
||||||
type: QuestionType
|
type: QuestionType
|
||||||
options: Optional[List[str]] = None
|
options: Optional[List[str]] = None
|
||||||
answer: str
|
|
||||||
analysis: Optional[str] = None
|
analysis: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionBase(QuestionPublicBase):
|
||||||
|
answer: str
|
||||||
|
|
||||||
|
|
||||||
class QuestionCreate(QuestionBase):
|
class QuestionCreate(QuestionBase):
|
||||||
exam_id: int
|
exam_id: int
|
||||||
|
|
||||||
|
|
||||||
class QuestionResponse(QuestionBase):
|
class QuestionResponse(QuestionPublicBase):
|
||||||
|
id: int
|
||||||
|
exam_id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionWithAnswerResponse(QuestionBase):
|
||||||
id: int
|
id: int
|
||||||
exam_id: int
|
exam_id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -194,7 +229,7 @@ class MistakeResponse(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
user_id: int
|
user_id: int
|
||||||
question_id: int
|
question_id: int
|
||||||
question: QuestionResponse
|
question: QuestionWithAnswerResponse
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@@ -15,6 +15,25 @@ from utils import calculate_content_hash
|
|||||||
class LLMService:
|
class LLMService:
|
||||||
"""Service for interacting with various LLM providers"""
|
"""Service for interacting with various LLM providers"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_openai_base_url(base_url: Optional[str], default: str) -> str:
|
||||||
|
normalized = (base_url or default).rstrip("/")
|
||||||
|
if normalized.endswith("/v1"):
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
if normalized.count("/") <= 2:
|
||||||
|
return f"{normalized}/v1"
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _openai_compat_headers() -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Some OpenAI-compatible gateways block the default OpenAI SDK user agent.
|
||||||
|
Use a neutral UA so requests behave like a generic HTTP client.
|
||||||
|
"""
|
||||||
|
return {"User-Agent": "QQuiz/1.0"}
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, str]] = None):
|
def __init__(self, config: Optional[Dict[str, str]] = None):
|
||||||
"""
|
"""
|
||||||
Initialize LLM Service with optional configuration.
|
Initialize LLM Service with optional configuration.
|
||||||
@@ -28,7 +47,10 @@ class LLMService:
|
|||||||
|
|
||||||
if self.provider == "openai":
|
if self.provider == "openai":
|
||||||
api_key = (config or {}).get("openai_api_key") or os.getenv("OPENAI_API_KEY")
|
api_key = (config or {}).get("openai_api_key") or os.getenv("OPENAI_API_KEY")
|
||||||
base_url = (config or {}).get("openai_base_url") or os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
base_url = self._normalize_openai_base_url(
|
||||||
|
(config or {}).get("openai_base_url") or os.getenv("OPENAI_BASE_URL"),
|
||||||
|
"https://api.openai.com/v1"
|
||||||
|
)
|
||||||
self.model = (config or {}).get("openai_model") or os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
self.model = (config or {}).get("openai_model") or os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
||||||
|
|
||||||
if not api_key:
|
if not api_key:
|
||||||
@@ -37,6 +59,7 @@ class LLMService:
|
|||||||
self.client = AsyncOpenAI(
|
self.client = AsyncOpenAI(
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
|
default_headers=self._openai_compat_headers(),
|
||||||
timeout=120.0, # 增加超时时间到 120 秒
|
timeout=120.0, # 增加超时时间到 120 秒
|
||||||
max_retries=3 # 自动重试 3 次
|
max_retries=3 # 自动重试 3 次
|
||||||
)
|
)
|
||||||
@@ -69,6 +92,7 @@ class LLMService:
|
|||||||
self.client = AsyncOpenAI(
|
self.client = AsyncOpenAI(
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
|
default_headers=self._openai_compat_headers(),
|
||||||
timeout=120.0, # 增加超时时间到 120 秒
|
timeout=120.0, # 增加超时时间到 120 秒
|
||||||
max_retries=3 # 自动重试 3 次
|
max_retries=3 # 自动重试 3 次
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# 数据库配置(SQLite 默认,使用持久化卷)
|
# 数据库配置(SQLite 默认,使用持久化卷)
|
||||||
- DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db
|
- DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db
|
||||||
|
- UPLOAD_DIR=/app/uploads
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# 持久化数据卷
|
# 持久化数据卷
|
||||||
@@ -25,7 +26,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "python", "-c", "import sys, urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5); sys.exit(0)"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
29
docker-compose.mysql.yml
Normal file
29
docker-compose.mysql.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: qquiz_mysql
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root_password
|
||||||
|
MYSQL_DATABASE: qquiz_db
|
||||||
|
MYSQL_USER: qquiz
|
||||||
|
MYSQL_PASSWORD: qquiz_password
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "qquiz", "-pqquiz_password"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@mysql:3306/qquiz_db
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
@@ -1,61 +1,47 @@
|
|||||||
services:
|
services:
|
||||||
mysql:
|
|
||||||
image: mysql:8.0
|
|
||||||
container_name: qquiz_mysql
|
|
||||||
environment:
|
|
||||||
MYSQL_ROOT_PASSWORD: root_password
|
|
||||||
MYSQL_DATABASE: qquiz_db
|
|
||||||
MYSQL_USER: qquiz
|
|
||||||
MYSQL_PASSWORD: qquiz_password
|
|
||||||
volumes:
|
|
||||||
- mysql_data:/var/lib/mysql
|
|
||||||
ports:
|
|
||||||
- "3306:3306"
|
|
||||||
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "qquiz", "-pqquiz_password"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: qquiz_backend
|
container_name: qquiz_backend
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@mysql:3306/qquiz_db
|
- DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db
|
||||||
- SECRET_KEY=${SECRET_KEY:?Set SECRET_KEY to a random string of at least 32 characters}
|
- SECRET_KEY=${SECRET_KEY:?Set SECRET_KEY to a random string of at least 32 characters}
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?Set ADMIN_PASSWORD to a strong password of at least 12 characters}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?Set ADMIN_PASSWORD to a strong password of at least 12 characters}
|
||||||
|
- UPLOAD_DIR=/app/uploads
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- sqlite_data:/app/data
|
||||||
- upload_files:/app/uploads
|
- upload_files:/app/uploads
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
depends_on:
|
healthcheck:
|
||||||
mysql:
|
test: ["CMD", "python", "-c", "import sys, urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5); sys.exit(0)"]
|
||||||
condition: service_healthy
|
interval: 30s
|
||||||
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./web
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: qquiz_frontend
|
container_name: qquiz_frontend
|
||||||
volumes:
|
|
||||||
- ./frontend:/app
|
|
||||||
- /app/node_modules
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- VITE_API_URL=/api
|
- API_BASE_URL=http://backend:8000
|
||||||
- REACT_APP_API_URL=http://backend:8000
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
command: npm start
|
condition: service_started
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/login').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
sqlite_data:
|
||||||
upload_files:
|
upload_files:
|
||||||
|
|||||||
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. 页面信息结构:标题、数据、动作优先,减少解释文字
|
||||||
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: React 18 + Vite SPA
|
||||||
|
- Routing: `react-router-dom`
|
||||||
|
- Auth state: client-only `localStorage` token + context
|
||||||
|
- API transport: axios interceptor with browser redirects
|
||||||
|
- Styling: Tailwind CSS with page-local utility classes
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
- `docker-compose.yml`: development-oriented split stack
|
||||||
|
- `docker-compose-single.yml`: monolith container with SQLite
|
||||||
|
- `Dockerfile`: FastAPI serves the built SPA as static assets
|
||||||
|
|
||||||
|
## Target Architecture
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- Keep FastAPI as the system API boundary
|
||||||
|
- Move heavy router logic into typed services
|
||||||
|
- Use Alembic as the only schema migration path
|
||||||
|
- Introduce durable ingestion execution semantics
|
||||||
|
- Replace implicit transaction patterns with explicit service-level boundaries
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- New app in `web/`
|
||||||
|
- Stack: Next.js App Router + TypeScript + Tailwind + shadcn/ui
|
||||||
|
- Auth: `HttpOnly` session cookie mediated by Next route handlers
|
||||||
|
- Data fetching: `fetch` wrappers for server/client usage
|
||||||
|
- Streaming: Next proxy route for exam progress SSE
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
- Split deployment becomes the primary production shape
|
||||||
|
- Monolith mode remains secondary compatibility mode
|
||||||
|
- Development and production Compose files must be separated
|
||||||
|
|
||||||
|
## Core Constraints
|
||||||
|
|
||||||
|
1. Do not overwrite existing uncommitted user changes in the legacy frontend.
|
||||||
|
2. Keep the legacy `frontend/` app available until the new `web/` app reaches functional parity.
|
||||||
|
3. Preserve backend API contracts where possible during the frontend migration.
|
||||||
|
4. Fix deployment/documentation drift before treating new frontend work as production-ready.
|
||||||
|
|
||||||
|
## Immediate Workstreams
|
||||||
|
|
||||||
|
1. Remove abandoned ESA captcha wiring from the legacy frontend.
|
||||||
|
2. Write audit documents and freeze the migration backlog.
|
||||||
|
3. Scaffold the new `web/` frontend without disturbing the legacy app.
|
||||||
|
4. Fix first-order deployment issues such as health checks and documented mount paths.
|
||||||
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
|
||||||
70
docs/audit/frontend-migration.md
Normal file
70
docs/audit/frontend-migration.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Frontend Migration Plan
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The legacy Vite SPA remains in `frontend/` as a fallback.
|
||||||
|
|
||||||
|
The new frontend is being built in `web/` with:
|
||||||
|
|
||||||
|
- Next.js App Router
|
||||||
|
- TypeScript
|
||||||
|
- Tailwind CSS
|
||||||
|
- shadcn/ui component model
|
||||||
|
|
||||||
|
The abandoned ESA captcha integration has been removed from the legacy login page.
|
||||||
|
|
||||||
|
## Why a Rewrite Instead of an In-Place Port
|
||||||
|
|
||||||
|
The legacy frontend mixes too many browser-only assumptions into core runtime
|
||||||
|
boundaries:
|
||||||
|
|
||||||
|
- token storage in `localStorage`
|
||||||
|
- `window.location` redirects inside transport code
|
||||||
|
- client-only route protection
|
||||||
|
- SSE token passing in query strings
|
||||||
|
|
||||||
|
Those patterns do not map cleanly onto Next App Router and server-first auth.
|
||||||
|
|
||||||
|
## New Runtime Model
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
- Login goes through Next route handlers
|
||||||
|
- Backend JWT is stored in an `HttpOnly` cookie
|
||||||
|
- Browser code never reads the raw token
|
||||||
|
|
||||||
|
### Data
|
||||||
|
|
||||||
|
- Server pages use server-side fetch helpers
|
||||||
|
- Client mutations use browser-side fetch helpers against Next proxy routes
|
||||||
|
- URL state is used for pagination and filters
|
||||||
|
|
||||||
|
### Streaming
|
||||||
|
|
||||||
|
- Browser connects to a same-origin Next progress route
|
||||||
|
- The route reads the session cookie and proxies backend SSE
|
||||||
|
- Backend URL tokens are hidden from the browser
|
||||||
|
|
||||||
|
## Directory Map
|
||||||
|
|
||||||
|
```text
|
||||||
|
web/
|
||||||
|
src/app/
|
||||||
|
src/components/
|
||||||
|
src/lib/
|
||||||
|
src/middleware.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Order
|
||||||
|
|
||||||
|
1. Auth shell, layouts, middleware, and proxy routes
|
||||||
|
2. Dashboard, exams list, questions list, and admin overview
|
||||||
|
3. Exam detail upload and progress streaming
|
||||||
|
4. Quiz and mistake-practice flows
|
||||||
|
5. Cutover, smoke testing, and legacy frontend retirement
|
||||||
|
|
||||||
|
## Non-Goals for This First Slice
|
||||||
|
|
||||||
|
- No immediate removal of the legacy `frontend/`
|
||||||
|
- No backend contract rewrite yet
|
||||||
|
- No server actions as the primary data mutation layer
|
||||||
BIN
docs/cover.png
Normal file
BIN
docs/cover.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 371 KiB |
@@ -6,14 +6,6 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="QQuiz - 智能刷题与题库管理平台" />
|
<meta name="description" content="QQuiz - 智能刷题与题库管理平台" />
|
||||||
<title>QQuiz - 智能刷题平台</title>
|
<title>QQuiz - 智能刷题平台</title>
|
||||||
<!-- ESA 人机认证配置 -->
|
|
||||||
<script>
|
|
||||||
window.AliyunCaptchaConfig = {
|
|
||||||
region: "cn",
|
|
||||||
prefix: "%VITE_ESA_PREFIX%",
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<script src="https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js" async></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -4,9 +4,53 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
export const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
|
||||||
|
export const AUTH_TOKEN_STORAGE_KEY = 'access_token'
|
||||||
|
|
||||||
|
const AUTH_USER_STORAGE_KEY = 'user'
|
||||||
|
const PUBLIC_REQUEST_PATHS = ['/auth/login', '/auth/register']
|
||||||
|
|
||||||
|
const getRequestPath = (config) => {
|
||||||
|
const url = config?.url || ''
|
||||||
|
|
||||||
|
if (!url) return ''
|
||||||
|
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
try {
|
||||||
|
return new URL(url).pathname
|
||||||
|
} catch (error) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.startsWith('/') ? url : `/${url}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPublicRequest = (config) => {
|
||||||
|
if (config?.skipAuthHandling === true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = getRequestPath(config)
|
||||||
|
return PUBLIC_REQUEST_PATHS.some((publicPath) => path.endsWith(publicPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildApiUrl = (path) => {
|
||||||
|
const base = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||||
|
return `${base}${normalizedPath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAccessToken = () => localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)
|
||||||
|
|
||||||
|
export const clearAuthStorage = () => {
|
||||||
|
localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY)
|
||||||
|
localStorage.removeItem(AUTH_USER_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
// Create axios instance
|
// Create axios instance
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_URL || '/api',
|
baseURL: API_BASE_URL,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -16,8 +60,9 @@ const api = axios.create({
|
|||||||
// Request interceptor - Add auth token
|
// Request interceptor - Add auth token
|
||||||
api.interceptors.request.use(
|
api.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const token = localStorage.getItem('access_token')
|
const token = getAccessToken()
|
||||||
if (token) {
|
if (token && !isPublicRequest(config)) {
|
||||||
|
config.headers = config.headers || {}
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
@@ -31,19 +76,26 @@ api.interceptors.request.use(
|
|||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
|
const status = error.response?.status
|
||||||
const message = error.response?.data?.detail || 'An error occurred'
|
const message = error.response?.data?.detail || 'An error occurred'
|
||||||
|
const requestConfig = error.config || {}
|
||||||
|
const hasAuthHeader = Boolean(
|
||||||
|
requestConfig.headers?.Authorization || requestConfig.headers?.authorization
|
||||||
|
)
|
||||||
|
|
||||||
if (error.response?.status === 401) {
|
if (status === 401 && !isPublicRequest(requestConfig) && hasAuthHeader) {
|
||||||
// Unauthorized - Clear token and redirect to login
|
clearAuthStorage()
|
||||||
localStorage.removeItem('access_token')
|
if (window.location.pathname !== '/login') {
|
||||||
localStorage.removeItem('user')
|
window.location.href = '/login'
|
||||||
window.location.href = '/login'
|
}
|
||||||
toast.error('Session expired. Please login again.')
|
toast.error('Session expired. Please login again.')
|
||||||
} else if (error.response?.status === 403) {
|
} else if (status === 401) {
|
||||||
toast.error('Permission denied')
|
|
||||||
} else if (error.response?.status === 429) {
|
|
||||||
toast.error(message)
|
toast.error(message)
|
||||||
} else if (error.response?.status >= 500) {
|
} else if (status === 403) {
|
||||||
|
toast.error('Permission denied')
|
||||||
|
} else if (status === 429) {
|
||||||
|
toast.error(message)
|
||||||
|
} else if (status >= 500) {
|
||||||
toast.error('Server error. Please try again later.')
|
toast.error('Server error. Please try again later.')
|
||||||
} else {
|
} else {
|
||||||
toast.error(message)
|
toast.error(message)
|
||||||
@@ -56,10 +108,10 @@ api.interceptors.response.use(
|
|||||||
// ============ Auth APIs ============
|
// ============ Auth APIs ============
|
||||||
export const authAPI = {
|
export const authAPI = {
|
||||||
register: (username, password) =>
|
register: (username, password) =>
|
||||||
api.post('/auth/register', { username, password }),
|
api.post('/auth/register', { username, password }, { skipAuthHandling: true }),
|
||||||
|
|
||||||
login: (username, password) =>
|
login: (username, password) =>
|
||||||
api.post('/auth/login', { username, password }),
|
api.post('/auth/login', { username, password }, { skipAuthHandling: true }),
|
||||||
|
|
||||||
getCurrentUser: () =>
|
getCurrentUser: () =>
|
||||||
api.get('/auth/me'),
|
api.get('/auth/me'),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Authentication Context
|
* Authentication Context
|
||||||
*/
|
*/
|
||||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
import React, { createContext, useContext, useState, useEffect } from 'react'
|
||||||
import { authAPI } from '../api/client'
|
import { authAPI, AUTH_TOKEN_STORAGE_KEY, clearAuthStorage } from '../api/client'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
const AuthContext = createContext(null)
|
const AuthContext = createContext(null)
|
||||||
@@ -22,15 +22,14 @@ export const AuthProvider = ({ children }) => {
|
|||||||
// Load user from localStorage on mount
|
// Load user from localStorage on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadUser = async () => {
|
const loadUser = async () => {
|
||||||
const token = localStorage.getItem('access_token')
|
const token = localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
const response = await authAPI.getCurrentUser()
|
const response = await authAPI.getCurrentUser()
|
||||||
setUser(response.data)
|
setUser(response.data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load user:', error)
|
console.error('Failed to load user:', error)
|
||||||
localStorage.removeItem('access_token')
|
clearAuthStorage()
|
||||||
localStorage.removeItem('user')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -45,7 +44,7 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const { access_token } = response.data
|
const { access_token } = response.data
|
||||||
|
|
||||||
// Save token
|
// Save token
|
||||||
localStorage.setItem('access_token', access_token)
|
localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, access_token)
|
||||||
|
|
||||||
// Get user info
|
// Get user info
|
||||||
const userResponse = await authAPI.getCurrentUser()
|
const userResponse = await authAPI.getCurrentUser()
|
||||||
@@ -71,8 +70,7 @@ export const AuthProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem('access_token')
|
clearAuthStorage()
|
||||||
localStorage.removeItem('user')
|
|
||||||
setUser(null)
|
setUser(null)
|
||||||
toast.success('Logged out successfully')
|
toast.success('Logged out successfully')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { examAPI, questionAPI } from '../api/client'
|
import { examAPI, buildApiUrl, getAccessToken } from '../api/client'
|
||||||
import ParsingProgress from '../components/ParsingProgress'
|
import ParsingProgress from '../components/ParsingProgress'
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Upload, Play, Loader, FileText, AlertCircle, RefreshCw, ArrowRight
|
ArrowLeft, Upload, Play, Loader, FileText, AlertCircle, RefreshCw, ArrowRight
|
||||||
@@ -51,6 +51,8 @@ export const ExamDetail = () => {
|
|||||||
// Connect to SSE if exam is processing
|
// Connect to SSE if exam is processing
|
||||||
if (examRes.data.status === 'processing') {
|
if (examRes.data.status === 'processing') {
|
||||||
connectSSE()
|
connectSSE()
|
||||||
|
} else {
|
||||||
|
setProgress(null)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load exam:', error)
|
console.error('Failed to load exam:', error)
|
||||||
@@ -68,8 +70,14 @@ export const ExamDetail = () => {
|
|||||||
|
|
||||||
console.log('[SSE] Connecting to progress stream for exam', examId)
|
console.log('[SSE] Connecting to progress stream for exam', examId)
|
||||||
|
|
||||||
const token = localStorage.getItem('token')
|
const token = getAccessToken()
|
||||||
const url = `/api/exams/${examId}/progress?token=${encodeURIComponent(token)}`
|
|
||||||
|
if (!token) {
|
||||||
|
console.error('[SSE] Missing access token')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${buildApiUrl(`/exams/${examId}/progress`)}?token=${encodeURIComponent(token)}`
|
||||||
|
|
||||||
const eventSource = new EventSource(url)
|
const eventSource = new EventSource(url)
|
||||||
eventSourceRef.current = eventSource
|
eventSourceRef.current = eventSource
|
||||||
@@ -173,6 +181,9 @@ export const ExamDetail = () => {
|
|||||||
const isReady = exam.status === 'ready'
|
const isReady = exam.status === 'ready'
|
||||||
const isFailed = exam.status === 'failed'
|
const isFailed = exam.status === 'failed'
|
||||||
const quizProgress = calculateProgress(exam.current_index, exam.total_questions)
|
const quizProgress = calculateProgress(exam.current_index, exam.total_questions)
|
||||||
|
const completionProgress = isProcessing
|
||||||
|
? Math.round(Number(progress?.progress ?? 0))
|
||||||
|
: quizProgress
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -252,17 +263,17 @@ export const ExamDetail = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
<p className="text-sm text-gray-600 mb-1">完成度</p>
|
<p className="text-sm text-gray-600 mb-1">完成度</p>
|
||||||
<p className="text-2xl font-bold text-green-600">{isProcessing ? progress : quizProgress}%</p>
|
<p className="text-2xl font-bold text-green-600">{completionProgress}%</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress Bar */}
|
{/* Progress Bar */}
|
||||||
{exam.total_questions > 0 && (
|
{(isProcessing || exam.total_questions > 0) && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||||
<div
|
<div
|
||||||
className="bg-primary-600 h-3 rounded-full transition-all"
|
className="bg-primary-600 h-3 rounded-full transition-all"
|
||||||
style={{ width: `${quizProgress}%` }}
|
style={{ width: `${completionProgress}%` }}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Login Page
|
* Login Page
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { BookOpen } from 'lucide-react'
|
import { BookOpen } from 'lucide-react'
|
||||||
@@ -15,110 +15,21 @@ export const Login = () => {
|
|||||||
password: ''
|
password: ''
|
||||||
})
|
})
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [captchaInstance, setCaptchaInstance] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleSubmit = async (e) => {
|
||||||
// 确保 window.initAliyunCaptcha 存在且 DOM 元素已渲染
|
e.preventDefault()
|
||||||
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)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
// 这里的 login 可能需要改造以接受验证码参数,或者利用 fetch 的拦截器
|
|
||||||
// 如果是 ESA 边缘拦截,通常它会看请求里带不带特定的 Header/Cookie
|
|
||||||
// 文档示例里是手动 fetch 并且带上了 header: 'captcha-Verify-param'
|
|
||||||
// 暂时我们假设 login 函数内部不需要显式传参(通过 ESA 自动拦截),或者 ESA 需要 headers
|
|
||||||
// 为了安全,建议把 captchaParam 传给 login,让 login 放到 headers 里
|
|
||||||
// 但现在我们先维持原样,或者您可以把 captchaParam 放到 sessionStorage 里由 axios 拦截器读取
|
|
||||||
|
|
||||||
// 注意:上面的 success 回调里我们直接调用了这个,说明验证通过了
|
|
||||||
const success = await login(formData.username, formData.password)
|
const success = await login(formData.username, formData.password)
|
||||||
if (success) {
|
if (success) {
|
||||||
navigate('/dashboard')
|
navigate('/dashboard')
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
if(captchaInstance) captchaInstance.refresh(); // 失败或完成后刷新验证码
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 这里的 handleSubmit 变成只是触发验证码(如果也没通过验证的话)
|
|
||||||
// 但 ESA 示例是绑定 button,点击 button 直接出验证码
|
|
||||||
// 所以我们可以把 type="submit" 变成 type="button" 且 id="login-btn"
|
|
||||||
const handlePreSubmit = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
// 此时不需要做任何事,因为按钮被 ESA 接管了,点击会自动弹窗
|
|
||||||
// 只有验证成功了才会走 success -> handleLoginSubmit
|
|
||||||
// 但是!如果没填用户名密码怎么办?
|
|
||||||
// 最好在点击前校验表单。
|
|
||||||
// ESA 的 button 参数会劫持点击事件。
|
|
||||||
// 我们可以不绑定 button 参数,而是手动验证表单后,调用 captchaInstance.show() (如果是无痕或弹窗)
|
|
||||||
// 官方文档说绑定 button 是“触发验证码弹窗或无痕验证的元素”
|
|
||||||
// 如果我们保留 form submit,拦截它,如果表单有效,则手动 captchaInstance.show() (如果 SDK 支持)
|
|
||||||
// 文档说“无痕模式首次验证不支持 show/hide”。
|
|
||||||
// 咱们还是按官方推荐绑定 button,但是这会导致校验逻辑变复杂
|
|
||||||
|
|
||||||
// 简化方案:为了不破坏现有逻辑,我们不绑定 button ?
|
|
||||||
// 不,必须绑定。那我们把“登录”按钮作为触发器。
|
|
||||||
// 可是如果不填表单直接点登录 -> 验证码 -> 成功 -> 提交空表单 -> 报错。流程不太对。
|
|
||||||
|
|
||||||
// 更好的流程:
|
|
||||||
// 用户填表 -> 点击登录 -> 校验表单 -> (有效) -> 弹出验证码 -> (成功) -> 提交后端
|
|
||||||
|
|
||||||
// 我们可以做一个不可见的 button 绑定给 ESA,验证表单通过后,用代码模拟点击这个 button?
|
|
||||||
// 或者直接用 id="login-btn" 绑定当前的登录按钮,
|
|
||||||
// 但是在 success 回调里检查 formData 是否为空?
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
@@ -144,8 +55,7 @@ export const Login = () => {
|
|||||||
<div className="bg-white rounded-2xl shadow-xl p-8">
|
<div className="bg-white rounded-2xl shadow-xl p-8">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">登录</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">登录</h2>
|
||||||
|
|
||||||
{/* 为了能正确使用 ESA,我们将 form 的 onSubmit 移除,改由按钮触发,或者保留 form 但不做提交 */}
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
<form className="space-y-6" onSubmit={(e) => e.preventDefault()}>
|
|
||||||
{/* Username */}
|
{/* Username */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
@@ -179,14 +89,8 @@ export const Login = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ESA Captcha Container */}
|
|
||||||
<div id="captcha-element"></div>
|
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
{/* 绑定 id="login-btn" 供 ESA 使用 */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
id="login-btn"
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
* Question Bank Page - View all questions
|
* Question Bank Page - View all questions
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { questionAPI } from '../api/client'
|
import { questionAPI } from '../api/client'
|
||||||
import Pagination from '../components/Pagination'
|
import Pagination from '../components/Pagination'
|
||||||
import { FileText, Loader, Search } from 'lucide-react'
|
import { FileText, Loader } from 'lucide-react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { getQuestionTypeText, formatRelativeTime } from '../utils/helpers'
|
import { getQuestionTypeText, formatRelativeTime } from '../utils/helpers'
|
||||||
|
|
||||||
export const QuestionBank = () => {
|
export const QuestionBank = () => {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
const [questions, setQuestions] = useState([])
|
const [questions, setQuestions] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [expandedId, setExpandedId] = useState(null)
|
const [expandedId, setExpandedId] = useState(null)
|
||||||
@@ -17,16 +19,23 @@ export const QuestionBank = () => {
|
|||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [limit, setLimit] = useState(10)
|
const [limit, setLimit] = useState(10)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
|
const examIdParam = searchParams.get('examId')
|
||||||
|
const examIdFilter = /^\d+$/.test(examIdParam || '') ? Number(examIdParam) : null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1)
|
||||||
|
setExpandedId(null)
|
||||||
|
}, [examIdFilter])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadQuestions()
|
loadQuestions()
|
||||||
}, [page, limit])
|
}, [page, limit, examIdFilter])
|
||||||
|
|
||||||
const loadQuestions = async () => {
|
const loadQuestions = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const skip = (page - 1) * limit
|
const skip = (page - 1) * limit
|
||||||
const response = await questionAPI.getAll(skip, limit)
|
const response = await questionAPI.getAll(skip, limit, examIdFilter)
|
||||||
setQuestions(response.data.questions)
|
setQuestions(response.data.questions)
|
||||||
setTotal(response.data.total)
|
setTotal(response.data.total)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -41,6 +50,11 @@ export const QuestionBank = () => {
|
|||||||
setExpandedId(expandedId === id ? null : id)
|
setExpandedId(expandedId === id ? null : id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const title = examIdFilter ? `题库 ${examIdFilter} 题目` : '全站题库'
|
||||||
|
const subtitle = examIdFilter
|
||||||
|
? `当前仅显示该题库下的 ${total} 道题目`
|
||||||
|
: `共 ${total} 道题目`
|
||||||
|
|
||||||
if (loading && questions.length === 0) {
|
if (loading && questions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen">
|
<div className="flex items-center justify-center h-screen">
|
||||||
@@ -54,8 +68,8 @@ export const QuestionBank = () => {
|
|||||||
<div className="p-4 md:p-8">
|
<div className="p-4 md:p-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">全站题库</h1>
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">{title}</h1>
|
||||||
<p className="text-gray-600 mt-1">共 {total} 道题目</p>
|
<p className="text-gray-600 mt-1">{subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
|
|||||||
@@ -9,15 +9,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
envDir, // Tell Vite to look for .env files in the project root
|
envDir, // Tell Vite to look for .env files in the project root
|
||||||
plugins: [
|
plugins: [react()],
|
||||||
react(),
|
|
||||||
{
|
|
||||||
name: 'html-transform',
|
|
||||||
transformIndexHtml(html) {
|
|
||||||
return html.replace(/%VITE_ESA_PREFIX%/g, env.VITE_ESA_PREFIX || '')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
|||||||
225
scripts/smoke_e2e.ps1
Normal file
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
|
||||||
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"]
|
||||||
15
web/README.md
Normal file
15
web/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# QQuiz Web
|
||||||
|
|
||||||
|
This directory contains the new Next.js frontend scaffold for the QQuiz
|
||||||
|
refactor.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- App Router skeleton: added
|
||||||
|
- Auth/session proxy routes: added
|
||||||
|
- Legacy Vite frontend replacement: in progress
|
||||||
|
- shadcn/ui component foundation: added
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Copy `.env.example` and point `API_BASE_URL` at the FastAPI backend.
|
||||||
18
web/components.json
Normal file
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
web/postcss.config.mjs
Normal file
6
web/postcss.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
web/src/app/(auth)/login/page.tsx
Normal file
111
web/src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ArrowRight, ShieldCheck } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload?.detail || "登录失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("登录成功");
|
||||||
|
router.push(searchParams.get("next") || "/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "登录失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative flex min-h-screen items-center justify-center overflow-hidden p-6">
|
||||||
|
<div className="absolute inset-0 bg-brand-grid bg-[size:34px_34px] opacity-40" />
|
||||||
|
<div className="relative grid w-full max-w-5xl gap-6 lg:grid-cols-[1.1fr_480px]">
|
||||||
|
<Card className="hidden border-slate-900/10 bg-slate-950 text-white lg:block">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-3 text-sm uppercase tracking-[0.2em] text-slate-300">
|
||||||
|
<ShieldCheck className="h-4 w-4" />
|
||||||
|
QQuiz Web
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-4xl leading-tight">登录</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 text-sm leading-7 text-slate-300">
|
||||||
|
<p>使用管理员或测试账号进入系统。</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-white/80 bg-white/92">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-3xl">登录</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-700">用户名</label>
|
||||||
|
<Input
|
||||||
|
autoComplete="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-700">密码</label>
|
||||||
|
<Input
|
||||||
|
autoComplete="current-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button className="w-full" disabled={loading} type="submit">
|
||||||
|
{loading ? "登录中..." : "登录"}
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
还没有账号?
|
||||||
|
<Link className="ml-2 font-medium text-primary underline-offset-4 hover:underline" href="/register">
|
||||||
|
立即注册
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
web/src/app/(auth)/register/page.tsx
Normal file
94
web/src/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/proxy/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload?.detail || "注册失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("注册成功,请登录");
|
||||||
|
router.push("/login");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "注册失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center p-6">
|
||||||
|
<Card className="w-full max-w-lg border-white/80 bg-white/92">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-3xl">创建账户</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-700">用户名</label>
|
||||||
|
<Input
|
||||||
|
autoComplete="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
placeholder="3-50 位字母、数字、_ 或 -"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-700">密码</label>
|
||||||
|
<Input
|
||||||
|
autoComplete="new-password"
|
||||||
|
type="password"
|
||||||
|
minLength={6}
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder="至少 6 位"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button className="w-full" disabled={loading} type="submit">
|
||||||
|
{loading ? "提交中..." : "注册"}
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
已有账号?
|
||||||
|
<Link className="ml-2 font-medium text-primary underline-offset-4 hover:underline" href="/login">
|
||||||
|
返回登录
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
web/src/app/api/auth/login/route.ts
Normal file
37
web/src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { SESSION_COOKIE_NAME, buildBackendUrl } from "@/lib/api/config";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const body = await request.json();
|
||||||
|
const forwardedProto = request.headers.get("x-forwarded-proto");
|
||||||
|
const isSecureRequest =
|
||||||
|
request.nextUrl.protocol === "https:" || forwardedProto === "https";
|
||||||
|
|
||||||
|
const response = await fetch(buildBackendUrl("/auth/login"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(payload, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
cookies().set({
|
||||||
|
name: SESSION_COOKIE_NAME,
|
||||||
|
value: payload.access_token,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: isSecureRequest,
|
||||||
|
path: "/"
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
17
web/src/app/api/auth/logout/route.ts
Normal file
17
web/src/app/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();
|
||||||
|
}
|
||||||
30
web/src/app/api/auth/me/route.ts
Normal file
30
web/src/app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
buildBackendUrl
|
||||||
|
} from "@/lib/api/config";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const token = cookies().get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ detail: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(buildBackendUrl("/auth/me"), {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
cookies().delete(SESSION_COOKIE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(payload, { status: response.status });
|
||||||
|
}
|
||||||
42
web/src/app/api/exams/[examId]/progress/route.ts
Normal file
42
web/src/app/api/exams/[examId]/progress/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
buildBackendUrl
|
||||||
|
} from "@/lib/api/config";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: { examId: string } }
|
||||||
|
) {
|
||||||
|
const token = cookies().get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ detail: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = `${buildBackendUrl(`/exams/${params.examId}/progress`)}?token=${encodeURIComponent(token)}`;
|
||||||
|
const response = await fetch(target, {
|
||||||
|
headers: {
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache"
|
||||||
|
},
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
const payload = await response.text();
|
||||||
|
return new NextResponse(payload || "Failed to open exam progress stream", {
|
||||||
|
status: response.status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
82
web/src/app/api/proxy/[...path]/route.ts
Normal file
82
web/src/app/api/proxy/[...path]/route.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
buildBackendUrl
|
||||||
|
} from "@/lib/api/config";
|
||||||
|
|
||||||
|
async function proxyRequest(
|
||||||
|
request: NextRequest,
|
||||||
|
params: { path: string[] }
|
||||||
|
) {
|
||||||
|
const token = cookies().get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
const requestPath = params.path.join("/");
|
||||||
|
const target = `${buildBackendUrl(`/${requestPath}`)}${request.nextUrl.search}`;
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
const contentType = request.headers.get("content-type");
|
||||||
|
if (contentType) {
|
||||||
|
headers.set("Content-Type", contentType);
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers.set("Authorization", `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = request.method;
|
||||||
|
const init: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
cache: "no-store"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!["GET", "HEAD"].includes(method)) {
|
||||||
|
init.body = await request.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(target, init);
|
||||||
|
const responseHeaders = new Headers(response.headers);
|
||||||
|
responseHeaders.delete("content-encoding");
|
||||||
|
responseHeaders.delete("content-length");
|
||||||
|
responseHeaders.delete("transfer-encoding");
|
||||||
|
|
||||||
|
return new NextResponse(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
headers: responseHeaders
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } }
|
||||||
|
) {
|
||||||
|
return proxyRequest(request, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } }
|
||||||
|
) {
|
||||||
|
return proxyRequest(request, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } }
|
||||||
|
) {
|
||||||
|
return proxyRequest(request, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } }
|
||||||
|
) {
|
||||||
|
return proxyRequest(request, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } }
|
||||||
|
) {
|
||||||
|
return proxyRequest(request, params);
|
||||||
|
}
|
||||||
47
web/src/app/globals.css
Normal file
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("/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(`/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
web/src/components/practice/mistake-practice-client.tsx
Normal file
263
web/src/components/practice/mistake-practice-client.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { ArrowLeft, ArrowRight, Check, Loader2, Trash2, X } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { browserApi } from "@/lib/api/browser";
|
||||||
|
import { AnswerCheckResponse, MistakeListResponse } from "@/lib/types";
|
||||||
|
import { getQuestionTypeLabel } from "@/lib/formatters";
|
||||||
|
|
||||||
|
type MistakeItem = MistakeListResponse["mistakes"][number];
|
||||||
|
|
||||||
|
export function MistakePracticeClient() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [mistakes, setMistakes] = useState<MistakeItem[]>([]);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [result, setResult] = useState<AnswerCheckResponse | null>(null);
|
||||||
|
const [userAnswer, setUserAnswer] = useState("");
|
||||||
|
const [multipleAnswers, setMultipleAnswers] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadMistakes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadMistakes() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const payload = await browserApi<MistakeListResponse>("/mistakes/?skip=0&limit=1000", {
|
||||||
|
method: "GET"
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextMistakes = payload.mistakes;
|
||||||
|
if (searchParams.get("mode") === "random") {
|
||||||
|
nextMistakes = [...payload.mistakes].sort(() => Math.random() - 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextMistakes = nextMistakes.map((item) => {
|
||||||
|
if (item.question.type === "judge" && (!item.question.options || item.question.options.length === 0)) {
|
||||||
|
item.question.options = ["A. 正确", "B. 错误"];
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
|
setMistakes(nextMistakes);
|
||||||
|
setCurrentIndex(0);
|
||||||
|
setResult(null);
|
||||||
|
setUserAnswer("");
|
||||||
|
setMultipleAnswers([]);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "加载失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMistake = mistakes[currentIndex] || null;
|
||||||
|
const question = currentMistake?.question || null;
|
||||||
|
const progressText = useMemo(
|
||||||
|
() => (mistakes.length ? `${currentIndex + 1} / ${mistakes.length}` : "0 / 0"),
|
||||||
|
[currentIndex, mistakes.length]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!question) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let answer = userAnswer;
|
||||||
|
if (question.type === "multiple") {
|
||||||
|
if (multipleAnswers.length === 0) {
|
||||||
|
toast.error("请至少选择一个选项");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
answer = [...multipleAnswers].sort().join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!answer.trim()) {
|
||||||
|
toast.error("请输入答案");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const payload = await browserApi<AnswerCheckResponse>("/questions/check", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
question_id: question.id,
|
||||||
|
user_answer: answer
|
||||||
|
})
|
||||||
|
});
|
||||||
|
setResult(payload);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "提交失败");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove() {
|
||||||
|
if (!currentMistake) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await browserApi<void>(`/mistakes/${currentMistake.id}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
const nextList = mistakes.filter((item) => item.id !== currentMistake.id);
|
||||||
|
setMistakes(nextList);
|
||||||
|
setCurrentIndex((current) => Math.max(0, Math.min(current, nextList.length - 1)));
|
||||||
|
setResult(null);
|
||||||
|
setUserAnswer("");
|
||||||
|
setMultipleAnswers([]);
|
||||||
|
toast.success("已移除");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "移除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
if (currentIndex < mistakes.length - 1) {
|
||||||
|
setCurrentIndex((current) => current + 1);
|
||||||
|
setResult(null);
|
||||||
|
setUserAnswer("");
|
||||||
|
setMultipleAnswers([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("已完成");
|
||||||
|
router.push("/mistakes");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!question) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-10 text-center text-sm text-slate-500">
|
||||||
|
错题本为空
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button onClick={() => router.push("/mistakes")} type="button" variant="outline">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
<div className="text-sm text-slate-600">{progressText}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-slate-200/70 bg-white/92">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<CardTitle>{question.content}</CardTitle>
|
||||||
|
<div className="text-sm text-slate-500">{getQuestionTypeLabel(question.type)}</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleRemove} size="sm" type="button" variant="outline">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
移除
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{question.options?.length ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{question.options.map((option) => {
|
||||||
|
const letter = option.charAt(0);
|
||||||
|
const selected =
|
||||||
|
question.type === "multiple"
|
||||||
|
? multipleAnswers.includes(letter)
|
||||||
|
: userAnswer === letter;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
className={`w-full rounded-2xl border px-4 py-3 text-left text-sm transition ${
|
||||||
|
selected
|
||||||
|
? "border-primary bg-blue-50 text-slate-950"
|
||||||
|
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300"
|
||||||
|
}`}
|
||||||
|
disabled={Boolean(result)}
|
||||||
|
onClick={() => {
|
||||||
|
if (result) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (question.type === "multiple") {
|
||||||
|
setMultipleAnswers((current) =>
|
||||||
|
current.includes(letter)
|
||||||
|
? current.filter((item) => item !== letter)
|
||||||
|
: [...current, letter]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setUserAnswer(letter);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{question.type === "short" ? (
|
||||||
|
<textarea
|
||||||
|
className="min-h-36 w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm outline-none ring-0 focus:border-primary"
|
||||||
|
onChange={(event) => setUserAnswer(event.target.value)}
|
||||||
|
placeholder="输入答案"
|
||||||
|
value={userAnswer}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!result ? (
|
||||||
|
<Button className="w-full" disabled={submitting} onClick={handleSubmit} type="button">
|
||||||
|
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
||||||
|
提交
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className={`rounded-2xl border p-4 ${result.correct ? "border-emerald-200 bg-emerald-50" : "border-red-200 bg-red-50"}`}>
|
||||||
|
<div className="flex items-center gap-2 font-medium">
|
||||||
|
{result.correct ? <Check className="h-4 w-4 text-emerald-600" /> : <X className="h-4 w-4 text-red-600" />}
|
||||||
|
{result.correct ? "回答正确" : "回答错误"}
|
||||||
|
</div>
|
||||||
|
{!result.correct ? (
|
||||||
|
<div className="mt-3 text-sm text-slate-700">
|
||||||
|
正确答案:{result.correct_answer}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{result.analysis ? (
|
||||||
|
<div className="mt-3 text-sm text-slate-600">{result.analysis}</div>
|
||||||
|
) : null}
|
||||||
|
{result.ai_feedback ? (
|
||||||
|
<div className="mt-3 text-sm text-slate-600">{result.ai_feedback}</div>
|
||||||
|
) : null}
|
||||||
|
<Button className="mt-4 w-full" onClick={handleNext} type="button">
|
||||||
|
{currentIndex < mistakes.length - 1 ? "下一题" : "完成"}
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
287
web/src/components/practice/quiz-player-client.tsx
Normal file
287
web/src/components/practice/quiz-player-client.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { ArrowLeft, ArrowRight, BookmarkPlus, BookmarkX, Check, Loader2, X } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { browserApi } from "@/lib/api/browser";
|
||||||
|
import { AnswerCheckResponse, ExamSummary, QuestionDetail } from "@/lib/types";
|
||||||
|
import { getQuestionTypeLabel } from "@/lib/formatters";
|
||||||
|
|
||||||
|
export function QuizPlayerClient({
|
||||||
|
examId
|
||||||
|
}: {
|
||||||
|
examId: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [exam, setExam] = useState<ExamSummary | null>(null);
|
||||||
|
const [question, setQuestion] = useState<QuestionDetail | null>(null);
|
||||||
|
const [result, setResult] = useState<AnswerCheckResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [inMistakeBook, setInMistakeBook] = useState(false);
|
||||||
|
const [userAnswer, setUserAnswer] = useState("");
|
||||||
|
const [multipleAnswers, setMultipleAnswers] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadQuiz();
|
||||||
|
}, [examId]);
|
||||||
|
|
||||||
|
async function loadQuiz() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (searchParams.get("reset") === "true") {
|
||||||
|
await browserApi(`/exams/${examId}/progress`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ current_index: 0 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [examPayload, questionPayload, mistakesPayload] = await Promise.all([
|
||||||
|
browserApi<ExamSummary>(`/exams/${examId}`, { method: "GET" }),
|
||||||
|
browserApi<QuestionDetail>(`/questions/exam/${examId}/current`, { method: "GET" }),
|
||||||
|
browserApi<{ mistakes: Array<{ question_id: number }> }>("/mistakes/?skip=0&limit=1000", {
|
||||||
|
method: "GET"
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (questionPayload.type === "judge" && (!questionPayload.options || questionPayload.options.length === 0)) {
|
||||||
|
questionPayload.options = ["A. 正确", "B. 错误"];
|
||||||
|
}
|
||||||
|
|
||||||
|
setExam(examPayload);
|
||||||
|
setQuestion(questionPayload);
|
||||||
|
setResult(null);
|
||||||
|
setUserAnswer("");
|
||||||
|
setMultipleAnswers([]);
|
||||||
|
setInMistakeBook(mistakesPayload.mistakes.some((item) => item.question_id === questionPayload.id));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "加载失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressText = useMemo(() => {
|
||||||
|
if (!exam) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${exam.current_index + 1} / ${exam.total_questions}`;
|
||||||
|
}, [exam]);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!question) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let answer = userAnswer;
|
||||||
|
if (question.type === "multiple") {
|
||||||
|
if (multipleAnswers.length === 0) {
|
||||||
|
toast.error("请至少选择一个选项");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
answer = [...multipleAnswers].sort().join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!answer.trim()) {
|
||||||
|
toast.error("请输入答案");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const payload = await browserApi<AnswerCheckResponse>("/questions/check", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
question_id: question.id,
|
||||||
|
user_answer: answer
|
||||||
|
})
|
||||||
|
});
|
||||||
|
setResult(payload);
|
||||||
|
setInMistakeBook(!payload.correct || inMistakeBook);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "提交失败");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNext() {
|
||||||
|
if (!exam) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await browserApi(`/exams/${examId}/progress`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ current_index: exam.current_index + 1 })
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadQuiz();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "跳转失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleMistake() {
|
||||||
|
if (!question) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (inMistakeBook) {
|
||||||
|
await browserApi(`/mistakes/question/${question.id}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
setInMistakeBook(false);
|
||||||
|
} else {
|
||||||
|
await browserApi("/mistakes/add", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ question_id: question.id })
|
||||||
|
});
|
||||||
|
setInMistakeBook(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "操作失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exam || !question) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-10 text-center text-sm text-slate-500">
|
||||||
|
当前没有可练习的题目
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button onClick={() => router.push(`/exams/${examId}`)} type="button" variant="outline">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
<div className="text-sm text-slate-600">{progressText}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-slate-200/70 bg-white/92">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<CardTitle>{question.content}</CardTitle>
|
||||||
|
<div className="text-sm text-slate-500">{getQuestionTypeLabel(question.type)}</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleToggleMistake} size="sm" type="button" variant="outline">
|
||||||
|
{inMistakeBook ? <BookmarkX className="h-4 w-4" /> : <BookmarkPlus className="h-4 w-4" />}
|
||||||
|
{inMistakeBook ? "移除错题" : "加入错题"}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{question.options?.length ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{question.options.map((option) => {
|
||||||
|
const letter = option.charAt(0);
|
||||||
|
const selected =
|
||||||
|
question.type === "multiple"
|
||||||
|
? multipleAnswers.includes(letter)
|
||||||
|
: userAnswer === letter;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
className={`w-full rounded-2xl border px-4 py-3 text-left text-sm transition ${
|
||||||
|
selected
|
||||||
|
? "border-primary bg-blue-50 text-slate-950"
|
||||||
|
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300"
|
||||||
|
}`}
|
||||||
|
disabled={Boolean(result)}
|
||||||
|
onClick={() => {
|
||||||
|
if (result) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (question.type === "multiple") {
|
||||||
|
setMultipleAnswers((current) =>
|
||||||
|
current.includes(letter)
|
||||||
|
? current.filter((item) => item !== letter)
|
||||||
|
: [...current, letter]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setUserAnswer(letter);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{question.type === "short" ? (
|
||||||
|
<textarea
|
||||||
|
className="min-h-36 w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm outline-none ring-0 focus:border-primary"
|
||||||
|
onChange={(event) => setUserAnswer(event.target.value)}
|
||||||
|
placeholder="输入答案"
|
||||||
|
value={userAnswer}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!result ? (
|
||||||
|
<Button className="w-full" disabled={submitting} onClick={handleSubmit} type="button">
|
||||||
|
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
||||||
|
提交
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className={`rounded-2xl border p-4 ${result.correct ? "border-emerald-200 bg-emerald-50" : "border-red-200 bg-red-50"}`}>
|
||||||
|
<div className="flex items-center gap-2 font-medium">
|
||||||
|
{result.correct ? <Check className="h-4 w-4 text-emerald-600" /> : <X className="h-4 w-4 text-red-600" />}
|
||||||
|
{result.correct ? "回答正确" : "回答错误"}
|
||||||
|
</div>
|
||||||
|
{!result.correct ? (
|
||||||
|
<div className="mt-3 text-sm text-slate-700">
|
||||||
|
正确答案:{result.correct_answer}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{result.analysis ? (
|
||||||
|
<div className="mt-3 text-sm text-slate-600">{result.analysis}</div>
|
||||||
|
) : null}
|
||||||
|
{result.ai_feedback ? (
|
||||||
|
<div className="mt-3 text-sm text-slate-600">{result.ai_feedback}</div>
|
||||||
|
) : null}
|
||||||
|
<Button className="mt-4 w-full" onClick={handleNext} type="button">
|
||||||
|
下一题
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
web/src/components/providers/query-provider.tsx
Normal file
22
web/src/components/providers/query-provider.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 10_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
web/src/components/questions/question-list.tsx
Normal file
73
web/src/components/questions/question-list.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { PaginationControls } from "@/components/ui/pagination-controls";
|
||||||
|
import { getQuestionTypeLabel, formatDate } from "@/lib/formatters";
|
||||||
|
import { QuestionListItem } from "@/lib/types";
|
||||||
|
|
||||||
|
export function QuestionList({
|
||||||
|
examId,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
questions,
|
||||||
|
total
|
||||||
|
}: {
|
||||||
|
examId?: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
questions: QuestionListItem[];
|
||||||
|
total: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="border-slate-200/70 bg-white/92">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle>题目</CardTitle>
|
||||||
|
{examId ? <div className="text-sm text-slate-500">题库 #{examId}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-500">{total} 条</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{questions.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
|
||||||
|
暂无题目
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-slate-200">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="bg-slate-50 text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 font-medium">题目</th>
|
||||||
|
<th className="px-4 py-3 font-medium">类型</th>
|
||||||
|
<th className="px-4 py-3 font-medium">题库</th>
|
||||||
|
<th className="px-4 py-3 font-medium">创建时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{questions.map((question) => (
|
||||||
|
<tr key={question.id} className="border-t border-slate-200">
|
||||||
|
<td className="px-4 py-3 text-slate-700">
|
||||||
|
<div className="line-clamp-2 max-w-3xl">{question.content}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-600">
|
||||||
|
{getQuestionTypeLabel(question.type)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-600">#{question.exam_id}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-600">
|
||||||
|
{formatDate(question.created_at)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PaginationControls
|
||||||
|
className="mt-4 rounded-2xl border border-slate-200"
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
web/src/components/ui/badge.tsx
Normal file
33
web/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-transparent bg-primary/12 text-primary",
|
||||||
|
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||||
|
outline: "border-border text-foreground",
|
||||||
|
success: "border-transparent bg-success/15 text-success",
|
||||||
|
warning: "border-transparent bg-warning/20 text-warning",
|
||||||
|
destructive: "border-transparent bg-destructive/15 text-destructive"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
54
web/src/components/ui/button.tsx
Normal file
54
web/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-sm hover:brightness-110",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
outline:
|
||||||
|
"border border-border bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground"
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-11 px-5 py-2",
|
||||||
|
sm: "h-9 px-4",
|
||||||
|
lg: "h-12 px-6",
|
||||||
|
icon: "h-10 w-10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
68
web/src/components/ui/card.tsx
Normal file
68
web/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-[28px] border border-border/60 bg-card/95 text-card-foreground shadow-panel backdrop-blur",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||||
|
));
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLHeadingElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-xl font-semibold tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||||
|
));
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
22
web/src/components/ui/input.tsx
Normal file
22
web/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-11 w-full rounded-2xl border border-input bg-background px-4 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
138
web/src/components/ui/pagination-controls.tsx
Normal file
138
web/src/components/ui/pagination-controls.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function getVisiblePages(currentPage: number, totalPages: number) {
|
||||||
|
const pages = new Set<number>([1, totalPages, currentPage]);
|
||||||
|
|
||||||
|
for (let page = currentPage - 1; page <= currentPage + 1; page += 1) {
|
||||||
|
if (page > 1 && page < totalPages) {
|
||||||
|
pages.add(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(pages).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageButton({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
active = false,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (disabled) {
|
||||||
|
return (
|
||||||
|
<Button className={className} disabled size="sm" type="button" variant="outline">
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className={className}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant={active ? "default" : "outline"}
|
||||||
|
>
|
||||||
|
<Link href={href}>{children}</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationControls({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const currentPage = Math.min(page, totalPages);
|
||||||
|
|
||||||
|
if (total <= pageSize) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeStart = (currentPage - 1) * pageSize + 1;
|
||||||
|
const rangeEnd = Math.min(total, currentPage * pageSize);
|
||||||
|
const visiblePages = getVisiblePages(currentPage, totalPages);
|
||||||
|
|
||||||
|
function buildHref(targetPage: number) {
|
||||||
|
const params = new URLSearchParams(searchParams?.toString());
|
||||||
|
if (targetPage <= 1) {
|
||||||
|
params.delete("page");
|
||||||
|
} else {
|
||||||
|
params.set("page", String(targetPage));
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = params.toString();
|
||||||
|
return query ? `${pathname}?${query}` : pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-3 border-t border-slate-200 px-4 py-4 text-sm text-slate-600 md:flex-row md:items-center md:justify-between",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
显示第 {rangeStart}-{rangeEnd} 条,共 {total} 条
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<PageButton disabled={currentPage <= 1} href={buildHref(1)}>
|
||||||
|
<ChevronsLeft className="h-4 w-4" />
|
||||||
|
</PageButton>
|
||||||
|
<PageButton disabled={currentPage <= 1} href={buildHref(currentPage - 1)}>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</PageButton>
|
||||||
|
|
||||||
|
{visiblePages.map((visiblePage, index) => {
|
||||||
|
const previousPage = visiblePages[index - 1];
|
||||||
|
const showGap = previousPage && visiblePage - previousPage > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={visiblePage} className="flex items-center gap-2">
|
||||||
|
{showGap ? <span className="px-1 text-slate-400">...</span> : null}
|
||||||
|
<PageButton
|
||||||
|
active={visiblePage === currentPage}
|
||||||
|
href={buildHref(visiblePage)}
|
||||||
|
>
|
||||||
|
{visiblePage}
|
||||||
|
</PageButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<PageButton disabled={currentPage >= totalPages} href={buildHref(currentPage + 1)}>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</PageButton>
|
||||||
|
<PageButton disabled={currentPage >= totalPages} href={buildHref(totalPages)}>
|
||||||
|
<ChevronsRight className="h-4 w-4" />
|
||||||
|
</PageButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
web/src/components/ui/separator.tsx
Normal file
26
web/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
57
web/src/lib/api/browser.ts
Normal file
57
web/src/lib/api/browser.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { buildProxyUrl } from "@/lib/api/config";
|
||||||
|
|
||||||
|
type BrowserApiOptions = Omit<RequestInit, "body"> & {
|
||||||
|
body?: BodyInit | null;
|
||||||
|
query?: Record<string, string | number | boolean | null | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildSearchParams(
|
||||||
|
query?: BrowserApiOptions["query"]
|
||||||
|
): URLSearchParams | undefined {
|
||||||
|
if (!query) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
Object.entries(query).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
params.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function browserApi<T>(
|
||||||
|
path: string,
|
||||||
|
options: BrowserApiOptions = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const { query, headers, ...init } = options;
|
||||||
|
const response = await fetch(buildProxyUrl(path, buildSearchParams(query)), {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(headers || {})
|
||||||
|
},
|
||||||
|
credentials: "same-origin",
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const fallback = `Request failed with status ${response.status}`;
|
||||||
|
try {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data?.detail || fallback);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
23
web/src/lib/api/config.ts
Normal file
23
web/src/lib/api/config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export const SESSION_COOKIE_NAME = "access_token";
|
||||||
|
|
||||||
|
export function getApiBaseUrl() {
|
||||||
|
return process.env.API_BASE_URL || "http://localhost:8000";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBackendUrl(path: string) {
|
||||||
|
const trimmedBase = getApiBaseUrl().replace(/\/$/, "");
|
||||||
|
const normalizedPath = path.startsWith("/api/")
|
||||||
|
? path
|
||||||
|
: `/api/${path.replace(/^\//, "")}`;
|
||||||
|
|
||||||
|
return `${trimmedBase}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProxyUrl(path: string, search?: URLSearchParams) {
|
||||||
|
const normalizedPath = path.replace(/^\/+/, "");
|
||||||
|
const query = search?.toString();
|
||||||
|
|
||||||
|
return query
|
||||||
|
? `/api/proxy/${normalizedPath}?${query}`
|
||||||
|
: `/api/proxy/${normalizedPath}`;
|
||||||
|
}
|
||||||
50
web/src/lib/api/server.ts
Normal file
50
web/src/lib/api/server.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
buildBackendUrl
|
||||||
|
} from "@/lib/api/config";
|
||||||
|
|
||||||
|
type ServerApiOptions = RequestInit & {
|
||||||
|
next?: { revalidate?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function serverApi<T>(
|
||||||
|
path: string,
|
||||||
|
init: ServerApiOptions = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const token = cookies().get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
const response = await fetch(buildBackendUrl(path), {
|
||||||
|
...init,
|
||||||
|
cache: init.cache || "no-store",
|
||||||
|
headers: {
|
||||||
|
...(init.headers || {}),
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const fallback = `Request failed with status ${response.status}`;
|
||||||
|
try {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data?.detail || fallback);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
21
web/src/lib/auth/guards.ts
Normal file
21
web/src/lib/auth/guards.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { serverApi } from "@/lib/api/server";
|
||||||
|
import { AuthUser } from "@/lib/types";
|
||||||
|
|
||||||
|
export async function requireCurrentUser() {
|
||||||
|
try {
|
||||||
|
return await serverApi<AuthUser>("/auth/me");
|
||||||
|
} catch (_error) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdminUser() {
|
||||||
|
const user = await requireCurrentUser();
|
||||||
|
if (!user.is_admin) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
17
web/src/lib/auth/session.ts
Normal file
17
web/src/lib/auth/session.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { SESSION_COOKIE_NAME } from "@/lib/api/config";
|
||||||
|
|
||||||
|
export function readSessionToken() {
|
||||||
|
return cookies().get(SESSION_COOKIE_NAME)?.value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireSessionToken() {
|
||||||
|
const token = readSessionToken();
|
||||||
|
if (!token) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
50
web/src/lib/formatters.ts
Normal file
50
web/src/lib/formatters.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
export function formatDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelativeTime(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
const diff = Date.now() - date.getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
const days = Math.floor(diff / 86400000);
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
return `${days} 天前`;
|
||||||
|
}
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours} 小时前`;
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes} 分钟前`;
|
||||||
|
}
|
||||||
|
return "刚刚";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExamStatusLabel(status: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
pending: "等待中",
|
||||||
|
processing: "处理中",
|
||||||
|
ready: "就绪",
|
||||||
|
failed: "失败"
|
||||||
|
};
|
||||||
|
|
||||||
|
return map[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuestionTypeLabel(type: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
single: "单选",
|
||||||
|
multiple: "多选",
|
||||||
|
judge: "判断",
|
||||||
|
short: "简答"
|
||||||
|
};
|
||||||
|
|
||||||
|
return map[type] || type;
|
||||||
|
}
|
||||||
41
web/src/lib/pagination.ts
Normal file
41
web/src/lib/pagination.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
export type SearchParamValue = string | string[] | undefined;
|
||||||
|
|
||||||
|
export function parsePositiveInt(
|
||||||
|
value: SearchParamValue,
|
||||||
|
fallback = 1
|
||||||
|
): number {
|
||||||
|
const raw = Array.isArray(value) ? value[0] : value;
|
||||||
|
const parsed = Number(raw);
|
||||||
|
|
||||||
|
if (!raw || !Number.isFinite(parsed) || parsed < 1) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.floor(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOptionalPositiveInt(
|
||||||
|
value: SearchParamValue
|
||||||
|
): number | undefined {
|
||||||
|
const raw = Array.isArray(value) ? value[0] : value;
|
||||||
|
if (!raw) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.floor(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOffset(page: number, pageSize = DEFAULT_PAGE_SIZE) {
|
||||||
|
return Math.max(0, page - 1) * pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTotalPages(total: number, pageSize = DEFAULT_PAGE_SIZE) {
|
||||||
|
return Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
}
|
||||||
149
web/src/lib/types.ts
Normal file
149
web/src/lib/types.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
export interface AuthUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamSummary {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
title: string;
|
||||||
|
status: "pending" | "processing" | "ready" | "failed";
|
||||||
|
current_index: number;
|
||||||
|
total_questions: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamListResponse {
|
||||||
|
exams: ExamSummary[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamSummaryStats {
|
||||||
|
total_exams: number;
|
||||||
|
total_questions: number;
|
||||||
|
completed_questions: number;
|
||||||
|
processing_exams: number;
|
||||||
|
ready_exams: number;
|
||||||
|
failed_exams: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestionListItem {
|
||||||
|
id: number;
|
||||||
|
exam_id: number;
|
||||||
|
content: string;
|
||||||
|
type: "single" | "multiple" | "judge" | "short";
|
||||||
|
options?: string[] | null;
|
||||||
|
analysis?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestionDetail extends QuestionListItem {}
|
||||||
|
|
||||||
|
export interface QuestionListResponse {
|
||||||
|
questions: QuestionListItem[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnswerCheckResponse {
|
||||||
|
correct: boolean;
|
||||||
|
user_answer: string;
|
||||||
|
correct_answer: string;
|
||||||
|
analysis?: string | null;
|
||||||
|
ai_score?: number | null;
|
||||||
|
ai_feedback?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamUploadResponse {
|
||||||
|
exam_id: number;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressEvent {
|
||||||
|
exam_id: number;
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
progress: number;
|
||||||
|
total_chunks: number;
|
||||||
|
current_chunk: number;
|
||||||
|
questions_extracted: number;
|
||||||
|
questions_added: number;
|
||||||
|
duplicates_removed: number;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MistakeListResponse {
|
||||||
|
mistakes: Array<{
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
question_id: number;
|
||||||
|
created_at: string;
|
||||||
|
question: {
|
||||||
|
id: number;
|
||||||
|
exam_id: number;
|
||||||
|
content: string;
|
||||||
|
type: "single" | "multiple" | "judge" | "short";
|
||||||
|
options?: string[] | null;
|
||||||
|
answer: string;
|
||||||
|
analysis?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUserSummary extends AuthUser {
|
||||||
|
exam_count: number;
|
||||||
|
mistake_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserListResponse {
|
||||||
|
users: AdminUserSummary[];
|
||||||
|
total: number;
|
||||||
|
skip: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminStatisticsResponse {
|
||||||
|
users: {
|
||||||
|
total: number;
|
||||||
|
admins: number;
|
||||||
|
regular_users: number;
|
||||||
|
};
|
||||||
|
exams: {
|
||||||
|
total: number;
|
||||||
|
today_uploads: number;
|
||||||
|
by_status: Record<string, number>;
|
||||||
|
upload_trend: Array<{ date: string; count: number }>;
|
||||||
|
};
|
||||||
|
questions: {
|
||||||
|
total: number;
|
||||||
|
by_type: Record<string, number>;
|
||||||
|
};
|
||||||
|
activity: {
|
||||||
|
today_active_users: number;
|
||||||
|
today_uploads: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemConfigResponse {
|
||||||
|
allow_registration: boolean;
|
||||||
|
max_upload_size_mb: number;
|
||||||
|
max_daily_uploads: number;
|
||||||
|
ai_provider: string;
|
||||||
|
openai_api_key?: string | null;
|
||||||
|
openai_base_url?: string | null;
|
||||||
|
openai_model?: string | null;
|
||||||
|
anthropic_api_key?: string | null;
|
||||||
|
anthropic_model?: string | null;
|
||||||
|
qwen_api_key?: string | null;
|
||||||
|
qwen_base_url?: string | null;
|
||||||
|
qwen_model?: string | null;
|
||||||
|
gemini_api_key?: string | null;
|
||||||
|
gemini_base_url?: string | null;
|
||||||
|
gemini_model?: string | null;
|
||||||
|
}
|
||||||
6
web/src/lib/utils.ts
Normal file
6
web/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
31
web/src/middleware.ts
Normal file
31
web/src/middleware.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { SESSION_COOKIE_NAME } from "@/lib/api/config";
|
||||||
|
|
||||||
|
const protectedPrefixes = [
|
||||||
|
"/dashboard",
|
||||||
|
"/exams",
|
||||||
|
"/quiz",
|
||||||
|
"/mistakes",
|
||||||
|
"/mistake-quiz",
|
||||||
|
"/questions",
|
||||||
|
"/admin"
|
||||||
|
];
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
if (protectedPrefixes.some((prefix) => pathname.startsWith(prefix)) && !token) {
|
||||||
|
const loginUrl = new URL("/login", request.url);
|
||||||
|
loginUrl.searchParams.set("next", pathname);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"]
|
||||||
|
};
|
||||||
72
web/tailwind.config.ts
Normal file
72
web/tailwind.config.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
import animate from "tailwindcss-animate";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: ["./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "1.5rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))"
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))"
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))"
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))"
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))"
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))"
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
DEFAULT: "hsl(var(--success))",
|
||||||
|
foreground: "hsl(var(--success-foreground))"
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
DEFAULT: "hsl(var(--warning))",
|
||||||
|
foreground: "hsl(var(--warning-foreground))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)"
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
panel: "0 24px 60px rgba(20, 32, 56, 0.14)"
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
"brand-grid":
|
||||||
|
"linear-gradient(to right, rgba(17, 24, 39, 0.04) 1px, transparent 1px), linear-gradient(to bottom, rgba(17, 24, 39, 0.04) 1px, transparent 1px)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [animate]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
25
web/tsconfig.json
Normal file
25
web/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": false,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user