From 9a1a9d32474616dd99e0b6cb981cfb2f18c76a52 Mon Sep 17 00:00:00 2001
From: handsomezhuzhu <2658601135@qq.com>
Date: Fri, 17 Apr 2026 21:15:06 +0800
Subject: [PATCH] refactor: remove legacy frontend code and implement new
Next.js structure
- Deleted the old Register page and utility functions.
- Removed Tailwind CSS configuration and Vite configuration files.
- Added a new script for starting a single container with FastAPI and Next.js.
- Updated README to reflect the current status of the Next.js frontend.
- Implemented new login and registration API routes with improved error handling.
- Refactored frontend API calls to use the new proxy structure.
- Enhanced error handling in API response processing.
- Updated components to align with the new API endpoints and structure.
---
.github/workflows/docker-publish.yml | 19 +-
.gitignore | 2 -
AGENTS.md | 16 +-
Dockerfile | 51 +-
README.md | 94 +-
backend/main.py | 210 +-
docker-compose-single.yml | 6 +-
docs/MYSQL_SETUP.md | 328 +-
docs/PROJECT_STRUCTURE.md | 136 +-
docs/audit/architecture.md | 38 +-
docs/audit/frontend-migration.md | 72 +-
frontend/.dockerignore | 9 -
frontend/.env.example | 4 -
frontend/Dockerfile | 18 -
frontend/index.html | 14 -
frontend/package-lock.json | 2928 -----------------
frontend/package.json | 45 -
frontend/postcss.config.js | 6 -
frontend/src/App.jsx | 101 -
frontend/src/api/client.js | 240 --
frontend/src/components/Layout.jsx | 142 -
frontend/src/components/Pagination.jsx | 87 -
frontend/src/components/ParsingProgress.jsx | 121 -
frontend/src/components/ProtectedRoute.jsx | 28 -
frontend/src/context/AuthContext.jsx | 93 -
frontend/src/index.css | 22 -
frontend/src/index.jsx | 10 -
frontend/src/pages/AdminPanel.jsx | 356 --
frontend/src/pages/AdminSettings.jsx | 562 ----
frontend/src/pages/Dashboard.jsx | 187 --
frontend/src/pages/ExamDetail.jsx | 387 ---
frontend/src/pages/ExamList.jsx | 342 --
frontend/src/pages/Login.jsx | 117 -
frontend/src/pages/MistakeList.jsx | 242 --
frontend/src/pages/MistakePlayer.jsx | 412 ---
frontend/src/pages/QuestionBank.jsx | 170 -
frontend/src/pages/QuizPlayer.jsx | 369 ---
frontend/src/pages/Register.jsx | 159 -
frontend/src/utils/helpers.js | 117 -
frontend/tailwind.config.js | 26 -
frontend/vite.config.js | 27 -
scripts/start_single_container.py | 98 +
web/README.md | 11 +-
web/src/app/(auth)/login/page.tsx | 15 +-
web/src/app/(auth)/register/page.tsx | 15 +-
web/src/app/api/auth/login/route.ts | 37 -
web/src/app/api/auth/me/route.ts | 30 -
web/src/app/frontend-api/auth/login/route.ts | 60 +
.../auth/logout/route.ts | 0
web/src/app/frontend-api/auth/me/route.ts | 57 +
.../exams/[examId]/progress/route.ts | 24 +-
.../proxy/[...path]/route.ts | 11 +-
.../components/app-shell/logout-button.tsx | 2 +-
.../components/exams/exam-detail-client.tsx | 2 +-
.../practice/mistake-practice-client.tsx | 8 +-
.../practice/quiz-player-client.tsx | 8 +-
web/src/lib/api/browser.ts | 27 +-
web/src/lib/api/config.ts | 4 +-
web/src/lib/api/response.ts | 61 +
web/src/lib/api/server.ts | 24 +-
60 files changed, 819 insertions(+), 7988 deletions(-)
delete mode 100644 frontend/.dockerignore
delete mode 100644 frontend/.env.example
delete mode 100644 frontend/Dockerfile
delete mode 100644 frontend/index.html
delete mode 100644 frontend/package-lock.json
delete mode 100644 frontend/package.json
delete mode 100644 frontend/postcss.config.js
delete mode 100644 frontend/src/App.jsx
delete mode 100644 frontend/src/api/client.js
delete mode 100644 frontend/src/components/Layout.jsx
delete mode 100644 frontend/src/components/Pagination.jsx
delete mode 100644 frontend/src/components/ParsingProgress.jsx
delete mode 100644 frontend/src/components/ProtectedRoute.jsx
delete mode 100644 frontend/src/context/AuthContext.jsx
delete mode 100644 frontend/src/index.css
delete mode 100644 frontend/src/index.jsx
delete mode 100644 frontend/src/pages/AdminPanel.jsx
delete mode 100644 frontend/src/pages/AdminSettings.jsx
delete mode 100644 frontend/src/pages/Dashboard.jsx
delete mode 100644 frontend/src/pages/ExamDetail.jsx
delete mode 100644 frontend/src/pages/ExamList.jsx
delete mode 100644 frontend/src/pages/Login.jsx
delete mode 100644 frontend/src/pages/MistakeList.jsx
delete mode 100644 frontend/src/pages/MistakePlayer.jsx
delete mode 100644 frontend/src/pages/QuestionBank.jsx
delete mode 100644 frontend/src/pages/QuizPlayer.jsx
delete mode 100644 frontend/src/pages/Register.jsx
delete mode 100644 frontend/src/utils/helpers.js
delete mode 100644 frontend/tailwind.config.js
delete mode 100644 frontend/vite.config.js
create mode 100644 scripts/start_single_container.py
delete mode 100644 web/src/app/api/auth/login/route.ts
delete mode 100644 web/src/app/api/auth/me/route.ts
create mode 100644 web/src/app/frontend-api/auth/login/route.ts
rename web/src/app/{api => frontend-api}/auth/logout/route.ts (100%)
create mode 100644 web/src/app/frontend-api/auth/me/route.ts
rename web/src/app/{api => frontend-api}/exams/[examId]/progress/route.ts (71%)
rename web/src/app/{api => frontend-api}/proxy/[...path]/route.ts (90%)
create mode 100644 web/src/lib/api/response.ts
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index e2687f0..1118554 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -1,4 +1,4 @@
-name: Build and Publish Docker Images
+name: Build and Publish Single-Container Image
on:
push:
@@ -18,15 +18,6 @@ jobs:
permissions:
contents: read
packages: write
- strategy:
- matrix:
- include:
- - image_suffix: backend
- context: ./backend
- file: ./backend/Dockerfile
- - image_suffix: frontend
- context: ./web
- file: ./web/Dockerfile
steps:
- name: Checkout repository
@@ -46,7 +37,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
- images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-${{ matrix.image_suffix }}
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
@@ -57,13 +48,13 @@ jobs:
id: build
uses: docker/build-push-action@v5
with:
- context: ${{ matrix.context }}
- file: ${{ matrix.file }}
+ context: .
+ file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
- cache-to: type=gha,mode=max,scope=${{ matrix.image_suffix }}
+ cache-to: type=gha,mode=max,scope=single-container
platforms: linux/amd64,linux/arm64
- name: Image digest
diff --git a/.gitignore b/.gitignore
index 3d49bab..baebcd4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,8 +44,6 @@ yarn-error.log
.pnp.js
# Build
-frontend/build/
-frontend/dist/
.next/
web/.next/
web/out/
diff --git a/AGENTS.md b/AGENTS.md
index 6c99ff3..e15623e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,19 +2,19 @@
## 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.
+- `web/`: Next.js frontend for active development. Keep route screens under `src/app/` or related route segments, shared UI in `src/components/`, API wrappers in `src/lib/`, and helpers close to their consumers.
+- `docs/` holds deployment and architecture notes, `scripts/run_local.sh` bootstraps local Linux/macOS development, `test_data/` contains sample question files, and `.github/workflows/docker-publish.yml` publishes the single-container image.
## Build, Test, and Development Commands
-- `docker compose 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.
+- `docker compose -f docker-compose-single.yml up -d --build`: start the default single-container deployment with FastAPI proxying the embedded Next.js frontend.
+- `docker compose up -d --build`: start the split development stack with backend on `:8000` and frontend on `:3000`.
- `cd backend && pip install -r requirements.txt && alembic upgrade head && uvicorn main:app --reload --host 0.0.0.0 --port 8000`: run the API locally.
-- `cd frontend && npm install && npm run dev`: start the Vite dev server.
-- `cd frontend && npm run build`: create a production frontend bundle.
+- `cd web && npm install && npm run dev`: start the Next.js dev server.
+- `cd web && npm run build`: create a production frontend bundle.
## Coding Style & Naming Conventions
- Python uses 4-space indentation, `snake_case` for modules/functions, and `PascalCase` for ORM or Pydantic classes.
-- React files use `PascalCase.jsx` for pages/components and `camelCase` for state, helpers, and API wrappers.
+- React and Next.js files use the naming conventions already established in `web/`; preserve route segment and component naming patterns in place.
- Keep route handlers thin: validation in schemas, orchestration in routers, reusable logic in `backend/services/`.
- No formatter or lint script is enforced today, so match surrounding style before making broad formatting changes.
@@ -22,7 +22,7 @@
- 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__/`.
+- If you add tests, place backend tests under `backend/tests/test_*.py` and frontend tests under `web/src/__tests__/`.
## Commit & Pull Request Guidelines
- Recent history favors short, focused subjects, often imperative and sometimes Chinese, such as `安全修复和管理员账号密码自定义`.
diff --git a/Dockerfile b/Dockerfile
index 29f321e..a337df3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,51 +1,50 @@
-# ==================== 多阶段构建:前后端整合单容器 ====================
-# Stage 1: 构建前端
-FROM node:18-slim AS frontend-builder
+# ==================== 多阶段构建:单容器运行 FastAPI + Next.js ====================
+# Stage 1: 构建 Next.js 前端
+FROM node:20-slim AS web-builder
-WORKDIR /frontend
+WORKDIR /web
-# 复制前端依赖文件
-COPY frontend/package*.json ./
-
-# 安装依赖
+COPY web/package*.json ./
RUN npm ci
-# 复制前端源代码
-COPY frontend/ ./
+COPY web/ ./
+
+ENV NEXT_TELEMETRY_DISABLED=1
-# 构建前端(生成静态文件到 dist 目录)
RUN npm run build
-# Stage 2: 构建后端并整合前端
-FROM python:3.11-slim
+# Stage 2: 运行 FastAPI + Next.js
+FROM node:20-slim
WORKDIR /app
-# 安装操作系统依赖(python-magic 需要 libmagic)
+# 安装 Python 运行时和操作系统依赖
RUN apt-get update \
- && apt-get install -y --no-install-recommends libmagic1 \
+ && apt-get install -y --no-install-recommends python3 python3-pip python3-venv libmagic1 \
&& rm -rf /var/lib/apt/lists/*
-# 复制后端依赖文件
-COPY backend/requirements.txt ./
+RUN python3 -m venv /opt/venv
+ENV PATH="/opt/venv/bin:$PATH"
-# 安装 Python 依赖(使用预编译wheel包,无需gcc)
-RUN pip install -r requirements.txt
+# 安装后端依赖
+COPY backend/requirements.txt ./backend-requirements.txt
+RUN python -m pip install --no-cache-dir -r backend-requirements.txt
-# 复制后端代码
+# 复制后端代码和启动脚本
COPY backend/ ./
+COPY scripts/start_single_container.py ./scripts/start_single_container.py
-# 从前端构建阶段复制静态文件到后端 static 目录
-COPY --from=frontend-builder /frontend/build ./static
+# 复制 Next.js standalone 产物
+COPY --from=web-builder /web/.next/standalone ./web
+COPY --from=web-builder /web/.next/static ./web/.next/static
# 创建上传目录
RUN mkdir -p ./uploads
-# 暴露端口
EXPOSE 8000
-# 设置环境变量
ENV PYTHONUNBUFFERED=1
+ENV NEXT_SERVER_URL=http://127.0.0.1:3000
+ENV NEXT_TELEMETRY_DISABLED=1
-# 启动命令
-CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+CMD ["python", "scripts/start_single_container.py"]
diff --git a/README.md b/README.md
index 13d1493..f37f83c 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,9 @@ QQuiz 是一个用于题库导入、刷题训练和错题管理的全栈应用
## 快速开始
-### 方式一:直接运行 GitHub Actions 构建好的镜像
+QQuiz 默认以单容器形式发布和部署。GitHub Actions 只构建根目录 `Dockerfile` 生成的单容器镜像,README 也以这个路径为主。
+
+### 方式一:直接运行 GitHub Actions 构建好的单容器镜像
适合只想快速启动,不想先克隆仓库。
@@ -58,58 +60,61 @@ GEMINI_API_KEY=your-real-gemini-api-key
#### 3. 拉取镜像
```bash
-docker pull ghcr.io/handsomezhuzhu/qquiz-backend:latest
-docker pull ghcr.io/handsomezhuzhu/qquiz-frontend:latest
+docker pull ghcr.io/handsomezhuzhu/qquiz:latest
```
-#### 4. 创建网络和数据卷
+#### 4. 创建数据卷
```bash
-docker network create qquiz_net
-docker volume create qquiz_sqlite_data
-docker volume create qquiz_upload_files
+docker volume create qquiz_data
+docker volume create qquiz_uploads
```
-#### 5. 启动后端
+#### 5. 启动容器
```bash
docker run -d \
- --name qquiz_backend \
- --network qquiz_net \
+ --name qquiz \
--env-file .env \
-e DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db \
-e UPLOAD_DIR=/app/uploads \
- -v qquiz_sqlite_data:/app/data \
- -v qquiz_upload_files:/app/uploads \
+ -v qquiz_data:/app/data \
+ -v qquiz_uploads:/app/uploads \
-p 8000:8000 \
- ghcr.io/handsomezhuzhu/qquiz-backend:latest
-```
-
-#### 6. 启动前端
-
-```bash
-docker run -d \
- --name qquiz_frontend \
- --network qquiz_net \
- -e API_BASE_URL=http://qquiz_backend:8000 \
- -p 3000:3000 \
- ghcr.io/handsomezhuzhu/qquiz-frontend:latest
+ --restart unless-stopped \
+ ghcr.io/handsomezhuzhu/qquiz:latest
```
访问:
-- 前端:`http://localhost:3000`
-- 后端:`http://localhost:8000`
+- 应用:`http://localhost:8000`
+- API 文档:`http://localhost:8000/docs`
停止:
```bash
-docker rm -f qquiz_frontend qquiz_backend
+docker rm -f qquiz
```
-### 方式二:从源码用 Docker Compose 启动
+### 方式二:从源码启动单容器
-#### 前后端分离,推荐
+适合需要自行构建镜像或修改代码后再部署。
+
+```bash
+cp .env.example .env
+docker compose -f docker-compose-single.yml up -d --build
+```
+
+访问:
+
+- 应用:`http://localhost:8000`
+- API 文档:`http://localhost:8000/docs`
+
+### 可选:开发或兼容性部署
+
+以下方式保留用于开发调试或兼容场景,不再作为默认部署方案:
+
+#### 前后端分离开发栈
```bash
cp .env.example .env
@@ -121,24 +126,14 @@ docker compose up -d --build
- 前端:`http://localhost:3000`
- 后端:`http://localhost:8000`
-#### 使用 MySQL
+#### 分离栈叠加 MySQL
```bash
cp .env.example .env
docker compose -f docker-compose.yml -f docker-compose.mysql.yml up -d --build
```
-#### 单容器模式
-
-```bash
-cp .env.example .env
-docker compose -f docker-compose-single.yml up -d --build
-```
-
-访问:
-
-- 应用:`http://localhost:8000`
-- API 文档:`http://localhost:8000/docs`
+MySQL 相关说明见 [docs/MYSQL_SETUP.md](docs/MYSQL_SETUP.md)。
## 本地开发
@@ -163,8 +158,8 @@ npm run dev
说明:
-- `web/` 是当前主前端,基于 Next.js
-- `frontend/` 是保留中的旧 Vite 前端,主要用于单容器兼容路径
+- `web/` 是唯一前端工程,基于 Next.js
+- 单容器镜像会在同一个容器里运行 FastAPI 和 Next.js,并由 FastAPI 代理前端请求
## 关键环境变量
@@ -191,14 +186,13 @@ npm run dev
```text
QQuiz/
├─ backend/ FastAPI 后端
-├─ web/ Next.js 前端
-├─ frontend/ Legacy Vite 前端
+├─ web/ Next.js 前端工程
├─ docs/ 文档与截图
├─ test_data/ 示例题库文件
-├─ docker-compose.yml 前后端分离部署
-├─ docker-compose.mysql.yml MySQL overlay
-├─ docker-compose-single.yml 单容器部署
-└─ Dockerfile 单容器镜像构建
+├─ docker-compose-single.yml 单容器部署(默认)
+├─ Dockerfile 单容器镜像构建(默认)
+├─ docker-compose.yml 前后端分离开发/兼容部署
+└─ docker-compose.mysql.yml MySQL overlay(可选)
```
## 技术栈
@@ -210,7 +204,7 @@ QQuiz/
```bash
cd web && npm run build
-docker compose build backend frontend
+docker compose -f docker-compose-single.yml build
```
建议至少手动验证:
diff --git a/backend/main.py b/backend/main.py
index f9e22b3..ac03a53 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,16 +1,16 @@
"""
-QQuiz FastAPI Application - 单容器模式(前后端整合)
+QQuiz FastAPI Application - single-container API and frontend proxy.
"""
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.staticfiles import StaticFiles
-from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
+from fastapi.responses import JSONResponse, StreamingResponse
from contextlib import asynccontextmanager
import os
-from pathlib import Path
from dotenv import load_dotenv
+import httpx
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
+from starlette.background import BackgroundTask
from database import init_db, init_default_config, get_db_context
from rate_limit import limiter
@@ -18,6 +18,22 @@ from rate_limit import limiter
# Load environment variables
load_dotenv()
+NEXT_SERVER_URL = os.getenv("NEXT_SERVER_URL", "http://127.0.0.1:3000").rstrip("/")
+INTERNAL_API_URL = os.getenv("INTERNAL_API_URL", "http://127.0.0.1:8000").rstrip("/")
+SESSION_COOKIE_NAME = "access_token"
+FRONTEND_PROXY_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
+HOP_BY_HOP_HEADERS = {
+ "connection",
+ "keep-alive",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "te",
+ "trailers",
+ "transfer-encoding",
+ "upgrade",
+ "content-length",
+}
+
async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
return JSONResponse(
@@ -32,6 +48,11 @@ async def lifespan(app: FastAPI):
# Startup
print("🚀 Starting QQuiz Application...")
+ app.state.frontend_client = httpx.AsyncClient(
+ follow_redirects=False,
+ timeout=httpx.Timeout(30.0, connect=5.0),
+ )
+
# Initialize database
await init_db()
@@ -49,6 +70,7 @@ async def lifespan(app: FastAPI):
yield
# Shutdown
+ await app.state.frontend_client.aclose()
print("👋 Shutting down QQuiz Application...")
@@ -89,44 +111,152 @@ app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
@app.get("/health")
async def health_check():
"""Health check endpoint"""
- return {"status": "healthy"}
+
+ try:
+ response = await app.state.frontend_client.get(
+ f"{NEXT_SERVER_URL}/login",
+ headers={"Accept-Encoding": "identity"},
+ )
+ except httpx.HTTPError:
+ return JSONResponse(
+ status_code=503,
+ content={
+ "status": "degraded",
+ "api": "healthy",
+ "frontend": "unavailable",
+ },
+ )
+
+ frontend_status = "healthy" if response.status_code < 500 else "unavailable"
+ if frontend_status != "healthy":
+ return JSONResponse(
+ status_code=503,
+ content={
+ "status": "degraded",
+ "api": "healthy",
+ "frontend": frontend_status,
+ },
+ )
+
+ return {"status": "healthy", "api": "healthy", "frontend": "healthy"}
-# ============ 静态文件服务(前后端整合) ============
+def build_frontend_target(request: Request, full_path: str) -> str:
+ normalized_path = f"/{full_path}" if full_path else "/"
+ query = request.url.query
+ return f"{NEXT_SERVER_URL}{normalized_path}{f'?{query}' if query else ''}"
-# 检查静态文件目录是否存在
-STATIC_DIR = Path(__file__).parent / "static"
-if STATIC_DIR.exists():
- # 挂载静态资源(JS、CSS、图片等)
- app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="static_assets")
- # 前端应用的所有路由(SPA路由)
- @app.get("/{full_path:path}")
- async def serve_frontend(full_path: str):
- """
- 服务前端应用
- - API 路由已在上面定义,优先匹配
- - 其他所有路由返回 index.html(SPA 单页应用)
- """
- index_file = STATIC_DIR / "index.html"
- if index_file.exists():
- return FileResponse(index_file)
- else:
- return {
- "message": "Frontend not built yet",
- "hint": "Run 'cd frontend && npm run build' to build the frontend"
- }
-else:
- print("⚠️ 静态文件目录不存在,前端功能不可用")
- print("提示:请先构建前端应用或使用开发模式")
+def build_internal_api_target(request: Request, full_path: str, trailing_slash: bool = False) -> str:
+ normalized_path = full_path.strip("/")
+ if trailing_slash and normalized_path:
+ normalized_path = f"{normalized_path}/"
+ query = request.url.query
+ return f"{INTERNAL_API_URL}/api/{normalized_path}{f'?{query}' if query else ''}"
- # 如果没有静态文件,显示 API 信息
- @app.get("/")
- async def root():
- """Root endpoint"""
- return {
- "message": "Welcome to QQuiz API",
- "version": "1.0.0",
- "docs": "/docs",
- "note": "Frontend not built. Please build frontend or use docker-compose."
- }
+
+def filter_proxy_headers(request: Request) -> dict[str, str]:
+ headers = {
+ key: value
+ for key, value in request.headers.items()
+ if key.lower() not in HOP_BY_HOP_HEADERS and key.lower() != "host"
+ }
+ # Avoid sending compressed payloads through the proxy so response headers stay accurate.
+ headers["Accept-Encoding"] = "identity"
+ return headers
+
+
+def apply_proxy_headers(proxy_response: StreamingResponse, upstream_headers: httpx.Headers) -> None:
+ proxy_response.raw_headers = [
+ (key.encode("latin-1"), value.encode("latin-1"))
+ for key, value in upstream_headers.multi_items()
+ if key.lower() not in HOP_BY_HOP_HEADERS
+ ]
+
+
+@app.api_route("/frontend-api/proxy/{full_path:path}", methods=FRONTEND_PROXY_METHODS, include_in_schema=False)
+async def proxy_browser_api(request: Request, full_path: str):
+ """
+ Serve browser-originated API calls directly from FastAPI in single-container mode.
+ This avoids relying on Next.js route handlers for the /frontend-api/proxy/* namespace.
+ """
+ target = build_internal_api_target(request, full_path)
+ body = await request.body()
+ client: httpx.AsyncClient = app.state.frontend_client
+ headers = filter_proxy_headers(request)
+ token = request.cookies.get(SESSION_COOKIE_NAME)
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+
+ try:
+ async def send_request(target_url: str) -> httpx.Response:
+ upstream_request = client.build_request(
+ method=request.method,
+ url=target_url,
+ headers=headers,
+ content=body if body else None,
+ )
+ return await client.send(
+ upstream_request,
+ stream=True,
+ follow_redirects=True,
+ )
+
+ upstream_response = await send_request(target)
+ if (
+ request.method in {"GET", "HEAD"}
+ and upstream_response.status_code == 404
+ and full_path
+ and not full_path.endswith("/")
+ ):
+ await upstream_response.aclose()
+ upstream_response = await send_request(
+ build_internal_api_target(request, full_path, trailing_slash=True)
+ )
+ except httpx.HTTPError:
+ return JSONResponse(
+ status_code=502,
+ content={"detail": "Backend API is unavailable."},
+ )
+
+ proxy_response = StreamingResponse(
+ upstream_response.aiter_raw(),
+ status_code=upstream_response.status_code,
+ background=BackgroundTask(upstream_response.aclose),
+ )
+ apply_proxy_headers(proxy_response, upstream_response.headers)
+ return proxy_response
+
+
+@app.api_route("/", methods=FRONTEND_PROXY_METHODS, include_in_schema=False)
+@app.api_route("/{full_path:path}", methods=FRONTEND_PROXY_METHODS, include_in_schema=False)
+async def proxy_frontend(request: Request, full_path: str = ""):
+ """
+ Forward all non-API traffic to the embedded Next.js server.
+ FastAPI keeps ownership of /api/*, /docs, /openapi.json, /redoc and /health.
+ """
+ target = build_frontend_target(request, full_path)
+ body = await request.body()
+ client: httpx.AsyncClient = app.state.frontend_client
+
+ try:
+ upstream_request = client.build_request(
+ method=request.method,
+ url=target,
+ headers=filter_proxy_headers(request),
+ content=body if body else None,
+ )
+ upstream_response = await client.send(upstream_request, stream=True)
+ except httpx.HTTPError:
+ return JSONResponse(
+ status_code=502,
+ content={"detail": "Frontend server is unavailable."},
+ )
+
+ proxy_response = StreamingResponse(
+ upstream_response.aiter_raw(),
+ status_code=upstream_response.status_code,
+ background=BackgroundTask(upstream_response.aclose),
+ )
+ apply_proxy_headers(proxy_response, upstream_response.headers)
+ return proxy_response
diff --git a/docker-compose-single.yml b/docker-compose-single.yml
index 799ee1a..7e660e2 100644
--- a/docker-compose-single.yml
+++ b/docker-compose-single.yml
@@ -1,8 +1,6 @@
# ==================== 单容器部署配置 ====================
# 使用方法:docker-compose -f docker-compose-single.yml up -d
-version: '3.8'
-
services:
qquiz:
build:
@@ -34,4 +32,8 @@ services:
volumes:
qquiz_data:
+ # Reuse the previous split-stack SQLite volume during migration.
+ name: qquiz_sqlite_data
qquiz_uploads:
+ # Reuse the previous split-stack uploads volume during migration.
+ name: qquiz_upload_files
diff --git a/docs/MYSQL_SETUP.md b/docs/MYSQL_SETUP.md
index 7997a04..056bb19 100644
--- a/docs/MYSQL_SETUP.md
+++ b/docs/MYSQL_SETUP.md
@@ -1,262 +1,140 @@
-# MySQL 安装与配置指南
+# MySQL 可选配置指南
-QQuiz 使用 MySQL 8.0 作为数据库,你可以选择 Docker 部署或本地安装。
+QQuiz 默认部署路径是单容器 + SQLite。README、根目录 `Dockerfile`、`docker-compose-single.yml` 和 GitHub Actions 发布镜像都围绕这个模式设计。
-## 方式一:使用 Docker (推荐)
+只有在你明确需要把数据库独立出去时,才需要 MySQL。常见原因:
-### 优点
-- 无需手动安装 MySQL
-- 自动配置和初始化
-- 隔离环境,不影响系统
+- 需要多个应用实例共享同一数据库
+- 已有 MySQL 运维体系
+- 希望把应用容器和数据库生命周期分开
-### 使用步骤
+## 场景一:源码部署时附加 MySQL 容器
-1. **安装 Docker Desktop**
- - 下载地址:https://www.docker.com/products/docker-desktop/
- - 安装后启动 Docker Desktop
+这是当前最直接的 MySQL 用法,适合你已经克隆仓库并接受“应用容器 + MySQL 容器”的可选部署方式。
-2. **运行启动脚本**
- ```bash
- scripts\fix_and_start.bat
- ```
- 选择 **[1] Use Docker**
-
-3. **完成!**
- - Docker 会自动下载 MySQL 镜像
- - 自动创建数据库和用户
- - 自动启动服务
-
----
-
-## 方式二:本地安装 MySQL
-
-### 下载 MySQL
-
-1. 访问 MySQL 官网下载页面:
- https://dev.mysql.com/downloads/installer/
-
-2. 选择 **MySQL Installer for Windows**
-
-3. 下载 `mysql-installer-community-8.0.x.x.msi`
-
-### 安装步骤
-
-1. **运行安装程序**
- - 双击下载的 .msi 文件
-
-2. **选择安装类型**
- - 选择 "Developer Default" 或 "Server only"
- - 点击 Next
-
-3. **配置 MySQL Server**
- - **Config Type**: Development Computer
- - **Port**: 3306 (默认)
- - **Authentication Method**: 选择 "Use Strong Password Encryption"
-
-4. **设置 Root 密码**
- - 输入并记住 root 用户的密码
- - 建议密码:`root` (开发环境)
-
-5. **Windows Service 配置**
- - ✅ Configure MySQL Server as a Windows Service
- - Service Name: MySQL80
- - ✅ Start the MySQL Server at System Startup
-
-6. **完成安装**
- - 点击 Execute 开始安装
- - 等待安装完成
- - 点击 Finish
-
-### 验证安装
-
-打开命令提示符,运行:
+1. 复制环境变量模板:
```bash
-mysql --version
+cp .env.example .env
```
-应该显示:`mysql Ver 8.0.x for Win64 on x86_64`
+Windows PowerShell:
-### 配置 QQuiz 数据库
-
-**方式 A:使用脚本自动创建 (推荐)**
-
-运行:
-```bash
-scripts\fix_and_start.bat
-```
-选择 **[2] Use Local MySQL**
-
-**方式 B:手动创建**
-
-1. 打开 MySQL 命令行客户端:
- ```bash
- mysql -u root -p
- ```
-
-2. 输入 root 密码
-
-3. 创建数据库和用户:
- ```sql
- CREATE DATABASE qquiz_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
- CREATE USER 'qquiz'@'localhost' IDENTIFIED BY 'qquiz_password';
- GRANT ALL PRIVILEGES ON qquiz_db.* TO 'qquiz'@'localhost';
- FLUSH PRIVILEGES;
- EXIT;
- ```
-
----
-
-## 数据库配置说明
-
-### .env 文件配置
-
-确保 `.env` 文件中的数据库连接字符串正确:
-
-**本地 MySQL:**
-```env
-DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@localhost:3306/qquiz_db
+```powershell
+Copy-Item .env.example .env
```
-**Docker MySQL:**
+2. 把 `.env` 中的数据库连接改成 MySQL 容器地址:
+
```env
DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@mysql:3306/qquiz_db
```
-### 连接参数说明
+3. 启动应用和 MySQL:
-- `mysql+aiomysql://` - 使用 aiomysql 异步驱动
-- `qquiz` - 数据库用户名
-- `qquiz_password` - 数据库密码
-- `localhost` 或 `mysql` - 数据库主机地址
-- `3306` - MySQL 默认端口
-- `qquiz_db` - 数据库名称
-
----
-
-## 常见问题
-
-### 1. 端口 3306 被占用
-
-**错误信息:**
-```
-Error: Port 3306 is already in use
+```bash
+docker compose -f docker-compose.yml -f docker-compose.mysql.yml up -d --build
```
-**解决方案:**
-- 检查是否已经有 MySQL 运行:`netstat -ano | findstr :3306`
-- 停止现有的 MySQL 服务
-- 或修改 `.env` 中的端口号
+4. 访问:
-### 2. 无法连接到 MySQL
+- 前端:`http://localhost:3000`
+- 后端:`http://localhost:8000`
-**错误信息:**
-```
-Can't connect to MySQL server on 'localhost'
-```
+说明:
-**解决方案:**
+- 这条路径是 MySQL 兼容部署,不是默认发布路径
+- 默认发布镜像仍然是根目录单容器镜像
-1. **检查 MySQL 服务是否运行**
- - 按 Win+R,输入 `services.msc`
- - 查找 "MySQL80" 服务
- - 确认状态为 "正在运行"
+## 场景二:单容器应用连接外部 MySQL
-2. **启动 MySQL 服务**
- ```bash
- net start MySQL80
- ```
+如果你想继续使用单容器应用镜像,但数据库由外部 MySQL 托管,可以直接让应用容器连接现有数据库。
-3. **检查防火墙设置**
- - 确保防火墙允许 MySQL 端口 3306
+### 1. 准备 MySQL 8.0 数据库
-### 3. 密码验证失败
+执行以下 SQL 创建数据库和账号:
-**错误信息:**
-```
-Access denied for user 'qquiz'@'localhost'
-```
-
-**解决方案:**
-
-重新创建用户并设置密码:
```sql
-mysql -u root -p
-DROP USER IF EXISTS 'qquiz'@'localhost';
-CREATE USER 'qquiz'@'localhost' IDENTIFIED BY 'qquiz_password';
-GRANT ALL PRIVILEGES ON qquiz_db.* TO 'qquiz'@'localhost';
+CREATE DATABASE qquiz_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+CREATE USER 'qquiz'@'%' IDENTIFIED BY 'qquiz_password';
+GRANT ALL PRIVILEGES ON qquiz_db.* TO 'qquiz'@'%';
FLUSH PRIVILEGES;
```
-### 4. 字符集问题
+### 2. 修改 `.env`
-**解决方案:**
+把 `DATABASE_URL` 改成你的 MySQL 地址,例如:
+
+```env
+DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@mysql.example.com:3306/qquiz_db
+UPLOAD_DIR=/app/uploads
+```
+
+### 3. 启动单容器镜像
+
+```bash
+docker pull ghcr.io/handsomezhuzhu/qquiz:latest
+
+docker volume create qquiz_uploads
+
+docker run -d \
+ --name qquiz \
+ --env-file .env \
+ -v qquiz_uploads:/app/uploads \
+ -p 8000:8000 \
+ --restart unless-stopped \
+ ghcr.io/handsomezhuzhu/qquiz:latest
+```
+
+说明:
+
+- 这里不需要本地 SQLite 数据卷,因为数据库已经外置到 MySQL
+- 仍然建议保留上传目录卷,避免容器重建后丢失上传文件
+
+## 本地开发连接 MySQL
+
+如果你是在本机直接跑后端,`.env` 中可使用本地 MySQL 地址:
+
+```env
+DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@localhost:3306/qquiz_db
+```
+
+然后分别启动后端和前端:
+
+```bash
+cd backend
+pip install -r requirements.txt
+alembic upgrade head
+uvicorn main:app --reload --host 0.0.0.0 --port 8000
+```
+
+```bash
+cd web
+npm install
+npm run dev
+```
+
+## 常见问题
+
+### 1. 连接不上 MySQL
+
+检查以下几项:
+
+- `DATABASE_URL` 中的主机名、端口、用户名和密码是否正确
+- MySQL 是否允许对应来源地址连接
+- 3306 端口是否开放
+
+### 2. 容器里能连,宿主机里不能连
+
+这是因为容器内部和宿主机访问地址不同:
+
+- 容器之间互联时通常使用服务名,例如 `mysql`
+- 宿主机连接本机 MySQL 时通常使用 `localhost`
+
+### 3. 字符集异常
+
+建议数据库和表统一使用 `utf8mb4`:
-确保数据库使用 UTF-8 字符集:
```sql
ALTER DATABASE qquiz_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
-
----
-
-## 管理工具推荐
-
-### 1. MySQL Workbench (官方)
-- 下载:https://dev.mysql.com/downloads/workbench/
-- 功能:可视化数据库管理、SQL 编辑器、备份还原
-
-### 2. DBeaver (免费开源)
-- 下载:https://dbeaver.io/download/
-- 功能:多数据库支持、数据导入导出、ER 图
-
-### 3. phpMyAdmin (Web 界面)
-- 适合习惯 Web 界面的用户
-
----
-
-## 数据库备份与恢复
-
-### 备份数据库
-
-```bash
-mysqldump -u qquiz -p qquiz_db > backup.sql
-```
-
-### 恢复数据库
-
-```bash
-mysql -u qquiz -p qquiz_db < backup.sql
-```
-
----
-
-## 切换回 PostgreSQL
-
-如果需要切换回 PostgreSQL:
-
-1. 修改 `requirements.txt`:
- ```
- asyncpg==0.29.0 # 替换 aiomysql
- ```
-
-2. 修改 `.env`:
- ```
- DATABASE_URL=postgresql+asyncpg://qquiz:qquiz_password@localhost:5432/qquiz_db
- ```
-
-3. 修改 `docker-compose.yml`:
- - 将 `mysql` 服务改回 `postgres`
-
-4. 重新安装依赖:
- ```bash
- pip install -r requirements.txt
- ```
-
----
-
-## 技术支持
-
-如遇到其他问题,请:
-1. 检查 MySQL 错误日志
-2. 确认防火墙和网络配置
-3. 查看项目 issues: https://github.com/handsomezhuzhu/QQuiz/issues
diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md
index 1ba77f1..c11c89c 100644
--- a/docs/PROJECT_STRUCTURE.md
+++ b/docs/PROJECT_STRUCTURE.md
@@ -30,36 +30,17 @@ QQuiz/
│ ├── alembic.ini # Alembic 配置
│ └── Dockerfile # 后端 Docker 镜像
│
-├── frontend/ # React 前端
+├── web/ # Next.js 前端
│ ├── src/
-│ │ ├── api/
-│ │ │ └── client.js # API 客户端(Axios)⭐
-│ │ ├── components/
-│ │ │ ├── Layout.jsx # 主布局(导航栏)
-│ │ │ └── ProtectedRoute.jsx # 路由保护
-│ │ ├── context/
-│ │ │ └── AuthContext.jsx # 认证上下文
-│ │ ├── pages/
-│ │ │ ├── Login.jsx # 登录页
-│ │ │ ├── Register.jsx # 注册页
-│ │ │ ├── Dashboard.jsx # 仪表盘
-│ │ │ ├── ExamList.jsx # 题库列表 ⭐
-│ │ │ ├── ExamDetail.jsx # 题库详情(追加上传)⭐
-│ │ │ ├── QuizPlayer.jsx # 刷题核心页面 ⭐
-│ │ │ ├── MistakeList.jsx # 错题本
-│ │ │ └── AdminSettings.jsx # 系统设置
-│ │ ├── utils/
-│ │ │ └── helpers.js # 工具函数
-│ │ ├── App.jsx # 应用根组件
-│ │ ├── index.jsx # 应用入口
-│ │ └── index.css # 全局样式
-│ ├── public/
-│ │ └── index.html # HTML 模板
+│ │ ├── app/ # App Router 页面、布局、Route Handlers
+│ │ ├── components/ # 共享 UI 组件
+│ │ ├── lib/ # API、认证、格式化等公共逻辑
+│ │ └── middleware.ts # 登录态守卫
│ ├── package.json # Node 依赖
-│ ├── vite.config.js # Vite 配置
-│ ├── tailwind.config.js # Tailwind CSS 配置
-│ ├── postcss.config.js # PostCSS 配置
-│ └── Dockerfile # 前端 Docker 镜像
+│ ├── next.config.mjs # Next.js 配置
+│ ├── tailwind.config.ts # Tailwind CSS 配置
+│ ├── postcss.config.mjs # PostCSS 配置
+│ └── Dockerfile # 分离部署前端镜像
│
├── docker-compose.yml # Docker 编排配置 ⭐
├── .env.example # 环境变量模板
@@ -133,74 +114,31 @@ for q in questions_data:
### 前端核心
-#### `client.js` - API 客户端
-封装了所有后端 API:
-- `authAPI`: 登录、注册、用户信息
-- `examAPI`: 题库 CRUD、追加文档
-- `questionAPI`: 获取题目、答题
-- `mistakeAPI`: 错题本管理
-- `adminAPI`: 系统配置
+#### `src/lib/api/server.ts` - 服务端 API 访问
+用于 Next Server Components 访问后端:
+- 从 `HttpOnly` Cookie 读取会话令牌
+- 直接请求 FastAPI `/api/*`
+- 401 时自动重定向回登录页
-**特性:**
-- 自动添加 JWT Token
-- 统一错误处理和 Toast 提示
-- 401 自动跳转登录
+#### `src/lib/api/browser.ts` - 浏览器端 API 访问
+用于客户端交互:
+- 请求同源 `/frontend-api/proxy/*`
+- 统一处理错误信息
+- 默认禁用缓存,保持刷题和后台状态最新
-#### `ExamDetail.jsx` - 题库详情
-最复杂的前端页面,包含:
-- **追加上传**: 上传新文档并去重
-- **状态轮询**: 每 3 秒轮询一次状态
-- **智能按钮**:
- - 处理中时禁用「添加文档」
- - 就绪后显示「开始/继续刷题」
-- **进度展示**: 题目数、完成度、进度条
+#### `src/components/exams/exam-detail-client.tsx` - 题库详情
+负责:
+- 追加上传文档
+- 展示解析进度
+- 通过 `/frontend-api/exams/{examId}/progress` 订阅同源 SSE
+- 处理解析完成/失败后的页面刷新
-**状态轮询实现:**
-```javascript
-useEffect(() => {
- const interval = setInterval(() => {
- pollExamStatus() // 轮询状态
- }, 3000)
-
- return () => clearInterval(interval)
-}, [examId])
-
-const pollExamStatus = async () => {
- const newExam = await examAPI.getDetail(examId)
-
- // 检测状态变化
- if (exam?.status === 'processing' && newExam.status === 'ready') {
- toast.success('文档解析完成!')
- await loadExamDetail() // 重新加载数据
- }
-
- setExam(newExam)
-}
-```
-
-#### `QuizPlayer.jsx` - 刷题核心
-实现完整的刷题流程:
-1. 基于 `current_index` 加载当前题目
-2. 根据题型显示不同的答题界面
-3. 提交答案并检查(简答题调用 AI 评分)
-4. 答错自动加入错题本
-5. 点击下一题自动更新进度
-
-**断点续做实现:**
-```javascript
-// 始终基于 exam.current_index 加载题目
-const loadCurrentQuestion = async () => {
- const question = await questionAPI.getCurrentQuestion(examId)
- // 后端会根据 current_index 返回对应题目
-}
-
-// 下一题时更新进度
-const handleNext = async () => {
- const newIndex = exam.current_index + 1
- await examAPI.updateProgress(examId, newIndex)
- await loadCurrentQuestion()
-}
-```
+#### `src/components/practice/quiz-player-client.tsx` - 刷题核心
+负责:
+- 加载当前题目
+- 提交答案并展示结果
+- 推进刷题进度
+- 管理简答题与错题练习等交互
---
@@ -323,17 +261,17 @@ CREATE UNIQUE INDEX ix_user_mistakes_unique ON user_mistakes(user_id, question_i
- **OpenAI/Anthropic/Qwen**: AI 解析和评分
### 前端
+- **Next.js 14 App Router**: 前端运行时
- **React 18**: UI 框架
-- **Vite**: 构建工具(比 CRA 更快)
+- **TypeScript**: 类型系统
- **Tailwind CSS**: 原子化 CSS
-- **Axios**: HTTP 客户端
-- **React Router**: 路由管理
-- **React Hot Toast**: 消息提示
+- **TanStack Query**: 客户端缓存和数据同步
+- **Route Handlers**: 同源认证与代理层
### 部署
- **Docker + Docker Compose**: 容器化部署
-- **PostgreSQL 15**: 关系型数据库
-- **Nginx** (可选): 反向代理
+- **SQLite / MySQL**: 关系型数据库
+- **FastAPI reverse proxy**: 单容器模式下代理 Next.js
---
diff --git a/docs/audit/architecture.md b/docs/audit/architecture.md
index 5abbf18..a0aa08e 100644
--- a/docs/audit/architecture.md
+++ b/docs/audit/architecture.md
@@ -19,17 +19,17 @@ Audit date: 2026-04-17
### 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
+- Runtime: Next.js App Router + TypeScript
+- Routing: file-system routing + middleware guards
+- Auth state: `HttpOnly` cookie managed by Next route handlers
+- API transport: server/client fetch helpers with same-origin proxy routes
+- Styling: Tailwind CSS + shadcn/ui patterns
### Deployment
-- `docker-compose.yml`: development-oriented split stack
-- `docker-compose-single.yml`: monolith container with SQLite
-- `Dockerfile`: FastAPI serves the built SPA as static assets
+- `docker-compose.yml`: split development stack
+- `docker-compose-single.yml`: default single-container deployment
+- `Dockerfile`: single image running FastAPI + embedded Next.js
## Target Architecture
@@ -51,20 +51,20 @@ Audit date: 2026-04-17
### Deployment
-- Split deployment becomes the primary production shape
-- Monolith mode remains secondary compatibility mode
-- Development and production Compose files must be separated
+- Single-container deployment is the primary release path
+- Split deployment remains available for development and compatibility testing
+- Development and production Compose files must stay explicitly separated
## Core Constraints
-1. 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.
+1. Preserve backend API contracts where possible across frontend changes.
+2. Keep single-container and split-stack behavior aligned on the same `web/` frontend.
+3. Fix deployment/documentation drift before treating changes as production-ready.
+4. Avoid reintroducing duplicate frontend implementations.
## Immediate Workstreams
-1. 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.
+1. Keep single-container delivery using the same `web/` frontend as split deployment.
+2. Continue moving backend orchestration into typed services.
+3. Tighten health checks and deployment docs around the embedded Next runtime.
+4. Cover remaining functional gaps with smoke tests.
diff --git a/docs/audit/frontend-migration.md b/docs/audit/frontend-migration.md
index 1632258..703c290 100644
--- a/docs/audit/frontend-migration.md
+++ b/docs/audit/frontend-migration.md
@@ -1,70 +1,50 @@
-# Frontend Migration Plan
+# Frontend Cutover Notes
## Decision
-The legacy Vite SPA remains in `frontend/` as a fallback.
+`web/` is now the only frontend in the repository.
-The new frontend is being built in `web/` with:
+The previous Vite SPA has been removed so that:
-- Next.js App Router
-- TypeScript
-- Tailwind CSS
-- shadcn/ui component model
+- split deployment and single-container deployment use the same UI
+- documentation no longer has to describe two competing frontend stacks
+- future frontend changes only need to be implemented once
-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
+## Runtime Model
### Auth
-- Login goes through Next route handlers
+- Login goes through Next route handlers under `/frontend-api/auth/*`
- Backend JWT is stored in an `HttpOnly` cookie
- Browser code never reads the raw token
### Data
-- Server pages use server-side fetch helpers
-- Client mutations use browser-side fetch helpers against Next proxy routes
-- URL state is used for pagination and filters
+- Server pages use server-side fetch helpers against FastAPI
+- Client mutations use browser-side fetch helpers against `/frontend-api/proxy/*`
+- FastAPI continues to own the public `/api/*` surface
### Streaming
-- Browser connects to a same-origin Next progress route
+- Browser connects to `/frontend-api/exams/{examId}/progress`
- The route reads the session cookie and proxies backend SSE
-- Backend URL tokens are hidden from the browser
+- Backend token query parameters stay hidden from the browser
-## Directory Map
+## Deployment Outcome
-```text
-web/
- src/app/
- src/components/
- src/lib/
- src/middleware.ts
-```
+### Split Stack
-## Migration Order
+- `backend` serves API traffic on `:8000`
+- `web` serves Next.js on `:3000`
-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
+### Single Container
-## Non-Goals for This First Slice
+- the container runs both FastAPI and Next.js
+- FastAPI stays on `:8000`
+- non-API requests are proxied from FastAPI to the embedded Next server
-- No immediate removal of the legacy `frontend/`
-- No backend contract rewrite yet
-- No server actions as the primary data mutation layer
+## Follow-up Expectations
+
+1. New frontend work lands only in `web/`
+2. Single-container smoke tests must validate both UI and API paths
+3. Deployment docs must continue to describe `web/` as the sole frontend
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
deleted file mode 100644
index c0bf172..0000000
--- a/frontend/.dockerignore
+++ /dev/null
@@ -1,9 +0,0 @@
-node_modules
-npm-debug.log
-build
-.git
-.gitignore
-.dockerignore
-Dockerfile
-.env
-.env.local
diff --git a/frontend/.env.example b/frontend/.env.example
deleted file mode 100644
index ae54091..0000000
--- a/frontend/.env.example
+++ /dev/null
@@ -1,4 +0,0 @@
-# Frontend Environment Variables
-
-# API URL
-VITE_API_URL=http://localhost:8000
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
deleted file mode 100644
index a53da8e..0000000
--- a/frontend/Dockerfile
+++ /dev/null
@@ -1,18 +0,0 @@
-FROM node:18-alpine
-
-WORKDIR /app
-
-# Copy package files
-COPY package*.json ./
-
-# Install dependencies
-RUN npm install
-
-# Copy application code
-COPY . .
-
-# Expose port
-EXPOSE 3000
-
-# Start development server
-CMD ["npm", "start"]
diff --git a/frontend/index.html b/frontend/index.html
deleted file mode 100644
index 4182b3c..0000000
--- a/frontend/index.html
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
- QQuiz - 智能刷题平台
-
-
-
-
-
-
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
deleted file mode 100644
index 7fc0811..0000000
--- a/frontend/package-lock.json
+++ /dev/null
@@ -1,2928 +0,0 @@
-{
- "name": "qquiz-frontend",
- "version": "1.0.0",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "qquiz-frontend",
- "version": "1.0.0",
- "dependencies": {
- "axios": "^1.6.5",
- "lucide-react": "^0.309.0",
- "react": "^18.2.0",
- "react-dom": "^18.2.0",
- "react-hot-toast": "^2.4.1",
- "react-router-dom": "^6.21.1"
- },
- "devDependencies": {
- "@vitejs/plugin-react": "^4.2.1",
- "autoprefixer": "^10.4.16",
- "postcss": "^8.4.33",
- "tailwindcss": "^3.4.1",
- "vite": "^5.0.11"
- }
- },
- "node_modules/@alloc/quick-lru": {
- "version": "5.2.0",
- "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
- "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/@babel/code-frame": {
- "version": "7.27.1",
- "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.27.1.tgz",
- "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-validator-identifier": "^7.27.1",
- "js-tokens": "^4.0.0",
- "picocolors": "^1.1.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/compat-data": {
- "version": "7.28.5",
- "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.28.5.tgz",
- "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/core": {
- "version": "7.28.5",
- "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.28.5.tgz",
- "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.28.5",
- "@babel/helper-compilation-targets": "^7.27.2",
- "@babel/helper-module-transforms": "^7.28.3",
- "@babel/helpers": "^7.28.4",
- "@babel/parser": "^7.28.5",
- "@babel/template": "^7.27.2",
- "@babel/traverse": "^7.28.5",
- "@babel/types": "^7.28.5",
- "@jridgewell/remapping": "^2.3.5",
- "convert-source-map": "^2.0.0",
- "debug": "^4.1.0",
- "gensync": "^1.0.0-beta.2",
- "json5": "^2.2.3",
- "semver": "^6.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/babel"
- }
- },
- "node_modules/@babel/generator": {
- "version": "7.28.5",
- "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.28.5.tgz",
- "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.28.5",
- "@babel/types": "^7.28.5",
- "@jridgewell/gen-mapping": "^0.3.12",
- "@jridgewell/trace-mapping": "^0.3.28",
- "jsesc": "^3.0.2"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-compilation-targets": {
- "version": "7.27.2",
- "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
- "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/compat-data": "^7.27.2",
- "@babel/helper-validator-option": "^7.27.1",
- "browserslist": "^4.24.0",
- "lru-cache": "^5.1.1",
- "semver": "^6.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-globals": {
- "version": "7.28.0",
- "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
- "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-module-imports": {
- "version": "7.27.1",
- "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
- "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/traverse": "^7.27.1",
- "@babel/types": "^7.27.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-module-transforms": {
- "version": "7.28.3",
- "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
- "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-module-imports": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.27.1",
- "@babel/traverse": "^7.28.3"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0"
- }
- },
- "node_modules/@babel/helper-plugin-utils": {
- "version": "7.27.1",
- "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
- "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-string-parser": {
- "version": "7.27.1",
- "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
- "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-identifier": {
- "version": "7.28.5",
- "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
- "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-option": {
- "version": "7.27.1",
- "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
- "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helpers": {
- "version": "7.28.4",
- "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.4.tgz",
- "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/template": "^7.27.2",
- "@babel/types": "^7.28.4"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/parser": {
- "version": "7.28.5",
- "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.5.tgz",
- "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/types": "^7.28.5"
- },
- "bin": {
- "parser": "bin/babel-parser.js"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@babel/plugin-transform-react-jsx-self": {
- "version": "7.27.1",
- "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
- "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-transform-react-jsx-source": {
- "version": "7.27.1",
- "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
- "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/template": {
- "version": "7.27.2",
- "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.27.2.tgz",
- "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.27.1",
- "@babel/parser": "^7.27.2",
- "@babel/types": "^7.27.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/traverse": {
- "version": "7.28.5",
- "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.28.5.tgz",
- "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.28.5",
- "@babel/helper-globals": "^7.28.0",
- "@babel/parser": "^7.28.5",
- "@babel/template": "^7.27.2",
- "@babel/types": "^7.28.5",
- "debug": "^4.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/types": {
- "version": "7.28.5",
- "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.28.5.tgz",
- "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.28.5"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
- "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/android-arm": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
- "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/android-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
- "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/android-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
- "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
- "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
- "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
- "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
- "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-arm": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
- "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
- "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
- "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
- "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
- "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
- "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
- "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
- "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
- "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
- "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
- "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
- "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
- "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
- "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/win32-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
- "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.13",
- "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
- "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.0",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/remapping": {
- "version": "2.3.5",
- "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz",
- "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
- "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.31",
- "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
- "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
- "node_modules/@nodelib/fs.scandir": {
- "version": "2.1.5",
- "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
- "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "2.0.5",
- "run-parallel": "^1.1.9"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.stat": {
- "version": "2.0.5",
- "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
- "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.walk": {
- "version": "1.2.8",
- "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
- "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@remix-run/router": {
- "version": "1.23.1",
- "resolved": "https://registry.npmmirror.com/@remix-run/router/-/router-1.23.1.tgz",
- "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==",
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-beta.27",
- "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
- "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
- "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-android-arm64": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
- "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
- "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
- "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
- "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
- "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
- "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
- "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
- "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
- "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
- "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
- "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
- "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
- "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
- "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
- "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
- "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
- "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ]
- },
- "node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
- "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
- "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
- "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
- "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@types/babel__core": {
- "version": "7.20.5",
- "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz",
- "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.20.7",
- "@babel/types": "^7.20.7",
- "@types/babel__generator": "*",
- "@types/babel__template": "*",
- "@types/babel__traverse": "*"
- }
- },
- "node_modules/@types/babel__generator": {
- "version": "7.27.0",
- "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz",
- "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/types": "^7.0.0"
- }
- },
- "node_modules/@types/babel__template": {
- "version": "7.4.4",
- "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz",
- "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.1.0",
- "@babel/types": "^7.0.0"
- }
- },
- "node_modules/@types/babel__traverse": {
- "version": "7.28.0",
- "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
- "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/types": "^7.28.2"
- }
- },
- "node_modules/@types/estree": {
- "version": "1.0.8",
- "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
- "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@vitejs/plugin-react": {
- "version": "4.7.0",
- "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
- "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/core": "^7.28.0",
- "@babel/plugin-transform-react-jsx-self": "^7.27.1",
- "@babel/plugin-transform-react-jsx-source": "^7.27.1",
- "@rolldown/pluginutils": "1.0.0-beta.27",
- "@types/babel__core": "^7.20.5",
- "react-refresh": "^0.17.0"
- },
- "engines": {
- "node": "^14.18.0 || >=16.0.0"
- },
- "peerDependencies": {
- "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
- }
- },
- "node_modules/any-promise": {
- "version": "1.3.0",
- "resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz",
- "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/anymatch": {
- "version": "3.1.3",
- "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz",
- "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "normalize-path": "^3.0.0",
- "picomatch": "^2.0.4"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/arg": {
- "version": "5.0.2",
- "resolved": "https://registry.npmmirror.com/arg/-/arg-5.0.2.tgz",
- "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/asynckit": {
- "version": "0.4.0",
- "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
- "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
- "license": "MIT"
- },
- "node_modules/autoprefixer": {
- "version": "10.4.22",
- "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.22.tgz",
- "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/autoprefixer"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "browserslist": "^4.27.0",
- "caniuse-lite": "^1.0.30001754",
- "fraction.js": "^5.3.4",
- "normalize-range": "^0.1.2",
- "picocolors": "^1.1.1",
- "postcss-value-parser": "^4.2.0"
- },
- "bin": {
- "autoprefixer": "bin/autoprefixer"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- },
- "peerDependencies": {
- "postcss": "^8.1.0"
- }
- },
- "node_modules/axios": {
- "version": "1.13.2",
- "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.2.tgz",
- "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
- "license": "MIT",
- "dependencies": {
- "follow-redirects": "^1.15.6",
- "form-data": "^4.0.4",
- "proxy-from-env": "^1.1.0"
- }
- },
- "node_modules/baseline-browser-mapping": {
- "version": "2.8.32",
- "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz",
- "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "baseline-browser-mapping": "dist/cli.js"
- }
- },
- "node_modules/binary-extensions": {
- "version": "2.3.0",
- "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz",
- "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/braces": {
- "version": "3.0.3",
- "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz",
- "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fill-range": "^7.1.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/browserslist": {
- "version": "4.28.0",
- "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.0.tgz",
- "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/browserslist"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "baseline-browser-mapping": "^2.8.25",
- "caniuse-lite": "^1.0.30001754",
- "electron-to-chromium": "^1.5.249",
- "node-releases": "^2.0.27",
- "update-browserslist-db": "^1.1.4"
- },
- "bin": {
- "browserslist": "cli.js"
- },
- "engines": {
- "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
- }
- },
- "node_modules/call-bind-apply-helpers": {
- "version": "1.0.2",
- "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
- "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/camelcase-css": {
- "version": "2.0.1",
- "resolved": "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz",
- "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/caniuse-lite": {
- "version": "1.0.30001757",
- "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
- "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "CC-BY-4.0"
- },
- "node_modules/chokidar": {
- "version": "3.6.0",
- "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
- "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "anymatch": "~3.1.2",
- "braces": "~3.0.2",
- "glob-parent": "~5.1.2",
- "is-binary-path": "~2.1.0",
- "is-glob": "~4.0.1",
- "normalize-path": "~3.0.0",
- "readdirp": "~3.6.0"
- },
- "engines": {
- "node": ">= 8.10.0"
- },
- "funding": {
- "url": "https://paulmillr.com/funding/"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.2"
- }
- },
- "node_modules/chokidar/node_modules/glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/combined-stream": {
- "version": "1.0.8",
- "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
- "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
- "license": "MIT",
- "dependencies": {
- "delayed-stream": "~1.0.0"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/commander": {
- "version": "4.1.1",
- "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz",
- "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/convert-source-map": {
- "version": "2.0.0",
- "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz",
- "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/cssesc": {
- "version": "3.0.0",
- "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
- "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "cssesc": "bin/cssesc"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/csstype": {
- "version": "3.2.3",
- "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
- "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "license": "MIT",
- "peer": true
- },
- "node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/delayed-stream": {
- "version": "1.0.0",
- "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
- "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
- "license": "MIT",
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/didyoumean": {
- "version": "1.2.2",
- "resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz",
- "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
- "dev": true,
- "license": "Apache-2.0"
- },
- "node_modules/dlv": {
- "version": "1.1.3",
- "resolved": "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz",
- "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/dunder-proto": {
- "version": "1.0.1",
- "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
- "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.1",
- "es-errors": "^1.3.0",
- "gopd": "^1.2.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/electron-to-chromium": {
- "version": "1.5.262",
- "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz",
- "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/es-define-property": {
- "version": "1.0.1",
- "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
- "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-errors": {
- "version": "1.3.0",
- "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
- "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-object-atoms": {
- "version": "1.1.1",
- "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
- "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-set-tostringtag": {
- "version": "2.1.0",
- "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
- "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "get-intrinsic": "^1.2.6",
- "has-tostringtag": "^1.0.2",
- "hasown": "^2.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/esbuild": {
- "version": "0.21.5",
- "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
- "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=12"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.21.5",
- "@esbuild/android-arm": "0.21.5",
- "@esbuild/android-arm64": "0.21.5",
- "@esbuild/android-x64": "0.21.5",
- "@esbuild/darwin-arm64": "0.21.5",
- "@esbuild/darwin-x64": "0.21.5",
- "@esbuild/freebsd-arm64": "0.21.5",
- "@esbuild/freebsd-x64": "0.21.5",
- "@esbuild/linux-arm": "0.21.5",
- "@esbuild/linux-arm64": "0.21.5",
- "@esbuild/linux-ia32": "0.21.5",
- "@esbuild/linux-loong64": "0.21.5",
- "@esbuild/linux-mips64el": "0.21.5",
- "@esbuild/linux-ppc64": "0.21.5",
- "@esbuild/linux-riscv64": "0.21.5",
- "@esbuild/linux-s390x": "0.21.5",
- "@esbuild/linux-x64": "0.21.5",
- "@esbuild/netbsd-x64": "0.21.5",
- "@esbuild/openbsd-x64": "0.21.5",
- "@esbuild/sunos-x64": "0.21.5",
- "@esbuild/win32-arm64": "0.21.5",
- "@esbuild/win32-ia32": "0.21.5",
- "@esbuild/win32-x64": "0.21.5"
- }
- },
- "node_modules/escalade": {
- "version": "3.2.0",
- "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
- "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/fast-glob": {
- "version": "3.3.3",
- "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
- "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "^2.0.2",
- "@nodelib/fs.walk": "^1.2.3",
- "glob-parent": "^5.1.2",
- "merge2": "^1.3.0",
- "micromatch": "^4.0.8"
- },
- "engines": {
- "node": ">=8.6.0"
- }
- },
- "node_modules/fast-glob/node_modules/glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/fastq": {
- "version": "1.19.1",
- "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz",
- "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "reusify": "^1.0.4"
- }
- },
- "node_modules/fill-range": {
- "version": "7.1.1",
- "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
- "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "to-regex-range": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/follow-redirects": {
- "version": "1.15.11",
- "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
- "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
- "funding": [
- {
- "type": "individual",
- "url": "https://github.com/sponsors/RubenVerborgh"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=4.0"
- },
- "peerDependenciesMeta": {
- "debug": {
- "optional": true
- }
- }
- },
- "node_modules/form-data": {
- "version": "4.0.5",
- "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz",
- "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
- "license": "MIT",
- "dependencies": {
- "asynckit": "^0.4.0",
- "combined-stream": "^1.0.8",
- "es-set-tostringtag": "^2.1.0",
- "hasown": "^2.0.2",
- "mime-types": "^2.1.12"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/fraction.js": {
- "version": "5.3.4",
- "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz",
- "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "*"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/rawify"
- }
- },
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/function-bind": {
- "version": "1.1.2",
- "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
- "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/gensync": {
- "version": "1.0.0-beta.2",
- "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
- "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/get-intrinsic": {
- "version": "1.3.0",
- "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
- "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.2",
- "es-define-property": "^1.0.1",
- "es-errors": "^1.3.0",
- "es-object-atoms": "^1.1.1",
- "function-bind": "^1.1.2",
- "get-proto": "^1.0.1",
- "gopd": "^1.2.0",
- "has-symbols": "^1.1.0",
- "hasown": "^2.0.2",
- "math-intrinsics": "^1.1.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-proto": {
- "version": "1.0.1",
- "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
- "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
- "license": "MIT",
- "dependencies": {
- "dunder-proto": "^1.0.1",
- "es-object-atoms": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/glob-parent": {
- "version": "6.0.2",
- "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz",
- "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.3"
- },
- "engines": {
- "node": ">=10.13.0"
- }
- },
- "node_modules/goober": {
- "version": "2.1.18",
- "resolved": "https://registry.npmmirror.com/goober/-/goober-2.1.18.tgz",
- "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
- "license": "MIT",
- "peerDependencies": {
- "csstype": "^3.0.10"
- }
- },
- "node_modules/gopd": {
- "version": "1.2.0",
- "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
- "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-symbols": {
- "version": "1.1.0",
- "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
- "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-tostringtag": {
- "version": "1.0.2",
- "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
- "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
- "license": "MIT",
- "dependencies": {
- "has-symbols": "^1.0.3"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/hasown": {
- "version": "2.0.2",
- "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
- "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
- "license": "MIT",
- "dependencies": {
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/is-binary-path": {
- "version": "2.1.0",
- "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
- "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "binary-extensions": "^2.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/is-core-module": {
- "version": "2.16.1",
- "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz",
- "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "hasown": "^2.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-extglob": {
- "version": "2.1.1",
- "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
- "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/is-glob": {
- "version": "4.0.3",
- "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
- "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-extglob": "^2.1.1"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/is-number": {
- "version": "7.0.0",
- "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz",
- "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.12.0"
- }
- },
- "node_modules/jiti": {
- "version": "1.21.7",
- "resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz",
- "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "bin": {
- "jiti": "bin/jiti.js"
- }
- },
- "node_modules/js-tokens": {
- "version": "4.0.0",
- "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
- "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "license": "MIT"
- },
- "node_modules/jsesc": {
- "version": "3.1.0",
- "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz",
- "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "jsesc": "bin/jsesc"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/json5": {
- "version": "2.2.3",
- "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz",
- "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "json5": "lib/cli.js"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/lilconfig": {
- "version": "3.1.3",
- "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
- "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/antonk52"
- }
- },
- "node_modules/lines-and-columns": {
- "version": "1.2.4",
- "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
- "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/loose-envify": {
- "version": "1.4.0",
- "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
- "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
- "license": "MIT",
- "dependencies": {
- "js-tokens": "^3.0.0 || ^4.0.0"
- },
- "bin": {
- "loose-envify": "cli.js"
- }
- },
- "node_modules/lru-cache": {
- "version": "5.1.1",
- "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
- "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "yallist": "^3.0.2"
- }
- },
- "node_modules/lucide-react": {
- "version": "0.309.0",
- "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.309.0.tgz",
- "integrity": "sha512-zNVPczuwFrCfksZH3zbd1UDE6/WYhYAdbe2k7CImVyPAkXLgIwbs6eXQ4loigqDnUFjyFYCI5jZ1y10Kqal0dg==",
- "license": "ISC",
- "peerDependencies": {
- "react": "^16.5.1 || ^17.0.0 || ^18.0.0"
- }
- },
- "node_modules/math-intrinsics": {
- "version": "1.1.0",
- "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
- "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/merge2": {
- "version": "1.4.1",
- "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz",
- "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/micromatch": {
- "version": "4.0.8",
- "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz",
- "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "braces": "^3.0.3",
- "picomatch": "^2.3.1"
- },
- "engines": {
- "node": ">=8.6"
- }
- },
- "node_modules/mime-db": {
- "version": "1.52.0",
- "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/mime-types": {
- "version": "2.1.35",
- "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
- "license": "MIT",
- "dependencies": {
- "mime-db": "1.52.0"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/mz": {
- "version": "2.7.0",
- "resolved": "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz",
- "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "any-promise": "^1.0.0",
- "object-assign": "^4.0.1",
- "thenify-all": "^1.0.0"
- }
- },
- "node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "bin": {
- "nanoid": "bin/nanoid.cjs"
- },
- "engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
- }
- },
- "node_modules/node-releases": {
- "version": "2.0.27",
- "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz",
- "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/normalize-path": {
- "version": "3.0.0",
- "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
- "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/normalize-range": {
- "version": "0.1.2",
- "resolved": "https://registry.npmmirror.com/normalize-range/-/normalize-range-0.1.2.tgz",
- "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/object-assign": {
- "version": "4.1.1",
- "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
- "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/object-hash": {
- "version": "3.0.0",
- "resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz",
- "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/path-parse": {
- "version": "1.0.7",
- "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz",
- "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/picocolors": {
- "version": "1.1.1",
- "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
- "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/pify": {
- "version": "2.3.0",
- "resolved": "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz",
- "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/pirates": {
- "version": "4.0.7",
- "resolved": "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz",
- "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/postcss": {
- "version": "8.5.6",
- "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz",
- "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "nanoid": "^3.3.11",
- "picocolors": "^1.1.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
- "node_modules/postcss-import": {
- "version": "15.1.0",
- "resolved": "https://registry.npmmirror.com/postcss-import/-/postcss-import-15.1.0.tgz",
- "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "postcss-value-parser": "^4.0.0",
- "read-cache": "^1.0.0",
- "resolve": "^1.1.7"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "peerDependencies": {
- "postcss": "^8.0.0"
- }
- },
- "node_modules/postcss-js": {
- "version": "4.1.0",
- "resolved": "https://registry.npmmirror.com/postcss-js/-/postcss-js-4.1.0.tgz",
- "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "camelcase-css": "^2.0.1"
- },
- "engines": {
- "node": "^12 || ^14 || >= 16"
- },
- "peerDependencies": {
- "postcss": "^8.4.21"
- }
- },
- "node_modules/postcss-load-config": {
- "version": "6.0.1",
- "resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
- "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "lilconfig": "^3.1.1"
- },
- "engines": {
- "node": ">= 18"
- },
- "peerDependencies": {
- "jiti": ">=1.21.0",
- "postcss": ">=8.0.9",
- "tsx": "^4.8.1",
- "yaml": "^2.4.2"
- },
- "peerDependenciesMeta": {
- "jiti": {
- "optional": true
- },
- "postcss": {
- "optional": true
- },
- "tsx": {
- "optional": true
- },
- "yaml": {
- "optional": true
- }
- }
- },
- "node_modules/postcss-nested": {
- "version": "6.2.0",
- "resolved": "https://registry.npmmirror.com/postcss-nested/-/postcss-nested-6.2.0.tgz",
- "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "postcss-selector-parser": "^6.1.1"
- },
- "engines": {
- "node": ">=12.0"
- },
- "peerDependencies": {
- "postcss": "^8.2.14"
- }
- },
- "node_modules/postcss-selector-parser": {
- "version": "6.1.2",
- "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
- "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "cssesc": "^3.0.0",
- "util-deprecate": "^1.0.2"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/postcss-value-parser": {
- "version": "4.2.0",
- "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
- "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/proxy-from-env": {
- "version": "1.1.0",
- "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
- "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
- "license": "MIT"
- },
- "node_modules/queue-microtask": {
- "version": "1.2.3",
- "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
- "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT"
- },
- "node_modules/react": {
- "version": "18.3.1",
- "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz",
- "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "loose-envify": "^1.1.0"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/react-dom": {
- "version": "18.3.1",
- "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz",
- "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "loose-envify": "^1.1.0",
- "scheduler": "^0.23.2"
- },
- "peerDependencies": {
- "react": "^18.3.1"
- }
- },
- "node_modules/react-hot-toast": {
- "version": "2.6.0",
- "resolved": "https://registry.npmmirror.com/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
- "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
- "license": "MIT",
- "dependencies": {
- "csstype": "^3.1.3",
- "goober": "^2.1.16"
- },
- "engines": {
- "node": ">=10"
- },
- "peerDependencies": {
- "react": ">=16",
- "react-dom": ">=16"
- }
- },
- "node_modules/react-refresh": {
- "version": "0.17.0",
- "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz",
- "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/react-router": {
- "version": "6.30.2",
- "resolved": "https://registry.npmmirror.com/react-router/-/react-router-6.30.2.tgz",
- "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==",
- "license": "MIT",
- "dependencies": {
- "@remix-run/router": "1.23.1"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "peerDependencies": {
- "react": ">=16.8"
- }
- },
- "node_modules/react-router-dom": {
- "version": "6.30.2",
- "resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-6.30.2.tgz",
- "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==",
- "license": "MIT",
- "dependencies": {
- "@remix-run/router": "1.23.1",
- "react-router": "6.30.2"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "peerDependencies": {
- "react": ">=16.8",
- "react-dom": ">=16.8"
- }
- },
- "node_modules/read-cache": {
- "version": "1.0.0",
- "resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz",
- "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "pify": "^2.3.0"
- }
- },
- "node_modules/readdirp": {
- "version": "3.6.0",
- "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
- "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "picomatch": "^2.2.1"
- },
- "engines": {
- "node": ">=8.10.0"
- }
- },
- "node_modules/resolve": {
- "version": "1.22.11",
- "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz",
- "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-core-module": "^2.16.1",
- "path-parse": "^1.0.7",
- "supports-preserve-symlinks-flag": "^1.0.0"
- },
- "bin": {
- "resolve": "bin/resolve"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/reusify": {
- "version": "1.1.0",
- "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz",
- "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "iojs": ">=1.0.0",
- "node": ">=0.10.0"
- }
- },
- "node_modules/rollup": {
- "version": "4.53.3",
- "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.53.3.tgz",
- "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "1.0.8"
- },
- "bin": {
- "rollup": "dist/bin/rollup"
- },
- "engines": {
- "node": ">=18.0.0",
- "npm": ">=8.0.0"
- },
- "optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.53.3",
- "@rollup/rollup-android-arm64": "4.53.3",
- "@rollup/rollup-darwin-arm64": "4.53.3",
- "@rollup/rollup-darwin-x64": "4.53.3",
- "@rollup/rollup-freebsd-arm64": "4.53.3",
- "@rollup/rollup-freebsd-x64": "4.53.3",
- "@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
- "@rollup/rollup-linux-arm-musleabihf": "4.53.3",
- "@rollup/rollup-linux-arm64-gnu": "4.53.3",
- "@rollup/rollup-linux-arm64-musl": "4.53.3",
- "@rollup/rollup-linux-loong64-gnu": "4.53.3",
- "@rollup/rollup-linux-ppc64-gnu": "4.53.3",
- "@rollup/rollup-linux-riscv64-gnu": "4.53.3",
- "@rollup/rollup-linux-riscv64-musl": "4.53.3",
- "@rollup/rollup-linux-s390x-gnu": "4.53.3",
- "@rollup/rollup-linux-x64-gnu": "4.53.3",
- "@rollup/rollup-linux-x64-musl": "4.53.3",
- "@rollup/rollup-openharmony-arm64": "4.53.3",
- "@rollup/rollup-win32-arm64-msvc": "4.53.3",
- "@rollup/rollup-win32-ia32-msvc": "4.53.3",
- "@rollup/rollup-win32-x64-gnu": "4.53.3",
- "@rollup/rollup-win32-x64-msvc": "4.53.3",
- "fsevents": "~2.3.2"
- }
- },
- "node_modules/run-parallel": {
- "version": "1.2.0",
- "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz",
- "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "queue-microtask": "^1.2.2"
- }
- },
- "node_modules/scheduler": {
- "version": "0.23.2",
- "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz",
- "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
- "license": "MIT",
- "dependencies": {
- "loose-envify": "^1.1.0"
- }
- },
- "node_modules/semver": {
- "version": "6.3.1",
- "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz",
- "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- }
- },
- "node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/sucrase": {
- "version": "3.35.1",
- "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.1.tgz",
- "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.2",
- "commander": "^4.0.0",
- "lines-and-columns": "^1.1.6",
- "mz": "^2.7.0",
- "pirates": "^4.0.1",
- "tinyglobby": "^0.2.11",
- "ts-interface-checker": "^0.1.9"
- },
- "bin": {
- "sucrase": "bin/sucrase",
- "sucrase-node": "bin/sucrase-node"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- }
- },
- "node_modules/supports-preserve-symlinks-flag": {
- "version": "1.0.0",
- "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
- "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/tailwindcss": {
- "version": "3.4.18",
- "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.18.tgz",
- "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@alloc/quick-lru": "^5.2.0",
- "arg": "^5.0.2",
- "chokidar": "^3.6.0",
- "didyoumean": "^1.2.2",
- "dlv": "^1.1.3",
- "fast-glob": "^3.3.2",
- "glob-parent": "^6.0.2",
- "is-glob": "^4.0.3",
- "jiti": "^1.21.7",
- "lilconfig": "^3.1.3",
- "micromatch": "^4.0.8",
- "normalize-path": "^3.0.0",
- "object-hash": "^3.0.0",
- "picocolors": "^1.1.1",
- "postcss": "^8.4.47",
- "postcss-import": "^15.1.0",
- "postcss-js": "^4.0.1",
- "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
- "postcss-nested": "^6.2.0",
- "postcss-selector-parser": "^6.1.2",
- "resolve": "^1.22.8",
- "sucrase": "^3.35.0"
- },
- "bin": {
- "tailwind": "lib/cli.js",
- "tailwindcss": "lib/cli.js"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/thenify": {
- "version": "3.3.1",
- "resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz",
- "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "any-promise": "^1.0.0"
- }
- },
- "node_modules/thenify-all": {
- "version": "1.6.0",
- "resolved": "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz",
- "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "thenify": ">= 3.1.0 < 4"
- },
- "engines": {
- "node": ">=0.8"
- }
- },
- "node_modules/tinyglobby": {
- "version": "0.2.15",
- "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
- "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fdir": "^6.5.0",
- "picomatch": "^4.0.3"
- },
- "engines": {
- "node": ">=12.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/SuperchupuDev"
- }
- },
- "node_modules/tinyglobby/node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/tinyglobby/node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/to-regex-range": {
- "version": "5.0.1",
- "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
- "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-number": "^7.0.0"
- },
- "engines": {
- "node": ">=8.0"
- }
- },
- "node_modules/ts-interface-checker": {
- "version": "0.1.13",
- "resolved": "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
- "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
- "dev": true,
- "license": "Apache-2.0"
- },
- "node_modules/update-browserslist-db": {
- "version": "1.1.4",
- "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
- "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/browserslist"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "escalade": "^3.2.0",
- "picocolors": "^1.1.1"
- },
- "bin": {
- "update-browserslist-db": "cli.js"
- },
- "peerDependencies": {
- "browserslist": ">= 4.21.0"
- }
- },
- "node_modules/util-deprecate": {
- "version": "1.0.2",
- "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
- "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/vite": {
- "version": "5.4.21",
- "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
- "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "esbuild": "^0.21.3",
- "postcss": "^8.4.43",
- "rollup": "^4.20.0"
- },
- "bin": {
- "vite": "bin/vite.js"
- },
- "engines": {
- "node": "^18.0.0 || >=20.0.0"
- },
- "funding": {
- "url": "https://github.com/vitejs/vite?sponsor=1"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- },
- "peerDependencies": {
- "@types/node": "^18.0.0 || >=20.0.0",
- "less": "*",
- "lightningcss": "^1.21.0",
- "sass": "*",
- "sass-embedded": "*",
- "stylus": "*",
- "sugarss": "*",
- "terser": "^5.4.0"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- },
- "less": {
- "optional": true
- },
- "lightningcss": {
- "optional": true
- },
- "sass": {
- "optional": true
- },
- "sass-embedded": {
- "optional": true
- },
- "stylus": {
- "optional": true
- },
- "sugarss": {
- "optional": true
- },
- "terser": {
- "optional": true
- }
- }
- },
- "node_modules/yallist": {
- "version": "3.1.1",
- "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
- "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
- "dev": true,
- "license": "ISC"
- }
- }
-}
diff --git a/frontend/package.json b/frontend/package.json
deleted file mode 100644
index e7a10a6..0000000
--- a/frontend/package.json
+++ /dev/null
@@ -1,45 +0,0 @@
-{
- "name": "qquiz-frontend",
- "version": "1.0.0",
- "type": "module",
- "description": "QQuiz Frontend - React Application",
- "private": true,
- "dependencies": {
- "react": "^18.2.0",
- "react-dom": "^18.2.0",
- "react-router-dom": "^6.21.1",
- "axios": "^1.6.5",
- "react-hot-toast": "^2.4.1",
- "lucide-react": "^0.309.0"
- },
- "devDependencies": {
- "@vitejs/plugin-react": "^4.2.1",
- "autoprefixer": "^10.4.16",
- "postcss": "^8.4.33",
- "tailwindcss": "^3.4.1",
- "vite": "^5.0.11"
- },
- "scripts": {
- "dev": "vite",
- "start": "vite",
- "build": "vite build",
- "preview": "vite preview"
- },
- "eslintConfig": {
- "extends": [
- "react-app"
- ]
- },
- "browserslist": {
- "production": [
- ">0.2%",
- "not dead",
- "not op_mini all"
- ],
- "development": [
- "last 1 chrome version",
- "last 1 firefox version",
- "last 1 safari version"
- ]
- }
-}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
deleted file mode 100644
index 2e7af2b..0000000
--- a/frontend/postcss.config.js
+++ /dev/null
@@ -1,6 +0,0 @@
-export default {
- plugins: {
- tailwindcss: {},
- autoprefixer: {},
- },
-}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
deleted file mode 100644
index b743100..0000000
--- a/frontend/src/App.jsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import React from 'react'
-import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
-import { Toaster } from 'react-hot-toast'
-import { AuthProvider } from './context/AuthContext'
-import { ProtectedRoute } from './components/ProtectedRoute'
-import Layout from './components/Layout'
-
-// Auth Pages
-import Login from './pages/Login'
-import Register from './pages/Register'
-
-// Main Pages
-import Dashboard from './pages/Dashboard'
-import ExamList from './pages/ExamList'
-import ExamDetail from './pages/ExamDetail'
-import QuizPlayer from './pages/QuizPlayer'
-import MistakeList from './pages/MistakeList'
-import MistakePlayer from './pages/MistakePlayer'
-import QuestionBank from './pages/QuestionBank'
-
-// Admin Pages
-import AdminPanel from './pages/AdminPanel'
-import AdminSettings from './pages/AdminSettings'
-
-function App() {
- return (
-
-
-
-
-
-
- {/* Public Routes */}
- } />
- } />
-
- {/* Protected Routes with Layout */}
- }>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
- {/* Admin Only Routes */}
-
-
-
- }
- />
-
-
-
- }
- />
-
-
- {/* Default Route */}
- } />
-
- {/* 404 */}
- } />
-
-
-
-
- )
-}
-
-export default App
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
deleted file mode 100644
index 70f2415..0000000
--- a/frontend/src/api/client.js
+++ /dev/null
@@ -1,240 +0,0 @@
-/**
- * API Client for QQuiz Backend
- */
-import axios from 'axios'
-import toast from 'react-hot-toast'
-
-export const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
-export const AUTH_TOKEN_STORAGE_KEY = 'access_token'
-
-const AUTH_USER_STORAGE_KEY = 'user'
-const PUBLIC_REQUEST_PATHS = ['/auth/login', '/auth/register']
-
-const getRequestPath = (config) => {
- const url = config?.url || ''
-
- if (!url) return ''
-
- if (url.startsWith('http://') || url.startsWith('https://')) {
- try {
- return new URL(url).pathname
- } catch (error) {
- return url
- }
- }
-
- return url.startsWith('/') ? url : `/${url}`
-}
-
-const isPublicRequest = (config) => {
- if (config?.skipAuthHandling === true) {
- return true
- }
-
- const path = getRequestPath(config)
- return PUBLIC_REQUEST_PATHS.some((publicPath) => path.endsWith(publicPath))
-}
-
-export const buildApiUrl = (path) => {
- const base = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL
- const normalizedPath = path.startsWith('/') ? path : `/${path}`
- return `${base}${normalizedPath}`
-}
-
-export const getAccessToken = () => localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)
-
-export const clearAuthStorage = () => {
- localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY)
- localStorage.removeItem(AUTH_USER_STORAGE_KEY)
-}
-
-// Create axios instance
-const api = axios.create({
- baseURL: API_BASE_URL,
- timeout: 30000,
- headers: {
- 'Content-Type': 'application/json'
- }
-})
-
-// Request interceptor - Add auth token
-api.interceptors.request.use(
- (config) => {
- const token = getAccessToken()
- if (token && !isPublicRequest(config)) {
- config.headers = config.headers || {}
- config.headers.Authorization = `Bearer ${token}`
- }
- return config
- },
- (error) => {
- return Promise.reject(error)
- }
-)
-
-// Response interceptor - Handle errors
-api.interceptors.response.use(
- (response) => response,
- (error) => {
- const status = error.response?.status
- const message = error.response?.data?.detail || 'An error occurred'
- const requestConfig = error.config || {}
- const hasAuthHeader = Boolean(
- requestConfig.headers?.Authorization || requestConfig.headers?.authorization
- )
-
- if (status === 401 && !isPublicRequest(requestConfig) && hasAuthHeader) {
- clearAuthStorage()
- if (window.location.pathname !== '/login') {
- window.location.href = '/login'
- }
- toast.error('Session expired. Please login again.')
- } else if (status === 401) {
- toast.error(message)
- } else if (status === 403) {
- toast.error('Permission denied')
- } else if (status === 429) {
- toast.error(message)
- } else if (status >= 500) {
- toast.error('Server error. Please try again later.')
- } else {
- toast.error(message)
- }
-
- return Promise.reject(error)
- }
-)
-
-// ============ Auth APIs ============
-export const authAPI = {
- register: (username, password) =>
- api.post('/auth/register', { username, password }, { skipAuthHandling: true }),
-
- login: (username, password) =>
- api.post('/auth/login', { username, password }, { skipAuthHandling: true }),
-
- getCurrentUser: () =>
- api.get('/auth/me'),
-
- changePassword: (oldPassword, newPassword) =>
- api.post('/auth/change-password', null, {
- params: { old_password: oldPassword, new_password: newPassword }
- })
-}
-
-// ============ Exam APIs ============
-export const examAPI = {
- // Create exam with first document
- create: (title, file, isRandom = false) => {
- const formData = new FormData()
- formData.append('title', title)
- formData.append('file', file)
- formData.append('is_random', isRandom)
- return api.post('/exams/create', formData, {
- headers: { 'Content-Type': 'multipart/form-data' }
- })
- },
-
- // Append document to existing exam
- appendDocument: (examId, file) => {
- const formData = new FormData()
- formData.append('file', file)
- return api.post(`/exams/${examId}/append`, formData, {
- headers: { 'Content-Type': 'multipart/form-data' }
- })
- },
-
- // Get user's exam list
- getList: (skip = 0, limit = 20) =>
- api.get('/exams/', { params: { skip, limit } }),
-
- // Get exam detail
- getDetail: (examId) =>
- api.get(`/exams/${examId}`),
-
- // Delete exam
- delete: (examId) =>
- api.delete(`/exams/${examId}`),
-
- // Update quiz progress
- updateProgress: (examId, currentIndex) =>
- api.put(`/exams/${examId}/progress`, { current_index: currentIndex })
-}
-
-// ============ Question APIs ============
-export const questionAPI = {
- // Get all questions (Question Bank)
- getAll: (skip = 0, limit = 50, examId = null) => {
- const params = { skip, limit }
- if (examId) params.exam_id = examId
- return api.get('/questions/', { params })
- },
-
- // Get all questions for an exam
- getExamQuestions: (examId, skip = 0, limit = 50) =>
- api.get(`/questions/exam/${examId}/questions`, { params: { skip, limit } }),
-
- // Get current question (based on exam's current_index)
- getCurrentQuestion: (examId) =>
- api.get(`/questions/exam/${examId}/current`),
-
- // Get question by ID
- getById: (questionId) =>
- api.get(`/questions/${questionId}`),
-
- // Check answer
- checkAnswer: (questionId, userAnswer) =>
- api.post('/questions/check', {
- question_id: questionId,
- user_answer: userAnswer
- })
-}
-
-// ============ Mistake APIs ============
-export const mistakeAPI = {
- // Get user's mistake book
- getList: (skip = 0, limit = 50, examId = null) => {
- const params = { skip, limit }
- if (examId) params.exam_id = examId
- return api.get('/mistakes/', { params })
- },
-
- // Add to mistake book
- add: (questionId) =>
- api.post('/mistakes/add', { question_id: questionId }),
-
- // Remove from mistake book by mistake ID
- remove: (mistakeId) =>
- api.delete(`/mistakes/${mistakeId}`),
-
- // Remove from mistake book by question ID
- removeByQuestionId: (questionId) =>
- api.delete(`/mistakes/question/${questionId}`)
-}
-
-// ============ Admin APIs ============
-export const adminAPI = {
- // Config
- getConfig: () => api.get('/admin/config'),
- updateConfig: (config) => api.put('/admin/config', config),
-
- // Users
- getUsers: (skip = 0, limit = 50, search = null) =>
- api.get('/admin/users', { params: { skip, limit, search } }),
- createUser: (username, password, is_admin = false) =>
- api.post('/admin/users', { username, password, is_admin }),
- updateUser: (userId, data) =>
- api.put(`/admin/users/${userId}`, data),
- deleteUser: (userId) =>
- api.delete(`/admin/users/${userId}`),
-
- // Statistics
- getStatistics: () => api.get('/admin/statistics'),
- getHealth: () => api.get('/admin/health'),
-
- // Export
- exportUsers: () => api.get('/admin/export/users', { responseType: 'blob' }),
- exportStatistics: () => api.get('/admin/export/statistics', { responseType: 'blob' })
-}
-
-export default api
diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx
deleted file mode 100644
index a1e2f76..0000000
--- a/frontend/src/components/Layout.jsx
+++ /dev/null
@@ -1,142 +0,0 @@
-/**
- * Main Layout Component with Navigation
- */
-import React, { useState } from 'react'
-import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom'
-import { useAuth } from '../context/AuthContext'
-import {
- BookOpen,
- LayoutDashboard,
- FolderOpen,
- XCircle,
- Settings,
- LogOut,
- Menu,
- X,
- Shield
-} from 'lucide-react'
-
-export const Layout = () => {
- const { user, logout, isAdmin } = useAuth()
- const navigate = useNavigate()
- const location = useLocation()
- const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
-
- const handleLogout = () => {
- logout()
- navigate('/login')
- }
-
- const navigation = [
- { name: '首页', href: '/dashboard', icon: LayoutDashboard },
- { name: '题库管理', href: '/exams', icon: FolderOpen },
- { name: '错题本', href: '/mistakes', icon: XCircle },
- ]
-
- if (isAdmin) {
- navigation.push({ name: '管理面板', href: '/admin', icon: Shield })
- navigation.push({ name: '系统设置', href: '/admin/settings', icon: Settings })
- }
-
- const isActive = (href) => location.pathname === href
-
- return (
-
- {/* Mobile Header */}
-
-
-
-
- QQuiz
-
-
-
-
- {/* Mobile Menu */}
- {mobileMenuOpen && (
-
- {navigation.map((item) => (
- setMobileMenuOpen(false)}
- className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive(item.href)
- ? 'bg-primary-50 text-primary-600'
- : 'text-gray-700 hover:bg-gray-100'
- }`}
- >
-
- {item.name}
-
- ))}
-
-
- )}
-
-
-
- {/* Desktop Sidebar */}
-
-
- {/* Logo */}
-
-
-
-
-
-
QQuiz
-
{user?.username}
-
-
-
- {/* Navigation */}
-
-
- {/* Logout */}
-
-
-
-
-
-
- {/* Main Content */}
-
-
-
-
-
- )
-}
-
-export default Layout
diff --git a/frontend/src/components/Pagination.jsx b/frontend/src/components/Pagination.jsx
deleted file mode 100644
index 7e51e3b..0000000
--- a/frontend/src/components/Pagination.jsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import React, { useState, useEffect } from 'react'
-import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react'
-
-const Pagination = ({
- currentPage,
- totalItems,
- pageSize,
- onPageChange,
- onPageSizeChange,
- pageSizeOptions = [10, 20, 50, 100]
-}) => {
- const totalPages = Math.ceil(totalItems / pageSize)
- const [inputPage, setInputPage] = useState(currentPage)
-
- useEffect(() => {
- setInputPage(currentPage)
- }, [currentPage])
-
- const handlePageSubmit = (e) => {
- e.preventDefault()
- let page = parseInt(inputPage)
- if (isNaN(page)) page = 1
- if (page < 1) page = 1
- if (page > totalPages) page = totalPages
- onPageChange(page)
- setInputPage(page)
- }
-
- if (totalItems === 0) return null
-
- return (
-
- {/* Info */}
-
- 显示 {Math.min((currentPage - 1) * pageSize + 1, totalItems)} - {Math.min(currentPage * pageSize, totalItems)} 共 {totalItems} 条
-
-
-
- {/* Page Size Selector */}
-
-
-
-
-
- {/* Navigation */}
-
-
-
- {/* Manual Input */}
-
-
-
-
-
-
- )
-}
-
-export default Pagination
diff --git a/frontend/src/components/ParsingProgress.jsx b/frontend/src/components/ParsingProgress.jsx
deleted file mode 100644
index 8c30020..0000000
--- a/frontend/src/components/ParsingProgress.jsx
+++ /dev/null
@@ -1,121 +0,0 @@
-/**
- * Parsing Progress Component
- * Displays real-time progress for document parsing
- */
-import React from 'react'
-import { Loader, CheckCircle, XCircle, FileText, Layers } from 'lucide-react'
-
-export const ParsingProgress = ({ progress }) => {
- if (!progress) return null
-
- const { status, message, progress: percentage, total_chunks, current_chunk, questions_extracted, questions_added, duplicates_removed } = progress
-
- const getStatusIcon = () => {
- switch (status) {
- case 'completed':
- return
- case 'failed':
- return
- default:
- return
- }
- }
-
- const getStatusColor = () => {
- switch (status) {
- case 'completed':
- return 'bg-green-500'
- case 'failed':
- return 'bg-red-500'
- case 'processing_chunk':
- return 'bg-blue-500'
- default:
- return 'bg-primary-500'
- }
- }
-
- return (
-
-
-
- {getStatusIcon()}
-
-
-
- {/* Status Message */}
-
- {status === 'completed' ? '解析完成' : status === 'failed' ? '解析失败' : '正在解析文档'}
-
-
{message}
-
- {/* Progress Bar */}
- {status !== 'completed' && status !== 'failed' && (
-
-
- 进度
- {percentage.toFixed(0)}%
-
-
-
- )}
-
- {/* Details Grid */}
-
- {total_chunks > 0 && (
-
-
-
- 文档拆分
-
-
- {current_chunk}/{total_chunks}
-
-
部分
-
- )}
-
- {questions_extracted > 0 && (
-
-
-
- 已提取
-
-
{questions_extracted}
-
题目
-
- )}
-
- {questions_added > 0 && (
-
-
-
- 已添加
-
-
{questions_added}
-
题目
-
- )}
-
- {duplicates_removed > 0 && (
-
-
-
- 已去重
-
-
{duplicates_removed}
-
题目
-
- )}
-
-
-
-
- )
-}
-
-export default ParsingProgress
diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx
deleted file mode 100644
index 512520f..0000000
--- a/frontend/src/components/ProtectedRoute.jsx
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * Protected Route Component
- */
-import React from 'react'
-import { Navigate } from 'react-router-dom'
-import { useAuth } from '../context/AuthContext'
-
-export const ProtectedRoute = ({ children, adminOnly = false }) => {
- const { user, loading } = useAuth()
-
- if (loading) {
- return (
-
- )
- }
-
- if (!user) {
- return
- }
-
- if (adminOnly && !user.is_admin) {
- return
- }
-
- return children
-}
diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx
deleted file mode 100644
index ae38602..0000000
--- a/frontend/src/context/AuthContext.jsx
+++ /dev/null
@@ -1,93 +0,0 @@
-/**
- * Authentication Context
- */
-import React, { createContext, useContext, useState, useEffect } from 'react'
-import { authAPI, AUTH_TOKEN_STORAGE_KEY, clearAuthStorage } from '../api/client'
-import toast from 'react-hot-toast'
-
-const AuthContext = createContext(null)
-
-export const useAuth = () => {
- const context = useContext(AuthContext)
- if (!context) {
- throw new Error('useAuth must be used within AuthProvider')
- }
- return context
-}
-
-export const AuthProvider = ({ children }) => {
- const [user, setUser] = useState(null)
- const [loading, setLoading] = useState(true)
-
- // Load user from localStorage on mount
- useEffect(() => {
- const loadUser = async () => {
- const token = localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)
- if (token) {
- try {
- const response = await authAPI.getCurrentUser()
- setUser(response.data)
- } catch (error) {
- console.error('Failed to load user:', error)
- clearAuthStorage()
- }
- }
- setLoading(false)
- }
-
- loadUser()
- }, [])
-
- const login = async (username, password) => {
- try {
- const response = await authAPI.login(username, password)
- const { access_token } = response.data
-
- // Save token
- localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, access_token)
-
- // Get user info
- const userResponse = await authAPI.getCurrentUser()
- setUser(userResponse.data)
-
- toast.success('Login successful!')
- return true
- } catch (error) {
- console.error('Login failed:', error)
- return false
- }
- }
-
- const register = async (username, password) => {
- try {
- await authAPI.register(username, password)
- toast.success('Registration successful! Please login.')
- return true
- } catch (error) {
- console.error('Registration failed:', error)
- return false
- }
- }
-
- const logout = () => {
- clearAuthStorage()
- setUser(null)
- toast.success('Logged out successfully')
- }
-
- const value = {
- user,
- loading,
- login,
- register,
- logout,
- isAuthenticated: !!user,
- isAdmin: user?.is_admin || false
- }
-
- return (
-
- {children}
-
- )
-}
diff --git a/frontend/src/index.css b/frontend/src/index.css
deleted file mode 100644
index 8970c1b..0000000
--- a/frontend/src/index.css
+++ /dev/null
@@ -1,22 +0,0 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
-
-* {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
-}
-
-body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
- 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
- sans-serif;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
-
-code {
- font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
- monospace;
-}
diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx
deleted file mode 100644
index 995053a..0000000
--- a/frontend/src/index.jsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import React from 'react'
-import ReactDOM from 'react-dom/client'
-import './index.css'
-import App from './App'
-
-ReactDOM.createRoot(document.getElementById('root')).render(
-
-
-
-)
diff --git a/frontend/src/pages/AdminPanel.jsx b/frontend/src/pages/AdminPanel.jsx
deleted file mode 100644
index 4e7436a..0000000
--- a/frontend/src/pages/AdminPanel.jsx
+++ /dev/null
@@ -1,356 +0,0 @@
-/**
- * Admin Panel - 完整的管理员面板
- */
-import React, { useState, useEffect } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { adminAPI } from '../api/client'
-import { useAuth } from '../context/AuthContext'
-import {
- Users, BarChart3, Settings, Trash2, Plus, Search,
- ArrowLeft, Shield, Activity, Database, Download
-} from 'lucide-react'
-import toast from 'react-hot-toast'
-
-export const AdminPanel = () => {
- const { user } = useAuth()
- const navigate = useNavigate()
- const [activeTab, setActiveTab] = useState('stats')
-
- // 统计数据
- const [stats, setStats] = useState(null)
- const [health, setHealth] = useState(null)
-
- // 用户数据
- const [users, setUsers] = useState([])
- const [usersTotal, setUsersTotal] = useState(0)
- const [searchQuery, setSearchQuery] = useState('')
- const [showCreateModal, setShowCreateModal] = useState(false)
- const [newUser, setNewUser] = useState({ username: '', password: '', is_admin: false })
-
- useEffect(() => {
- loadStats()
- loadHealth()
- loadUsers()
- }, [])
-
- const loadStats = async () => {
- try {
- const res = await adminAPI.getStatistics()
- setStats(res.data)
- } catch (error) {
- console.error('Failed to load statistics:', error)
- }
- }
-
- const loadHealth = async () => {
- try {
- const res = await adminAPI.getHealth()
- setHealth(res.data)
- } catch (error) {
- console.error('Failed to load health:', error)
- }
- }
-
- const loadUsers = async () => {
- try {
- const res = await adminAPI.getUsers(0, 100, searchQuery || null)
- setUsers(res.data.users)
- setUsersTotal(res.data.total)
- } catch (error) {
- console.error('Failed to load users:', error)
- toast.error('加载用户列表失败')
- }
- }
-
- const handleCreateUser = async () => {
- if (!newUser.username || !newUser.password) {
- toast.error('请填写用户名和密码')
- return
- }
- try {
- await adminAPI.createUser(newUser.username, newUser.password, newUser.is_admin)
- toast.success('用户创建成功')
- setShowCreateModal(false)
- setNewUser({ username: '', password: '', is_admin: false })
- loadUsers()
- } catch (error) {
- toast.error(error.response?.data?.detail || '创建用户失败')
- }
- }
-
- const handleDeleteUser = async (userId, username) => {
- if (!confirm(`确定删除用户 ${username}?`)) return
- try {
- await adminAPI.deleteUser(userId)
- toast.success('用户已删除')
- loadUsers()
- } catch (error) {
- toast.error(error.response?.data?.detail || '删除失败')
- }
- }
-
- const handleExportUsers = async () => {
- try {
- const response = await adminAPI.exportUsers()
- const url = window.URL.createObjectURL(new Blob([response.data]))
- const link = document.createElement('a')
- link.href = url
- link.setAttribute('download', 'users.csv')
- document.body.appendChild(link)
- link.click()
- link.remove()
- toast.success('导出成功')
- } catch (error) {
- toast.error('导出失败')
- }
- }
-
- return (
-
-
-
- {/* Tabs */}
-
-
-
-
-
-
- {/* Stats Tab */}
- {activeTab === 'stats' && stats && (
-
- {/* Overview Cards */}
-
-
-
-
-
用户总数
-
{stats.users?.total || 0}
-
-
-
-
-
-
-
-
-
题库总数
-
{stats.exams?.total || 0}
-
-
-
-
-
-
-
-
-
题目总数
-
{stats.questions?.total || 0}
-
-
-
-
-
-
-
-
-
今日活跃
-
{stats.activity?.today_active_users || 0}
-
-
-
-
-
-
- {/* System Health */}
- {health && (
-
-
系统状态
-
-
- 状态
-
- {health.status}
-
-
-
- 数据库
- {health.system?.database_url || 'SQLite'}
-
- {health.database?.size_mb && (
-
- 数据库大小
- {health.database.size_mb} MB
-
- )}
-
-
- )}
-
- )}
-
- {/* Users Tab */}
- {activeTab === 'users' && (
-
- {/* Actions */}
-
-
-
- setSearchQuery(e.target.value)}
- onKeyDown={(e) => e.key === 'Enter' && loadUsers()}
- className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
- />
-
-
-
-
-
-
-
- {/* Users Table */}
-
-
-
-
- | ID |
- 用户名 |
- 角色 |
- 题库数 |
- 错题数 |
- 注册时间 |
- 操作 |
-
-
-
- {users.map((u) => (
-
- | {u.id} |
- {u.username} |
-
- {u.is_admin ? (
- 管理员
- ) : (
- 普通用户
- )}
- |
- {u.exam_count || 0} |
- {u.mistake_count || 0} |
-
- {new Date(u.created_at).toLocaleDateString()}
- |
-
-
- |
-
- ))}
-
-
-
-
- )}
-
-
- {/* Create User Modal */}
- {showCreateModal && (
-
-
-
创建新用户
-
-
-
-
-
-
-
- )}
-
- )
-}
-
-export default AdminPanel
diff --git a/frontend/src/pages/AdminSettings.jsx b/frontend/src/pages/AdminSettings.jsx
deleted file mode 100644
index b25943a..0000000
--- a/frontend/src/pages/AdminSettings.jsx
+++ /dev/null
@@ -1,562 +0,0 @@
-/**
- * Admin Settings Page - Enhanced with API Configuration
- */
-import React, { useState, useEffect } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { adminAPI } from '../api/client'
-import { useAuth } from '../context/AuthContext'
-import { Settings, Save, Loader, Key, Link as LinkIcon, Eye, EyeOff, ArrowLeft } from 'lucide-react'
-import toast from 'react-hot-toast'
-
-export const AdminSettings = () => {
- const { user } = useAuth()
- const navigate = useNavigate()
- const [loading, setLoading] = useState(true)
- const [saving, setSaving] = useState(false)
- const [showApiKeys, setShowApiKeys] = useState({
- openai: false,
- anthropic: false,
- qwen: false,
- gemini: false
- })
-
- const [config, setConfig] = useState({
- allow_registration: true,
- max_upload_size_mb: 10,
- max_daily_uploads: 20,
- ai_provider: 'gemini',
- // OpenAI
- openai_api_key: '',
- openai_base_url: 'https://api.openai.com/v1',
- openai_model: 'gpt-4o-mini',
- // Anthropic
- anthropic_api_key: '',
- anthropic_model: 'claude-3-haiku-20240307',
- // Qwen
- qwen_api_key: '',
- qwen_base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
- qwen_model: 'qwen-plus',
- // Gemini
- gemini_api_key: '',
- gemini_base_url: '',
- gemini_model: 'gemini-2.0-flash-exp'
- })
-
- useEffect(() => {
- loadConfig()
- }, [])
-
- const loadConfig = async () => {
- try {
- const response = await adminAPI.getConfig()
- setConfig(response.data)
- } catch (error) {
- console.error('Failed to load config:', error)
- toast.error('加载配置失败')
- } finally {
- setLoading(false)
- }
- }
-
- const handleSave = async () => {
- setSaving(true)
- try {
- await adminAPI.updateConfig(config)
- toast.success('配置保存成功!')
- } catch (error) {
- console.error('Failed to save config:', error)
- toast.error('保存配置失败')
- } finally {
- setSaving(false)
- }
- }
-
- const handleChange = (key, value) => {
- setConfig({
- ...config,
- [key]: value
- })
- }
-
- const toggleApiKeyVisibility = (provider) => {
- setShowApiKeys({
- ...showApiKeys,
- [provider]: !showApiKeys[provider]
- })
- }
-
- // Get complete API endpoint URL
- const getCompleteEndpoint = (provider) => {
- const endpoints = {
- openai: '/chat/completions',
- anthropic: '/messages',
- qwen: '/chat/completions'
- }
-
- let baseUrl = ''
- if (provider === 'openai') {
- baseUrl = config.openai_base_url || 'https://api.openai.com/v1'
- } else if (provider === 'anthropic') {
- baseUrl = 'https://api.anthropic.com/v1'
- } else if (provider === 'qwen') {
- baseUrl = config.qwen_base_url || 'https://dashscope.aliyuncs.com/compatible-mode/v1'
- }
-
- // Remove trailing slash
- baseUrl = baseUrl.replace(/\/$/, '')
-
- return `${baseUrl}${endpoints[provider]}`
- }
-
- if (loading) {
- return (
-
-
-
- )
- }
-
- return (
-
-
-
- {/* Content */}
-
- {/* Basic Settings */}
-
-
基础设置
-
- {/* Allow Registration */}
-
-
-
-
-
- {/* Max Upload Size */}
-
-
-
handleChange('max_upload_size_mb', parseInt(e.target.value))}
- className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
- />
-
建议:5-20 MB
-
-
- {/* Max Daily Uploads */}
-
-
-
handleChange('max_daily_uploads', parseInt(e.target.value))}
- className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
- />
-
建议:10-50 次
-
-
- {/* AI Provider */}
-
-
-
-
- 选择后在下方配置对应的 API 密钥。Gemini 支持原生 PDF 解析
-
-
-
-
- {/* OpenAI Configuration */}
-
-
-
-
OpenAI 配置
- {config.ai_provider === 'openai' && (
- 当前使用
- )}
-
-
- {/* Text-only warning */}
-
-
- ⚠️ OpenAI 仅支持文本解析,不支持 PDF 原生理解。PDF 文件将通过文本提取处理,可能丢失格式和图片信息。
-
-
-
- {/* API Key */}
-
-
-
- handleChange('openai_api_key', e.target.value)}
- placeholder="sk-proj-..."
- className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
- />
-
-
-
从 https://platform.openai.com/api-keys 获取
-
-
- {/* Base URL */}
-
-
-
-
- handleChange('openai_base_url', e.target.value)}
- placeholder="https://api.openai.com/v1"
- className="w-full pl-10 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
- />
-
-
- 完整 endpoint: {getCompleteEndpoint('openai')}
-
-
-
- {/* Model */}
-
-
-
handleChange('openai_model', e.target.value)}
- placeholder="gpt-4o-mini"
- className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
- />
-
-
可输入自定义模型名称,或从建议中选择
-
-
-
- {/* Anthropic Configuration */}
-
-
-
-
Anthropic 配置
- {config.ai_provider === 'anthropic' && (
- 当前使用
- )}
-
-
- {/* Text-only warning */}
-
-
- ⚠️ Anthropic 仅支持文本解析,不支持 PDF 原生理解。PDF 文件将通过文本提取处理,可能丢失格式和图片信息。
-
-
-
- {/* API Key */}
-
-
-
- handleChange('anthropic_api_key', e.target.value)}
- placeholder="sk-ant-..."
- className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
- />
-
-
-
从 https://console.anthropic.com/settings/keys 获取
-
-
- {/* Base URL (fixed for Anthropic) */}
-
-
-
-
-
-
-
- 完整 endpoint: {getCompleteEndpoint('anthropic')}
-
-
-
- {/* Model */}
-
-
-
handleChange('anthropic_model', e.target.value)}
- placeholder="claude-3-haiku-20240307"
- className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
- />
-
-
可输入自定义模型名称,或从建议中选择
-
-
-
- {/* Qwen Configuration */}
-
-
-
-
通义千问 配置
- {config.ai_provider === 'qwen' && (
- 当前使用
- )}
-
-
- {/* Text-only warning */}
-
-
- ⚠️ 通义千问 仅支持文本解析,不支持 PDF 原生理解。PDF 文件将通过文本提取处理,可能丢失格式和图片信息。
-
-
-
- {/* API Key */}
-
-
-
- handleChange('qwen_api_key', e.target.value)}
- placeholder="sk-..."
- className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
- />
-
-
-
从 https://dashscope.console.aliyun.com/apiKey 获取
-
-
- {/* Base URL */}
-
-
-
-
- handleChange('qwen_base_url', e.target.value)}
- placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1"
- className="w-full pl-10 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
- />
-
-
- 完整 endpoint: {getCompleteEndpoint('qwen')}
-
-
-
- {/* Model */}
-
-
-
handleChange('qwen_model', e.target.value)}
- placeholder="qwen-plus"
- className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
- />
-
-
可输入自定义模型名称,或从建议中选择
-
-
-
- {/* Gemini Configuration */}
-
-
-
-
Google Gemini 配置
- {config.ai_provider === 'gemini' && (
- 当前使用
- )}
-
-
- {/* PDF support highlight */}
-
-
- ✅ Gemini 支持原生 PDF 理解,可直接处理 PDF 文件(最多 1000 页),完整保留图片、表格、公式等内容。
-
-
-
- {/* API Key */}
-
-
-
- handleChange('gemini_api_key', e.target.value)}
- placeholder="AIza..."
- className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
- />
-
-
-
从 https://aistudio.google.com/apikey 获取
-
-
- {/* Base URL (optional) */}
-
-
-
-
- handleChange('gemini_base_url', e.target.value)}
- placeholder="https://generativelanguage.googleapis.com(留空使用默认)"
- className="w-full pl-10 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
- />
-
-
- 可配置自定义代理或中转服务(支持 Key 轮训等)。留空则使用 Google 官方 API
-
-
-
- {/* Model */}
-
-
-
handleChange('gemini_model', e.target.value)}
- placeholder="gemini-2.0-flash-exp"
- className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
- />
-
-
可输入自定义模型名称,或从建议中选择
-
-
-
- {/* Save Button */}
-
-
-
-
-
- )
-}
-
-export default AdminSettings
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
deleted file mode 100644
index b773cc1..0000000
--- a/frontend/src/pages/Dashboard.jsx
+++ /dev/null
@@ -1,187 +0,0 @@
-/**
- * Dashboard Page
- */
-import React, { useState, useEffect } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { examAPI, mistakeAPI } from '../api/client'
-import { useAuth } from '../context/AuthContext'
-
-import {
- FolderOpen, XCircle, TrendingUp, BookOpen, ArrowRight, Settings, Shield
-} from 'lucide-react'
-import { getStatusColor, getStatusText, formatRelativeTime, calculateProgress } from '../utils/helpers'
-
-export const Dashboard = () => {
- const { user, isAdmin } = useAuth()
- const navigate = useNavigate()
-
- const [stats, setStats] = useState({
- totalExams: 0,
- totalQuestions: 0,
- completedQuestions: 0,
- mistakeCount: 0
- })
-
- const [recentExams, setRecentExams] = useState([])
- const [loading, setLoading] = useState(true)
-
- useEffect(() => {
- loadDashboardData()
- }, [])
-
- const loadDashboardData = async () => {
- try {
- const [examsRes, mistakesRes] = await Promise.all([
- examAPI.getList(0, 5),
- mistakeAPI.getList(0, 1)
- ])
-
- const exams = examsRes.data.exams
-
- // Calculate stats
- const totalQuestions = exams.reduce((sum, e) => sum + e.total_questions, 0)
- const completedQuestions = exams.reduce((sum, e) => sum + e.current_index, 0)
-
- setStats({
- totalExams: exams.length,
- totalQuestions,
- completedQuestions,
- mistakeCount: mistakesRes.data.total
- })
-
- setRecentExams(exams)
- } catch (error) {
- console.error('Failed to load dashboard:', error)
- } finally {
- setLoading(false)
- }
- }
-
- return (
- <>
-
- {/* Welcome */}
-
-
- 欢迎回来,{user?.username}!
-
-
继续你的学习之旅
-
-
- {/* Stats Cards */}
-
-
navigate('/exams')}
- >
-
-
-
-
-
{stats.totalExams}
-
-
题库总数
-
-
-
navigate('/questions')}
- >
-
-
-
-
-
{stats.totalQuestions}
-
-
题目总数 (点击查看)
-
-
-
-
-
-
-
-
{stats.completedQuestions}
-
-
已完成
-
-
-
navigate('/mistakes')}
- >
-
-
-
-
-
{stats.mistakeCount}
-
-
错题数量
-
-
-
- {/* Recent Exams */}
-
-
-
最近的题库
-
-
-
- {recentExams.length === 0 ? (
-
- ) : (
-
- {recentExams.map((exam) => (
-
navigate(`/exams/${exam.id}`)}
- className="border border-gray-200 rounded-lg p-4 hover:border-primary-300 hover:bg-primary-50 transition-all cursor-pointer"
- >
-
-
{exam.title}
-
- {getStatusText(exam.status)}
-
-
-
-
-
- {exam.current_index} / {exam.total_questions} 题
-
- {formatRelativeTime(exam.updated_at)}
-
-
- {exam.total_questions > 0 && (
- <>
-
-
- {calculateProgress(exam.current_index, exam.total_questions)}%
-
- >
- )}
-
- ))}
-
- )}
-
-
-
- >
- )
-}
-
-export default Dashboard
diff --git a/frontend/src/pages/ExamDetail.jsx b/frontend/src/pages/ExamDetail.jsx
deleted file mode 100644
index 730a1ed..0000000
--- a/frontend/src/pages/ExamDetail.jsx
+++ /dev/null
@@ -1,387 +0,0 @@
-/**
- * Exam Detail Page - with real-time parsing progress via SSE
- */
-import React, { useState, useEffect, useRef } from 'react'
-import { useParams, useNavigate } from 'react-router-dom'
-import { examAPI, buildApiUrl, getAccessToken } from '../api/client'
-import ParsingProgress from '../components/ParsingProgress'
-import {
- ArrowLeft, Upload, Play, Loader, FileText, AlertCircle, RefreshCw, ArrowRight
-} from 'lucide-react'
-import toast from 'react-hot-toast'
-import {
- getStatusColor,
- getStatusText,
- formatDate,
- calculateProgress,
- isValidFileType,
- getQuestionTypeText
-} from '../utils/helpers'
-
-export const ExamDetail = () => {
- const { examId } = useParams()
- const navigate = useNavigate()
-
- const [exam, setExam] = useState(null)
- const [loading, setLoading] = useState(true)
- const [uploading, setUploading] = useState(false)
- const [showUploadModal, setShowUploadModal] = useState(false)
- const [uploadFile, setUploadFile] = useState(null)
- const [progress, setProgress] = useState(null)
-
- const eventSourceRef = useRef(null)
-
- useEffect(() => {
- loadExamDetail()
-
- // Cleanup on unmount
- return () => {
- if (eventSourceRef.current) {
- eventSourceRef.current.close()
- eventSourceRef.current = null
- }
- }
- }, [examId])
-
- const loadExamDetail = async () => {
- try {
- const examRes = await examAPI.getDetail(examId)
- setExam(examRes.data)
-
- // Connect to SSE if exam is processing
- if (examRes.data.status === 'processing') {
- connectSSE()
- } else {
- setProgress(null)
- }
- } catch (error) {
- console.error('Failed to load exam:', error)
- toast.error('加载题库失败')
- } finally {
- setLoading(false)
- }
- }
-
- const connectSSE = () => {
- // Close existing connection if any
- if (eventSourceRef.current) {
- eventSourceRef.current.close()
- }
-
- console.log('[SSE] Connecting to progress stream for exam', examId)
-
- const token = getAccessToken()
-
- if (!token) {
- console.error('[SSE] Missing access token')
- return
- }
-
- const url = `${buildApiUrl(`/exams/${examId}/progress`)}?token=${encodeURIComponent(token)}`
-
- const eventSource = new EventSource(url)
- eventSourceRef.current = eventSource
-
- eventSource.onmessage = (event) => {
- try {
- const progressData = JSON.parse(event.data)
- console.log('[SSE] Progress update:', progressData)
-
- setProgress(progressData)
-
- // Update exam status if completed or failed
- if (progressData.status === 'completed') {
- toast.success(progressData.message)
- setExam(prev => ({ ...prev, status: 'ready' }))
- loadExamDetail() // Reload to get updated questions
- eventSource.close()
- eventSourceRef.current = null
- } else if (progressData.status === 'failed') {
- toast.error(progressData.message)
- setExam(prev => ({ ...prev, status: 'failed' }))
- eventSource.close()
- eventSourceRef.current = null
- }
- } catch (error) {
- console.error('[SSE] Failed to parse progress data:', error)
- }
- }
-
- eventSource.onerror = (error) => {
- console.error('[SSE] Connection error:', error)
- eventSource.close()
- eventSourceRef.current = null
- }
-
- eventSource.onopen = () => {
- console.log('[SSE] Connection established')
- }
- }
-
- const handleAppendDocument = async (e) => {
- e.preventDefault()
-
- if (!uploadFile) {
- toast.error('请选择文件')
- return
- }
-
- if (!isValidFileType(uploadFile.name)) {
- toast.error('不支持的文件类型')
- return
- }
-
- setUploading(true)
-
- try {
- await examAPI.appendDocument(examId, uploadFile)
- toast.success('文档上传成功,正在解析并去重...')
- setShowUploadModal(false)
- setUploadFile(null)
- setExam(prev => ({ ...prev, status: 'processing' }))
-
- // Connect to SSE for real-time progress
- connectSSE()
- } catch (error) {
- console.error('Failed to append document:', error)
- toast.error('文档上传失败')
- } finally {
- setUploading(false)
- }
- }
-
- const handleStartQuiz = () => {
- if (exam.current_index >= exam.total_questions) {
- if (window.confirm('已经完成所有题目,是否从头开始?')) {
- navigate(`/quiz/${examId}?reset=true`)
- }
- } else {
- navigate(`/quiz/${examId}`)
- }
- }
-
- if (loading) {
- return (
-
-
-
- )
- }
-
- if (!exam) {
- return (
-
- )
- }
-
- const isProcessing = exam.status === 'processing'
- const isReady = exam.status === 'ready'
- const isFailed = exam.status === 'failed'
- const quizProgress = calculateProgress(exam.current_index, exam.total_questions)
- const completionProgress = isProcessing
- ? Math.round(Number(progress?.progress ?? 0))
- : quizProgress
-
- return (
- <>
-
- {/* Back Button */}
-
-
- {/* Parsing Progress (only shown when processing) */}
- {isProcessing && progress && (
-
- )}
-
- {/* Header */}
-
-
-
-
- {exam.title}
-
-
-
- {getStatusText(exam.status)}
-
- {isProcessing && (
-
-
- 正在处理中...
-
- )}
-
-
-
- {/* Actions */}
-
-
-
- {isReady && exam.total_questions > 0 && (
-
- )}
-
-
-
- {/* Stats */}
-
-
-
题目总数
-
{exam.total_questions}
-
-
-
已完成
-
{exam.current_index}
-
-
-
剩余
-
- {Math.max(0, exam.total_questions - exam.current_index)}
-
-
-
-
完成度
-
{completionProgress}%
-
-
-
- {/* Progress Bar */}
- {(isProcessing || exam.total_questions > 0) && (
-
- )}
-
- {/* Info */}
-
-
创建时间:{formatDate(exam.created_at)}
-
最后更新:{formatDate(exam.updated_at)}
-
-
-
- {/* Failed Status Warning */}
- {isFailed && (
-
-
-
-
-
文档解析失败
-
- 请检查文档格式是否正确,或尝试重新上传。
-
-
-
-
- )}
-
- {/* View All Questions Link */}
-
navigate(`/questions?examId=${examId}`)}
- >
-
-
-
-
-
-
查看题库所有题目
-
浏览、搜索和查看该题库中的所有题目详情
-
-
-
-
-
-
- {/* Upload Modal */}
- {showUploadModal && (
-
-
-
添加题目文档
-
- 上传新文档后,系统会自动解析题目并去除重复题目。
-
-
-
-
-
- )}
- >
- )
-}
-
-export default ExamDetail
diff --git a/frontend/src/pages/ExamList.jsx b/frontend/src/pages/ExamList.jsx
deleted file mode 100644
index d25ec40..0000000
--- a/frontend/src/pages/ExamList.jsx
+++ /dev/null
@@ -1,342 +0,0 @@
-/**
- * Exam List Page
- */
-import React, { useState, useEffect } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { examAPI } from '../api/client'
-import {
- Plus, FolderOpen, Loader, AlertCircle, Trash2, Upload
-} from 'lucide-react'
-import toast from 'react-hot-toast'
-import {
- getStatusColor,
- getStatusText,
- formatRelativeTime,
- calculateProgress,
- isValidFileType
-} from '../utils/helpers'
-
-export const ExamList = () => {
- const navigate = useNavigate()
- const [exams, setExams] = useState([])
- const [loading, setLoading] = useState(true)
- const [showCreateModal, setShowCreateModal] = useState(false)
- const [creating, setCreating] = useState(false)
- const [pollInterval, setPollInterval] = useState(null)
-
- const [formData, setFormData] = useState({
- title: '',
- file: null,
- isRandom: false
- })
-
- useEffect(() => {
- loadExams()
-
- // Start polling for processing exams
- const interval = setInterval(() => {
- checkProcessingExams()
- }, 3000) // Poll every 3 seconds
-
- setPollInterval(interval)
-
- return () => {
- if (interval) clearInterval(interval)
- }
- }, [])
-
- const loadExams = async () => {
- try {
- const response = await examAPI.getList()
- setExams(response.data.exams)
- } catch (error) {
- console.error('Failed to load exams:', error)
- toast.error('加载题库失败')
- } finally {
- setLoading(false)
- }
- }
-
- const checkProcessingExams = async () => {
- try {
- const response = await examAPI.getList()
- const newExams = response.data.exams
-
- // Check if any processing exam is now ready
- const oldProcessing = exams.filter(e => e.status === 'processing')
- const newReady = newExams.filter(e =>
- oldProcessing.some(old => old.id === e.id && e.status === 'ready')
- )
-
- if (newReady.length > 0) {
- toast.success(`${newReady.length} 个题库解析完成!`)
- }
-
- setExams(newExams)
- } catch (error) {
- console.error('Failed to poll exams:', error)
- }
- }
-
- const handleCreate = async (e) => {
- e.preventDefault()
-
- if (!formData.file) {
- toast.error('请选择文件')
- return
- }
-
- if (!isValidFileType(formData.file.name)) {
- toast.error('不支持的文件类型')
- return
- }
-
- setCreating(true)
-
- try {
- const response = await examAPI.create(formData.title, formData.file, formData.isRandom)
- toast.success('题库创建成功,正在解析文档...')
- setShowCreateModal(false)
- setFormData({ title: '', file: null, isRandom: false })
-
- // 跳转到新创建的试卷详情页
- if (response.data && response.data.exam_id) {
- navigate(`/exams/${response.data.exam_id}`)
- } else {
- // 如果没有返回 exam_id,刷新列表
- await loadExams()
- }
- } catch (error) {
- console.error('Failed to create exam:', error)
- toast.error('创建失败:' + (error.response?.data?.detail || error.message))
- } finally {
- setCreating(false)
- }
- }
-
- const handleDelete = async (examId) => {
- if (!window.confirm('确定要删除这个题库吗?删除后无法恢复。')) {
- return
- }
-
- try {
- await examAPI.delete(examId)
- toast.success('题库已删除')
- await loadExams()
- } catch (error) {
- console.error('Failed to delete exam:', error)
- }
- }
-
- if (loading) {
- return (
-
-
-
- )
- }
-
- return (
- <>
-
- {/* Header */}
-
-
-
题库管理
-
共 {exams.length} 个题库
-
-
-
-
- {/* Exam Grid */}
- {exams.length === 0 ? (
-
-
-
还没有题库
-
创建第一个题库开始刷题吧!
-
-
- ) : (
-
- {exams.map((exam) => (
-
- {/* Header */}
-
-
- {exam.title}
-
-
- {getStatusText(exam.status)}
-
-
-
- {/* Stats */}
-
-
- 题目数量
- {exam.total_questions}
-
-
- 已完成
-
- {exam.current_index} / {exam.total_questions}
-
-
- {exam.total_questions > 0 && (
-
- )}
-
-
- {/* Time */}
-
- 创建于 {formatRelativeTime(exam.created_at)}
-
-
- {/* Actions */}
-
-
-
-
-
- ))}
-
- )}
-
-
- {/* Create Modal */}
- {showCreateModal && (
-
- )}
- >
- )
-}
-
-export default ExamList
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx
deleted file mode 100644
index 73fdd71..0000000
--- a/frontend/src/pages/Login.jsx
+++ /dev/null
@@ -1,117 +0,0 @@
-/**
- * Login Page
- */
-import React, { useState } from 'react'
-import { Link, useNavigate } from 'react-router-dom'
-import { useAuth } from '../context/AuthContext'
-import { BookOpen } from 'lucide-react'
-
-export const Login = () => {
- const navigate = useNavigate()
- const { login } = useAuth()
-
- const [formData, setFormData] = useState({
- username: '',
- password: ''
- })
- const [loading, setLoading] = useState(false)
-
- const handleSubmit = async (e) => {
- e.preventDefault()
-
- setLoading(true)
- try {
- const success = await login(formData.username, formData.password)
- if (success) {
- navigate('/dashboard')
- }
- } finally {
- setLoading(false)
- }
- }
-
- const handleChange = (e) => {
- setFormData({
- ...formData,
- [e.target.name]: e.target.value
- })
- }
-
- return (
-
-
- {/* Logo and Title */}
-
-
-
QQuiz
-
智能刷题与题库管理平台
-
-
- {/* Login Form */}
-
-
登录
-
-
-
- {/* Register Link */}
-
-
- 还没有账号?{' '}
-
- 立即注册
-
-
-
-
-
-
-
- )
-}
-
-export default Login
diff --git a/frontend/src/pages/MistakeList.jsx b/frontend/src/pages/MistakeList.jsx
deleted file mode 100644
index 3f2b90f..0000000
--- a/frontend/src/pages/MistakeList.jsx
+++ /dev/null
@@ -1,242 +0,0 @@
-/**
- * Mistake List Page (错题本)
- */
-import React, { useState, useEffect } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { mistakeAPI } from '../api/client'
-import Pagination from '../components/Pagination'
-import { XCircle, Loader, Trash2, BookOpen, Play, ChevronRight } from 'lucide-react'
-import toast from 'react-hot-toast'
-import { getQuestionTypeText, formatRelativeTime } from '../utils/helpers'
-
-export const MistakeList = () => {
- const [mistakes, setMistakes] = useState([])
- const [loading, setLoading] = useState(true)
- const [expandedId, setExpandedId] = useState(null)
- const [showModeModal, setShowModeModal] = useState(false)
-
- // Pagination
- const [page, setPage] = useState(1)
- const [limit, setLimit] = useState(10)
- const [total, setTotal] = useState(0)
- const navigate = useNavigate()
-
- useEffect(() => {
- loadMistakes()
- }, [page, limit])
-
- const loadMistakes = async () => {
- try {
- setLoading(true)
- const skip = (page - 1) * limit
- const response = await mistakeAPI.getList(skip, limit)
- setMistakes(response.data.mistakes)
- setTotal(response.data.total)
- } catch (error) {
- console.error('Failed to load mistakes:', error)
- toast.error('加载错题本失败')
- } finally {
- setLoading(false)
- }
- }
-
- const handleRemove = async (mistakeId) => {
- if (!window.confirm('确定要从错题本中移除这道题吗?')) {
- return
- }
-
- try {
- await mistakeAPI.remove(mistakeId)
- toast.success('已移除')
- await loadMistakes()
- } catch (error) {
- console.error('Failed to remove mistake:', error)
- toast.error('移除失败')
- }
- }
-
- const toggleExpand = (id) => {
- setExpandedId(expandedId === id ? null : id)
- }
-
- if (loading) {
- return (
-
-
-
- )
- }
-
- return (
- <>
-
- {/* Header */}
-
-
-
- {mistakes.length > 0 && (
-
- )}
-
-
- {/* Empty State */}
- {mistakes.length === 0 ? (
-
-
-
错题本是空的
-
继续刷题,错题会自动添加到这里
-
- ) : (
-
- {mistakes.map((mistake) => {
- const q = mistake.question
- const isExpanded = expandedId === mistake.id
-
- return (
-
- {/* Question Preview */}
-
toggleExpand(mistake.id)}
- >
-
-
-
-
-
-
-
-
- {getQuestionTypeText(q.type)}
-
-
- {formatRelativeTime(mistake.created_at)}
-
-
-
-
- {q.content}
-
-
- {isExpanded && (
-
- {/* Options */}
- {q.options && q.options.length > 0 && (
-
- {q.options.map((opt, i) => (
-
- {opt}
-
- ))}
-
- )}
-
- {/* Answer */}
-
-
- 正确答案
-
-
{q.answer}
-
-
- {/* Analysis */}
- {q.analysis && (
-
-
- 解析
-
-
{q.analysis}
-
- )}
-
- )}
-
-
-
-
-
-
- )
- })}
-
- {/* Pagination */}
-
{
- setLimit(newLimit)
- setPage(1)
- }}
- />
-
- )}
-
-
- {/* Mode Selection Modal */}
- {showModeModal && (
-
-
-
选择刷题模式
-
-
-
-
-
-
-
-
- )}
- >
- )
-}
-
-export default MistakeList
diff --git a/frontend/src/pages/MistakePlayer.jsx b/frontend/src/pages/MistakePlayer.jsx
deleted file mode 100644
index 0ab8c19..0000000
--- a/frontend/src/pages/MistakePlayer.jsx
+++ /dev/null
@@ -1,412 +0,0 @@
-/**
- * Mistake Player Page - Re-do wrong questions
- */
-import React, { useState, useEffect } from 'react'
-import { useNavigate, useLocation } from 'react-router-dom'
-import { mistakeAPI, questionAPI } from '../api/client'
-import {
- ArrowLeft, ArrowRight, Check, X, Loader, Trash2, AlertCircle
-} from 'lucide-react'
-import toast from 'react-hot-toast'
-import { getQuestionTypeText } from '../utils/helpers'
-
-export const MistakePlayer = () => {
- const navigate = useNavigate()
- const location = useLocation()
- const searchParams = new URLSearchParams(location.search)
- const mode = searchParams.get('mode') || 'sequential'
-
- console.log('MistakePlayer mounted, mode:', mode)
-
- const [loading, setLoading] = useState(true)
- const [mistake, setMistake] = useState(null)
- const [currentIndex, setCurrentIndex] = useState(0)
- const [total, setTotal] = useState(0)
- const [randomMistakes, setRandomMistakes] = useState([]) // Store full mistake objects
-
- const [submitting, setSubmitting] = useState(false)
- const [userAnswer, setUserAnswer] = useState('')
- const [multipleAnswers, setMultipleAnswers] = useState([])
- const [result, setResult] = useState(null)
-
- useEffect(() => {
- loadMistake()
- }, [currentIndex, mode])
-
- const loadMistake = async () => {
- try {
- setLoading(true)
-
- let currentMistake = null
-
- if (mode === 'random') {
- // Random Mode Logic
- if (randomMistakes.length === 0) {
- // First load: fetch all mistakes
- const response = await mistakeAPI.getList(0, 1000)
- const allMistakes = response.data.mistakes
- setTotal(response.data.total)
-
- if (allMistakes.length > 0) {
- // Shuffle mistakes
- const shuffled = [...allMistakes]
- for (let i = shuffled.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
- }
- setRandomMistakes(shuffled)
- currentMistake = shuffled[0]
- }
- } else {
- // Subsequent loads: use stored mistakes
- if (currentIndex < randomMistakes.length) {
- currentMistake = randomMistakes[currentIndex]
- }
- }
- } else {
- // Sequential Mode Logic
- const response = await mistakeAPI.getList(currentIndex, 1)
- setTotal(response.data.total)
- if (response.data.mistakes.length > 0) {
- currentMistake = response.data.mistakes[0]
- }
- }
-
- if (currentMistake) {
- // Ensure options exist for judge type
- if (currentMistake.question.type === 'judge' && (!currentMistake.question.options || currentMistake.question.options.length === 0)) {
- currentMistake.question.options = ['A. 正确', 'B. 错误']
- }
- setMistake(currentMistake)
- console.log('Mistake loaded:', currentMistake)
- setResult(null)
- setUserAnswer('')
- setMultipleAnswers([])
- } else {
- setMistake(null)
- }
- } catch (error) {
- console.error('Failed to load mistake:', error)
- toast.error('加载错题失败')
- } finally {
- setLoading(false)
- console.log('Loading finished')
- }
- }
-
- const handleSubmitAnswer = async () => {
- let answer = userAnswer
-
- if (mistake.question.type === 'multiple') {
- if (multipleAnswers.length === 0) {
- toast.error('请至少选择一个选项')
- return
- }
- answer = multipleAnswers.sort().join('')
- }
-
- if (!answer.trim()) {
- toast.error('请输入答案')
- return
- }
-
- setSubmitting(true)
-
- try {
- const response = await questionAPI.checkAnswer(mistake.question.id, answer)
- setResult(response.data)
-
- if (response.data.correct) {
- toast.success('回答正确!')
- } else {
- toast.error('回答错误')
- }
- } catch (error) {
- console.error('Failed to check answer:', error)
- toast.error('提交答案失败')
- } finally {
- setSubmitting(false)
- }
- }
-
- const handleNext = () => {
- if (currentIndex < total - 1) {
- setCurrentIndex(prev => prev + 1)
- } else {
- toast.success('已完成所有错题!')
- navigate('/mistakes')
- }
- }
-
- const handleRemove = async () => {
- if (!window.confirm('确定要从错题本中移除这道题吗?')) {
- return
- }
- try {
- await mistakeAPI.remove(mistake.id)
- toast.success('已移除')
- // Reload current index (which will now be the next item or empty)
- // If we remove the last item, we need to go back one step or show empty
- if (mode === 'random') {
- // Remove from random list
- const newRandomList = randomMistakes.filter(m => m.id !== mistake.id)
- setRandomMistakes(newRandomList)
- setTotal(newRandomList.length)
-
- if (currentIndex >= newRandomList.length && newRandomList.length > 0) {
- setCurrentIndex(prev => prev - 1)
- } else if (newRandomList.length === 0) {
- setMistake(null)
- } else {
- // Force reload with new list
- const nextMistake = newRandomList[currentIndex]
- if (nextMistake.question.type === 'judge' && (!nextMistake.question.options || nextMistake.question.options.length === 0)) {
- nextMistake.question.options = ['A. 正确', 'B. 错误']
- }
- setMistake(nextMistake)
- setResult(null)
- setUserAnswer('')
- setMultipleAnswers([])
- }
- } else {
- if (currentIndex >= total - 1 && total > 1) {
- setCurrentIndex(prev => prev - 1)
- } else {
- loadMistake()
- }
- }
- } catch (error) {
- console.error('Failed to remove mistake:', error)
- toast.error('移除失败')
- }
- }
-
- const handleMultipleChoice = (option) => {
- const letter = option.charAt(0)
- if (multipleAnswers.includes(letter)) {
- setMultipleAnswers(multipleAnswers.filter(a => a !== letter))
- } else {
- setMultipleAnswers([...multipleAnswers, letter])
- }
- }
-
- if (loading && !mistake) {
- return (
-
-
-
- )
- }
-
- if (!mistake) {
- return (
-
-
-
错题本为空
-
-
- )
- }
-
- const question = mistake.question
-
- if (!question) {
- return (
-
-
-
题目数据缺失
-
-
- )
- }
-
- return (
- <>
-
- {/* Header */}
-
-
-
-
- 进度: {currentIndex + 1} / {total}
-
-
-
- {/* Question Card */}
-
- {/* Question Header */}
-
-
-
- {currentIndex + 1}
-
-
- {getQuestionTypeText(question.type)}
-
-
-
-
-
-
- {/* Question Content */}
-
-
- {question.content}
-
-
-
- {/* Options */}
- {question.options && question.options.length > 0 && (
-
- {question.options.map((option, index) => {
- const letter = option.charAt(0)
- const isSelected = question.type === 'multiple'
- ? multipleAnswers.includes(letter)
- : userAnswer === letter
-
- return (
-
- )
- })}
-
- )}
-
- {/* Short Answer Input */}
- {question.type === 'short' && !result && (
-
-
- )}
-
- {/* Submit Button */}
- {!result && (
-
- )}
-
-
- {/* Result */}
- {result && (
-
-
- {result.correct ? (
-
- ) : (
-
- )}
-
-
- {result.correct ? '回答正确!' : '回答错误'}
-
-
- {!result.correct && (
-
-
- 你的答案:{result.user_answer}
-
-
- 正确答案:{result.correct_answer}
-
-
- )}
-
- {/* AI Score */}
- {result.ai_score !== null && result.ai_score !== undefined && (
-
-
- AI 评分:{(result.ai_score * 100).toFixed(0)}%
-
- {result.ai_feedback && (
-
{result.ai_feedback}
- )}
-
- )}
-
- {/* Analysis */}
- {result.analysis && (
-
-
解析:
-
{result.analysis}
-
- )}
-
-
-
- {/* Next Button */}
-
-
- )}
-
- >
- )
-}
-
-export default MistakePlayer
diff --git a/frontend/src/pages/QuestionBank.jsx b/frontend/src/pages/QuestionBank.jsx
deleted file mode 100644
index 3c7c9c2..0000000
--- a/frontend/src/pages/QuestionBank.jsx
+++ /dev/null
@@ -1,170 +0,0 @@
-/**
- * Question Bank Page - View all questions
- */
-import React, { useState, useEffect } from 'react'
-import { useSearchParams } from 'react-router-dom'
-import { questionAPI } from '../api/client'
-import Pagination from '../components/Pagination'
-import { FileText, Loader } from 'lucide-react'
-import toast from 'react-hot-toast'
-import { getQuestionTypeText, formatRelativeTime } from '../utils/helpers'
-
-export const QuestionBank = () => {
- const [searchParams] = useSearchParams()
- const [questions, setQuestions] = useState([])
- const [loading, setLoading] = useState(true)
- const [expandedId, setExpandedId] = useState(null)
-
- // Pagination
- const [page, setPage] = useState(1)
- const [limit, setLimit] = useState(10)
- const [total, setTotal] = useState(0)
- const examIdParam = searchParams.get('examId')
- const examIdFilter = /^\d+$/.test(examIdParam || '') ? Number(examIdParam) : null
-
- useEffect(() => {
- setPage(1)
- setExpandedId(null)
- }, [examIdFilter])
-
- useEffect(() => {
- loadQuestions()
- }, [page, limit, examIdFilter])
-
- const loadQuestions = async () => {
- try {
- setLoading(true)
- const skip = (page - 1) * limit
- const response = await questionAPI.getAll(skip, limit, examIdFilter)
- setQuestions(response.data.questions)
- setTotal(response.data.total)
- } catch (error) {
- console.error('Failed to load questions:', error)
- toast.error('加载题库失败')
- } finally {
- setLoading(false)
- }
- }
-
- const toggleExpand = (id) => {
- setExpandedId(expandedId === id ? null : id)
- }
-
- const title = examIdFilter ? `题库 ${examIdFilter} 题目` : '全站题库'
- const subtitle = examIdFilter
- ? `当前仅显示该题库下的 ${total} 道题目`
- : `共 ${total} 道题目`
-
- if (loading && questions.length === 0) {
- return (
-
-
-
- )
- }
-
- return (
- <>
-
- {/* Header */}
-
-
- {/* List */}
-
- {questions.map((q) => {
- const isExpanded = expandedId === q.id
-
- return (
-
-
toggleExpand(q.id)}
- >
-
-
-
-
-
-
-
-
- {getQuestionTypeText(q.type)}
-
-
- ID: {q.id}
-
-
- {formatRelativeTime(q.created_at)}
-
-
-
-
- {q.content}
-
-
- {isExpanded && (
-
- {/* Options */}
- {q.options && q.options.length > 0 && (
-
- {q.options.map((opt, i) => (
-
- {opt}
-
- ))}
-
- )}
-
- {/* Answer */}
-
-
- 正确答案
-
-
{q.answer}
-
-
- {/* Analysis */}
- {q.analysis && (
-
-
- 解析
-
-
{q.analysis}
-
- )}
-
- )}
-
-
-
-
- )
- })}
-
-
- {/* Pagination */}
-
{
- setLimit(newLimit)
- setPage(1)
- }}
- />
-
- >
- )
-}
-
-export default QuestionBank
diff --git a/frontend/src/pages/QuizPlayer.jsx b/frontend/src/pages/QuizPlayer.jsx
deleted file mode 100644
index 49df908..0000000
--- a/frontend/src/pages/QuizPlayer.jsx
+++ /dev/null
@@ -1,369 +0,0 @@
-/**
- * Quiz Player Page - Core quiz functionality
- */
-import React, { useState, useEffect } from 'react'
-import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
-import { examAPI, questionAPI, mistakeAPI } from '../api/client'
-import {
- ArrowLeft, ArrowRight, Check, X, Loader, BookmarkPlus, BookmarkX, AlertCircle
-} from 'lucide-react'
-import toast from 'react-hot-toast'
-import { getQuestionTypeText } from '../utils/helpers'
-
-export const QuizPlayer = () => {
- const { examId } = useParams()
- const navigate = useNavigate()
- const [searchParams] = useSearchParams()
-
- const [exam, setExam] = useState(null)
- const [question, setQuestion] = useState(null)
- const [loading, setLoading] = useState(true)
- const [submitting, setSubmitting] = useState(false)
- const [userAnswer, setUserAnswer] = useState('')
- const [multipleAnswers, setMultipleAnswers] = useState([])
- const [result, setResult] = useState(null)
- const [inMistakeBook, setInMistakeBook] = useState(false)
-
- useEffect(() => {
- loadQuiz()
- }, [examId])
-
- const loadQuiz = async () => {
- try {
- // Check if reset flag is present
- const shouldReset = searchParams.get('reset') === 'true'
- if (shouldReset) {
- await examAPI.updateProgress(examId, 0)
- }
-
- const examRes = await examAPI.getDetail(examId)
- setExam(examRes.data)
-
- await loadCurrentQuestion()
- } catch (error) {
- console.error('Failed to load quiz:', error)
- toast.error('加载题目失败')
- } finally {
- setLoading(false)
- }
- }
-
- const loadCurrentQuestion = async () => {
- try {
- const response = await questionAPI.getCurrentQuestion(examId)
- // For judge questions, ensure options exist
- if (response.data.type === 'judge' && (!response.data.options || response.data.options.length === 0)) {
- response.data.options = ['A. 正确', 'B. 错误']
- }
- setQuestion(response.data)
- setResult(null)
- setUserAnswer('')
- setMultipleAnswers([])
- await checkIfInMistakeBook(response.data.id)
- } catch (error) {
- if (error.response?.status === 404) {
- toast.success('恭喜!所有题目已完成!')
- navigate(`/exams/${examId}`)
- } else {
- console.error('Failed to load question:', error)
- toast.error('加载题目失败')
- }
- }
- }
-
- const checkIfInMistakeBook = async (questionId) => {
- try {
- const response = await mistakeAPI.getList(0, 1000) // TODO: Optimize this
- const inBook = response.data.mistakes.some(m => m.question_id === questionId)
- setInMistakeBook(inBook)
- } catch (error) {
- console.error('Failed to check mistake book:', error)
- }
- }
-
- const handleSubmitAnswer = async () => {
- let answer = userAnswer
-
- // For multiple choice, join selected options
- if (question.type === 'multiple') {
- if (multipleAnswers.length === 0) {
- toast.error('请至少选择一个选项')
- return
- }
- answer = multipleAnswers.sort().join('')
- }
-
- if (!answer.trim()) {
- toast.error('请输入答案')
- return
- }
-
- setSubmitting(true)
-
- try {
- const response = await questionAPI.checkAnswer(question.id, answer)
- setResult(response.data)
-
- if (response.data.correct) {
- toast.success('回答正确!')
- } else {
- toast.error('回答错误')
- }
- } catch (error) {
- console.error('Failed to check answer:', error)
- toast.error('提交答案失败')
- } finally {
- setSubmitting(false)
- }
- }
-
- const handleNext = async () => {
- try {
- const newIndex = exam.current_index + 1
- await examAPI.updateProgress(examId, newIndex)
-
- const examRes = await examAPI.getDetail(examId)
- setExam(examRes.data)
-
- await loadCurrentQuestion()
- } catch (error) {
- console.error('Failed to move to next question:', error)
- }
- }
-
- const handleToggleMistake = async () => {
- try {
- if (inMistakeBook) {
- await mistakeAPI.removeByQuestionId(question.id)
- setInMistakeBook(false)
- toast.success('已从错题本移除')
- } else {
- await mistakeAPI.add(question.id)
- setInMistakeBook(true)
- toast.success('已加入错题本')
- }
- } catch (error) {
- console.error('Failed to toggle mistake:', error)
- }
- }
-
- const handleMultipleChoice = (option) => {
- const letter = option.charAt(0)
- if (multipleAnswers.includes(letter)) {
- setMultipleAnswers(multipleAnswers.filter(a => a !== letter))
- } else {
- setMultipleAnswers([...multipleAnswers, letter])
- }
- }
-
- if (loading) {
- return (
-
-
-
- )
- }
-
- if (!question) {
- return (
-
- )
- }
-
- return (
- <>
-
- {/* Header */}
-
-
-
-
- 进度: {exam.current_index + 1} / {exam.total_questions}
-
-
-
- {/* Question Card */}
-
- {/* Question Header */}
-
-
-
- {exam.current_index + 1}
-
-
- {getQuestionTypeText(question.type)}
-
-
-
-
-
-
- {/* Question Content */}
-
-
- {question.content}
-
-
-
- {/* Options (for choice questions) */}
- {question.options && question.options.length > 0 && (
-
- {question.options.map((option, index) => {
- const letter = option.charAt(0)
- const isSelected = question.type === 'multiple'
- ? multipleAnswers.includes(letter)
- : userAnswer === letter
-
- return (
-
- )
- })}
-
- )}
-
- {/* Short Answer Input */}
- {question.type === 'short' && !result && (
-
-
- )}
-
-
-
- {/* Submit Button */}
- {!result && (
-
- )}
-
-
- {/* Result */}
- {result && (
-
-
- {result.correct ? (
-
- ) : (
-
- )}
-
-
- {result.correct ? '回答正确!' : '回答错误'}
-
-
- {!result.correct && (
-
-
- 你的答案:{result.user_answer}
-
-
- 正确答案:{result.correct_answer}
-
-
- )}
-
- {/* AI Score for short answers */}
- {result.ai_score !== null && result.ai_score !== undefined && (
-
-
- AI 评分:{(result.ai_score * 100).toFixed(0)}%
-
- {result.ai_feedback && (
-
{result.ai_feedback}
- )}
-
- )}
-
- {/* Analysis */}
- {result.analysis && (
-
-
解析:
-
{result.analysis}
-
- )}
-
-
-
- {/* Next Button */}
-
-
- )}
-
- >
- )
-}
-
-export default QuizPlayer
diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx
deleted file mode 100644
index 7ccf0c0..0000000
--- a/frontend/src/pages/Register.jsx
+++ /dev/null
@@ -1,159 +0,0 @@
-/**
- * Register Page
- */
-import React, { useState } from 'react'
-import { Link, useNavigate } from 'react-router-dom'
-import { useAuth } from '../context/AuthContext'
-import { BookOpen } from 'lucide-react'
-
-export const Register = () => {
- const navigate = useNavigate()
- const { register } = useAuth()
-
- const [formData, setFormData] = useState({
- username: '',
- password: '',
- confirmPassword: ''
- })
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState('')
-
- const handleSubmit = async (e) => {
- e.preventDefault()
- setError('')
-
- // Validate
- if (formData.password !== formData.confirmPassword) {
- setError('两次输入的密码不一致')
- return
- }
-
- if (formData.password.length < 6) {
- setError('密码至少需要 6 位')
- return
- }
-
- setLoading(true)
-
- try {
- const success = await register(formData.username, formData.password)
- if (success) {
- navigate('/login')
- }
- } finally {
- setLoading(false)
- }
- }
-
- const handleChange = (e) => {
- setFormData({
- ...formData,
- [e.target.name]: e.target.value
- })
- setError('')
- }
-
- return (
-
-
- {/* Logo and Title */}
-
-
-
QQuiz
-
智能刷题与题库管理平台
-
-
- {/* Register Form */}
-
-
注册
-
-
-
- {/* Login Link */}
-
-
- 已有账号?{' '}
-
- 立即登录
-
-
-
-
-
-
- )
-}
-
-export default Register
diff --git a/frontend/src/utils/helpers.js b/frontend/src/utils/helpers.js
deleted file mode 100644
index d0517dd..0000000
--- a/frontend/src/utils/helpers.js
+++ /dev/null
@@ -1,117 +0,0 @@
-/**
- * Utility Helper Functions
- */
-
-/**
- * Format date to readable string
- */
-export const formatDate = (dateString) => {
- const date = new Date(dateString)
- return new Intl.DateTimeFormat('zh-CN', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit'
- }).format(date)
-}
-
-/**
- * Format relative time (e.g., "2 days ago")
- */
-export const formatRelativeTime = (dateString) => {
- const date = new Date(dateString)
- const now = new Date()
- const diff = now - date
- const seconds = Math.floor(diff / 1000)
- const minutes = Math.floor(seconds / 60)
- const hours = Math.floor(minutes / 60)
- const days = Math.floor(hours / 24)
-
- if (days > 7) {
- return formatDate(dateString)
- } else if (days > 0) {
- return `${days} 天前`
- } else if (hours > 0) {
- return `${hours} 小时前`
- } else if (minutes > 0) {
- return `${minutes} 分钟前`
- } else {
- return '刚刚'
- }
-}
-
-/**
- * Get exam status badge color
- */
-export const getStatusColor = (status) => {
- const colors = {
- pending: 'bg-gray-100 text-gray-800',
- processing: 'bg-blue-100 text-blue-800',
- ready: 'bg-green-100 text-green-800',
- failed: 'bg-red-100 text-red-800'
- }
- return colors[status] || 'bg-gray-100 text-gray-800'
-}
-
-/**
- * Get exam status text
- */
-export const getStatusText = (status) => {
- const texts = {
- pending: '等待中',
- processing: '处理中',
- ready: '就绪',
- failed: '失败'
- }
- return texts[status] || status
-}
-
-/**
- * Get question type text
- */
-export const getQuestionTypeText = (type) => {
- const texts = {
- single: '单选题',
- multiple: '多选题',
- judge: '判断题 (单选)',
- short: '简答题'
- }
- return texts[type] || type
-}
-
-/**
- * Calculate progress percentage
- */
-export const calculateProgress = (current, total) => {
- if (total === 0) return 0
- return Math.round((current / total) * 100)
-}
-
-/**
- * Validate file type
- */
-export const isValidFileType = (filename) => {
- const allowedExtensions = ['txt', 'pdf', 'doc', 'docx', 'xlsx', 'xls']
- const extension = filename.split('.').pop().toLowerCase()
- return allowedExtensions.includes(extension)
-}
-
-/**
- * Format file size
- */
-export const formatFileSize = (bytes) => {
- if (bytes === 0) return '0 Bytes'
- const k = 1024
- const sizes = ['Bytes', 'KB', 'MB', 'GB']
- const i = Math.floor(Math.log(bytes) / Math.log(k))
- return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
-}
-
-/**
- * Truncate text
- */
-export const truncateText = (text, maxLength = 100) => {
- if (text.length <= maxLength) return text
- return text.substring(0, maxLength) + '...'
-}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
deleted file mode 100644
index d9ef2d7..0000000
--- a/frontend/tailwind.config.js
+++ /dev/null
@@ -1,26 +0,0 @@
-/** @type {import('tailwindcss').Config} */
-export default {
- content: [
- "./index.html",
- "./src/**/*.{js,ts,jsx,tsx}",
- ],
- theme: {
- extend: {
- colors: {
- primary: {
- 50: '#eff6ff',
- 100: '#dbeafe',
- 200: '#bfdbfe',
- 300: '#93c5fd',
- 400: '#60a5fa',
- 500: '#3b82f6',
- 600: '#2563eb',
- 700: '#1d4ed8',
- 800: '#1e40af',
- 900: '#1e3a8a',
- },
- },
- },
- },
- plugins: [],
-}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
deleted file mode 100644
index ea33b4d..0000000
--- a/frontend/vite.config.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import { defineConfig, loadEnv } from 'vite'
-import react from '@vitejs/plugin-react'
-import path from 'path'
-
-export default defineConfig(({ mode }) => {
- // Assume running from frontend directory
- const envDir = path.resolve(process.cwd(), '..')
- const env = loadEnv(mode, envDir, '')
-
- return {
- envDir, // Tell Vite to look for .env files in the project root
- plugins: [react()],
- server: {
- host: '0.0.0.0',
- port: 3000,
- proxy: {
- '/api': {
- target: env.VITE_API_URL || env.REACT_APP_API_URL || 'http://localhost:8000',
- changeOrigin: true,
- }
- }
- },
- build: {
- outDir: 'build'
- }
- }
-})
diff --git a/scripts/start_single_container.py b/scripts/start_single_container.py
new file mode 100644
index 0000000..6bfd668
--- /dev/null
+++ b/scripts/start_single_container.py
@@ -0,0 +1,98 @@
+import os
+import signal
+import subprocess
+import sys
+import time
+
+
+ROOT_DIR = "/app"
+WEB_DIR = "/app/web"
+
+
+def terminate_process(process: subprocess.Popen | None, label: str) -> None:
+ if process is None or process.poll() is not None:
+ return
+
+ print(f"Stopping {label}...")
+ process.terminate()
+ try:
+ process.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait(timeout=5)
+
+
+def main() -> int:
+ shared_env = os.environ.copy()
+ shared_env.setdefault("API_BASE_URL", "http://127.0.0.1:8000")
+ shared_env.setdefault("NEXT_SERVER_URL", "http://127.0.0.1:3000")
+ shared_env.setdefault("NEXT_TELEMETRY_DISABLED", "1")
+
+ next_env = shared_env.copy()
+ next_env["NODE_ENV"] = "production"
+ next_env["HOSTNAME"] = "0.0.0.0"
+ next_env["PORT"] = "3000"
+
+ next_process = subprocess.Popen(
+ ["node", "server.js"],
+ cwd=WEB_DIR,
+ env=next_env,
+ )
+
+ api_process: subprocess.Popen | None = None
+
+ def shutdown(signum, _frame):
+ print(f"Received signal {signum}, shutting down...")
+ terminate_process(api_process, "FastAPI")
+ terminate_process(next_process, "Next.js")
+ raise SystemExit(0)
+
+ signal.signal(signal.SIGINT, shutdown)
+ signal.signal(signal.SIGTERM, shutdown)
+
+ try:
+ migrate_result = subprocess.run(
+ [sys.executable, "-m", "alembic", "upgrade", "head"],
+ cwd=ROOT_DIR,
+ env=shared_env,
+ check=False,
+ )
+ if migrate_result.returncode != 0:
+ terminate_process(next_process, "Next.js")
+ return migrate_result.returncode
+
+ api_process = subprocess.Popen(
+ [
+ sys.executable,
+ "-m",
+ "uvicorn",
+ "main:app",
+ "--host",
+ "0.0.0.0",
+ "--port",
+ "8000",
+ ],
+ cwd=ROOT_DIR,
+ env=shared_env,
+ )
+
+ while True:
+ next_returncode = next_process.poll()
+ api_returncode = api_process.poll()
+
+ if next_returncode is not None:
+ terminate_process(api_process, "FastAPI")
+ return next_returncode
+
+ if api_returncode is not None:
+ terminate_process(next_process, "Next.js")
+ return api_returncode
+
+ time.sleep(1)
+ finally:
+ terminate_process(api_process, "FastAPI")
+ terminate_process(next_process, "Next.js")
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/web/README.md b/web/README.md
index 8eb238b..c15d538 100644
--- a/web/README.md
+++ b/web/README.md
@@ -1,14 +1,13 @@
# QQuiz Web
-This directory contains the new Next.js frontend scaffold for the QQuiz
-refactor.
+This directory contains the Next.js frontend for QQuiz.
## Status
-- App Router skeleton: added
-- Auth/session proxy routes: added
-- Legacy Vite frontend replacement: in progress
-- shadcn/ui component foundation: added
+- App Router application: active
+- Auth/session proxy routes: active
+- Single-container deployment target: active
+- Split-stack frontend: active
## Environment
diff --git a/web/src/app/(auth)/login/page.tsx b/web/src/app/(auth)/login/page.tsx
index 3cb8a69..9915a12 100644
--- a/web/src/app/(auth)/login/page.tsx
+++ b/web/src/app/(auth)/login/page.tsx
@@ -9,6 +9,11 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
+import {
+ getResponseErrorMessage,
+ isRecord,
+ readResponsePayload
+} from "@/lib/api/response";
export default function LoginPage() {
const router = useRouter();
@@ -22,7 +27,7 @@ export default function LoginPage() {
setLoading(true);
try {
- const response = await fetch("/api/auth/login", {
+ const response = await fetch("/frontend-api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -30,9 +35,13 @@ export default function LoginPage() {
body: JSON.stringify({ username, password })
});
- const payload = await response.json();
+ const payload = await readResponsePayload(response);
if (!response.ok) {
- throw new Error(payload?.detail || "登录失败");
+ throw new Error(getResponseErrorMessage(payload, "登录失败"));
+ }
+
+ if (!isRecord(payload)) {
+ throw new Error("登录接口返回了无效响应");
}
toast.success("登录成功");
diff --git a/web/src/app/(auth)/register/page.tsx b/web/src/app/(auth)/register/page.tsx
index 76f47ea..cd236bc 100644
--- a/web/src/app/(auth)/register/page.tsx
+++ b/web/src/app/(auth)/register/page.tsx
@@ -9,6 +9,11 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
+import {
+ getResponseErrorMessage,
+ isRecord,
+ readResponsePayload
+} from "@/lib/api/response";
export default function RegisterPage() {
const router = useRouter();
@@ -21,7 +26,7 @@ export default function RegisterPage() {
setLoading(true);
try {
- const response = await fetch("/api/proxy/auth/register", {
+ const response = await fetch("/frontend-api/proxy/auth/register", {
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -29,9 +34,13 @@ export default function RegisterPage() {
body: JSON.stringify({ username, password })
});
- const payload = await response.json();
+ const payload = await readResponsePayload(response);
if (!response.ok) {
- throw new Error(payload?.detail || "注册失败");
+ throw new Error(getResponseErrorMessage(payload, "注册失败"));
+ }
+
+ if (!isRecord(payload)) {
+ throw new Error("注册接口返回了无效响应");
}
toast.success("注册成功,请登录");
diff --git a/web/src/app/api/auth/login/route.ts b/web/src/app/api/auth/login/route.ts
deleted file mode 100644
index d07c538..0000000
--- a/web/src/app/api/auth/login/route.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { cookies } from "next/headers";
-import { NextRequest, NextResponse } from "next/server";
-
-import { SESSION_COOKIE_NAME, buildBackendUrl } from "@/lib/api/config";
-
-export async function POST(request: NextRequest) {
- const body = await request.json();
- const forwardedProto = request.headers.get("x-forwarded-proto");
- const isSecureRequest =
- request.nextUrl.protocol === "https:" || forwardedProto === "https";
-
- const response = await fetch(buildBackendUrl("/auth/login"), {
- method: "POST",
- headers: {
- "Content-Type": "application/json"
- },
- body: JSON.stringify(body),
- cache: "no-store"
- });
-
- const payload = await response.json();
-
- if (!response.ok) {
- return NextResponse.json(payload, { status: response.status });
- }
-
- cookies().set({
- name: SESSION_COOKIE_NAME,
- value: payload.access_token,
- httpOnly: true,
- sameSite: "lax",
- secure: isSecureRequest,
- path: "/"
- });
-
- return NextResponse.json({ ok: true });
-}
diff --git a/web/src/app/api/auth/me/route.ts b/web/src/app/api/auth/me/route.ts
deleted file mode 100644
index f06f679..0000000
--- a/web/src/app/api/auth/me/route.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { cookies } from "next/headers";
-import { NextResponse } from "next/server";
-
-import {
- SESSION_COOKIE_NAME,
- buildBackendUrl
-} from "@/lib/api/config";
-
-export async function GET() {
- const token = cookies().get(SESSION_COOKIE_NAME)?.value;
-
- if (!token) {
- return NextResponse.json({ detail: "Unauthorized" }, { status: 401 });
- }
-
- const response = await fetch(buildBackendUrl("/auth/me"), {
- headers: {
- Authorization: `Bearer ${token}`
- },
- cache: "no-store"
- });
-
- const payload = await response.json();
-
- if (response.status === 401) {
- cookies().delete(SESSION_COOKIE_NAME);
- }
-
- return NextResponse.json(payload, { status: response.status });
-}
diff --git a/web/src/app/frontend-api/auth/login/route.ts b/web/src/app/frontend-api/auth/login/route.ts
new file mode 100644
index 0000000..9f39659
--- /dev/null
+++ b/web/src/app/frontend-api/auth/login/route.ts
@@ -0,0 +1,60 @@
+import { cookies } from "next/headers";
+import { NextRequest, NextResponse } from "next/server";
+
+import { SESSION_COOKIE_NAME, buildBackendUrl } from "@/lib/api/config";
+import {
+ getResponseErrorMessage,
+ isRecord,
+ readResponsePayload
+} from "@/lib/api/response";
+
+export async function POST(request: NextRequest) {
+ const body = await request.json();
+ const forwardedProto = request.headers.get("x-forwarded-proto");
+ const isSecureRequest =
+ request.nextUrl.protocol === "https:" || forwardedProto === "https";
+
+ let response: Response;
+ try {
+ response = await fetch(buildBackendUrl("/auth/login"), {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify(body),
+ cache: "no-store"
+ });
+ } catch {
+ return NextResponse.json(
+ { detail: "Backend API is unavailable." },
+ { status: 502 }
+ );
+ }
+
+ const payload = await readResponsePayload(response);
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { detail: getResponseErrorMessage(payload, "登录失败") },
+ { status: response.status }
+ );
+ }
+
+ if (!isRecord(payload) || typeof payload.access_token !== "string") {
+ return NextResponse.json(
+ { detail: "Backend returned an invalid login response." },
+ { status: 502 }
+ );
+ }
+
+ cookies().set({
+ name: SESSION_COOKIE_NAME,
+ value: payload.access_token,
+ httpOnly: true,
+ sameSite: "lax",
+ secure: isSecureRequest,
+ path: "/"
+ });
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/web/src/app/api/auth/logout/route.ts b/web/src/app/frontend-api/auth/logout/route.ts
similarity index 100%
rename from web/src/app/api/auth/logout/route.ts
rename to web/src/app/frontend-api/auth/logout/route.ts
diff --git a/web/src/app/frontend-api/auth/me/route.ts b/web/src/app/frontend-api/auth/me/route.ts
new file mode 100644
index 0000000..9c76fb8
--- /dev/null
+++ b/web/src/app/frontend-api/auth/me/route.ts
@@ -0,0 +1,57 @@
+import { cookies } from "next/headers";
+import { NextResponse } from "next/server";
+
+import {
+ SESSION_COOKIE_NAME,
+ buildBackendUrl
+} from "@/lib/api/config";
+import {
+ getResponseErrorMessage,
+ isRecord,
+ readResponsePayload
+} from "@/lib/api/response";
+
+export async function GET() {
+ const token = cookies().get(SESSION_COOKIE_NAME)?.value;
+
+ if (!token) {
+ return NextResponse.json({ detail: "Unauthorized" }, { status: 401 });
+ }
+
+ let response: Response;
+ try {
+ response = await fetch(buildBackendUrl("/auth/me"), {
+ headers: {
+ Authorization: `Bearer ${token}`
+ },
+ cache: "no-store"
+ });
+ } catch {
+ return NextResponse.json(
+ { detail: "Backend API is unavailable." },
+ { status: 502 }
+ );
+ }
+
+ const payload = await readResponsePayload(response);
+
+ if (response.status === 401) {
+ cookies().delete(SESSION_COOKIE_NAME);
+ }
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { detail: getResponseErrorMessage(payload, "获取当前用户失败") },
+ { status: response.status }
+ );
+ }
+
+ if (!isRecord(payload)) {
+ return NextResponse.json(
+ { detail: "Backend returned an invalid auth response." },
+ { status: 502 }
+ );
+ }
+
+ return NextResponse.json(payload, { status: response.status });
+}
diff --git a/web/src/app/api/exams/[examId]/progress/route.ts b/web/src/app/frontend-api/exams/[examId]/progress/route.ts
similarity index 71%
rename from web/src/app/api/exams/[examId]/progress/route.ts
rename to web/src/app/frontend-api/exams/[examId]/progress/route.ts
index 61c8990..463e8fd 100644
--- a/web/src/app/api/exams/[examId]/progress/route.ts
+++ b/web/src/app/frontend-api/exams/[examId]/progress/route.ts
@@ -16,13 +16,23 @@ export async function GET(
}
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"
- });
+ let response: Response;
+ try {
+ response = await fetch(target, {
+ headers: {
+ Accept: "text/event-stream",
+ "Cache-Control": "no-cache"
+ },
+ cache: "no-store"
+ });
+ } catch {
+ return new NextResponse("Backend API is unavailable.", {
+ status: 502,
+ headers: {
+ "Content-Type": "text/plain; charset=utf-8"
+ }
+ });
+ }
if (!response.ok || !response.body) {
const payload = await response.text();
diff --git a/web/src/app/api/proxy/[...path]/route.ts b/web/src/app/frontend-api/proxy/[...path]/route.ts
similarity index 90%
rename from web/src/app/api/proxy/[...path]/route.ts
rename to web/src/app/frontend-api/proxy/[...path]/route.ts
index c889904..1cce17c 100644
--- a/web/src/app/api/proxy/[...path]/route.ts
+++ b/web/src/app/frontend-api/proxy/[...path]/route.ts
@@ -34,7 +34,16 @@ async function proxyRequest(
init.body = await request.arrayBuffer();
}
- const response = await fetch(target, init);
+ let response: Response;
+ try {
+ response = await fetch(target, init);
+ } catch {
+ return NextResponse.json(
+ { detail: "Backend API is unavailable." },
+ { status: 502 }
+ );
+ }
+
const responseHeaders = new Headers(response.headers);
responseHeaders.delete("content-encoding");
responseHeaders.delete("content-length");
diff --git a/web/src/components/app-shell/logout-button.tsx b/web/src/components/app-shell/logout-button.tsx
index 84b5eb9..cafd24e 100644
--- a/web/src/components/app-shell/logout-button.tsx
+++ b/web/src/components/app-shell/logout-button.tsx
@@ -10,7 +10,7 @@ export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
- const response = await fetch("/api/auth/logout", {
+ const response = await fetch("/frontend-api/auth/logout", {
method: "POST"
});
diff --git a/web/src/components/exams/exam-detail-client.tsx b/web/src/components/exams/exam-detail-client.tsx
index 026a179..325637e 100644
--- a/web/src/components/exams/exam-detail-client.tsx
+++ b/web/src/components/exams/exam-detail-client.tsx
@@ -37,7 +37,7 @@ export function ExamDetailClient({
return;
}
- const source = new EventSource(`/api/exams/${exam.id}/progress`);
+ const source = new EventSource(`/frontend-api/exams/${exam.id}/progress`);
eventSourceRef.current = source;
source.onmessage = (event) => {
diff --git a/web/src/components/practice/mistake-practice-client.tsx b/web/src/components/practice/mistake-practice-client.tsx
index 2a6185a..c5cb8a7 100644
--- a/web/src/components/practice/mistake-practice-client.tsx
+++ b/web/src/components/practice/mistake-practice-client.tsx
@@ -31,8 +31,12 @@ export function MistakePracticeClient() {
async function loadMistakes() {
setLoading(true);
try {
- const payload = await browserApi("/mistakes/?skip=0&limit=1000", {
- method: "GET"
+ const payload = await browserApi("/mistakes", {
+ method: "GET",
+ query: {
+ skip: 0,
+ limit: 1000
+ }
});
let nextMistakes = payload.mistakes;
diff --git a/web/src/components/practice/quiz-player-client.tsx b/web/src/components/practice/quiz-player-client.tsx
index dfaba33..459241e 100644
--- a/web/src/components/practice/quiz-player-client.tsx
+++ b/web/src/components/practice/quiz-player-client.tsx
@@ -47,8 +47,12 @@ export function QuizPlayerClient({
const [examPayload, questionPayload, mistakesPayload] = await Promise.all([
browserApi(`/exams/${examId}`, { method: "GET" }),
browserApi(`/questions/exam/${examId}/current`, { method: "GET" }),
- browserApi<{ mistakes: Array<{ question_id: number }> }>("/mistakes/?skip=0&limit=1000", {
- method: "GET"
+ browserApi<{ mistakes: Array<{ question_id: number }> }>("/mistakes", {
+ method: "GET",
+ query: {
+ skip: 0,
+ limit: 1000
+ }
})
]);
diff --git a/web/src/lib/api/browser.ts b/web/src/lib/api/browser.ts
index 1d342a4..c16b3ff 100644
--- a/web/src/lib/api/browser.ts
+++ b/web/src/lib/api/browser.ts
@@ -1,4 +1,10 @@
import { buildProxyUrl } from "@/lib/api/config";
+import {
+ getResponseErrorMessage,
+ getUnexpectedJsonMessage,
+ isRecord,
+ readResponsePayload
+} from "@/lib/api/response";
type BrowserApiOptions = Omit & {
body?: BodyInit | null;
@@ -27,7 +33,8 @@ export async function browserApi(
options: BrowserApiOptions = {}
): Promise {
const { query, headers, ...init } = options;
- const response = await fetch(buildProxyUrl(path, buildSearchParams(query)), {
+ const target = buildProxyUrl(path, buildSearchParams(query));
+ const response = await fetch(target, {
...init,
headers: {
...(headers || {})
@@ -38,20 +45,18 @@ export async function browserApi(
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);
- }
+ const payload = await readResponsePayload(response);
+ throw new Error(getResponseErrorMessage(payload, fallback));
}
if (response.status === 204) {
return undefined as T;
}
- return response.json() as Promise;
+ const payload = await readResponsePayload(response);
+ if (!isRecord(payload) && !Array.isArray(payload)) {
+ throw new Error(getUnexpectedJsonMessage(response));
+ }
+
+ return payload as T;
}
diff --git a/web/src/lib/api/config.ts b/web/src/lib/api/config.ts
index 7122856..376d693 100644
--- a/web/src/lib/api/config.ts
+++ b/web/src/lib/api/config.ts
@@ -18,6 +18,6 @@ export function buildProxyUrl(path: string, search?: URLSearchParams) {
const query = search?.toString();
return query
- ? `/api/proxy/${normalizedPath}?${query}`
- : `/api/proxy/${normalizedPath}`;
+ ? `/frontend-api/proxy/${normalizedPath}?${query}`
+ : `/frontend-api/proxy/${normalizedPath}`;
}
diff --git a/web/src/lib/api/response.ts b/web/src/lib/api/response.ts
new file mode 100644
index 0000000..2dfb4a9
--- /dev/null
+++ b/web/src/lib/api/response.ts
@@ -0,0 +1,61 @@
+function isJsonContentType(contentType: string | null) {
+ return Boolean(contentType && (contentType.includes("application/json") || contentType.includes("+json")));
+}
+
+export function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+export async function readResponsePayload(response: Response): Promise {
+ if (response.status === 204 || response.status === 205) {
+ return null;
+ }
+
+ const raw = await response.text();
+ if (!raw) {
+ return null;
+ }
+
+ if (!isJsonContentType(response.headers.get("content-type"))) {
+ return raw;
+ }
+
+ try {
+ return JSON.parse(raw) as unknown;
+ } catch {
+ return raw;
+ }
+}
+
+export function getResponseErrorMessage(payload: unknown, fallback: string) {
+ if (isRecord(payload)) {
+ const detail = payload.detail;
+ if (typeof detail === "string" && detail.trim()) {
+ return detail;
+ }
+
+ const message = payload.message;
+ if (typeof message === "string" && message.trim()) {
+ return message;
+ }
+ }
+
+ if (typeof payload === "string" && payload.trim()) {
+ const trimmed = payload.trim();
+ if (trimmed.startsWith("(
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);
- }
+ const payload = await readResponsePayload(response);
+ throw new Error(getResponseErrorMessage(payload, fallback));
}
if (response.status === 204) {
return undefined as T;
}
- return response.json() as Promise;
+ const payload = await readResponsePayload(response);
+ if (!isRecord(payload) && !Array.isArray(payload)) {
+ throw new Error(getUnexpectedJsonMessage(response));
+ }
+
+ return payload as T;
}