f9fd0ad5d9262c4bf142ce79eb7293e6066d2488..3f96c9c6502243b06efd4bb34a189e72e3a5557c
11 小时以前 yearning
复制项目
3f96c9 对比 | 目录
2个文件已修改
61个文件已添加
9122 ■■■■■ 已修改文件
.gitignore 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
README.md 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/__init__.py 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/auth.py 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/config.py 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/database.py 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/grader_scope.py 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/grading.py 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/import_service.py 501 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/login_guard.py 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/main.py 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/models.py 175 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/routers/__init__.py 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/routers/admin_graders_router.py 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/routers/admin_import_router.py 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/routers/attempts_router.py 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/routers/auth_router.py 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/routers/scores_router.py 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/routers/settings_router.py 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/schemas.py 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/sql_parser.py 183 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/app/system_settings.py 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/requirements.txt 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/scripts/import_local.py 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/scripts/seed_admin.py 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/tests/test_grading.py 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/tests/test_import_service.py 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-to-huaweicloud.html 868 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/e2e/scores.spec.ts 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/index.html 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/package-lock.json 3237 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/package.json 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/playwright.config.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/public/login-bg.webp 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/AdminRoute.tsx 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/App.tsx 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/api.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/components/ChangePasswordModal.tsx 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/components/SystemBrand.tsx 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/index.css 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/layout/AppBreadcrumb.tsx 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/layout/MainLayout.css 108 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/layout/MainLayout.tsx 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/main.tsx 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/pages/AdminGradersPage.tsx 201 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/pages/AdminImportPage.tsx 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/pages/AdminSystemSettingsPage.tsx 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/pages/AttemptReviewPage.css 392 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/pages/AttemptReviewPage.tsx 574 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/pages/LoginPage.css 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/pages/LoginPage.tsx 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/pages/ScoreQueryPage.css 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/pages/ScoreQueryPage.tsx 169 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/scoreQueryParams.ts 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/systemSettings.tsx 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/tsconfig.json 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/vite.config.ts 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/build-frontend.sh 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/lib.sh 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/restart.sh 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/start.sh 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/status.sh 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/stop.sh 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
@@ -1,44 +1,28 @@
# Byte-compiled / optimized / DLL files
backend/.venv/
backend/data/*.db
backend/data/uploads/
.env
.env.*
*.pem
*.key
frontend/test-results/
playwright-report/
*.zip
frontend/node_modules/
frontend/dist/
run/
logs/
__pycache__/
*.py[cod]
# C extensions
*.so
*.pyc
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.cache
nosetests.xml
coverage.xml
# Translations
*.mo
*.pot
# Django stuff:
*.log
# Sphinx documentation
docs/_build/
README.md
@@ -1,4 +1,71 @@
## app-full-stack-score-query
# 考试阅卷系统
考务系统客观题批阅复核
SQLite + FastAPI + React (Ant Design + Vite)
## 端口
- API:`8010`(仅后端)
- **前端生产(默认)**:`5174`(Node `vite preview` 托管 `dist`,`/api` 代理到 8010)
- 前端开发:`5175`(`./scripts/start.sh dev`,Vite HMR)
## 访问域名
已默认允许 CORS 来源:`https://grader.gps120.com`、`http://grader.gps120.com`(及本地开发地址)。
生产访问:将 `grader.gps120.com` 反代到本机 **`5174`**(或直连 `http://服务器:5174`),无需 Nginx 也可由 Node 提供页面。若需改 CORS:
```bash
export CORS_ORIGINS="https://grader.gps120.com,http://grader.gps120.com"
```
Vite 开发已配置 `allowedHosts` 含 `grader.gps120.com`(经隧道/反代访问 dev 时)。
## 服务启停(后台运行,关闭终端不退出)
```bash
# 首次安装后
cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cd ../frontend && npm install
# 启动 / 停止 / 重启 / 状态
./scripts/start.sh          # 生产:build + preview @5174 + 后端 8010
./scripts/start.sh dev      # 开发:Vite dev @5175 + 后端 8010
./scripts/build-frontend.sh # 仅重新构建 dist(改代码后生产需 build 再 restart frontend)
./scripts/stop.sh
./scripts/restart.sh
./scripts/status.sh
# 日志:logs/backend.log  logs/frontend.log(preview)  logs/frontend-dev.log  logs/frontend-build.log
# PID: run/backend.pid  run/frontend.pid
```
单独控制:`./scripts/start.sh backend` | `frontend`
## 后端(手动调试)
```bash
cd backend
source .venv/bin/activate
python scripts/seed_admin.py
uvicorn app.main:app --host 127.0.0.1 --port 8010
```
默认账号:`admin` / `admin123`(管理员),`grader` / `grader123`(阅卷员)
## 本地导入样例
```bash
source backend/.venv/bin/activate
python backend/scripts/import_local.py "data/碳排放核查员" "碳排放核查员三级理论"
```
## 前端(手动调试)
```bash
cd frontend
npm run dev
```
## ZIP 格式
ZIP 内须包含 5 类 SQL:`examination_examinee`、`examination_exam_examinee`、`examination_paper_source`、`examination_paper_item`、`examination_examinee_exam_answer`。
backend/app/__init__.py
New file
@@ -0,0 +1 @@
# app package
backend/app/auth.py
New file
@@ -0,0 +1,54 @@
from datetime import datetime, timedelta
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
bearer = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
    return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
    return pwd_context.verify(plain, hashed)
def create_access_token(user_id: int, role: str) -> str:
    expire = datetime.utcnow() + timedelta(hours=settings.jwt_expire_hours)
    payload = {"sub": str(user_id), "role": role, "exp": expire}
    return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def get_current_user(
    creds: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
    db: Annotated[Session, Depends(get_db)],
) -> User:
    if not creds or not creds.credentials:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
    try:
        payload = jwt.decode(
            creds.credentials, settings.jwt_secret, algorithms=[settings.jwt_algorithm]
        )
        user_id = int(payload.get("sub", 0))
    except (JWTError, ValueError):
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效令牌")
    user = db.get(User, user_id)
    if not user or not user.is_active:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不可用")
    return user
def require_admin(user: Annotated[User, Depends(get_current_user)]) -> User:
    if user.role != "admin":
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
    return user
backend/app/config.py
New file
@@ -0,0 +1,26 @@
from pathlib import Path
from pydantic_settings import BaseSettings
ROOT = Path(__file__).resolve().parents[2]
class Settings(BaseSettings):
    database_url: str = f"sqlite:///{ROOT / 'backend' / 'data' / 'app.db'}"
    jwt_secret: str = "score-query-dev-secret-change-in-prod"
    jwt_algorithm: str = "HS256"
    jwt_expire_hours: int = 24
    login_max_failures: int = 5
    login_lock_minutes: int = 15
    # 逗号分隔;可通过环境变量 CORS_ORIGINS 覆盖
    cors_origins: str = (
        "http://localhost:5174,"
        "http://127.0.0.1:5174,"
        "http://localhost:5175,"
        "http://127.0.0.1:5175,"
        "http://grader.gps120.com,"
        "https://grader.gps120.com"
    )
settings = Settings()
backend/app/database.py
New file
@@ -0,0 +1,31 @@
from sqlalchemy import create_engine, event
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.config import settings
engine = create_engine(
    settings.database_url,
    connect_args={"check_same_thread": False},
)
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_conn, _):
    cursor = dbapi_conn.cursor()
    cursor.execute("PRAGMA foreign_keys=ON")
    cursor.close()
class Base(DeclarativeBase):
    pass
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()
backend/app/grader_scope.py
New file
@@ -0,0 +1,71 @@
from fastapi import HTTPException
from sqlalchemy import false, select
from sqlalchemy.orm import Session
from app.models import GraderSubject, ImportBatch, User
def list_all_subject_names(db: Session) -> list[str]:
    return list(db.scalars(select(ImportBatch.subject_name).distinct().order_by(ImportBatch.subject_name)))
def get_grader_subjects(db: Session, user_id: int) -> list[str]:
    return list(
        db.scalars(
            select(GraderSubject.subject_name)
            .where(GraderSubject.user_id == user_id)
            .order_by(GraderSubject.subject_name)
        )
    )
def validate_subject_names(db: Session, names: list[str]) -> list[str]:
    cleaned = []
    seen = set()
    for n in names:
        s = (n or "").strip()
        if not s or s in seen:
            continue
        seen.add(s)
        cleaned.append(s)
    if not cleaned:
        raise HTTPException(400, detail="请至少选择一个科目")
    known = set(list_all_subject_names(db))
    unknown = [s for s in cleaned if s not in known]
    if unknown:
        raise HTTPException(400, detail=f"未知科目:{', '.join(unknown)}")
    return cleaned
def set_grader_subjects(db: Session, user_id: int, names: list[str]) -> None:
    validated = validate_subject_names(db, names)
    db.query(GraderSubject).filter(GraderSubject.user_id == user_id).delete()
    for name in validated:
        db.add(GraderSubject(user_id=user_id, subject_name=name))
def allowed_subjects_for_user(db: Session, user: User) -> list[str] | None:
    """None = 不限制(管理员);列表 = 考评员可见科目。"""
    if user.role == "admin":
        return None
    if user.role == "grader":
        return get_grader_subjects(db, user.id)
    return []
def assert_subject_allowed(db: Session, user: User, subject_name: str | None) -> None:
    if not subject_name:
        raise HTTPException(404, detail="记录不存在")
    allowed = allowed_subjects_for_user(db, user)
    if allowed is None:
        return
    if subject_name not in allowed:
        raise HTTPException(403, detail="无权查看该科目数据")
def apply_subject_scope(q, subject_column, allowed: list[str] | None):
    if allowed is None:
        return q
    if not allowed:
        return q.where(false())
    return q.where(subject_column.in_(allowed))
backend/app/grading.py
New file
@@ -0,0 +1,69 @@
import json
import re
from typing import Any
def normalize_answer(raw: str, item_type: int | None) -> str:
    if not raw:
        return ""
    s = raw.strip().upper()
    if item_type == 288:
        letters = re.findall(r"[A-Z]", s.replace(",", ""))
        return "".join(sorted(set(letters)))
    return s.replace(",", "").strip()
def normalize_reference(ref: str, item_type: int | None) -> str:
    if not ref:
        return ""
    s = ref.strip().upper()
    if item_type == 288:
        letters = re.findall(r"[A-Z]", s)
        return "".join(sorted(set(letters)))
    return s
def get_correct_mark(answer_rules: str | None, default: float = 1.0) -> float:
    if not answer_rules:
        return default
    try:
        rules = json.loads(answer_rules)
        return float(rules.get("correctMark", default))
    except (json.JSONDecodeError, TypeError, ValueError):
        return default
def grade_item(
    user_answer: str,
    reference_answer: str,
    answer_rules: str | None,
    item_type: int | None,
    max_score: float | None,
) -> tuple[bool, float]:
    ua = normalize_answer(user_answer, item_type)
    ra = normalize_reference(reference_answer, item_type)
    if not ra and answer_rules:
        try:
            rules = json.loads(answer_rules)
            ra = normalize_reference(str(rules.get("reference", "")), item_type)
        except json.JSONDecodeError:
            pass
    mark = get_correct_mark(answer_rules, max_score or 1.0)
    ok = ua == ra and bool(ra)
    return ok, (mark if ok else 0.0)
def answers_json_to_map(answers_json: str) -> dict[str, str]:
    try:
        arr = json.loads(answers_json)
    except json.JSONDecodeError:
        return {}
    out: dict[str, str] = {}
    if isinstance(arr, list):
        for item in arr:
            if isinstance(item, dict):
                iid = item.get("itemId") or item.get("item_id")
                ans = item.get("answer", "")
                if iid:
                    out[str(iid)] = str(ans) if ans is not None else ""
    return out
backend/app/import_service.py
New file
@@ -0,0 +1,501 @@
import hashlib
import io
import json
import zipfile
from pathlib import Path
from typing import Any
from fastapi import HTTPException
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.grading import answers_json_to_map, grade_item
from app.models import (
    ExamPaperMap,
    ExaminationExamExaminee,
    ExaminationExaminee,
    ExaminationExamineeExamAnswer,
    ExaminationExamineeExamAnswerItem,
    ExaminationPaperItem,
    ExaminationPaperSource,
    ImportBatch,
)
from app.sql_parser import find_file_content, rows_from_sql
def load_zip_files(zip_bytes: bytes) -> dict[str, bytes]:
    out: dict[str, bytes] = {}
    with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
        for info in zf.infolist():
            if info.is_dir():
                continue
            if info.filename.lower().endswith(".sql"):
                out[info.filename] = zf.read(info)
    return out
def load_dir_sql(dir_path: Path) -> dict[str, bytes]:
    out: dict[str, bytes] = {}
    for p in dir_path.rglob("*.sql"):
        out[str(p.relative_to(dir_path))] = p.read_bytes()
    return out
def _require_sql_kind(files: dict[str, bytes], kind: str, label: str) -> str:
    text = find_file_content(files, kind)
    if not text:
        raise HTTPException(400, detail=f"ZIP 缺少 {label} SQL 文件")
    return text
def _paper_identity(row: dict[str, Any]) -> tuple[str, str]:
    return (str(row.get("external_paper_id", "")), str(row.get("revision", "")))
def _collect_exam_ids(exam_rows: list[dict[str, Any]]) -> list[int]:
    ids = sorted({int(r["exam_id"]) for r in exam_rows if r.get("exam_id") is not None})
    if not ids:
        raise HTTPException(400, detail="考试考生 SQL 中未找到有效的 exam_id")
    return ids
def _parse_exam_to_paper_sql_map(exam_sql: str) -> dict[int, int]:
    out: dict[int, int] = {}
    for r in rows_from_sql(exam_sql):
        eid = r.get("id")
        if eid is None:
            continue
        psid = r.get("paper_source_id")
        if psid is None:
            psid = r.get("paper_id")
        if psid is None:
            continue
        out[int(eid)] = int(psid)
    return out
def _resolve_exam_paper_map(
    exam_ids: list[int],
    paper_rows: list[dict[str, Any]],
    files: dict[str, bytes],
) -> dict[int, int]:
    """exam_id -> paper_source_id(SQL 文件中的试卷 id)。"""
    keys = {_paper_identity(r) for r in paper_rows}
    sql_paper_ids = {int(r["id"]) for r in paper_rows if r.get("id") is not None}
    if len(keys) <= 1:
        if not paper_rows:
            raise HTTPException(400, detail="试卷 SQL 无有效数据")
        canonical = int(paper_rows[0]["id"])
        return {eid: canonical for eid in exam_ids}
    exam_text = find_file_content(files, "exam")
    if not exam_text:
        raise HTTPException(
            400,
            detail="ZIP 含多份试卷时,必须提供 examination_exam SQL,用于关联每场考试的试卷",
        )
    meta = _parse_exam_to_paper_sql_map(exam_text)
    if not meta:
        raise HTTPException(400, detail="examination_exam SQL 无有效数据或未包含 paper_source_id")
    result: dict[int, int] = {}
    for eid in exam_ids:
        ps_sql = meta.get(eid)
        if ps_sql is None:
            raise HTTPException(400, detail=f"examination_exam 中缺少 exam_id={eid} 的试卷关联")
        if ps_sql not in sql_paper_ids:
            raise HTTPException(
                400,
                detail=f"exam_id={eid} 关联的 paper_source_id={ps_sql} 不在 ZIP 试卷 SQL 中",
            )
        result[eid] = ps_sql
    return result
def _get_or_create_paper_source(
    db: Session,
    batch: ImportBatch,
    row: dict[str, Any],
) -> int:
    ext_id, revision = _paper_identity(row)
    existing = db.scalar(
        select(ExaminationPaperSource.id).where(
            ExaminationPaperSource.external_paper_id == ext_id,
            ExaminationPaperSource.revision == revision,
        )
    )
    if existing:
        return int(existing)
    ps = ExaminationPaperSource(
        id=int(row["id"]),
        external_paper_id=ext_id,
        revision=revision,
        sha256=str(row.get("sha256") or ""),
        paper_name=str(row.get("paper_name") or ""),
        occupation=str(row.get("occupation") or ""),
        level=str(row.get("level") or ""),
        subject=int(row["subject"]) if row.get("subject") is not None else None,
        struct_json=str(row.get("struct_json") or ""),
        item_count=int(row["item_count"]) if row.get("item_count") is not None else None,
        import_batch_id=batch.id,
    )
    db.add(ps)
    db.flush()
    return int(ps.id)
def _items_for_grading(
    db: Session,
    batch: ImportBatch,
    sql_ps_id: int,
    db_ps_id: int,
    item_rows: list[dict[str, Any]],
) -> dict[str, ExaminationPaperItem]:
    ps = db.get(ExaminationPaperSource, db_ps_id)
    if not ps:
        raise HTTPException(500, detail=f"试卷 {db_ps_id} 不存在")
    if ps.import_batch_id != batch.id:
        items = list(
            db.scalars(
                select(ExaminationPaperItem).where(ExaminationPaperItem.paper_source_id == db_ps_id)
            )
        )
        return {it.external_item_id: it for it in items}
    items_by_ext: dict[str, ExaminationPaperItem] = {}
    total_max = 0.0
    for r in item_rows:
        if int(r.get("paper_source_id") or 0) != sql_ps_id:
            continue
        ms = float(r.get("max_score") or 1)
        total_max += ms
        it = ExaminationPaperItem(
            id=int(r["id"]),
            paper_source_id=db_ps_id,
            external_item_id=str(r["external_item_id"]),
            section_ref=str(r.get("section_ref") or ""),
            part_ref=str(r.get("part_ref") or ""),
            part_name=str(r.get("part_name") or ""),
            item_type=int(r["item_type"]) if r.get("item_type") is not None else None,
            item_order=int(r["item_order"]) if r.get("item_order") is not None else None,
            title_html=str(r.get("title_html") or ""),
            detail_json=str(r.get("detail_json") or ""),
            max_score=ms,
            reference_answer=str(r.get("reference_answer") or ""),
            answer_rules=str(r.get("answer_rules") or ""),
        )
        db.add(it)
        items_by_ext[it.external_item_id] = it
    ps.total_score = total_max
    return items_by_ext
def run_import(
    db: Session,
    subject_name: str,
    zip_filename: str,
    file_bytes: bytes,
    user_id: int | None,
) -> dict[str, Any]:
    files = load_zip_files(file_bytes)
    sha = hashlib.sha256(file_bytes).hexdigest()
    examinee_sql = _require_sql_kind(files, "examinee", "考生")
    exam_examinee_sql = _require_sql_kind(files, "exam_examinee", "考试考生")
    paper_sql = _require_sql_kind(files, "paper_source", "试卷")
    item_sql = _require_sql_kind(files, "paper_item", "试题")
    answer_sql = _require_sql_kind(files, "answer", "考生答案")
    paper_rows = rows_from_sql(paper_sql)
    if not paper_rows:
        raise HTTPException(400, detail="试卷 SQL 无有效数据")
    exam_rows = rows_from_sql(exam_examinee_sql)
    if not exam_rows:
        raise HTTPException(400, detail="考试考生 SQL 无有效数据")
    exam_ids = _collect_exam_ids(exam_rows)
    for eid in exam_ids:
        if db.scalar(select(ExamPaperMap.id).where(ExamPaperMap.exam_id == eid)):
            raise HTTPException(409, detail=f"exam_id {eid} 已存在,请先清除对应导入批次")
    exam_to_sql_paper = _resolve_exam_paper_map(exam_ids, paper_rows, files)
    paper_row_by_sql_id = {int(r["id"]): r for r in paper_rows if r.get("id") is not None}
    needed_sql_paper_ids = {exam_to_sql_paper[e] for e in exam_ids}
    for sql_id in needed_sql_paper_ids:
        if sql_id not in paper_row_by_sql_id:
            raise HTTPException(400, detail=f"试卷 SQL 缺少 id={sql_id}")
    warnings: list[dict[str, Any]] = []
    report: dict[str, Any] = {"warnings": warnings, "counts": {}}
    primary_paper_row = paper_row_by_sql_id[exam_to_sql_paper[exam_ids[0]]]
    ext_id, revision = _paper_identity(primary_paper_row)
    try:
        batch = ImportBatch(
            subject_name=subject_name.strip(),
            zip_filename=zip_filename,
            sha256=sha,
            status="success",
            imported_by=user_id,
            external_paper_id=ext_id,
            revision=revision,
            exam_id=exam_ids[0],
            exam_ids_json=json.dumps(exam_ids, ensure_ascii=False),
            occupation=str(primary_paper_row.get("occupation") or ""),
            level=str(primary_paper_row.get("level") or ""),
            subject_code=int(primary_paper_row["subject"])
            if primary_paper_row.get("subject") is not None
            else None,
        )
        db.add(batch)
        db.flush()
        sql_to_db_paper: dict[int, int] = {}
        for sql_id in needed_sql_paper_ids:
            sql_to_db_paper[sql_id] = _get_or_create_paper_source(
                db, batch, paper_row_by_sql_id[sql_id]
            )
        batch.paper_source_id = sql_to_db_paper[exam_to_sql_paper[exam_ids[0]]]
        item_rows = rows_from_sql(item_sql)
        grading_items: dict[int, dict[str, ExaminationPaperItem]] = {}
        for sql_id in set(exam_to_sql_paper.values()):
            db_ps = sql_to_db_paper[sql_id]
            grading_items[db_ps] = _items_for_grading(db, batch, sql_id, db_ps, item_rows)
        for r in rows_from_sql(examinee_sql):
            eid = int(r["id"])
            existing = db.get(ExaminationExaminee, eid)
            if existing:
                existing.username = r.get("username")
                existing.nickname = r.get("nickname")
                existing.id_card = r.get("id_card")
                existing.mobile = r.get("mobile")
                existing.sex = int(r["sex"]) if r.get("sex") is not None else None
            else:
                db.add(
                    ExaminationExaminee(
                        id=eid,
                        username=r.get("username"),
                        nickname=r.get("nickname"),
                        id_card=r.get("id_card"),
                        mobile=r.get("mobile"),
                        sex=int(r["sex"]) if r.get("sex") is not None else None,
                        deleted=int(r.get("deleted") or 0),
                    )
                )
        db.flush()
        answer_rows = {int(r["exam_examinee_id"]): r for r in rows_from_sql(answer_sql)}
        for r in exam_rows:
            aid = int(r["id"])
            row_exam_id = int(r["exam_id"])
            imported_score = float(r.get("score") or 0)
            sql_ps = exam_to_sql_paper[row_exam_id]
            db_ps = sql_to_db_paper[sql_ps]
            items_by_ext = grading_items[db_ps]
            attempt = ExaminationExamExaminee(
                id=aid,
                exam_id=row_exam_id,
                examinee_id=int(r["examinee_id"]),
                room_id=r.get("room_id"),
                seat_number=r.get("seat_number"),
                status=int(r["status"]) if r.get("status") is not None else None,
                score=0,
                imported_score=imported_score,
                level_str=r.get("level_str"),
                start_time=r.get("start_time"),
                end_time=r.get("end_time"),
                subject=int(r["subject"]) if r.get("subject") is not None else None,
                import_batch_id=batch.id,
            )
            db.add(attempt)
            ar = answer_rows.get(aid)
            if not ar:
                raise HTTPException(400, detail=f"缺少考生答案 exam_examinee_id={aid}")
            answers_str = str(ar.get("answers") or "[]")
            db.add(
                ExaminationExamineeExamAnswer(
                    id=int(ar["id"]),
                    exam_examinee_id=aid,
                    answers=answers_str,
                    deleted=int(ar.get("deleted") or 0),
                )
            )
            amap = answers_json_to_map(answers_str)
            calc = 0.0
            for ext, item in items_by_ext.items():
                ua = amap.get(ext, "")
                ok, sc = grade_item(
                    ua,
                    item.reference_answer or "",
                    item.answer_rules,
                    item.item_type,
                    item.max_score,
                )
                calc += sc
                db.add(
                    ExaminationExamineeExamAnswerItem(
                        exam_examinee_id=aid,
                        external_item_id=ext,
                        answer_raw=ua,
                        is_correct=ok,
                        score=sc,
                    )
                )
            attempt.score = calc
            if abs(calc - imported_score) > 0.01:
                warnings.append(
                    {
                        "exam_examinee_id": aid,
                        "imported_score": imported_score,
                        "calculated_score": calc,
                    }
                )
        for eid in exam_ids:
            db.add(
                ExamPaperMap(
                    exam_id=eid,
                    paper_source_id=sql_to_db_paper[exam_to_sql_paper[eid]],
                    import_batch_id=batch.id,
                )
            )
        report["counts"] = {
            "examinees": len(rows_from_sql(examinee_sql)),
            "attempts": len(exam_rows),
            "items": len(item_rows),
            "exam_id": exam_ids[0],
            "exam_ids": exam_ids,
            "papers": len(set(sql_to_db_paper.values())),
        }
        batch.report_json = json.dumps(report, ensure_ascii=False)
        db.commit()
        db.refresh(batch)
        return {"batch_id": batch.id, "report": report}
    except HTTPException:
        db.rollback()
        raise
    except Exception as e:
        db.rollback()
        raise HTTPException(500, detail=f"导入失败: {e}") from e
def _paper_owned_by_batch(db: Session, ps_id: int, batch_id: int) -> bool:
    ps = db.get(ExaminationPaperSource, ps_id)
    return ps is not None and ps.import_batch_id == batch_id
def _paper_still_referenced(db: Session, ps_id: int, batch_id: int) -> bool:
    other_maps = db.scalar(
        select(func.count())
        .select_from(ExamPaperMap)
        .where(
            ExamPaperMap.paper_source_id == ps_id,
            ExamPaperMap.import_batch_id != batch_id,
        )
    )
    return (other_maps or 0) > 0
def purge_batch(db: Session, batch_id: int) -> dict[str, int]:
    batch = db.get(ImportBatch, batch_id)
    if not batch:
        raise HTTPException(404, detail="导入批次不存在")
    counts: dict[str, int] = {}
    attempt_ids = list(
        db.scalars(
            select(ExaminationExamExaminee.id).where(
                ExaminationExamExaminee.import_batch_id == batch_id
            )
        )
    )
    examinee_ids_to_check: set[int] = set()
    if attempt_ids:
        examinee_ids_to_check = set(
            db.scalars(
                select(ExaminationExamExaminee.examinee_id).where(
                    ExaminationExamExaminee.id.in_(attempt_ids)
                )
            )
        )
        counts["answer_item"] = db.execute(
            delete(ExaminationExamineeExamAnswerItem).where(
                ExaminationExamineeExamAnswerItem.exam_examinee_id.in_(attempt_ids)
            )
        ).rowcount
        counts["exam_answer"] = db.execute(
            delete(ExaminationExamineeExamAnswer).where(
                ExaminationExamineeExamAnswer.exam_examinee_id.in_(attempt_ids)
            )
        ).rowcount
    counts["exam_examinee"] = db.execute(
        delete(ExaminationExamExaminee).where(ExaminationExamExaminee.import_batch_id == batch_id)
    ).rowcount
    paper_ids_to_check = set(
        db.scalars(
            select(ExamPaperMap.paper_source_id).where(ExamPaperMap.import_batch_id == batch_id)
        )
    )
    if batch.paper_source_id:
        paper_ids_to_check.add(batch.paper_source_id)
    counts["exam_paper_map"] = db.execute(
        delete(ExamPaperMap).where(ExamPaperMap.import_batch_id == batch_id)
    ).rowcount
    for ps_id in paper_ids_to_check:
        if not _paper_owned_by_batch(db, ps_id, batch_id):
            continue
        if _paper_still_referenced(db, ps_id, batch_id):
            continue
        counts["paper_item"] = counts.get("paper_item", 0) + (
            db.execute(
                delete(ExaminationPaperItem).where(ExaminationPaperItem.paper_source_id == ps_id)
            ).rowcount
            or 0
        )
        counts["paper_source"] = counts.get("paper_source", 0) + (
            db.execute(delete(ExaminationPaperSource).where(ExaminationPaperSource.id == ps_id)).rowcount
            or 0
        )
    if examinee_ids_to_check:
        for eid in examinee_ids_to_check:
            ref = db.scalar(
                select(func.count())
                .select_from(ExaminationExamExaminee)
                .where(ExaminationExamExaminee.examinee_id == eid)
            )
            if ref == 0:
                db.execute(delete(ExaminationExaminee).where(ExaminationExaminee.id == eid))
    counts["import_batch"] = db.execute(delete(ImportBatch).where(ImportBatch.id == batch_id)).rowcount
    db.commit()
    return counts
def parse_batch_exam_ids(batch: ImportBatch) -> list[int]:
    if batch.exam_ids_json:
        try:
            raw = json.loads(batch.exam_ids_json)
            if isinstance(raw, list) and raw:
                return [int(x) for x in raw]
        except (json.JSONDecodeError, TypeError, ValueError):
            pass
    if batch.exam_id is not None:
        return [int(batch.exam_id)]
    return []
backend/app/login_guard.py
New file
@@ -0,0 +1,55 @@
from datetime import datetime, timedelta
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.config import settings
from app.models import LoginLock
def _lock_message(locked_until: datetime) -> str:
    remain = int((locked_until - datetime.utcnow()).total_seconds())
    if remain < 60:
        return f"登录尝试过多,请 {max(remain, 1)} 秒后重试"
    minutes = max(remain // 60, 1)
    return f"登录尝试过多,请 {minutes} 分钟后重试"
def ensure_login_allowed(db: Session, username: str) -> None:
    key = username.strip().lower()
    if not key:
        return
    row = db.get(LoginLock, key)
    if not row or not row.locked_until:
        return
    if row.locked_until <= datetime.utcnow():
        row.fail_count = 0
        row.locked_until = None
        db.commit()
        return
    raise HTTPException(429, detail=_lock_message(row.locked_until))
def record_login_failure(db: Session, username: str) -> None:
    key = username.strip().lower()
    if not key:
        return
    row = db.get(LoginLock, key)
    if not row:
        row = LoginLock(username=key, fail_count=0)
        db.add(row)
    row.fail_count += 1
    row.updated_at = datetime.utcnow()
    if row.fail_count >= settings.login_max_failures:
        row.locked_until = datetime.utcnow() + timedelta(minutes=settings.login_lock_minutes)
    db.commit()
def clear_login_lock(db: Session, username: str) -> None:
    key = username.strip().lower()
    if not key:
        return
    row = db.get(LoginLock, key)
    if row:
        db.delete(row)
        db.commit()
backend/app/main.py
New file
@@ -0,0 +1,66 @@
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.config import settings
from app.database import Base, engine
from app.routers import (
    admin_graders_router,
    admin_import_router,
    attempts_router,
    auth_router,
    scores_router,
    settings_router,
)
from app.system_settings import ensure_upload_dir
app = FastAPI(title="考试阅卷系统")
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)
app.include_router(auth_router.router)
app.include_router(scores_router.router)
app.include_router(attempts_router.router)
app.include_router(admin_import_router.router)
app.include_router(admin_graders_router.router)
app.include_router(settings_router.router)
@app.on_event("startup")
def startup():
    import app.models  # noqa: F401 — 注册全部 ORM 表
    Path(settings.database_url.replace("sqlite:///", "")).parent.mkdir(parents=True, exist_ok=True)
    ensure_upload_dir()
    Base.metadata.create_all(bind=engine)
    with engine.begin() as conn:
        conn.exec_driver_sql("CREATE INDEX IF NOT EXISTS ix_users_role ON users (role)")
        try:
            conn.exec_driver_sql(
                "ALTER TABLE import_batch ADD COLUMN exam_ids_json TEXT NOT NULL DEFAULT '[]'"
            )
        except Exception:
            pass
    from app.database import SessionLocal
    from app.system_settings import get_or_create_settings
    db = SessionLocal()
    try:
        get_or_create_settings(db)
    finally:
        db.close()
dist = Path(__file__).resolve().parents[2] / "frontend" / "dist"
if dist.exists():
    app.mount("/", StaticFiles(directory=str(dist), html=True), name="static")
backend/app/models.py
New file
@@ -0,0 +1,175 @@
from datetime import datetime
from sqlalchemy import (
    BigInteger,
    Boolean,
    DateTime,
    Float,
    ForeignKey,
    Integer,
    String,
    Text,
    UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class AppSettings(Base):
    __tablename__ = "app_settings"
    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    system_name: Mapped[str] = mapped_column(String(128), default="考试阅卷系统")
    logo_ext: Mapped[str] = mapped_column(String(16), default="")
class LoginLock(Base):
    __tablename__ = "login_lock"
    username: Mapped[str] = mapped_column(String(64), primary_key=True)
    fail_count: Mapped[int] = mapped_column(Integer, default=0)
    locked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class GraderSubject(Base):
    __tablename__ = "grader_subject"
    user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
    subject_name: Mapped[str] = mapped_column(String(256), primary_key=True)
class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
    password_hash: Mapped[str] = mapped_column(String(255))
    display_name: Mapped[str] = mapped_column(String(128), default="")
    role: Mapped[str] = mapped_column(String(16), default="grader", index=True)  # admin | grader
    is_active: Mapped[bool] = mapped_column(Boolean, default=True)
    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class ImportBatch(Base):
    __tablename__ = "import_batch"
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    subject_name: Mapped[str] = mapped_column(String(256), index=True)
    zip_filename: Mapped[str] = mapped_column(String(512), default="")
    sha256: Mapped[str] = mapped_column(String(64), default="")
    status: Mapped[str] = mapped_column(String(32), default="success")
    report_json: Mapped[str] = mapped_column(Text, default="{}")
    imported_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
    imported_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
    paper_source_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
    exam_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
    exam_ids_json: Mapped[str] = mapped_column(Text, default="[]")
    external_paper_id: Mapped[str] = mapped_column(String(64), default="")
    revision: Mapped[str] = mapped_column(String(32), default="")
    occupation: Mapped[str] = mapped_column(String(128), default="")
    level: Mapped[str] = mapped_column(String(64), default="")
    subject_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
class ExamPaperMap(Base):
    __tablename__ = "exam_paper_map"
    __table_args__ = (UniqueConstraint("exam_id", name="uq_exam_paper_map_exam_id"),)
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    exam_id: Mapped[int] = mapped_column(BigInteger, index=True)
    paper_source_id: Mapped[int] = mapped_column(BigInteger)
    import_batch_id: Mapped[int] = mapped_column(Integer, ForeignKey("import_batch.id"))
class ExaminationExaminee(Base):
    __tablename__ = "examination_examinee"
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    username: Mapped[str | None] = mapped_column(String(64), nullable=True)
    nickname: Mapped[str | None] = mapped_column(String(128), nullable=True)
    id_card: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
    mobile: Mapped[str | None] = mapped_column(String(32), nullable=True)
    sex: Mapped[int | None] = mapped_column(Integer, nullable=True)
    deleted: Mapped[int] = mapped_column(Integer, default=0)
class ExaminationPaperSource(Base):
    __tablename__ = "examination_paper_source"
    __table_args__ = (
        UniqueConstraint("external_paper_id", "revision", name="uq_paper_ext_rev"),
    )
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    external_paper_id: Mapped[str] = mapped_column(String(64))
    revision: Mapped[str] = mapped_column(String(32))
    sha256: Mapped[str | None] = mapped_column(String(128), nullable=True)
    paper_name: Mapped[str | None] = mapped_column(String(512), nullable=True)
    occupation: Mapped[str | None] = mapped_column(String(128), nullable=True)
    level: Mapped[str | None] = mapped_column(String(64), nullable=True)
    subject: Mapped[int | None] = mapped_column(Integer, nullable=True)
    struct_json: Mapped[str | None] = mapped_column(Text, nullable=True)
    item_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
    import_batch_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("import_batch.id"))
    total_score: Mapped[float | None] = mapped_column(Float, nullable=True)
class ExaminationPaperItem(Base):
    __tablename__ = "examination_paper_item"
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    paper_source_id: Mapped[int] = mapped_column(BigInteger, index=True)
    external_item_id: Mapped[str] = mapped_column(String(64), index=True)
    section_ref: Mapped[str | None] = mapped_column(String(16), nullable=True)
    part_ref: Mapped[str | None] = mapped_column(String(16), nullable=True)
    part_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
    item_type: Mapped[int | None] = mapped_column(Integer, nullable=True)
    item_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
    title_html: Mapped[str | None] = mapped_column(Text, nullable=True)
    detail_json: Mapped[str | None] = mapped_column(Text, nullable=True)
    max_score: Mapped[float | None] = mapped_column(Float, nullable=True)
    reference_answer: Mapped[str | None] = mapped_column(String(64), nullable=True)
    answer_rules: Mapped[str | None] = mapped_column(Text, nullable=True)
class ExaminationExamExaminee(Base):
    __tablename__ = "examination_exam_examinee"
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    exam_id: Mapped[int] = mapped_column(BigInteger, index=True)
    examinee_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("examination_examinee.id"))
    room_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
    seat_number: Mapped[str | None] = mapped_column(String(32), nullable=True)
    status: Mapped[int | None] = mapped_column(Integer, nullable=True)
    score: Mapped[float] = mapped_column(Float, default=0)
    imported_score: Mapped[float | None] = mapped_column(Float, nullable=True)
    level_str: Mapped[str | None] = mapped_column(String(16), nullable=True)
    start_time: Mapped[str | None] = mapped_column(String(32), nullable=True)
    end_time: Mapped[str | None] = mapped_column(String(32), nullable=True)
    subject: Mapped[int | None] = mapped_column(Integer, nullable=True)
    import_batch_id: Mapped[int] = mapped_column(Integer, ForeignKey("import_batch.id"), index=True)
class ExaminationExamineeExamAnswer(Base):
    __tablename__ = "examination_examinee_exam_answer"
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    exam_examinee_id: Mapped[int] = mapped_column(
        BigInteger, ForeignKey("examination_exam_examinee.id"), unique=True
    )
    answers: Mapped[str] = mapped_column(Text)
    deleted: Mapped[int] = mapped_column(Integer, default=0)
class ExaminationExamineeExamAnswerItem(Base):
    __tablename__ = "examination_examinee_exam_answer_item"
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    exam_examinee_id: Mapped[int] = mapped_column(
        BigInteger, ForeignKey("examination_exam_examinee.id"), index=True
    )
    external_item_id: Mapped[str] = mapped_column(String(64))
    answer_raw: Mapped[str] = mapped_column(String(256), default="")
    is_correct: Mapped[bool] = mapped_column(Boolean, default=False)
    score: Mapped[float] = mapped_column(Float, default=0)
backend/app/routers/__init__.py
New file
@@ -0,0 +1 @@
# routers package
backend/app/routers/admin_graders_router.py
New file
@@ -0,0 +1,105 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth import hash_password, require_admin
from app.database import get_db
from app.grader_scope import get_grader_subjects, set_grader_subjects, validate_subject_names
from app.models import User
from app.schemas import GraderCreate, GraderOut, GraderUpdate, GradersResponse
router = APIRouter(prefix="/api/admin/graders", tags=["admin-graders"])
def _get_grader(db: Session, grader_id: int) -> User:
    user = db.get(User, grader_id)
    if not user or user.role != "grader":
        raise HTTPException(404, detail="考评员不存在")
    return user
def _to_out(db: Session, u: User) -> GraderOut:
    return GraderOut(
        id=u.id,
        username=u.username,
        display_name=u.display_name or "",
        is_active=u.is_active,
        created_at=u.created_at.isoformat() if u.created_at else "",
        subjects=get_grader_subjects(db, u.id),
    )
@router.get("", response_model=GradersResponse)
def list_graders(
    db: Session = Depends(get_db),
    _: User = Depends(require_admin),
):
    rows = db.scalars(select(User).where(User.role == "grader").order_by(User.id)).all()
    return GradersResponse(items=[_to_out(db, u) for u in rows])
@router.post("", response_model=GraderOut, status_code=201)
def create_grader(
    body: GraderCreate,
    db: Session = Depends(get_db),
    _: User = Depends(require_admin),
):
    username = body.username.strip()
    if db.query(User).filter(User.username == username).first():
        raise HTTPException(409, detail="用户名已存在")
    validate_subject_names(db, body.subjects)
    user = User(
        username=username,
        password_hash=hash_password(body.password),
        display_name=body.display_name.strip(),
        role="grader",
        is_active=True,
    )
    db.add(user)
    db.flush()
    set_grader_subjects(db, user.id, body.subjects)
    db.commit()
    db.refresh(user)
    return _to_out(db, user)
@router.put("/{grader_id}", response_model=GraderOut)
def update_grader(
    grader_id: int,
    body: GraderUpdate,
    db: Session = Depends(get_db),
    _: User = Depends(require_admin),
):
    user = _get_grader(db, grader_id)
    if body.username is not None:
        username = body.username.strip()
        if not username:
            raise HTTPException(400, detail="用户名不能为空")
        other = db.query(User).filter(User.username == username, User.id != grader_id).first()
        if other:
            raise HTTPException(409, detail="用户名已存在")
        user.username = username
    if body.display_name is not None:
        user.display_name = body.display_name.strip()
    if body.password is not None:
        user.password_hash = hash_password(body.password)
    if body.is_active is not None:
        user.is_active = body.is_active
    if body.subjects is not None:
        set_grader_subjects(db, user.id, body.subjects)
    db.commit()
    db.refresh(user)
    return _to_out(db, user)
@router.delete("/{grader_id}", status_code=204)
def delete_grader(
    grader_id: int,
    db: Session = Depends(get_db),
    admin: User = Depends(require_admin),
):
    if grader_id == admin.id:
        raise HTTPException(400, detail="不能删除当前登录账号")
    user = _get_grader(db, grader_id)
    db.delete(user)
    db.commit()
backend/app/routers/admin_import_router.py
New file
@@ -0,0 +1,74 @@
import json
from datetime import datetime
from fastapi import APIRouter, Depends, File, Form, UploadFile
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth import require_admin
from app.database import get_db
from app.import_service import parse_batch_exam_ids, purge_batch, run_import
from app.models import ExaminationExamExaminee, ExaminationPaperSource, ImportBatch, User
router = APIRouter(prefix="/api/admin/import", tags=["admin-import"])
@router.get("/batches")
def list_batches(
    db: Session = Depends(get_db),
    admin: User = Depends(require_admin),
):
    batches = db.scalars(select(ImportBatch).order_by(ImportBatch.imported_at.desc())).all()
    out = []
    for b in batches:
        cnt = db.scalar(
            select(func.count())
            .select_from(ExaminationExamExaminee)
            .where(ExaminationExamExaminee.import_batch_id == b.id)
        )
        paper_name = None
        if b.paper_source_id:
            ps = db.get(ExaminationPaperSource, b.paper_source_id)
            paper_name = ps.paper_name if ps else None
        out.append(
            {
                "id": b.id,
                "subject_name": b.subject_name,
                "exam_id": b.exam_id,
                "exam_ids": parse_batch_exam_ids(b),
                "zip_filename": b.zip_filename,
                "imported_at": b.imported_at.isoformat() if b.imported_at else "",
                "status": b.status,
                "attempt_count": cnt or 0,
                "paper_name": paper_name,
                "report": json.loads(b.report_json or "{}"),
            }
        )
    return {"items": out}
@router.post("")
async def import_zip(
    subject_name: str = Form(...),
    file: UploadFile = File(...),
    db: Session = Depends(get_db),
    admin: User = Depends(require_admin),
):
    data = await file.read()
    return run_import(
        db,
        subject_name=subject_name,
        zip_filename=file.filename or "upload.zip",
        file_bytes=data,
        user_id=admin.id,
    )
@router.delete("/batches/{batch_id}")
def delete_batch(
    batch_id: int,
    db: Session = Depends(get_db),
    admin: User = Depends(require_admin),
):
    counts = purge_batch(db, batch_id)
    return {"deleted_counts": counts}
backend/app/routers/attempts_router.py
New file
@@ -0,0 +1,100 @@
from collections import defaultdict
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth import get_current_user
from app.database import get_db
from app.grader_scope import assert_subject_allowed
from app.models import (
    ExamPaperMap,
    ExaminationExamExaminee,
    ExaminationExaminee,
    ExaminationExamineeExamAnswerItem,
    ExaminationPaperItem,
    ExaminationPaperSource,
    ImportBatch,
    User,
)
from app.schemas import AttemptDetail, PartGroup, QuestionDetail
router = APIRouter(prefix="/api", tags=["attempts"])
@router.get("/attempts/{attempt_id}", response_model=AttemptDetail)
def get_attempt(
    attempt_id: str,
    db: Session = Depends(get_db),
    user: User = Depends(get_current_user),
):
    try:
        attempt_pk = int(attempt_id)
    except ValueError:
        raise HTTPException(404, detail="记录不存在")
    attempt = db.get(ExaminationExamExaminee, attempt_pk)
    if not attempt:
        raise HTTPException(404, detail="记录不存在")
    examinee = db.get(ExaminationExaminee, attempt.examinee_id)
    batch = db.get(ImportBatch, attempt.import_batch_id)
    assert_subject_allowed(db, user, batch.subject_name if batch else None)
    pmap = db.scalar(
        select(ExamPaperMap).where(ExamPaperMap.exam_id == attempt.exam_id)
    )
    paper_id = pmap.paper_source_id if pmap else (batch.paper_source_id if batch else None)
    paper = db.get(ExaminationPaperSource, paper_id) if paper_id else None
    items = list(
        db.scalars(
            select(ExaminationPaperItem)
            .where(ExaminationPaperItem.paper_source_id == paper_id)
            .order_by(ExaminationPaperItem.part_ref, ExaminationPaperItem.item_order)
        )
    ) if paper else []
    answer_items = {
        ai.external_item_id: ai
        for ai in db.scalars(
            select(ExaminationExamineeExamAnswerItem).where(
                ExaminationExamineeExamAnswerItem.exam_examinee_id == attempt_pk
            )
        )
    }
    groups: dict[str, list[QuestionDetail]] = defaultdict(list)
    part_order: list[str] = []
    for it in items:
        pn = it.part_name or "题目"
        if pn not in part_order:
            part_order.append(pn)
        ai = answer_items.get(it.external_item_id)
        groups[pn].append(
            QuestionDetail(
                external_item_id=it.external_item_id,
                part_name=it.part_name,
                item_type=it.item_type,
                item_order=it.item_order,
                title_html=it.title_html,
                detail_json=it.detail_json,
                max_score=it.max_score,
                reference_answer=it.reference_answer,
                user_answer=ai.answer_raw if ai else None,
                is_correct=ai.is_correct if ai else False,
                score=ai.score if ai else 0,
            )
        )
    return AttemptDetail(
        attempt_id=str(attempt.id),
        examinee_name=examinee.nickname if examinee else None,
        id_card=examinee.id_card if examinee else None,
        score=attempt.score,
        paper_total_score=paper.total_score if paper else None,
        paper_name=paper.paper_name if paper else None,
        subject_name=batch.subject_name if batch else None,
        room_id=attempt.room_id,
        seat_number=attempt.seat_number,
        start_time=attempt.start_time,
        end_time=attempt.end_time,
        parts=[PartGroup(part_name=p, questions=groups[p]) for p in part_order],
    )
backend/app/routers/auth_router.py
New file
@@ -0,0 +1,42 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.auth import create_access_token, get_current_user, hash_password, verify_password
from app.database import get_db
from app.login_guard import clear_login_lock, ensure_login_allowed, record_login_failure
from app.models import User
from app.schemas import ChangePasswordRequest, LoginRequest, TokenResponse, UserMe
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, db: Session = Depends(get_db)):
    ensure_login_allowed(db, body.username)
    user = db.query(User).filter(User.username == body.username).first()
    if not user or not user.is_active or not verify_password(body.password, user.password_hash):
        record_login_failure(db, body.username)
        raise HTTPException(401, detail="用户名或密码错误")
    clear_login_lock(db, body.username)
    token = create_access_token(user.id, user.role)
    return TokenResponse(access_token=token)
@router.get("/me", response_model=UserMe)
def me(user: User = Depends(get_current_user)):
    return user
@router.post("/change-password")
def change_password(
    body: ChangePasswordRequest,
    db: Session = Depends(get_db),
    user: User = Depends(get_current_user),
):
    if body.old_password == body.new_password:
        raise HTTPException(400, detail="新密码不能与旧密码相同")
    if not verify_password(body.old_password, user.password_hash):
        raise HTTPException(400, detail="原密码不正确")
    user.password_hash = hash_password(body.new_password)
    db.commit()
    return {"ok": True}
backend/app/routers/scores_router.py
New file
@@ -0,0 +1,92 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth import get_current_user
from app.database import get_db
from app.grader_scope import allowed_subjects_for_user, apply_subject_scope
from app.models import (
    ExamPaperMap,
    ExaminationExamExaminee,
    ExaminationExaminee,
    ExaminationPaperSource,
    ImportBatch,
    User,
)
from app.schemas import ScoreRow, ScoresResponse
router = APIRouter(prefix="/api", tags=["scores"])
@router.get("/subjects")
def list_subjects(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
    q = select(ImportBatch.subject_name).distinct().order_by(ImportBatch.subject_name)
    allowed = allowed_subjects_for_user(db, user)
    if allowed is not None:
        if not allowed:
            return {"items": []}
        q = q.where(ImportBatch.subject_name.in_(allowed))
    rows = db.scalars(q)
    return {"items": list(rows)}
@router.get("/scores", response_model=ScoresResponse)
def list_scores(
    page: int = Query(1, ge=1),
    page_size: int = Query(20, ge=1, le=100),
    name: str | None = None,
    id_card: str | None = None,
    subject: str | None = None,
    db: Session = Depends(get_db),
    user: User = Depends(get_current_user),
):
    allowed = allowed_subjects_for_user(db, user)
    if subject and allowed is not None and subject not in allowed:
        raise HTTPException(403, detail="无权查看该科目")
    q = (
        select(
            ExaminationExamExaminee,
            ExaminationExaminee,
            ImportBatch.subject_name,
            ExaminationPaperSource.paper_name,
        )
        .join(ExaminationExaminee, ExaminationExaminee.id == ExaminationExamExaminee.examinee_id)
        .join(ImportBatch, ImportBatch.id == ExaminationExamExaminee.import_batch_id)
        .outerjoin(ExamPaperMap, ExamPaperMap.exam_id == ExaminationExamExaminee.exam_id)
        .outerjoin(
            ExaminationPaperSource,
            ExaminationPaperSource.id == ExamPaperMap.paper_source_id,
        )
    )
    if name:
        q = q.where(ExaminationExaminee.nickname.contains(name))
    if id_card:
        q = q.where(ExaminationExaminee.id_card.contains(id_card))
    if subject:
        q = q.where(ImportBatch.subject_name == subject)
    q = apply_subject_scope(q, ImportBatch.subject_name, allowed)
    count_q = select(func.count()).select_from(q.subquery())
    total = db.scalar(count_q) or 0
    rows = db.execute(
        q.order_by(ExaminationExamExaminee.id.desc())
        .offset((page - 1) * page_size)
        .limit(page_size)
    ).all()
    items = [
        ScoreRow(
            examinee_id=str(ex.examinee_id),
            name=ee.nickname,
            id_card=ee.id_card,
            subject_name=sn,
            score=ex.score,
            attempt_id=str(ex.id),
            room_id=ex.room_id,
            paper_name=pn,
            start_time=ex.start_time,
            end_time=ex.end_time,
        )
        for ex, ee, sn, pn in rows
    ]
    return ScoresResponse(total=total, page=page, page_size=page_size, items=items)
backend/app/routers/settings_router.py
New file
@@ -0,0 +1,102 @@
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.auth import require_admin
from app.database import get_db
from app.models import User
from app.schemas import SystemSettingsOut, SystemSettingsUpdate
from app.system_settings import (
    clear_logo_files,
    ensure_upload_dir,
    find_logo_file,
    get_or_create_settings,
    logo_path,
    public_logo_url,
)
router = APIRouter(prefix="/api", tags=["settings"])
ALLOWED_LOGO_EXT = {".png", ".jpg", ".jpeg", ".webp", ".svg", ".gif"}
MAX_LOGO_BYTES = 2 * 1024 * 1024
def _settings_out(db: Session) -> SystemSettingsOut:
    row = get_or_create_settings(db)
    return SystemSettingsOut(system_name=row.system_name, logo_url=public_logo_url(row.logo_ext or None))
@router.get("/settings", response_model=SystemSettingsOut)
def get_settings(db: Session = Depends(get_db)):
    return _settings_out(db)
@router.get("/settings/logo")
def get_logo(db: Session = Depends(get_db)):
    row = get_or_create_settings(db)
    path = find_logo_file(row.logo_ext or None)
    if not path:
        raise HTTPException(404, detail="未设置 Logo")
    media = {
        ".png": "image/png",
        ".jpg": "image/jpeg",
        ".jpeg": "image/jpeg",
        ".webp": "image/webp",
        ".svg": "image/svg+xml",
        ".gif": "image/gif",
    }.get(path.suffix.lower(), "application/octet-stream")
    return FileResponse(path, media_type=media)
@router.put("/admin/settings", response_model=SystemSettingsOut)
def update_settings(
    body: SystemSettingsUpdate,
    db: Session = Depends(get_db),
    _: User = Depends(require_admin),
):
    row = get_or_create_settings(db)
    row.system_name = body.system_name.strip()
    db.commit()
    db.refresh(row)
    return _settings_out(db)
@router.post("/admin/settings/logo", response_model=SystemSettingsOut)
async def upload_logo(
    file: UploadFile = File(...),
    db: Session = Depends(get_db),
    _: User = Depends(require_admin),
):
    if not file.filename:
        raise HTTPException(400, detail="请选择文件")
    ext = Path(file.filename).suffix.lower()
    if ext not in ALLOWED_LOGO_EXT:
        raise HTTPException(400, detail="仅支持 png、jpg、webp、svg、gif")
    content = await file.read()
    if len(content) > MAX_LOGO_BYTES:
        raise HTTPException(400, detail="Logo 不能超过 2MB")
    ensure_upload_dir()
    clear_logo_files()
    ext_clean = ext.lstrip(".")
    dest = logo_path(ext_clean)
    dest.write_bytes(content)
    row = get_or_create_settings(db)
    row.logo_ext = ext_clean
    db.commit()
    db.refresh(row)
    return _settings_out(db)
@router.delete("/admin/settings/logo", response_model=SystemSettingsOut)
def delete_logo(
    db: Session = Depends(get_db),
    _: User = Depends(require_admin),
):
    clear_logo_files()
    row = get_or_create_settings(db)
    row.logo_ext = ""
    db.commit()
    db.refresh(row)
    return _settings_out(db)
backend/app/schemas.py
New file
@@ -0,0 +1,132 @@
from pydantic import BaseModel, Field
class SystemSettingsOut(BaseModel):
    system_name: str
    logo_url: str | None = None
class SystemSettingsUpdate(BaseModel):
    system_name: str = Field(min_length=1, max_length=128)
class LoginRequest(BaseModel):
    username: str
    password: str
class ChangePasswordRequest(BaseModel):
    old_password: str = Field(min_length=1, max_length=128)
    new_password: str = Field(min_length=6, max_length=128)
class TokenResponse(BaseModel):
    access_token: str
    token_type: str = "bearer"
class UserMe(BaseModel):
    id: int
    username: str
    display_name: str
    role: str
    class Config:
        from_attributes = True
class ScoreRow(BaseModel):
    examinee_id: str
    name: str | None
    id_card: str | None
    subject_name: str
    score: float
    attempt_id: str
    room_id: str | None = None
    paper_name: str | None = None
    start_time: str | None = None
    end_time: str | None = None
class ScoresResponse(BaseModel):
    total: int
    page: int
    page_size: int
    items: list[ScoreRow]
class QuestionDetail(BaseModel):
    external_item_id: str
    part_name: str | None
    item_type: int | None
    item_order: int | None
    title_html: str | None
    detail_json: str | None
    max_score: float | None
    reference_answer: str | None
    user_answer: str | None
    is_correct: bool
    score: float
class PartGroup(BaseModel):
    part_name: str
    questions: list[QuestionDetail]
class AttemptDetail(BaseModel):
    attempt_id: str
    examinee_name: str | None
    id_card: str | None
    score: float
    paper_total_score: float | None
    paper_name: str | None
    subject_name: str | None
    room_id: str | None
    seat_number: str | None
    start_time: str | None
    end_time: str | None
    parts: list[PartGroup]
class GraderOut(BaseModel):
    id: int
    username: str
    display_name: str
    is_active: bool
    created_at: str
    subjects: list[str] = []
class GraderCreate(BaseModel):
    username: str = Field(min_length=2, max_length=64)
    password: str = Field(min_length=6, max_length=128)
    display_name: str = Field(default="", max_length=128)
    subjects: list[str] = Field(min_length=1)
class GraderUpdate(BaseModel):
    username: str | None = Field(default=None, min_length=2, max_length=64)
    password: str | None = Field(default=None, min_length=6, max_length=128)
    display_name: str | None = Field(default=None, max_length=128)
    is_active: bool | None = None
    subjects: list[str] | None = None
class GradersResponse(BaseModel):
    items: list[GraderOut]
class ImportBatchOut(BaseModel):
    id: int
    subject_name: str
    exam_id: int | None
    exam_ids: list[int] = []
    zip_filename: str
    imported_at: str
    status: str
    attempt_count: int = 0
    paper_name: str | None = None
    class Config:
        from_attributes = True
backend/app/sql_parser.py
New file
@@ -0,0 +1,183 @@
import re
from typing import Any
def split_inserts(sql_text: str) -> list[str]:
    parts = re.split(r"(?=INSERT INTO)", sql_text, flags=re.IGNORECASE)
    return [p.strip().rstrip(";") for p in parts if p.strip().upper().startswith("INSERT")]
def parse_insert_columns(insert_sql: str) -> list[str]:
    m = re.search(r"INSERT INTO\s+[`\"]?[^`\"(\s]+[`\"]?\s*\(([^)]+)\)\s*VALUES", insert_sql, re.I)
    if not m:
        return []
    return [c.strip().strip("`\"") for c in m.group(1).split(",")]
def parse_values_tuple(insert_sql: str) -> list[Any]:
    idx = insert_sql.upper().find("VALUES")
    if idx < 0:
        return []
    rest = insert_sql[idx + 6 :].strip()
    if not rest.startswith("("):
        return []
    return _parse_tuple(rest)
def _parse_tuple(s: str) -> list[Any]:
    assert s[0] == "("
    i = 1
    values: list[Any] = []
    while i < len(s):
        while i < len(s) and s[i] in " \t\n\r":
            i += 1
        if i >= len(s):
            break
        if s[i] == ")":
            break
        if i < len(s) - 3 and s[i : i + 4].upper() == "NULL":
            values.append(None)
            i += 4
            while i < len(s) and s[i] in " \t":
                i += 1
            if i < len(s) and s[i] == ",":
                i += 1
            continue
        if s[i] == "'":
            i += 1
            buf: list[str] = []
            while i < len(s):
                ch = s[i]
                if ch == "\\" and i + 1 < len(s):
                    n = s[i + 1]
                    if n == "'":
                        buf.append("'")
                        i += 2
                        continue
                    if n == '"':
                        buf.append('"')
                        i += 2
                        continue
                    if n == "\\":
                        buf.append("\\")
                        i += 2
                        continue
                    buf.append(ch)
                    i += 1
                    continue
                if ch == "'":
                    if i + 1 < len(s) and s[i + 1] == "'":
                        buf.append("'")
                        i += 2
                        continue
                    i += 1
                    break
                buf.append(ch)
                i += 1
            values.append("".join(buf))
            while i < len(s) and s[i] in " \t":
                i += 1
            if i < len(s) and s[i] == ",":
                i += 1
            continue
        if s[i : i + 2] == "b'":
            i += 2
            while i < len(s) and s[i] != "'":
                i += 1
            i += 1
            values.append(0)
            while i < len(s) and s[i] in " \t":
                i += 1
            if i < len(s) and s[i] == ",":
                i += 1
            continue
        m = re.match(r"-?\d+(?:\.\d+)?", s[i:])
        if m:
            num = m.group(0)
            values.append(float(num) if "." in num else int(num))
            i += len(num)
            while i < len(s) and s[i] in " \t":
                i += 1
            if i < len(s) and s[i] == ",":
                i += 1
            continue
        i += 1
    return values
def rows_from_sql(sql_text: str) -> list[dict[str, Any]]:
    rows = []
    for ins in split_inserts(sql_text):
        cols = parse_insert_columns(ins)
        vals = parse_values_tuple(ins)
        if not cols or len(vals) != len(cols):
            continue
        rows.append(dict(zip(cols, vals)))
    return rows
def _match_examinee(low: str) -> bool:
    if "examination_examinee" not in low:
        return False
    if "exam_answer" in low or "exam_examinee" in low:
        return False
    return True
def _match_exam_examinee(low: str) -> bool:
    return "exam_examinee" in low or "考试考生" in low
def _match_answer(low: str) -> bool:
    return "exam_answer" in low or "考生答案" in low
def _match_paper_source(low: str) -> bool:
    return "paper_source" in low or ("试卷" in low and "item" not in low)
def _match_paper_item(low: str) -> bool:
    return "paper_item" in low or "试题" in low
def _match_exam(low: str) -> bool:
    if "examinee" in low or "exam_answer" in low:
        return False
    if "exam_examinee" in low:
        return False
    return "examination_exam" in low
KIND_MATCHERS = {
    "examinee": _match_examinee,
    "exam_examinee": _match_exam_examinee,
    "answer": _match_answer,
    "paper_source": _match_paper_source,
    "paper_item": _match_paper_item,
    "exam": _match_exam,
}
def find_file_content(files: dict[str, bytes], kind: str) -> str | None:
    matcher = KIND_MATCHERS.get(kind)
    if not matcher:
        return None
    candidates: list[tuple[str, bytes]] = []
    for name, data in files.items():
        low = name.lower().replace("(", "(").replace(")", ")")
        if matcher(low):
            candidates.append((name, data))
    if not candidates:
        return None
    candidates.sort(key=lambda x: len(x[0]))
    return candidates[0][1].decode("utf-8", errors="replace")
def find_file_content_legacy(files: dict[str, bytes], patterns: list[str]) -> str | None:
    """Deprecated: use kind-based find_file_content in import_service."""
    for name, data in files.items():
        low = name.lower()
        for pat in patterns:
            if pat.lower() in low:
                return data.decode("utf-8", errors="replace")
    return None
backend/app/system_settings.py
New file
@@ -0,0 +1,54 @@
from pathlib import Path
from sqlalchemy.orm import Session
from app.config import ROOT
from app.models import AppSettings
UPLOAD_DIR = ROOT / "backend" / "data" / "uploads"
LOGO_BASENAME = "site_logo"
DEFAULT_SYSTEM_NAME = "考试阅卷系统"
def ensure_upload_dir() -> None:
    UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
def get_or_create_settings(db: Session) -> AppSettings:
    row = db.get(AppSettings, 1)
    if not row:
        row = AppSettings(id=1, system_name=DEFAULT_SYSTEM_NAME, logo_ext="")
        db.add(row)
        db.commit()
        db.refresh(row)
    return row
def logo_path(ext: str) -> Path:
    return UPLOAD_DIR / f"{LOGO_BASENAME}.{ext}"
def find_logo_file(ext: str | None) -> Path | None:
    if ext:
        p = logo_path(ext)
        if p.is_file():
            return p
    ensure_upload_dir()
    for p in UPLOAD_DIR.glob(f"{LOGO_BASENAME}.*"):
        if p.is_file():
            return p
    return None
def clear_logo_files() -> None:
    ensure_upload_dir()
    for p in UPLOAD_DIR.glob(f"{LOGO_BASENAME}.*"):
        p.unlink(missing_ok=True)
def public_logo_url(ext: str | None) -> str | None:
    if ext and find_logo_file(ext):
        return "/api/settings/logo"
    if find_logo_file(None):
        return "/api/settings/logo"
    return None
backend/requirements.txt
New file
@@ -0,0 +1,10 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
sqlalchemy==2.0.36
pydantic==2.10.3
pydantic-settings==2.6.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.2.1
python-multipart==0.0.19
aiosqlite==0.20.0
backend/scripts/import_local.py
New file
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Import SQL from a directory (dev). Usage: python scripts/import_local.py <dir> <subject_name>"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import io
import zipfile
from app.database import SessionLocal
from app.import_service import load_dir_sql, run_import
from app.models import User
def dir_to_zip_bytes(dir_path: Path) -> bytes:
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
        for name, data in load_dir_sql(dir_path).items():
            zf.writestr(Path(name).name, data)
    return buf.getvalue()
def main():
    if len(sys.argv) < 3:
        print("Usage: import_local.py <sql_dir> <subject_name>")
        sys.exit(1)
    dir_path = Path(sys.argv[1])
    subject = sys.argv[2]
    db = SessionLocal()
    user = db.query(User).filter(User.username == "admin").first()
    uid = user.id if user else None
    result = run_import(
        db,
        subject_name=subject,
        zip_filename=dir_path.name + ".zip",
        file_bytes=dir_to_zip_bytes(dir_path),
        user_id=uid,
    )
    print(result)
if __name__ == "__main__":
    main()
backend/scripts/seed_admin.py
New file
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Create default admin: admin / admin123"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.auth import hash_password
from app.database import Base, SessionLocal, engine
from app.models import User
def main():
    Base.metadata.create_all(bind=engine)
    db = SessionLocal()
    if db.query(User).filter(User.username == "admin").first():
        print("admin already exists")
        return
    db.add(
        User(
            username="admin",
            password_hash=hash_password("admin123"),
            display_name="管理员",
            role="admin",
        )
    )
    db.add(
        User(
            username="grader",
            password_hash=hash_password("grader123"),
            display_name="阅卷员",
            role="grader",
        )
    )
    db.commit()
    print("Created users: admin/admin123, grader/grader123")
if __name__ == "__main__":
    main()
backend/tests/test_grading.py
New file
@@ -0,0 +1,18 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.grading import grade_item, normalize_answer
def test_multi_choice_normalize():
    assert normalize_answer("A,B,C,D", 288) == "ABCD"
    assert normalize_answer("ABCD", 288) == "ABCD"
def test_single_grade():
    ok, sc = grade_item("A", "A", None, 272, 1.0)
    assert ok and sc == 1.0
    ok, sc = grade_item("B", "A", None, 272, 1.0)
    assert not ok and sc == 0.0
backend/tests/test_import_service.py
New file
@@ -0,0 +1,62 @@
import pytest
from fastapi import HTTPException
from app.import_service import (
    _collect_exam_ids,
    _parse_exam_to_paper_sql_map,
    _resolve_exam_paper_map,
)
def test_collect_exam_ids_ok():
    rows = [{"exam_id": 102}, {"exam_id": 101}, {"exam_id": 101}]
    assert _collect_exam_ids(rows) == [101, 102]
def test_collect_exam_ids_empty():
    with pytest.raises(HTTPException) as exc:
        _collect_exam_ids([{"exam_id": None}, {}])
    assert exc.value.status_code == 400
def test_parse_exam_to_paper_sql_map():
    sql = """
    INSERT INTO examination_exam (id, paper_source_id, name) VALUES (101, 9001, 'a');
    INSERT INTO examination_exam (id, paper_id, name) VALUES (102, 9002, 'b');
    """
    m = _parse_exam_to_paper_sql_map(sql)
    assert m == {101: 9001, 102: 9002}
def test_resolve_single_paper_multi_exam():
    paper_rows = [
        {"id": 9001, "external_paper_id": "P1", "revision": "1"},
    ]
    exam_rows_exam_ids = [101, 102]
    files: dict[str, bytes] = {}
    m = _resolve_exam_paper_map(exam_rows_exam_ids, paper_rows, files)
    assert m == {101: 9001, 102: 9001}
def test_resolve_multi_paper_requires_exam_sql():
    paper_rows = [
        {"id": 9001, "external_paper_id": "P1", "revision": "1"},
        {"id": 9002, "external_paper_id": "P2", "revision": "1"},
    ]
    with pytest.raises(HTTPException) as exc:
        _resolve_exam_paper_map([101, 102], paper_rows, {})
    assert "examination_exam" in exc.value.detail
def test_resolve_multi_paper_with_exam_sql():
    paper_rows = [
        {"id": 9001, "external_paper_id": "P1", "revision": "1"},
        {"id": 9002, "external_paper_id": "P2", "revision": "1"},
    ]
    exam_sql = """
    INSERT INTO examination_exam (id, paper_source_id) VALUES (101, 9001);
    INSERT INTO examination_exam (id, paper_source_id) VALUES (102, 9002);
    """
    files = {"examination_exam.sql": exam_sql.encode()}
    m = _resolve_exam_paper_map([101, 102], paper_rows, files)
    assert m == {101: 9001, 102: 9002}
deploy-to-huaweicloud.html
New file
@@ -0,0 +1,868 @@
<!-- Generated by Trae Work -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>考试阅卷系统 - 华为云部署新手教程</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
    background: #f5f7fa;
    color: #333;
    line-height: 1.8;
  }
  .container {
    max-width: 960px;
    margin: 0 auto;
    padding: 40px 20px;
  }
  h1 {
    font-size: 32px;
    color: #1a1a2e;
    margin-bottom: 10px;
    text-align: center;
  }
  .subtitle {
    text-align: center;
    color: #666;
    margin-bottom: 40px;
    font-size: 16px;
  }
  .toc {
    background: #fff;
    border-radius: 12px;
    padding: 24px 32px;
    margin-bottom: 32px;
    box-shadow: 0 2px 12px rgba(0,0,0,0.06);
  }
  .toc h2 {
    font-size: 18px;
    margin-bottom: 12px;
    color: #1a1a2e;
  }
  .toc ol {
    padding-left: 24px;
  }
  .toc li {
    margin: 6px 0;
  }
  .toc a {
    color: #0052d9;
    text-decoration: none;
  }
  .toc a:hover {
    text-decoration: underline;
  }
  .section {
    background: #fff;
    border-radius: 12px;
    padding: 32px;
    margin-bottom: 24px;
    box-shadow: 0 2px 12px rgba(0,0,0,0.06);
  }
  .section h2 {
    font-size: 24px;
    color: #1a1a2e;
    margin-bottom: 16px;
    padding-bottom: 12px;
    border-bottom: 2px solid #e8eaed;
    display: flex;
    align-items: center;
    gap: 10px;
  }
  .section h2 .step-num {
    background: linear-gradient(135deg, #0052d9, #36cffb);
    color: #fff;
    width: 36px;
    height: 36px;
    border-radius: 50%;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-size: 16px;
    font-weight: bold;
  }
  .section h3 {
    font-size: 18px;
    color: #1a1a2e;
    margin: 20px 0 10px;
  }
  .section p {
    margin: 10px 0;
    color: #444;
  }
  .section ul, .section ol {
    padding-left: 24px;
    margin: 10px 0;
  }
  .section li {
    margin: 6px 0;
    color: #444;
  }
  .code-block {
    background: #1e1e2e;
    color: #cdd6f4;
    padding: 16px 20px;
    border-radius: 8px;
    margin: 12px 0;
    font-family: "Fira Code", "Consolas", monospace;
    font-size: 14px;
    overflow-x: auto;
    line-height: 1.6;
  }
  .code-block .comment { color: #6c7086; }
  .code-block .cmd { color: #a6e3a1; }
  .code-block .var { color: #f9e2af; }
  .tip {
    background: #ecf5ff;
    border-left: 4px solid #409eff;
    padding: 12px 16px;
    border-radius: 4px;
    margin: 12px 0;
    color: #333;
  }
  .tip::before {
    content: "💡 提示:";
    font-weight: bold;
    color: #409eff;
  }
  .warning {
    background: #fdf6ec;
    border-left: 4px solid #e6a23c;
    padding: 12px 16px;
    border-radius: 4px;
    margin: 12px 0;
    color: #333;
  }
  .warning::before {
    content: "⚠️ 注意:";
    font-weight: bold;
    color: #e6a23c;
  }
  .success {
    background: #f0f9eb;
    border-left: 4px solid #67c23a;
    padding: 12px 16px;
    border-radius: 4px;
    margin: 12px 0;
    color: #333;
  }
  .success::before {
    content: "✅ 成功:";
    font-weight: bold;
    color: #67c23a;
  }
  .table-wrapper {
    overflow-x: auto;
    margin: 16px 0;
  }
  table {
    width: 100%;
    border-collapse: collapse;
    font-size: 14px;
  }
  th, td {
    border: 1px solid #e8eaed;
    padding: 10px 14px;
    text-align: left;
  }
  th {
    background: #f5f7fa;
    font-weight: 600;
    color: #1a1a2e;
  }
  tr:nth-child(even) {
    background: #fafbfc;
  }
  .highlight {
    background: #fffbe6;
    padding: 2px 6px;
    border-radius: 3px;
    font-family: monospace;
    font-size: 13px;
  }
  .architecture {
    background: #f8f9fc;
    border-radius: 8px;
    padding: 24px;
    margin: 16px 0;
    text-align: center;
  }
  .arch-box {
    display: inline-block;
    background: #fff;
    border: 2px solid #0052d9;
    color: #0052d9;
    padding: 12px 24px;
    border-radius: 8px;
    margin: 8px;
    font-weight: 600;
  }
  .arch-arrow {
    display: inline-block;
    color: #999;
    font-size: 24px;
    margin: 0 8px;
    vertical-align: middle;
  }
  .faq-item {
    border: 1px solid #e8eaed;
    border-radius: 8px;
    margin: 10px 0;
    overflow: hidden;
  }
  .faq-q {
    background: #f5f7fa;
    padding: 12px 16px;
    font-weight: 600;
    color: #1a1a2e;
  }
  .faq-a {
    padding: 12px 16px;
    color: #555;
  }
  .footer {
    text-align: center;
    color: #999;
    padding: 20px;
    font-size: 14px;
  }
</style>
</head>
<body>
<div class="container">
  <h1>🚀 考试阅卷系统 - 华为云部署新手教程</h1>
  <p class="subtitle">从购买服务器到上线运行,一步步带你完成部署</p>
  <div class="toc">
    <h2>📑 目录</h2>
    <ol>
      <li><a href="#overview">项目概览与部署方案</a></li>
      <li><a href="#step1">第一步:购买华为云 ECS 服务器</a></li>
      <li><a href="#step2">第二步:配置安全组(开放端口)</a></li>
      <li><a href="#step3">第三步:远程登录服务器</a></li>
      <li><a href="#step4">第四步:安装运行环境</a></li>
      <li><a href="#step5">第五步:上传项目代码</a></li>
      <li><a href="#step6">第六步:安装项目依赖</a></li>
      <li><a href="#step7">第七步:初始化数据库与管理员</a></li>
      <li><a href="#step8">第八步:启动服务并验证</a></li>
      <li><a href="#step9">第九步:配置开机自启(systemd)</a></li>
      <li><a href="#step10">第十步:绑定域名(可选)</a></li>
      <li><a href="#faq">常见问题 FAQ</a></li>
    </ol>
  </div>
  <!-- 0. 项目概览 -->
  <div class="section" id="overview">
    <h2><span class="step-num">📌</span> 项目概览与部署方案</h2>
    <h3>项目技术栈</h3>
    <div class="table-wrapper">
      <table>
        <tr><th>层级</th><th>技术</th><th>端口</th></tr>
        <tr><td>前端</td><td>React + TypeScript + Vite + Ant Design</td><td>5174(生产)</td></tr>
        <tr><td>后端</td><td>Python FastAPI + SQLAlchemy + Uvicorn</td><td>8010</td></tr>
        <tr><td>数据库</td><td>SQLite(文件型,无需单独部署)</td><td>-</td></tr>
      </table>
    </div>
    <h3>部署架构</h3>
    <div class="architecture">
      <div class="arch-box">用户浏览器</div>
      <div class="arch-arrow">→</div>
      <div class="arch-box">ECS 云服务器</div>
      <div style="margin-top:12px;">
        <div class="arch-box" style="border-color:#67c23a;color:#67c23a;">前端 :5174</div>
        <div class="arch-arrow">→</div>
        <div class="arch-box" style="border-color:#e6a23c;color:#e6a23a;">后端 :8010</div>
        <div class="arch-arrow">→</div>
        <div class="arch-box" style="border-color:#909399;color:#909399;">SQLite 文件</div>
      </div>
    </div>
    <h3>为什么选 ECS?</h3>
    <ul>
      <li><strong>最简单</strong>:一台服务器搞定全部,适合新手</li>
      <li><strong>成本低</strong>:SQLite 不需要额外买数据库</li>
      <li><strong>灵活</strong>:后续可以随时升级配置</li>
    </ul>
    <div class="tip">
      本教程选择 <strong>华为云弹性云服务器 ECS + Ubuntu 22.04</strong> 操作系统,这是最通用、文档最多的组合。
    </div>
  </div>
  <!-- 第一步:购买 ECS -->
  <div class="section" id="step1">
    <h2><span class="step-num">1</span> 购买华为云 ECS 服务器</h2>
    <h3>1.1 注册华为云账号</h3>
    <p>访问 <a href="https://www.huaweicloud.com/" target="_blank">华为云官网</a>,点击右上角「注册」,按照提示完成注册和实名认证。</p>
    <h3>1.2 购买 ECS</h3>
    <ol>
      <li>登录后,在顶部搜索框输入「弹性云服务器 ECS」,点击进入</li>
      <li>点击「购买弹性云服务器」</li>
      <li>按照以下配置选择:</li>
    </ol>
    <div class="table-wrapper">
      <table>
        <tr><th>配置项</th><th>推荐选择</th><th>说明</th></tr>
        <tr><td>计费模式</td><td>按需计费 / 包年包月</td><td>新手先用按需计费试试,稳定后转包月更便宜</td></tr>
        <tr><td>区域</td><td>选离你最近的(如:北京四、上海一)</td><td>影响访问速度</td></tr>
        <tr><td>CPU 架构</td><td>x86 计算</td><td>兼容性最好</td></tr>
        <tr><td>规格</td><td>通用计算型 | s6.large.2(2核4G)</td><td>💡 最低 2核2G 也能跑,但推荐 2核4G 更流畅</td></tr>
        <tr><td>镜像</td><td>公共镜像 → Ubuntu → Ubuntu 22.04 64bit</td><td>本教程基于此系统</td></tr>
        <tr><td>系统盘</td><td>通用型 SSD,40GB</td><td>足够用了</td></tr>
        <tr><td>带宽</td><td>按流量计费,5Mbit/s</td><td>小流量场景省钱</td></tr>
      </table>
    </div>
    <h3>1.3 设置登录凭证</h3>
    <p>在「高级配置」中设置登录方式:</p>
    <ul>
      <li><strong>密码方式(推荐新手)</strong>:设置 root 用户密码,请牢记!</li>
      <li>密钥对方式:更安全,但需要生成密钥对</li>
    </ul>
    <div class="warning">
      请务必记住你的服务器 <strong>公网 IP 地址</strong> 和 <strong>root 密码</strong>,后面会反复用到。
    </div>
    <p>确认配置无误后,点击「立即购买」→ 「去支付」,等待一两分钟服务器就创建好了。</p>
  </div>
  <!-- 第二步:配置安全组 -->
  <div class="section" id="step2">
    <h2><span class="step-num">2</span> 配置安全组(开放端口)</h2>
    <p>华为云默认只开放了 22 端口(SSH 登录用),我们需要手动开放项目要用的端口。</p>
    <h3>2.1 进入安全组配置</h3>
    <ol>
      <li>在 ECS 控制台,找到你的服务器,点击服务器名称进入详情</li>
      <li>找到「安全组」标签页</li>
      <li>点击安全组名称(如 default)进入配置</li>
    </ol>
    <h3>2.2 添加入方向规则</h3>
    <p>点击「添加规则」,依次添加以下规则:</p>
    <div class="table-wrapper">
      <table>
        <tr><th>优先级</th><th>策略</th><th>协议端口</th><th>类型</th><th>源地址</th><th>说明</th></tr>
        <tr><td>1</td><td>允许</td><td>TCP:22</td><td>IPv4</td><td>0.0.0.0/0</td><td>SSH 远程登录</td></tr>
        <tr><td>1</td><td>允许</td><td>TCP:5174</td><td>IPv4</td><td>0.0.0.0/0</td><td>前端访问端口</td></tr>
        <tr><td>1</td><td>允许</td><td>TCP:8010</td><td>IPv4</td><td>0.0.0.0/0</td><td>后端 API 端口</td></tr>
        <tr><td>1</td><td>允许</td><td>TCP:80</td><td>IPv4</td><td>0.0.0.0/0</td><td>HTTP(后面绑域名用)</td></tr>
        <tr><td>1</td><td>允许</td><td>TCP:443</td><td>IPv4</td><td>0.0.0.0/0</td><td>HTTPS(后面绑域名用)</td></tr>
      </table>
    </div>
    <div class="tip">
      源地址填 <span class="highlight">0.0.0.0/0</span> 表示允许所有 IP 访问。如果只有你自己用,可以填你电脑的公网 IP,更安全。
    </div>
  </div>
  <!-- 第三步:登录服务器 -->
  <div class="section" id="step3">
    <h2><span class="step-num">3</span> 远程登录服务器</h2>
    <h3>Windows 用户:使用 PowerShell</h3>
    <p>按下 <kbd>Win</kbd> + <kbd>X</kbd>,选择「Windows PowerShell」或「终端」,输入:</p>
    <div class="code-block">
<span class="cmd">ssh</span> root@你的服务器公网IP
<span class="comment"># 例如:ssh root@123.45.67.89</span>
    </div>
    <p>第一次连接会提示「是否继续连接」,输入 <span class="highlight">yes</span> 回车,然后输入密码(密码不会显示,输完直接回车)。</p>
    <h3>Mac 用户:使用终端</h3>
    <p>打开「终端」App,同样输入:</p>
    <div class="code-block"><span class="cmd">ssh</span> root@你的服务器公网IP</div>
    <div class="success">
      登录成功后,你会看到类似 <span class="highlight">root@ecs-xxx:~#</span> 的提示符,说明已经进入服务器了!
    </div>
    <div class="tip">
      也可以使用华为云控制台的「远程登录」功能(VNC),在 ECS 详情页点击「远程登录」按钮即可。
    </div>
  </div>
  <!-- 第四步:安装环境 -->
  <div class="section" id="step4">
    <h2><span class="step-num">4</span> 安装运行环境</h2>
    <p>登录服务器后,依次执行以下命令。每输完一行按回车执行,等待完成再继续。</p>
    <h3>4.1 更新系统包</h3>
    <div class="code-block">
<span class="cmd">apt</span> update && <span class="cmd">apt</span> upgrade -y
    </div>
    <p>这一步可能需要几分钟,耐心等待。</p>
    <h3>4.2 安装 Python 3 和 pip</h3>
    <div class="code-block">
<span class="cmd">apt</span> install -y python3 python3-venv python3-pip git
    </div>
    <p>验证安装:</p>
    <div class="code-block">
<span class="cmd">python3</span> --version
<span class="comment"># 输出类似 Python 3.10.x 就对了</span>
    </div>
    <h3>4.3 安装 Node.js</h3>
    <p>前端构建需要 Node.js,我们安装 LTS 版本:</p>
    <div class="code-block">
<span class="comment"># 下载 Node.js 安装脚本</span>
<span class="cmd">curl</span> -fsSL https://deb.nodesource.com/setup_20.x | bash -
<span class="comment"># 安装 Node.js</span>
<span class="cmd">apt</span> install -y nodejs
<span class="comment"># 验证安装</span>
<span class="cmd">node</span> --version
<span class="cmd">npm</span> --version
<span class="comment"># 输出 v20.x.x 和 10.x.x 就对了</span>
    </div>
    <div class="success">
      环境安装完成!现在有了 Python、Node.js 和 Git,可以部署项目了。
    </div>
  </div>
  <!-- 第五步:上传代码 -->
  <div class="section" id="step5">
    <h2><span class="step-num">5</span> 上传项目代码到服务器</h2>
    <p>有两种方式上传代码,选择你方便的一种:</p>
    <h3>方式一:Git 克隆(推荐,如果代码在 Git 仓库)</h3>
    <p>如果你的代码已经推送到 Gitee / GitHub / GitLab 等平台:</p>
    <div class="code-block">
<span class="cmd">cd</span> /opt
<span class="cmd">git</span> clone 你的仓库地址 score-query
<span class="comment"># 例如:git clone https://gitee.com/你的用户名/score-query.git</span>
<span class="cmd">cd</span> score-query
<span class="cmd">ls</span>
<span class="comment"># 应该能看到 backend、frontend、scripts 等目录</span>
    </div>
    <h3>方式二:本地上传(适合代码在本地电脑)</h3>
    <p><strong>Windows 用户:</strong>下载 <a href="https://winscp.net/" target="_blank">WinSCP</a> 或使用 <a href="https://www.putty.org/" target="_blank">PuTTY</a> 的 pscp</p>
    <p><strong>Mac 用户:</strong>在本地终端执行:</p>
    <div class="code-block">
<span class="cmd">scp</span> -r /本地项目路径 root@你的服务器IP:/opt/score-query
    </div>
    <div class="tip">
      如果你用的是 VS Code,可以安装「Remote - SSH」插件,直接在 VS Code 里编辑服务器上的文件,非常方便!
    </div>
  </div>
  <!-- 第六步:安装依赖 -->
  <div class="section" id="step6">
    <h2><span class="step-num">6</span> 安装项目依赖</h2>
    <p>假设项目在 <span class="highlight">/opt/score-query</span> 目录下:</p>
    <h3>6.1 安装后端 Python 依赖</h3>
    <div class="code-block">
<span class="cmd">cd</span> /opt/score-query/backend
<span class="comment"># 创建虚拟环境(推荐,隔离依赖)</span>
<span class="cmd">python3</span> -m venv .venv
<span class="comment"># 激活虚拟环境并安装依赖</span>
<span class="cmd">source</span> .venv/bin/activate
<span class="cmd">pip</span> install -r requirements.txt
<span class="comment"># 等待安装完成,验证一下</span>
<span class="cmd">python</span> -c "import fastapi; print('FastAPI 版本:', fastapi.__version__)"
    </div>
    <h3>6.2 安装前端 Node.js 依赖</h3>
    <div class="code-block">
<span class="cmd">cd</span> /opt/score-query/frontend
<span class="comment"># 安装依赖,可能需要几分钟</span>
<span class="cmd">npm</span> install
<span class="comment"># 验证</span>
<span class="cmd">npm</span> run build
<span class="comment"># 如果能成功构建出 dist 目录就说明没问题</span>
    </div>
    <div class="warning">
      如果 <span class="highlight">npm install</span> 很慢,可以切换到国内镜像:<br>
      <span class="highlight">npm config set registry https://registry.npmmirror.com</span>
    </div>
  </div>
  <!-- 第七步:初始化 -->
  <div class="section" id="step7">
    <h2><span class="step-num">7</span> 初始化数据库与管理员</h2>
    <h3>7.1 创建数据目录</h3>
    <div class="code-block">
<span class="cmd">cd</span> /opt/score-query/backend
<span class="cmd">mkdir</span> -p data uploads
    </div>
    <h3>7.2 初始化管理员账号</h3>
    <div class="code-block">
<span class="cmd">cd</span> /opt/score-query/backend
<span class="cmd">source</span> .venv/bin/activate
<span class="cmd">python</span> scripts/seed_admin.py
    </div>
    <div class="success">
      默认账号:<br>
      管理员:<span class="highlight">admin</span> / 密码:<span class="highlight">admin123</span><br>
      阅卷员:<span class="highlight">grader</span> / 密码:<span class="highlight">grader123</span><br>
      <strong>上线后请务必修改默认密码!</strong>
    </div>
  </div>
  <!-- 第八步:启动服务 -->
  <div class="section" id="step8">
    <h2><span class="step-num">8</span> 启动服务并验证</h2>
    <h3>8.1 使用项目自带脚本启动</h3>
    <p>项目已经提供了方便的启动脚本:</p>
    <div class="code-block">
<span class="cmd">cd</span> /opt/score-query
<span class="comment"># 给脚本添加执行权限(首次需要)</span>
<span class="cmd">chmod</span> +x scripts/*.sh
<span class="comment"># 启动全部服务(生产模式)</span>
./scripts/start.sh
    </div>
    <p>启动成功后,你会看到类似输出:</p>
    <div class="code-block">
后端已启动 → http://127.0.0.1:8010
前端生产 → http://0.0.0.0:5174
    </div>
    <h3>8.2 验证服务是否正常</h3>
    <p>检查进程是否在运行:</p>
    <div class="code-block">
./scripts/status.sh
<span class="comment"># 或</span>
<span class="cmd">ps</span> aux | grep uvicorn
<span class="cmd">ps</span> aux | grep vite
    </div>
    <p>检查端口是否监听:</p>
    <div class="code-block">
<span class="cmd">ss</span> -tlnp | grep -E '5174|8010'
    </div>
    <h3>8.3 在浏览器访问</h3>
    <p>打开浏览器,访问:</p>
    <div class="code-block">
http://你的服务器公网IP:5174
    </div>
    <div class="success">
      如果能看到登录页面,输入 <span class="highlight">admin / admin123</span> 能成功登录,那么恭喜你,部署成功了!🎉
    </div>
    <h3>8.4 常用命令</h3>
    <div class="table-wrapper">
      <table>
        <tr><th>命令</th><th>作用</th></tr>
        <tr><td><span class="highlight">./scripts/start.sh</span></td><td>启动全部服务(生产模式)</td></tr>
        <tr><td><span class="highlight">./scripts/stop.sh</span></td><td>停止全部服务</td></tr>
        <tr><td><span class="highlight">./scripts/restart.sh</span></td><td>重启全部服务</td></tr>
        <tr><td><span class="highlight">./scripts/status.sh</span></td><td>查看服务状态</td></tr>
        <tr><td><span class="highlight">./scripts/build-frontend.sh</span></td><td>重新构建前端</td></tr>
      </table>
    </div>
    <h3>8.5 查看日志</h3>
    <div class="code-block">
<span class="comment"># 后端日志</span>
<span class="cmd">tail</span> -f logs/backend.log
<span class="comment"># 前端日志</span>
<span class="cmd">tail</span> -f logs/frontend.log
    </div>
    <p>按 <kbd>Ctrl</kbd> + <kbd>C</kbd> 退出日志查看。</p>
  </div>
  <!-- 第九步:开机自启 -->
  <div class="section" id="step9">
    <h2><span class="step-num">9</span> 配置开机自启(强烈推荐)</h2>
    <p>前面的方式启动后,如果服务器重启,服务不会自动恢复。我们配置 systemd 服务来实现开机自启。</p>
    <h3>9.1 创建后端服务</h3>
    <div class="code-block">
<span class="cmd">cat</span> > /etc/systemd/system/score-backend.service << 'EOF'
[Unit]
Description=Score Query Backend (FastAPI)
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/score-query/backend
ExecStart=/opt/score-query/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8010
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
EOF
    </div>
    <h3>9.2 创建前端服务</h3>
    <div class="code-block">
<span class="cmd">cat</span> > /etc/systemd/system/score-frontend.service << 'EOF'
[Unit]
Description=Score Query Frontend (Vite Preview)
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/score-query/frontend
ExecStart=/usr/bin/npm run preview
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
    </div>
    <h3>9.3 启用并启动服务</h3>
    <div class="code-block">
<span class="comment"># 先停止之前的脚本启动的服务</span>
<span class="cmd">cd</span> /opt/score-query && ./scripts/stop.sh
<span class="comment"># 重新加载 systemd 配置</span>
<span class="cmd">systemctl</span> daemon-reload
<span class="comment"># 设置开机自启</span>
<span class="cmd">systemctl</span> enable score-backend score-frontend
<span class="comment"># 启动服务</span>
<span class="cmd">systemctl</span> start score-backend score-frontend
<span class="comment"># 查看状态</span>
<span class="cmd">systemctl</span> status score-backend
<span class="cmd">systemctl</span> status score-frontend
    </div>
    <div class="success">
      看到 <span class="highlight">active (running)</span> 就说明配置成功了!服务器重启后服务会自动启动。
    </div>
    <h3>9.4 systemd 常用命令</h3>
    <div class="table-wrapper">
      <table>
        <tr><th>命令</th><th>作用</th></tr>
        <tr><td>systemctl start 服务名</td><td>启动服务</td></tr>
        <tr><td>systemctl stop 服务名</td><td>停止服务</td></tr>
        <tr><td>systemctl restart 服务名</td><td>重启服务</td></tr>
        <tr><td>systemctl status 服务名</td><td>查看服务状态</td></tr>
        <tr><td>journalctl -u 服务名 -f</td><td>查看服务日志</td></tr>
      </table>
    </div>
  </div>
  <!-- 第十步:绑定域名 -->
  <div class="section" id="step10">
    <h2><span class="step-num">10</span> 绑定域名(可选进阶)</h2>
    <p>用 IP 访问不太方便,也不专业。我们可以绑定一个域名。</p>
    <h3>10.1 购买域名</h3>
    <p>在华为云「域名注册」服务购买一个域名,例如 <span class="highlight">yourdomain.com</span>。</p>
    <h3>10.2 配置 DNS 解析</h3>
    <ol>
      <li>进入「云解析服务 DNS」</li>
      <li>找到你的域名,点击「解析」</li>
      <li>添加记录:</li>
    </ol>
    <div class="table-wrapper">
      <table>
        <tr><th>主机记录</th><th>类型</th><th>值</th></tr>
        <tr><td>grader(或 www)</td><td>A</td><td>你的服务器公网IP</td></tr>
      </table>
    </div>
    <p>等待几分钟解析生效。</p>
    <h3>10.3 修改 CORS 配置</h3>
    <p>因为项目有 CORS 跨域限制,需要把你的域名加到允许列表中。创建环境变量配置:</p>
    <div class="code-block">
<span class="cmd">cat</span> > /opt/score-query/.env << 'EOF'
CORS_ORIGINS="http://grader.yourdomain.com,https://grader.yourdomain.com,http://localhost:5174"
JWT_SECRET="这里改成一个随机的长字符串,比如 mashuang123456!@#"
EOF
    </div>
    <p>然后修改 systemd 服务加载这个环境变量:</p>
    <div class="code-block">
<span class="cmd">sed</span> -i '/ExecStart/i EnvironmentFile=/opt/score-query/.env' /etc/systemd/system/score-backend.service
<span class="cmd">systemctl</span> daemon-reload
<span class="cmd">systemctl</span> restart score-backend
    </div>
    <h3>10.4 修改前端 vite 配置中的 allowedHosts</h3>
    <p>编辑 <span class="highlight">/opt/score-query/frontend/vite.config.ts</span>,把你的域名加到 <span class="highlight">allowedHosts</span> 数组中,然后重新构建:</p>
    <div class="code-block">
<span class="cmd">cd</span> /opt/score-query/frontend
<span class="cmd">npm</span> run build
<span class="cmd">systemctl</span> restart score-frontend
    </div>
    <h3>10.5 配置 Nginx 反向代理(推荐)</h3>
    <p>用 Nginx 做反向代理,可以直接用 80 端口访问,不需要加端口号:</p>
    <div class="code-block">
<span class="comment"># 安装 Nginx</span>
<span class="cmd">apt</span> install -y nginx
<span class="comment"># 创建配置文件</span>
<span class="cmd">cat</span> > /etc/nginx/sites-available/score-query << 'EOF'
server {
    listen 80;
    server_name grader.yourdomain.com;
    # 前端静态文件(也可以继续用 vite preview,这里演示直接由 Nginx 托管)
    location / {
        proxy_pass http://127.0.0.1:5174;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}
EOF
<span class="comment"># 启用站点</span>
<span class="cmd">ln</span> -s /etc/nginx/sites-available/score-query /etc/nginx/sites-enabled/
<span class="cmd">rm</span> /etc/nginx/sites-enabled/default
<span class="comment"># 测试配置</span>
<span class="cmd">nginx</span> -t
<span class="comment"># 重载 Nginx</span>
<span class="cmd">systemctl</span> reload nginx
    </div>
    <p>现在就可以用 <span class="highlight">http://grader.yourdomain.com</span> 直接访问了!</p>
    <div class="tip">
      如果需要 HTTPS,可以用 Let's Encrypt 免费证书,安装 certbot 后执行:<br>
      <span class="highlight">apt install -y certbot python3-certbot-nginx && certbot --nginx</span>
    </div>
  </div>
  <!-- FAQ -->
  <div class="section" id="faq">
    <h2><span class="step-num">❓</span> 常见问题 FAQ</h2>
    <div class="faq-item">
      <div class="faq-q">Q1: 浏览器访问不了怎么办?</div>
      <div class="faq-a">
        <p>按以下顺序排查:</p>
        <ol>
          <li>确认安全组是否开放了 5174 端口</li>
          <li>确认服务是否在运行:<span class="highlight">systemctl status score-backend score-frontend</span></li>
          <li>在服务器本地测试:<span class="highlight">curl http://127.0.0.1:5174</span>,能返回 HTML 说明服务正常</li>
          <li>如果本地通、外网不通,基本是安全组或防火墙的问题</li>
          <li>检查系统防火墙:<span class="highlight">ufw status</span>,如果开了就 <span class="highlight">ufw allow 5174</span></li>
        </ol>
      </div>
    </div>
    <div class="faq-item">
      <div class="faq-q">Q2: 前端页面打开了,但登录/接口报错?</div>
      <div class="faq-a">
        <p>大概率是后端没启动或跨域配置问题:</p>
        <ol>
          <li>确认后端在运行:<span class="highlight">systemctl status score-backend</span></li>
          <li>本地测试后端:<span class="highlight">curl http://127.0.0.1:8010/docs</span>,能看到 Swagger 文档说明正常</li>
          <li>如果是跨域错误(浏览器 F12 控制台有 CORS 字样),检查 CORS_ORIGINS 配置是否包含你的访问地址</li>
        </ol>
      </div>
    </div>
    <div class="faq-item">
      <div class="faq-q">Q3: SQLite 数据库文件在哪?怎么备份?</div>
      <div class="faq-a">
        <p>数据库文件在 <span class="highlight">/opt/score-query/backend/data/app.db</span>,直接复制这个文件就是备份。建议定期备份:</p>
        <div class="code-block"><span class="cmd">cp</span> /opt/score-query/backend/data/app.db /opt/score-query/backend/data/app.db.bak.$(date +%Y%m%d)</div>
      </div>
    </div>
    <div class="faq-item">
      <div class="faq-q">Q4: 修改了代码怎么更新?</div>
      <div class="faq-a">
        <p>分两种情况:</p>
        <ul>
          <li><strong>改了前端代码:</strong>重新构建 → 重启前端
            <div class="code-block"><span class="cmd">cd</span> /opt/score-query/frontend && npm run build && systemctl restart score-frontend</div>
          </li>
          <li><strong>改了后端代码:</strong>直接重启后端
            <div class="code-block"><span class="cmd">systemctl</span> restart score-backend</div>
          </li>
        </ul>
      </div>
    </div>
    <div class="faq-item">
      <div class="faq-q">Q5: 服务器太卡了怎么办?</div>
      <div class="faq-a">
        <p>可以在华为云控制台「变更规格」升级配置。也可以优化:</p>
        <ul>
          <li>用 Nginx 直接托管前端静态文件,关掉 vite preview(节省内存)</li>
          <li>开启 gzip 压缩</li>
          <li>如果数据量大,可以考虑把 SQLite 换成 MySQL</li>
        </ul>
      </div>
    </div>
    <div class="faq-item">
      <div class="faq-q">Q6: 忘记管理员密码怎么办?</div>
      <div class="faq-a">
        <p>重新运行 seed_admin.py 脚本会重置默认账号密码:</p>
        <div class="code-block">
<span class="cmd">cd</span> /opt/score-query/backend
<span class="cmd">source</span> .venv/bin/activate
<span class="cmd">python</span> scripts/seed_admin.py
        </div>
      </div>
    </div>
    <div class="faq-item">
      <div class="faq-q">Q7: 用脚本启动还是 systemd 启动?</div>
      <div class="faq-a">
        <p>推荐用 <strong>systemd</strong>,因为:</p>
        <ul>
          <li>开机自动启动,服务器重启不怕</li>
          <li>进程崩溃自动重启</li>
          <li>统一的日志管理(journalctl)</li>
          <li>更专业、更稳定</li>
        </ul>
        <p>脚本方式适合临时调试使用。</p>
      </div>
    </div>
  </div>
  <div class="footer">
    <p>📚 考试阅卷系统华为云部署教程 | 祝你部署顺利!🎉</p>
  </div>
</div>
</body>
</html>
frontend/e2e/scores.spec.ts
New file
@@ -0,0 +1,58 @@
import { test, expect } from "@playwright/test";
const BASE = process.env.E2E_BASE_URL || "https://grader.gps120.com";
const USER = process.env.E2E_USER || "admin";
const PASS = process.env.E2E_PASS || "admin123";
async function login(page: import("@playwright/test").Page) {
  await page.getByLabel("账号").fill(USER);
  await page.getByLabel("密码").fill(PASS);
  await page.locator("button.ant-btn-primary").click();
  await expect(page).toHaveURL(/\/scores/, { timeout: 15_000 });
}
test.describe("考试阅卷系统 E2E", () => {
  test("登录页可正常打开并显示登录表单", async ({ page }) => {
    const errors: string[] = [];
    page.on("pageerror", (e) => errors.push(e.message));
    await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
    await expect(page.locator(".ant-card").filter({ hasText: "登录" })).toBeVisible({ timeout: 30_000 });
    await expect(page.getByLabel("账号")).toBeVisible();
    await expect(page.getByLabel("密码")).toBeVisible();
    expect(errors).toEqual([]);
  });
  test("未登录访问 /scores 会进入登录并成功查询成绩与答卷详情", async ({ page }) => {
    await page.goto(`${BASE}/scores`, { waitUntil: "domcontentloaded" });
    await expect(page.locator(".ant-card").filter({ hasText: "登录" })).toBeVisible({ timeout: 30_000 });
    await login(page);
    await expect(page.locator(".app-breadcrumb").getByText("成绩查询")).toBeVisible();
    const table = page.locator(".ant-table");
    await expect(table).toBeVisible({ timeout: 15_000 });
    const dataRow = page.locator("tr[data-row-key]").first();
    await expect(dataRow).toBeVisible({ timeout: 15_000 });
    await dataRow.getByRole("button", { name: /查\s*看/ }).click();
    await expect(page).toHaveURL(/\/attempts\/\d+/, { timeout: 15_000 });
    await expect(page.locator('aside[aria-label="题目索引"]')).toBeVisible({ timeout: 30_000 });
  });
  test("管理员可访问数据导入、考评员、系统设置", async ({ page }) => {
    await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
    await login(page);
    await page.getByRole("menuitem", { name: "数据导入" }).click();
    await expect(page).toHaveURL(/\/admin\/import/);
    await expect(page.locator(".ant-card").filter({ hasText: "导入数据" })).toBeVisible();
    await page.getByRole("menuitem", { name: "考评员管理" }).click();
    await expect(page).toHaveURL(/\/admin\/graders/);
    await expect(page.locator(".ant-card").filter({ hasText: "考评员管理" })).toBeVisible();
    await page.getByRole("menuitem", { name: "系统设置" }).click();
    await expect(page).toHaveURL(/\/admin\/settings/);
    await expect(page.locator(".ant-card").filter({ hasText: "系统设置" })).toBeVisible();
  });
});
frontend/index.html
New file
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>考试阅卷系统</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
frontend/package-lock.json
New file
@@ -0,0 +1,3237 @@
{
  "name": "score-query-frontend",
  "version": "0.1.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "score-query-frontend",
      "version": "0.1.0",
      "dependencies": {
        "@ant-design/icons": "^5.5.2",
        "antd": "^5.22.5",
        "axios": "^1.7.9",
        "react": "^18.3.1",
        "react-dom": "^18.3.1",
        "react-router-dom": "^6.28.0"
      },
      "devDependencies": {
        "@types/react": "^18.3.12",
        "@types/react-dom": "^18.3.1",
        "@vitejs/plugin-react": "^4.3.4",
        "typescript": "^5.7.2",
        "vite": "^6.0.3"
      }
    },
    "node_modules/@ant-design/colors": {
      "version": "7.2.1",
      "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz",
      "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==",
      "license": "MIT",
      "dependencies": {
        "@ant-design/fast-color": "^2.0.6"
      }
    },
    "node_modules/@ant-design/cssinjs": {
      "version": "1.24.0",
      "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz",
      "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.11.1",
        "@emotion/hash": "^0.8.0",
        "@emotion/unitless": "^0.7.5",
        "classnames": "^2.3.1",
        "csstype": "^3.1.3",
        "rc-util": "^5.35.0",
        "stylis": "^4.3.4"
      },
      "peerDependencies": {
        "react": ">=16.0.0",
        "react-dom": ">=16.0.0"
      }
    },
    "node_modules/@ant-design/cssinjs-utils": {
      "version": "1.1.3",
      "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz",
      "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==",
      "license": "MIT",
      "dependencies": {
        "@ant-design/cssinjs": "^1.21.0",
        "@babel/runtime": "^7.23.2",
        "rc-util": "^5.38.0"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/@ant-design/fast-color": {
      "version": "2.0.6",
      "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz",
      "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.24.7"
      },
      "engines": {
        "node": ">=8.x"
      }
    },
    "node_modules/@ant-design/icons": {
      "version": "5.6.1",
      "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz",
      "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==",
      "license": "MIT",
      "dependencies": {
        "@ant-design/colors": "^7.0.0",
        "@ant-design/icons-svg": "^4.4.0",
        "@babel/runtime": "^7.24.8",
        "classnames": "^2.2.6",
        "rc-util": "^5.31.1"
      },
      "engines": {
        "node": ">=8"
      },
      "peerDependencies": {
        "react": ">=16.0.0",
        "react-dom": ">=16.0.0"
      }
    },
    "node_modules/@ant-design/icons-svg": {
      "version": "4.6.0",
      "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.6.0.tgz",
      "integrity": "sha512-PRomU725ABMf/lnQp5HiB7my1kjEbFY0D10N4lXYxK6TIB1gKjIVD5MRThpDaezLgw1D774J8eOeeDB0M6wHrQ==",
      "license": "MIT"
    },
    "node_modules/@ant-design/react-slick": {
      "version": "1.1.2",
      "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz",
      "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.10.4",
        "classnames": "^2.2.5",
        "json2mq": "^0.2.0",
        "resize-observer-polyfill": "^1.5.1",
        "throttle-debounce": "^5.0.0"
      },
      "peerDependencies": {
        "react": ">=16.9.0"
      }
    },
    "node_modules/@babel/code-frame": {
      "version": "7.29.7",
      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
      "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/helper-validator-identifier": "^7.29.7",
        "js-tokens": "^4.0.0",
        "picocolors": "^1.1.1"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/compat-data": {
      "version": "7.29.7",
      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
      "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/core": {
      "version": "7.29.7",
      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
      "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/code-frame": "^7.29.7",
        "@babel/generator": "^7.29.7",
        "@babel/helper-compilation-targets": "^7.29.7",
        "@babel/helper-module-transforms": "^7.29.7",
        "@babel/helpers": "^7.29.7",
        "@babel/parser": "^7.29.7",
        "@babel/template": "^7.29.7",
        "@babel/traverse": "^7.29.7",
        "@babel/types": "^7.29.7",
        "@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.29.8",
      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
      "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/parser": "^7.29.8",
        "@babel/types": "^7.29.8",
        "@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.29.7",
      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
      "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/compat-data": "^7.29.7",
        "@babel/helper-validator-option": "^7.29.7",
        "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.29.7",
      "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
      "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/helper-module-imports": {
      "version": "7.29.7",
      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
      "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/traverse": "^7.29.7",
        "@babel/types": "^7.29.7"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/helper-module-transforms": {
      "version": "7.29.7",
      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
      "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/helper-module-imports": "^7.29.7",
        "@babel/helper-validator-identifier": "^7.29.7",
        "@babel/traverse": "^7.29.7"
      },
      "engines": {
        "node": ">=6.9.0"
      },
      "peerDependencies": {
        "@babel/core": "^7.0.0"
      }
    },
    "node_modules/@babel/helper-plugin-utils": {
      "version": "7.29.7",
      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
      "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/helper-string-parser": {
      "version": "7.29.7",
      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
      "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/helper-validator-identifier": {
      "version": "7.29.7",
      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
      "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/helper-validator-option": {
      "version": "7.29.7",
      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
      "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/helpers": {
      "version": "7.29.7",
      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
      "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/template": "^7.29.7",
        "@babel/types": "^7.29.7"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/parser": {
      "version": "7.29.9",
      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz",
      "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/types": "^7.29.8"
      },
      "bin": {
        "parser": "bin/babel-parser.js"
      },
      "engines": {
        "node": ">=6.0.0"
      }
    },
    "node_modules/@babel/plugin-transform-react-jsx-self": {
      "version": "7.29.7",
      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
      "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/helper-plugin-utils": "^7.29.7"
      },
      "engines": {
        "node": ">=6.9.0"
      },
      "peerDependencies": {
        "@babel/core": "^7.0.0-0"
      }
    },
    "node_modules/@babel/plugin-transform-react-jsx-source": {
      "version": "7.29.7",
      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
      "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/helper-plugin-utils": "^7.29.7"
      },
      "engines": {
        "node": ">=6.9.0"
      },
      "peerDependencies": {
        "@babel/core": "^7.0.0-0"
      }
    },
    "node_modules/@babel/runtime": {
      "version": "7.29.7",
      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
      "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/template": {
      "version": "7.29.7",
      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
      "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/code-frame": "^7.29.7",
        "@babel/parser": "^7.29.7",
        "@babel/types": "^7.29.7"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/traverse": {
      "version": "7.29.8",
      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
      "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/code-frame": "^7.29.7",
        "@babel/generator": "^7.29.8",
        "@babel/helper-globals": "^7.29.7",
        "@babel/parser": "^7.29.8",
        "@babel/template": "^7.29.7",
        "@babel/types": "^7.29.8",
        "debug": "^4.3.1"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/types": {
      "version": "7.29.8",
      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
      "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/helper-string-parser": "^7.29.7",
        "@babel/helper-validator-identifier": "^7.29.7"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@emotion/hash": {
      "version": "0.8.0",
      "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
      "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
      "license": "MIT"
    },
    "node_modules/@emotion/unitless": {
      "version": "0.7.5",
      "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz",
      "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==",
      "license": "MIT"
    },
    "node_modules/@esbuild/aix-ppc64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
      "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
      "cpu": [
        "ppc64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "aix"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/android-arm": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
      "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/android-arm64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
      "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/android-x64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
      "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/darwin-arm64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
      "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/darwin-x64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
      "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/freebsd-arm64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
      "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "freebsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/freebsd-x64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
      "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "freebsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-arm": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
      "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-arm64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
      "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-ia32": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
      "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
      "cpu": [
        "ia32"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-loong64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
      "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
      "cpu": [
        "loong64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-mips64el": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
      "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
      "cpu": [
        "mips64el"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-ppc64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
      "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
      "cpu": [
        "ppc64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-riscv64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
      "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
      "cpu": [
        "riscv64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-s390x": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
      "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
      "cpu": [
        "s390x"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-x64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
      "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/netbsd-arm64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
      "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "netbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/netbsd-x64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
      "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "netbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/openbsd-arm64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
      "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "openbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/openbsd-x64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
      "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "openbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/openharmony-arm64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
      "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "openharmony"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/sunos-x64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
      "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "sunos"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/win32-arm64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
      "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/win32-ia32": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
      "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
      "cpu": [
        "ia32"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/win32-x64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
      "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@jridgewell/gen-mapping": {
      "version": "0.3.13",
      "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.6.0",
      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
      "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@jridgewell/trace-mapping": {
      "version": "0.3.31",
      "resolved": "https://registry.npmjs.org/@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/@napi-rs/lzma-linux-x64-gnu": {
      "version": "1.5.1",
      "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
      "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^22.20 || ^24.12 || >=25"
      }
    },
    "node_modules/@rc-component/async-validator": {
      "version": "5.1.2",
      "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.2.tgz",
      "integrity": "sha512-WYbrZSjzznU1ekD0qFq2qRxt309VoS61MTG5npnFQlKYcoy9IzU8T+ZCIhq5bGAXRbXysABFWTspicMfmWFwow==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.24.4"
      },
      "engines": {
        "node": ">=14.x"
      }
    },
    "node_modules/@rc-component/color-picker": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz",
      "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==",
      "license": "MIT",
      "dependencies": {
        "@ant-design/fast-color": "^2.0.6",
        "@babel/runtime": "^7.23.6",
        "classnames": "^2.2.6",
        "rc-util": "^5.38.1"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/@rc-component/context": {
      "version": "1.4.0",
      "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz",
      "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.10.1",
        "rc-util": "^5.27.0"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/@rc-component/mini-decimal": {
      "version": "1.1.4",
      "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.4.tgz",
      "integrity": "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.18.0"
      },
      "engines": {
        "node": ">=8.x"
      }
    },
    "node_modules/@rc-component/mutate-observer": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz",
      "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.18.0",
        "classnames": "^2.3.2",
        "rc-util": "^5.24.4"
      },
      "engines": {
        "node": ">=8.x"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/@rc-component/portal": {
      "version": "1.1.2",
      "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz",
      "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.18.0",
        "classnames": "^2.3.2",
        "rc-util": "^5.24.4"
      },
      "engines": {
        "node": ">=8.x"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/@rc-component/qrcode": {
      "version": "1.1.3",
      "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.3.tgz",
      "integrity": "sha512-aGv6alnn4HbDEsURzKP+jv13rbi1VxmAYfBNZr5GKF1iohMNWy5tAVoJ1E3cOvzMB1kbUPvCXchM6zSFlRGPhA==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.24.7"
      },
      "engines": {
        "node": ">=8.x"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/@rc-component/tour": {
      "version": "1.15.1",
      "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz",
      "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.18.0",
        "@rc-component/portal": "^1.0.0-9",
        "@rc-component/trigger": "^2.0.0",
        "classnames": "^2.3.2",
        "rc-util": "^5.24.4"
      },
      "engines": {
        "node": ">=8.x"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/@rc-component/trigger": {
      "version": "2.3.1",
      "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.1.tgz",
      "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.23.2",
        "@rc-component/portal": "^1.1.0",
        "classnames": "^2.3.2",
        "rc-motion": "^2.0.0",
        "rc-resize-observer": "^1.3.1",
        "rc-util": "^5.44.0"
      },
      "engines": {
        "node": ">=8.x"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/@remix-run/router": {
      "version": "1.23.4",
      "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
      "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
      "license": "MIT",
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/@rolldown/pluginutils": {
      "version": "1.0.0-beta.27",
      "resolved": "https://registry.npmjs.org/@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.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz",
      "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ]
    },
    "node_modules/@rollup/rollup-android-arm64": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz",
      "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ]
    },
    "node_modules/@rollup/rollup-darwin-arm64": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz",
      "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ]
    },
    "node_modules/@rollup/rollup-darwin-x64": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz",
      "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ]
    },
    "node_modules/@rollup/rollup-freebsd-arm64": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz",
      "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "freebsd"
      ]
    },
    "node_modules/@rollup/rollup-freebsd-x64": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz",
      "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "freebsd"
      ]
    },
    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz",
      "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz",
      "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-arm64-gnu": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz",
      "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-arm64-musl": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz",
      "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-loong64-gnu": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz",
      "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==",
      "cpu": [
        "loong64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-loong64-musl": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz",
      "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==",
      "cpu": [
        "loong64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz",
      "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==",
      "cpu": [
        "ppc64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-ppc64-musl": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz",
      "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==",
      "cpu": [
        "ppc64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz",
      "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==",
      "cpu": [
        "riscv64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-riscv64-musl": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz",
      "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==",
      "cpu": [
        "riscv64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-s390x-gnu": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz",
      "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==",
      "cpu": [
        "s390x"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-x64-gnu": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz",
      "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-x64-musl": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz",
      "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-openbsd-x64": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz",
      "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "openbsd"
      ]
    },
    "node_modules/@rollup/rollup-openharmony-arm64": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz",
      "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "openharmony"
      ]
    },
    "node_modules/@rollup/rollup-win32-arm64-msvc": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz",
      "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ]
    },
    "node_modules/@rollup/rollup-win32-ia32-msvc": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz",
      "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==",
      "cpu": [
        "ia32"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ]
    },
    "node_modules/@rollup/rollup-win32-x64-gnu": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz",
      "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ]
    },
    "node_modules/@rollup/rollup-win32-x64-msvc": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz",
      "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ]
    },
    "node_modules/@types/babel__core": {
      "version": "7.20.5",
      "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.9",
      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
      "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@types/prop-types": {
      "version": "15.7.15",
      "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
      "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@types/react": {
      "version": "18.3.31",
      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
      "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@types/prop-types": "*",
        "csstype": "^3.2.2"
      }
    },
    "node_modules/@types/react-dom": {
      "version": "18.3.7",
      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
      "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
      "dev": true,
      "license": "MIT",
      "peerDependencies": {
        "@types/react": "^18.0.0"
      }
    },
    "node_modules/@vitejs/plugin-react": {
      "version": "4.7.0",
      "resolved": "https://registry.npmjs.org/@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/agent-base": {
      "version": "6.0.2",
      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
      "license": "MIT",
      "dependencies": {
        "debug": "4"
      },
      "engines": {
        "node": ">= 6.0.0"
      }
    },
    "node_modules/antd": {
      "version": "5.29.3",
      "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz",
      "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==",
      "license": "MIT",
      "dependencies": {
        "@ant-design/colors": "^7.2.1",
        "@ant-design/cssinjs": "^1.23.0",
        "@ant-design/cssinjs-utils": "^1.1.3",
        "@ant-design/fast-color": "^2.0.6",
        "@ant-design/icons": "^5.6.1",
        "@ant-design/react-slick": "~1.1.2",
        "@babel/runtime": "^7.26.0",
        "@rc-component/color-picker": "~2.0.1",
        "@rc-component/mutate-observer": "^1.1.0",
        "@rc-component/qrcode": "~1.1.0",
        "@rc-component/tour": "~1.15.1",
        "@rc-component/trigger": "^2.3.0",
        "classnames": "^2.5.1",
        "copy-to-clipboard": "^3.3.3",
        "dayjs": "^1.11.11",
        "rc-cascader": "~3.34.0",
        "rc-checkbox": "~3.5.0",
        "rc-collapse": "~3.9.0",
        "rc-dialog": "~9.6.0",
        "rc-drawer": "~7.3.0",
        "rc-dropdown": "~4.2.1",
        "rc-field-form": "~2.7.1",
        "rc-image": "~7.12.0",
        "rc-input": "~1.8.0",
        "rc-input-number": "~9.5.0",
        "rc-mentions": "~2.20.0",
        "rc-menu": "~9.16.1",
        "rc-motion": "^2.9.5",
        "rc-notification": "~5.6.4",
        "rc-pagination": "~5.1.0",
        "rc-picker": "~4.11.3",
        "rc-progress": "~4.0.0",
        "rc-rate": "~2.13.1",
        "rc-resize-observer": "^1.4.3",
        "rc-segmented": "~2.7.0",
        "rc-select": "~14.16.8",
        "rc-slider": "~11.1.9",
        "rc-steps": "~6.0.1",
        "rc-switch": "~4.1.0",
        "rc-table": "~7.54.0",
        "rc-tabs": "~15.7.0",
        "rc-textarea": "~1.10.2",
        "rc-tooltip": "~6.4.0",
        "rc-tree": "~5.13.1",
        "rc-tree-select": "~5.27.0",
        "rc-upload": "~4.11.0",
        "rc-util": "^5.44.4",
        "scroll-into-view-if-needed": "^3.1.0",
        "throttle-debounce": "^5.0.2"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/ant-design"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/asynckit": {
      "version": "0.4.0",
      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
      "license": "MIT"
    },
    "node_modules/axios": {
      "version": "1.20.0",
      "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
      "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
      "license": "MIT",
      "dependencies": {
        "follow-redirects": "^1.16.0",
        "form-data": "^4.0.6",
        "https-proxy-agent": "^5.0.1",
        "proxy-from-env": "^2.1.0"
      }
    },
    "node_modules/baseline-browser-mapping": {
      "version": "2.11.25",
      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz",
      "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==",
      "dev": true,
      "license": "Apache-2.0",
      "bin": {
        "baseline-browser-mapping": "dist/cli.cjs"
      },
      "engines": {
        "node": ">=6.0.0"
      }
    },
    "node_modules/browserslist": {
      "version": "4.29.0",
      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz",
      "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==",
      "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": {
        "baseline-browser-mapping": "^2.11.23",
        "caniuse-lite": "^1.0.30001810",
        "electron-to-chromium": "^1.5.427",
        "node-releases": "^2.0.55",
        "update-browserslist-db": "^1.3.3"
      },
      "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.npmjs.org/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/caniuse-lite": {
      "version": "1.0.30001810",
      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
      "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
      "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/classnames": {
      "version": "2.5.1",
      "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
      "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
      "license": "MIT"
    },
    "node_modules/combined-stream": {
      "version": "1.0.8",
      "resolved": "https://registry.npmjs.org/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/compute-scroll-into-view": {
      "version": "3.1.1",
      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
      "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==",
      "license": "MIT"
    },
    "node_modules/convert-source-map": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/copy-to-clipboard": {
      "version": "3.3.3",
      "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
      "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==",
      "license": "MIT",
      "dependencies": {
        "toggle-selection": "^1.0.6"
      }
    },
    "node_modules/csstype": {
      "version": "3.2.3",
      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
      "license": "MIT"
    },
    "node_modules/dayjs": {
      "version": "1.11.23",
      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
      "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
      "license": "MIT"
    },
    "node_modules/debug": {
      "version": "4.4.3",
      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
      "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.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
      "license": "MIT",
      "engines": {
        "node": ">=0.4.0"
      }
    },
    "node_modules/dunder-proto": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/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.436",
      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.436.tgz",
      "integrity": "sha512-iG79/xCF3iThADicbgbES2+liFYmTvelo7jWcEfg6lQ+Adrh7zShrXSg2ZdEDLbKjJ298kGAKMnnzVWobp0L+g==",
      "dev": true,
      "license": "ISC"
    },
    "node_modules/es-define-property": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/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.npmjs.org/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.2",
      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-set-tostringtag": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/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.25.12",
      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
      "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
      "dev": true,
      "hasInstallScript": true,
      "license": "MIT",
      "bin": {
        "esbuild": "bin/esbuild"
      },
      "engines": {
        "node": ">=18"
      },
      "optionalDependencies": {
        "@esbuild/aix-ppc64": "0.25.12",
        "@esbuild/android-arm": "0.25.12",
        "@esbuild/android-arm64": "0.25.12",
        "@esbuild/android-x64": "0.25.12",
        "@esbuild/darwin-arm64": "0.25.12",
        "@esbuild/darwin-x64": "0.25.12",
        "@esbuild/freebsd-arm64": "0.25.12",
        "@esbuild/freebsd-x64": "0.25.12",
        "@esbuild/linux-arm": "0.25.12",
        "@esbuild/linux-arm64": "0.25.12",
        "@esbuild/linux-ia32": "0.25.12",
        "@esbuild/linux-loong64": "0.25.12",
        "@esbuild/linux-mips64el": "0.25.12",
        "@esbuild/linux-ppc64": "0.25.12",
        "@esbuild/linux-riscv64": "0.25.12",
        "@esbuild/linux-s390x": "0.25.12",
        "@esbuild/linux-x64": "0.25.12",
        "@esbuild/netbsd-arm64": "0.25.12",
        "@esbuild/netbsd-x64": "0.25.12",
        "@esbuild/openbsd-arm64": "0.25.12",
        "@esbuild/openbsd-x64": "0.25.12",
        "@esbuild/openharmony-arm64": "0.25.12",
        "@esbuild/sunos-x64": "0.25.12",
        "@esbuild/win32-arm64": "0.25.12",
        "@esbuild/win32-ia32": "0.25.12",
        "@esbuild/win32-x64": "0.25.12"
      }
    },
    "node_modules/escalade": {
      "version": "3.2.0",
      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/fdir": {
      "version": "6.5.0",
      "resolved": "https://registry.npmjs.org/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/follow-redirects": {
      "version": "1.16.0",
      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
      "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
      "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.6",
      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
      "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
      "license": "MIT",
      "dependencies": {
        "asynckit": "^0.4.0",
        "combined-stream": "^1.0.8",
        "es-set-tostringtag": "^2.1.0",
        "hasown": "^2.0.4",
        "mime-types": "^2.1.35"
      },
      "engines": {
        "node": ">= 6"
      }
    },
    "node_modules/fsevents": {
      "version": "2.3.3",
      "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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/gopd": {
      "version": "1.2.0",
      "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.4",
      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
      "license": "MIT",
      "dependencies": {
        "function-bind": "^1.1.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/https-proxy-agent": {
      "version": "5.0.1",
      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
      "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
      "license": "MIT",
      "dependencies": {
        "agent-base": "6",
        "debug": "4"
      },
      "engines": {
        "node": ">= 6"
      }
    },
    "node_modules/js-tokens": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
      "license": "MIT"
    },
    "node_modules/jsesc": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
      "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
      "dev": true,
      "license": "MIT",
      "bin": {
        "jsesc": "bin/jsesc"
      },
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/json2mq": {
      "version": "0.2.0",
      "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz",
      "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==",
      "license": "MIT",
      "dependencies": {
        "string-convert": "^0.2.0"
      }
    },
    "node_modules/json5": {
      "version": "2.2.3",
      "resolved": "https://registry.npmjs.org/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/loose-envify": {
      "version": "1.4.0",
      "resolved": "https://registry.npmjs.org/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.npmjs.org/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/math-intrinsics": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/mime-db": {
      "version": "1.52.0",
      "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/ms/-/ms-2.1.3.tgz",
      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
      "license": "MIT"
    },
    "node_modules/nanoid": {
      "version": "3.3.19",
      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
      "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
      "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.56",
      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz",
      "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/picocolors": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
      "dev": true,
      "license": "ISC"
    },
    "node_modules/picomatch": {
      "version": "4.0.7",
      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
      "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://github.com/sponsors/jonschlinkert"
      }
    },
    "node_modules/postcss": {
      "version": "8.5.28",
      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
      "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
      "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",
      "dependencies": {
        "nanoid": "^3.3.18",
        "picocolors": "^1.1.1",
        "source-map-js": "^1.2.1"
      },
      "engines": {
        "node": "^10 || ^12 || >=14"
      }
    },
    "node_modules/proxy-from-env": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
      "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
      "license": "MIT",
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/rc-cascader": {
      "version": "3.34.0",
      "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz",
      "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.25.7",
        "classnames": "^2.3.1",
        "rc-select": "~14.16.2",
        "rc-tree": "~5.13.0",
        "rc-util": "^5.43.0"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-checkbox": {
      "version": "3.5.0",
      "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz",
      "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.10.1",
        "classnames": "^2.3.2",
        "rc-util": "^5.25.2"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-collapse": {
      "version": "3.9.0",
      "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz",
      "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.10.1",
        "classnames": "2.x",
        "rc-motion": "^2.3.4",
        "rc-util": "^5.27.0"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-dialog": {
      "version": "9.6.0",
      "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz",
      "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.10.1",
        "@rc-component/portal": "^1.0.0-8",
        "classnames": "^2.2.6",
        "rc-motion": "^2.3.0",
        "rc-util": "^5.21.0"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-drawer": {
      "version": "7.3.0",
      "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz",
      "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.23.9",
        "@rc-component/portal": "^1.1.1",
        "classnames": "^2.2.6",
        "rc-motion": "^2.6.1",
        "rc-util": "^5.38.1"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-dropdown": {
      "version": "4.2.1",
      "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz",
      "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.18.3",
        "@rc-component/trigger": "^2.0.0",
        "classnames": "^2.2.6",
        "rc-util": "^5.44.1"
      },
      "peerDependencies": {
        "react": ">=16.11.0",
        "react-dom": ">=16.11.0"
      }
    },
    "node_modules/rc-field-form": {
      "version": "2.7.1",
      "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz",
      "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.18.0",
        "@rc-component/async-validator": "^5.0.3",
        "rc-util": "^5.32.2"
      },
      "engines": {
        "node": ">=8.x"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-image": {
      "version": "7.12.0",
      "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz",
      "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.11.2",
        "@rc-component/portal": "^1.0.2",
        "classnames": "^2.2.6",
        "rc-dialog": "~9.6.0",
        "rc-motion": "^2.6.2",
        "rc-util": "^5.34.1"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-input": {
      "version": "1.8.0",
      "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz",
      "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.11.1",
        "classnames": "^2.2.1",
        "rc-util": "^5.18.1"
      },
      "peerDependencies": {
        "react": ">=16.0.0",
        "react-dom": ">=16.0.0"
      }
    },
    "node_modules/rc-input-number": {
      "version": "9.5.0",
      "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz",
      "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.10.1",
        "@rc-component/mini-decimal": "^1.0.1",
        "classnames": "^2.2.5",
        "rc-input": "~1.8.0",
        "rc-util": "^5.40.1"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-mentions": {
      "version": "2.20.0",
      "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz",
      "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.22.5",
        "@rc-component/trigger": "^2.0.0",
        "classnames": "^2.2.6",
        "rc-input": "~1.8.0",
        "rc-menu": "~9.16.0",
        "rc-textarea": "~1.10.0",
        "rc-util": "^5.34.1"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-menu": {
      "version": "9.16.1",
      "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz",
      "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.10.1",
        "@rc-component/trigger": "^2.0.0",
        "classnames": "2.x",
        "rc-motion": "^2.4.3",
        "rc-overflow": "^1.3.1",
        "rc-util": "^5.27.0"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-motion": {
      "version": "2.9.5",
      "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz",
      "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.11.1",
        "classnames": "^2.2.1",
        "rc-util": "^5.44.0"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-notification": {
      "version": "5.6.4",
      "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz",
      "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.10.1",
        "classnames": "2.x",
        "rc-motion": "^2.9.0",
        "rc-util": "^5.20.1"
      },
      "engines": {
        "node": ">=8.x"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-overflow": {
      "version": "1.5.0",
      "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz",
      "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.11.1",
        "classnames": "^2.2.1",
        "rc-resize-observer": "^1.0.0",
        "rc-util": "^5.37.0"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-pagination": {
      "version": "5.1.0",
      "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz",
      "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.10.1",
        "classnames": "^2.3.2",
        "rc-util": "^5.38.0"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-picker": {
      "version": "4.11.3",
      "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz",
      "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.24.7",
        "@rc-component/trigger": "^2.0.0",
        "classnames": "^2.2.1",
        "rc-overflow": "^1.3.2",
        "rc-resize-observer": "^1.4.0",
        "rc-util": "^5.43.0"
      },
      "engines": {
        "node": ">=8.x"
      },
      "peerDependencies": {
        "date-fns": ">= 2.x",
        "dayjs": ">= 1.x",
        "luxon": ">= 3.x",
        "moment": ">= 2.x",
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      },
      "peerDependenciesMeta": {
        "date-fns": {
          "optional": true
        },
        "dayjs": {
          "optional": true
        },
        "luxon": {
          "optional": true
        },
        "moment": {
          "optional": true
        }
      }
    },
    "node_modules/rc-progress": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz",
      "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.10.1",
        "classnames": "^2.2.6",
        "rc-util": "^5.16.1"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-rate": {
      "version": "2.13.1",
      "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz",
      "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.10.1",
        "classnames": "^2.2.5",
        "rc-util": "^5.0.1"
      },
      "engines": {
        "node": ">=8.x"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-resize-observer": {
      "version": "1.4.3",
      "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz",
      "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.20.7",
        "classnames": "^2.2.1",
        "rc-util": "^5.44.1",
        "resize-observer-polyfill": "^1.5.1"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-segmented": {
      "version": "2.7.1",
      "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.1.tgz",
      "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.11.1",
        "classnames": "^2.2.1",
        "rc-motion": "^2.4.4",
        "rc-util": "^5.17.0"
      },
      "peerDependencies": {
        "react": ">=16.0.0",
        "react-dom": ">=16.0.0"
      }
    },
    "node_modules/rc-select": {
      "version": "14.16.8",
      "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz",
      "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.10.1",
        "@rc-component/trigger": "^2.1.1",
        "classnames": "2.x",
        "rc-motion": "^2.0.1",
        "rc-overflow": "^1.3.1",
        "rc-util": "^5.16.1",
        "rc-virtual-list": "^3.5.2"
      },
      "engines": {
        "node": ">=8.x"
      },
      "peerDependencies": {
        "react": "*",
        "react-dom": "*"
      }
    },
    "node_modules/rc-slider": {
      "version": "11.1.9",
      "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz",
      "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.10.1",
        "classnames": "^2.2.5",
        "rc-util": "^5.36.0"
      },
      "engines": {
        "node": ">=8.x"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-steps": {
      "version": "6.0.1",
      "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz",
      "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.16.7",
        "classnames": "^2.2.3",
        "rc-util": "^5.16.1"
      },
      "engines": {
        "node": ">=8.x"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-switch": {
      "version": "4.1.0",
      "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz",
      "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.21.0",
        "classnames": "^2.2.1",
        "rc-util": "^5.30.0"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-table": {
      "version": "7.54.0",
      "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz",
      "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.10.1",
        "@rc-component/context": "^1.4.0",
        "classnames": "^2.2.5",
        "rc-resize-observer": "^1.1.0",
        "rc-util": "^5.44.3",
        "rc-virtual-list": "^3.14.2"
      },
      "engines": {
        "node": ">=8.x"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-tabs": {
      "version": "15.7.0",
      "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz",
      "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.11.2",
        "classnames": "2.x",
        "rc-dropdown": "~4.2.0",
        "rc-menu": "~9.16.0",
        "rc-motion": "^2.6.2",
        "rc-resize-observer": "^1.0.0",
        "rc-util": "^5.34.1"
      },
      "engines": {
        "node": ">=8.x"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-textarea": {
      "version": "1.10.2",
      "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz",
      "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.10.1",
        "classnames": "^2.2.1",
        "rc-input": "~1.8.0",
        "rc-resize-observer": "^1.0.0",
        "rc-util": "^5.27.0"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-tooltip": {
      "version": "6.4.0",
      "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz",
      "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.11.2",
        "@rc-component/trigger": "^2.0.0",
        "classnames": "^2.3.1",
        "rc-util": "^5.44.3"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-tree": {
      "version": "5.13.1",
      "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz",
      "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.10.1",
        "classnames": "2.x",
        "rc-motion": "^2.0.1",
        "rc-util": "^5.16.1",
        "rc-virtual-list": "^3.5.1"
      },
      "engines": {
        "node": ">=10.x"
      },
      "peerDependencies": {
        "react": "*",
        "react-dom": "*"
      }
    },
    "node_modules/rc-tree-select": {
      "version": "5.27.0",
      "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz",
      "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.25.7",
        "classnames": "2.x",
        "rc-select": "~14.16.2",
        "rc-tree": "~5.13.0",
        "rc-util": "^5.43.0"
      },
      "peerDependencies": {
        "react": "*",
        "react-dom": "*"
      }
    },
    "node_modules/rc-upload": {
      "version": "4.11.0",
      "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz",
      "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.18.3",
        "classnames": "^2.2.5",
        "rc-util": "^5.2.0"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-util": {
      "version": "5.44.4",
      "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz",
      "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.18.3",
        "react-is": "^18.2.0"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/rc-virtual-list": {
      "version": "3.19.2",
      "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz",
      "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.20.0",
        "classnames": "^2.2.6",
        "rc-resize-observer": "^1.0.0",
        "rc-util": "^5.36.0"
      },
      "engines": {
        "node": ">=8.x"
      },
      "peerDependencies": {
        "react": ">=16.9.0",
        "react-dom": ">=16.9.0"
      }
    },
    "node_modules/react": {
      "version": "18.3.1",
      "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
      "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
      "license": "MIT",
      "dependencies": {
        "loose-envify": "^1.1.0"
      },
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/react-dom": {
      "version": "18.3.1",
      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
      "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
      "license": "MIT",
      "dependencies": {
        "loose-envify": "^1.1.0",
        "scheduler": "^0.23.2"
      },
      "peerDependencies": {
        "react": "^18.3.1"
      }
    },
    "node_modules/react-is": {
      "version": "18.3.1",
      "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
      "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
      "license": "MIT"
    },
    "node_modules/react-refresh": {
      "version": "0.17.0",
      "resolved": "https://registry.npmjs.org/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.6",
      "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
      "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
      "license": "MIT",
      "dependencies": {
        "@remix-run/router": "1.23.4"
      },
      "engines": {
        "node": ">=14.0.0"
      },
      "peerDependencies": {
        "react": ">=16.8"
      }
    },
    "node_modules/react-router-dom": {
      "version": "6.30.6",
      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
      "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
      "license": "MIT",
      "dependencies": {
        "@remix-run/router": "1.23.4",
        "react-router": "6.30.6"
      },
      "engines": {
        "node": ">=14.0.0"
      },
      "peerDependencies": {
        "react": ">=16.8",
        "react-dom": ">=16.8"
      }
    },
    "node_modules/resize-observer-polyfill": {
      "version": "1.5.1",
      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
      "license": "MIT"
    },
    "node_modules/rollup": {
      "version": "4.63.4",
      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
      "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@types/estree": "1.0.9"
      },
      "bin": {
        "rollup": "dist/bin/rollup"
      },
      "engines": {
        "node": ">=18.0.0",
        "npm": ">=8.0.0"
      },
      "optionalDependencies": {
        "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
        "@rollup/rollup-android-arm-eabi": "4.63.4",
        "@rollup/rollup-android-arm64": "4.63.4",
        "@rollup/rollup-darwin-arm64": "4.63.4",
        "@rollup/rollup-darwin-x64": "4.63.4",
        "@rollup/rollup-freebsd-arm64": "4.63.4",
        "@rollup/rollup-freebsd-x64": "4.63.4",
        "@rollup/rollup-linux-arm-gnueabihf": "4.63.4",
        "@rollup/rollup-linux-arm-musleabihf": "4.63.4",
        "@rollup/rollup-linux-arm64-gnu": "4.63.4",
        "@rollup/rollup-linux-arm64-musl": "4.63.4",
        "@rollup/rollup-linux-loong64-gnu": "4.63.4",
        "@rollup/rollup-linux-loong64-musl": "4.63.4",
        "@rollup/rollup-linux-ppc64-gnu": "4.63.4",
        "@rollup/rollup-linux-ppc64-musl": "4.63.4",
        "@rollup/rollup-linux-riscv64-gnu": "4.63.4",
        "@rollup/rollup-linux-riscv64-musl": "4.63.4",
        "@rollup/rollup-linux-s390x-gnu": "4.63.4",
        "@rollup/rollup-linux-x64-gnu": "4.63.4",
        "@rollup/rollup-linux-x64-musl": "4.63.4",
        "@rollup/rollup-openbsd-x64": "4.63.4",
        "@rollup/rollup-openharmony-arm64": "4.63.4",
        "@rollup/rollup-win32-arm64-msvc": "4.63.4",
        "@rollup/rollup-win32-ia32-msvc": "4.63.4",
        "@rollup/rollup-win32-x64-gnu": "4.63.4",
        "@rollup/rollup-win32-x64-msvc": "4.63.4",
        "fsevents": "~2.3.2"
      }
    },
    "node_modules/scheduler": {
      "version": "0.23.2",
      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
      "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
      "license": "MIT",
      "dependencies": {
        "loose-envify": "^1.1.0"
      }
    },
    "node_modules/scroll-into-view-if-needed": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
      "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==",
      "license": "MIT",
      "dependencies": {
        "compute-scroll-into-view": "^3.0.2"
      }
    },
    "node_modules/semver": {
      "version": "6.3.1",
      "resolved": "https://registry.npmjs.org/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.npmjs.org/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/string-convert": {
      "version": "0.2.1",
      "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
      "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==",
      "license": "MIT"
    },
    "node_modules/stylis": {
      "version": "4.4.0",
      "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
      "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
      "license": "MIT"
    },
    "node_modules/throttle-debounce": {
      "version": "5.0.2",
      "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
      "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==",
      "license": "MIT",
      "engines": {
        "node": ">=12.22"
      }
    },
    "node_modules/tinyglobby": {
      "version": "0.2.17",
      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "fdir": "^6.5.0",
        "picomatch": "^4.0.4"
      },
      "engines": {
        "node": ">=12.0.0"
      },
      "funding": {
        "url": "https://github.com/sponsors/SuperchupuDev"
      }
    },
    "node_modules/toggle-selection": {
      "version": "1.0.6",
      "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
      "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
      "license": "MIT"
    },
    "node_modules/typescript": {
      "version": "5.9.3",
      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
      "dev": true,
      "license": "Apache-2.0",
      "bin": {
        "tsc": "bin/tsc",
        "tsserver": "bin/tsserver"
      },
      "engines": {
        "node": ">=14.17"
      }
    },
    "node_modules/update-browserslist-db": {
      "version": "1.3.3",
      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
      "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
      "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/vite": {
      "version": "6.4.3",
      "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
      "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "esbuild": "^0.25.0",
        "fdir": "^6.4.4",
        "picomatch": "^4.0.2",
        "postcss": "^8.5.3",
        "rollup": "^4.34.9",
        "tinyglobby": "^0.2.13"
      },
      "bin": {
        "vite": "bin/vite.js"
      },
      "engines": {
        "node": "^18.0.0 || ^20.0.0 || >=22.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 || >=22.0.0",
        "jiti": ">=1.21.0",
        "less": "*",
        "lightningcss": "^1.21.0",
        "sass": "*",
        "sass-embedded": "*",
        "stylus": "*",
        "sugarss": "*",
        "terser": "^5.16.0",
        "tsx": "^4.8.1",
        "yaml": "^2.4.2"
      },
      "peerDependenciesMeta": {
        "@types/node": {
          "optional": true
        },
        "jiti": {
          "optional": true
        },
        "less": {
          "optional": true
        },
        "lightningcss": {
          "optional": true
        },
        "sass": {
          "optional": true
        },
        "sass-embedded": {
          "optional": true
        },
        "stylus": {
          "optional": true
        },
        "sugarss": {
          "optional": true
        },
        "terser": {
          "optional": true
        },
        "tsx": {
          "optional": true
        },
        "yaml": {
          "optional": true
        }
      }
    },
    "node_modules/yallist": {
      "version": "3.1.1",
      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
      "dev": true,
      "license": "ISC"
    }
  }
}
frontend/package.json
New file
@@ -0,0 +1,28 @@
{
  "name": "score-query-frontend",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite --host 0.0.0.0",
    "build": "vite build",
    "preview": "vite preview --host 0.0.0.0",
    "test:e2e": "playwright test"
  },
  "dependencies": {
    "@ant-design/icons": "^5.5.2",
    "antd": "^5.22.5",
    "axios": "^1.7.9",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-router-dom": "^6.28.0"
  },
  "devDependencies": {
    "@playwright/test": "^1.50.1",
    "@types/react": "^18.3.12",
    "@types/react-dom": "^18.3.1",
    "@vitejs/plugin-react": "^4.3.4",
    "typescript": "^5.7.2",
    "vite": "^6.0.3"
  }
}
frontend/playwright.config.ts
New file
@@ -0,0 +1,15 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
  testDir: "./e2e",
  timeout: 60_000,
  expect: { timeout: 15_000 },
  retries: 1,
  use: {
    baseURL: process.env.E2E_BASE_URL || "https://grader.gps120.com",
    headless: true,
    trace: "on-first-retry",
    screenshot: "only-on-failure",
  },
  reporter: [["list"]],
});
frontend/public/login-bg.webp
Binary files differ
frontend/src/AdminRoute.tsx
New file
@@ -0,0 +1,16 @@
import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import type { UserMe } from "./App";
export default function AdminRoute({
  user,
  children,
}: {
  user: UserMe | null;
  children: ReactNode;
}) {
  if (!user || user.role !== "admin") {
    return <Navigate to="/scores" replace />;
  }
  return <>{children}</>;
}
frontend/src/App.tsx
New file
@@ -0,0 +1,69 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { useEffect, useState } from "react";
import { Spin } from "antd";
import { api, getToken } from "./api";
import LoginPage from "./pages/LoginPage";
import MainLayout from "./layout/MainLayout";
import ScoreQueryPage from "./pages/ScoreQueryPage";
import AttemptReviewPage from "./pages/AttemptReviewPage";
import AdminImportPage from "./pages/AdminImportPage";
import AdminGradersPage from "./pages/AdminGradersPage";
import AdminSystemSettingsPage from "./pages/AdminSystemSettingsPage";
import AdminRoute from "./AdminRoute";
export type UserMe = { id: number; username: string; display_name: string; role: string };
function App() {
  const [user, setUser] = useState<UserMe | null>(null);
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    if (!getToken()) {
      setLoading(false);
      return;
    }
    api
      .get<UserMe>("/api/auth/me")
      .then((r) => setUser(r.data))
      .catch(() => setUser(null))
      .finally(() => setLoading(false));
  }, []);
  if (loading) {
    return (
      <div style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center" }}>
        <Spin size="large" />
      </div>
    );
  }
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/login" element={<LoginPage onLogin={setUser} />} />
        <Route
          path="/"
          element={user ? <MainLayout user={user} onLogout={() => setUser(null)} /> : <Navigate to="/login" />}
        >
          <Route index element={<Navigate to="/scores" replace />} />
          <Route path="scores" element={<ScoreQueryPage />} />
          <Route path="attempts/:attemptId" element={<AttemptReviewPage />} />
          <Route
            path="admin/import"
            element={<AdminRoute user={user}><AdminImportPage /></AdminRoute>}
          />
          <Route
            path="admin/graders"
            element={<AdminRoute user={user}><AdminGradersPage /></AdminRoute>}
          />
          <Route
            path="admin/settings"
            element={<AdminRoute user={user}><AdminSystemSettingsPage /></AdminRoute>}
          />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}
export default App;
frontend/src/api.ts
New file
@@ -0,0 +1,23 @@
import axios from "axios";
const TOKEN_KEY = "score_query_token";
export const api = axios.create({ baseURL: "" });
api.interceptors.request.use((config) => {
  const t = localStorage.getItem(TOKEN_KEY);
  if (t) config.headers.Authorization = `Bearer ${t}`;
  return config;
});
export function setToken(token: string) {
  localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
  localStorage.removeItem(TOKEN_KEY);
}
export function getToken() {
  return localStorage.getItem(TOKEN_KEY);
}
frontend/src/components/ChangePasswordModal.tsx
New file
@@ -0,0 +1,69 @@
import { Form, Input, Modal, message } from "antd";
import { api } from "../api";
export default function ChangePasswordModal({
  open,
  onClose,
}: {
  open: boolean;
  onClose: () => void;
}) {
  const [form] = Form.useForm<{
    old_password: string;
    new_password: string;
    confirm: string;
  }>();
  return (
    <Modal
      title="修改密码"
      open={open}
      destroyOnClose
      onCancel={() => {
        form.resetFields();
        onClose();
      }}
      onOk={async () => {
        const v = await form.validateFields();
        try {
          await api.post("/api/auth/change-password", {
            old_password: v.old_password,
            new_password: v.new_password,
          });
          message.success("密码已修改");
          form.resetFields();
          onClose();
        } catch (e: unknown) {
          const msg =
            (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || "修改失败";
          message.error(typeof msg === "string" ? msg : "修改失败");
        }
      }}
    >
      <Form form={form} layout="vertical">
        <Form.Item name="old_password" label="原密码" rules={[{ required: true }]}>
          <Input.Password autoComplete="current-password" />
        </Form.Item>
        <Form.Item name="new_password" label="新密码" rules={[{ required: true, min: 6 }]}>
          <Input.Password autoComplete="new-password" />
        </Form.Item>
        <Form.Item
          name="confirm"
          label="确认新密码"
          dependencies={["new_password"]}
          rules={[
            { required: true },
            ({ getFieldValue }) => ({
              validator(_, value) {
                if (!value || getFieldValue("new_password") === value) return Promise.resolve();
                return Promise.reject(new Error("两次输入的新密码不一致"));
              },
            }),
          ]}
        >
          <Input.Password autoComplete="new-password" />
        </Form.Item>
      </Form>
    </Modal>
  );
}
frontend/src/components/SystemBrand.tsx
New file
@@ -0,0 +1,32 @@
import { logoSrc, useSystemSettings } from "../systemSettings";
export default function SystemBrand({ size = "md" }: { size?: "sm" | "md" | "lg" }) {
  const { settings, logoCacheKey } = useSystemSettings();
  const logo = logoSrc(settings.logo_url, logoCacheKey);
  const imgH = size === "lg" ? 48 : size === "sm" ? 28 : 32;
  const fontSize = size === "lg" ? 22 : size === "sm" ? 15 : 16;
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
      {logo && (
        <img
          src={logo}
          alt=""
          style={{ height: imgH, maxWidth: 160, objectFit: "contain", flexShrink: 0 }}
        />
      )}
      <span
        style={{
          color: "#1677ff",
          fontWeight: 600,
          fontSize,
          overflow: "hidden",
          textOverflow: "ellipsis",
          whiteSpace: "nowrap",
        }}
      >
        {settings.system_name}
      </span>
    </div>
  );
}
frontend/src/index.css
New file
@@ -0,0 +1,6 @@
html,
body,
#root {
  height: 100%;
  margin: 0;
}
frontend/src/layout/AppBreadcrumb.tsx
New file
@@ -0,0 +1,72 @@
import { Breadcrumb, Button } from "antd";
import { ArrowLeftOutlined, HomeOutlined } from "@ant-design/icons";
import { Link, matchPath, useLocation, useNavigate } from "react-router-dom";
import type { ReactNode } from "react";
import { readScoreListQuery, scoresListPath } from "../scoreQueryParams";
type Crumb = { title: ReactNode; key: string };
function buildCrumbs(pathname: string, search: string): Crumb[] {
  const home: Crumb = {
    key: "home",
    title: (
      <Link to="/scores">
        <HomeOutlined style={{ marginRight: 4 }} />
        首页
      </Link>
    ),
  };
  if (matchPath("/scores", pathname)) {
    return [home, { key: "scores", title: "成绩查询" }];
  }
  if (matchPath("/attempts/:attemptId", pathname)) {
    const scoresTo = scoresListPath(readScoreListQuery(search));
    return [
      home,
      { key: "scores", title: <Link to={scoresTo}>成绩查询</Link> },
      { key: "attempt", title: "答卷详情" },
    ];
  }
  if (matchPath("/admin/import", pathname)) {
    return [home, { key: "admin-import", title: "数据导入" }];
  }
  if (matchPath("/admin/graders", pathname)) {
    return [home, { key: "admin-graders", title: "考评员管理" }];
  }
  if (matchPath("/admin/settings", pathname)) {
    return [home, { key: "admin-settings", title: "系统设置" }];
  }
  return [home];
}
export default function AppBreadcrumb() {
  const { pathname, search } = useLocation();
  const navigate = useNavigate();
  const crumbs = buildCrumbs(pathname, search);
  const onAttempt = Boolean(matchPath("/attempts/:attemptId", pathname));
  return (
    <div className="app-breadcrumb-row">
      {onAttempt && (
        <Button
          type="link"
          className="app-breadcrumb__back"
          icon={<ArrowLeftOutlined />}
          onClick={() => navigate(scoresListPath(readScoreListQuery(search)))}
        >
          返回
        </Button>
      )}
      <Breadcrumb
        className="app-breadcrumb"
        items={crumbs.map((c) => ({ key: c.key, title: c.title }))}
      />
    </div>
  );
}
frontend/src/layout/MainLayout.css
New file
@@ -0,0 +1,108 @@
.app-header {
  display: flex;
  align-items: center;
  padding: 0 16px;
  flex-shrink: 0;
  background: #fff !important;
  border-bottom: 1px solid #e8e8e8;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
  z-index: 10;
}
.app-sider.ant-layout-sider-light {
  background: #eef3f9 !important;
  border-right: 1px solid #dde5ef;
}
.app-sider .ant-layout-sider-children {
  display: flex;
  flex-direction: column;
}
.app-sider-menu.ant-menu-light {
  flex: 1;
  background: transparent !important;
  border-inline-end: none !important;
  padding: 8px 6px;
}
.app-sider-menu.ant-menu-light .ant-menu-item {
  margin: 4px 0;
  border-radius: 6px;
  width: 100%;
}
.app-sider-menu.ant-menu-light .ant-menu-item-selected {
  background: #fff !important;
  font-weight: 600;
  box-shadow: 0 1px 3px rgba(22, 119, 255, 0.12);
}
.app-shell-body.ant-layout {
  display: flex;
  flex: 1;
  min-height: 0;
}
.main-layout__content.ant-layout-content {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
.main-layout__content--attempt,
.main-layout__content--scores {
  overflow: hidden !important;
}
.main-layout__content--attempt .main-layout__content-inner,
.main-layout__content--scores .main-layout__content-inner {
  flex: 1 1 0;
  min-height: 0;
  overflow: hidden;
}
.main-layout__content {
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.main-layout__content-inner {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
.app-breadcrumb-row {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  gap: 4px;
  margin-bottom: 12px;
  padding: 6px 14px;
  background: #fff;
  border-radius: 8px;
  border: 1px solid #f0f0f0;
}
.app-breadcrumb__back {
  flex-shrink: 0;
  padding-left: 0;
  padding-right: 8px;
  border-right: 1px solid #f0f0f0;
  margin-right: 4px;
  height: auto;
}
.app-breadcrumb {
  flex: 1;
  min-width: 0;
  margin-bottom: 0;
  padding: 4px 0;
  background: transparent;
  border: none;
  border-radius: 0;
}
frontend/src/layout/MainLayout.tsx
New file
@@ -0,0 +1,106 @@
import { Layout, Menu, Button, Grid, Drawer } from "antd";
import { Outlet, useNavigate, useLocation } from "react-router-dom";
import { useMemo, useState } from "react";
import { MenuOutlined } from "@ant-design/icons";
import type { UserMe } from "../App";
import { clearToken } from "../api";
import SystemBrand from "../components/SystemBrand";
import ChangePasswordModal from "../components/ChangePasswordModal";
import AppBreadcrumb from "./AppBreadcrumb";
import "./MainLayout.css";
const { Header, Sider, Content } = Layout;
const { useBreakpoint } = Grid;
export default function MainLayout({
  user,
  onLogout,
}: {
  user: UserMe;
  onLogout: () => void;
}) {
  const nav = useNavigate();
  const loc = useLocation();
  const screens = useBreakpoint();
  const [drawerOpen, setDrawerOpen] = useState(false);
  const [pwdOpen, setPwdOpen] = useState(false);
  const items = useMemo(() => {
    const m = [{ key: "/scores", label: "成绩查询" }];
    if (user.role === "admin") {
      m.push({ key: "/admin/import", label: "数据导入" });
      m.push({ key: "/admin/graders", label: "考评员管理" });
      m.push({ key: "/admin/settings", label: "系统设置" });
    }
    return m;
  }, [user.role]);
  const menu = (
    <Menu
      className="app-sider-menu"
      mode="inline"
      selectedKeys={[loc.pathname.startsWith("/attempts") ? "/scores" : loc.pathname]}
      items={items}
      onClick={({ key }) => {
        nav(key);
        setDrawerOpen(false);
      }}
    />
  );
  const isMobile = !screens.md;
  return (
    <Layout style={{ minHeight: "100vh", display: "flex", flexDirection: "column" }}>
      <Header className="app-header">
        {isMobile && (
          <Button type="text" icon={<MenuOutlined />} onClick={() => setDrawerOpen(true)} style={{ marginRight: 8 }} />
        )}
        <div style={{ flex: 1, minWidth: 0 }}>
          <SystemBrand size="sm" />
        </div>
        <span style={{ marginRight: 12 }}>{user.display_name || user.username}</span>
        <Button type="link" onClick={() => setPwdOpen(true)}>修改密码</Button>
        <Button
          type="link"
          onClick={() => {
            clearToken();
            onLogout();
            nav("/login");
          }}
        >
          退出
        </Button>
      </Header>
      <ChangePasswordModal open={pwdOpen} onClose={() => setPwdOpen(false)} />
      <Layout className="app-shell-body" style={{ flex: 1, minHeight: 0 }}>
        {!isMobile && (
          <Sider width={200} theme="light" className="app-sider">
            {menu}
          </Sider>
        )}
        <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} placement="left" width={220}>
          {menu}
        </Drawer>
        <Content
          className={[
            "main-layout__content",
            loc.pathname.startsWith("/attempts/") ? "main-layout__content--attempt" : "",
            loc.pathname === "/scores" ? "main-layout__content--scores" : "",
          ]
            .filter(Boolean)
            .join(" ")}
          style={{
            padding: 16,
            background: loc.pathname.startsWith("/attempts/") ? "#fff" : "#f5f5f5",
          }}
        >
          <AppBreadcrumb />
          <div className="main-layout__content-inner">
            <Outlet />
          </div>
        </Content>
      </Layout>
    </Layout>
  );
}
frontend/src/main.tsx
New file
@@ -0,0 +1,17 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN";
import App from "./App";
import { SystemSettingsProvider } from "./systemSettings";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <ConfigProvider locale={zhCN}>
      <SystemSettingsProvider>
        <App />
      </SystemSettingsProvider>
    </ConfigProvider>
  </React.StrictMode>
);
frontend/src/pages/AdminGradersPage.tsx
New file
@@ -0,0 +1,201 @@
import { useEffect, useState } from "react";
import { Button, Card, Form, Input, Modal, Select, Space, Switch, Table, Tag, message } from "antd";
import { api } from "../api";
type Grader = {
  id: number;
  username: string;
  display_name: string;
  is_active: boolean;
  created_at: string;
  subjects: string[];
};
type FormValues = {
  username: string;
  password?: string;
  display_name?: string;
  is_active?: boolean;
  subjects: string[];
};
export default function AdminGradersPage() {
  const [items, setItems] = useState<Grader[]>([]);
  const [loading, setLoading] = useState(false);
  const [modalOpen, setModalOpen] = useState(false);
  const [editing, setEditing] = useState<Grader | null>(null);
  const [form] = Form.useForm<FormValues>();
  const [subjectOptions, setSubjectOptions] = useState<string[]>([]);
  const loadSubjects = async () => {
    const { data } = await api.get<{ items: string[] }>("/api/subjects");
    setSubjectOptions(data.items);
  };
  const load = async () => {
    setLoading(true);
    try {
      const { data } = await api.get<{ items: Grader[] }>("/api/admin/graders");
      setItems(data.items);
    } finally {
      setLoading(false);
    }
  };
  useEffect(() => {
    load();
    loadSubjects();
  }, []);
  const openCreate = () => {
    setEditing(null);
    form.resetFields();
    form.setFieldsValue({ is_active: true });
    setModalOpen(true);
  };
  const openEdit = (row: Grader) => {
    setEditing(row);
    form.setFieldsValue({
      username: row.username,
      display_name: row.display_name,
      is_active: row.is_active,
      subjects: row.subjects ?? [],
      password: "",
    });
    setModalOpen(true);
  };
  const submit = async () => {
    const v = await form.validateFields();
    try {
      if (editing) {
        const body: Record<string, unknown> = {
          username: v.username,
          display_name: v.display_name ?? "",
          is_active: v.is_active,
          subjects: v.subjects,
        };
        if (v.password) body.password = v.password;
        await api.put(`/api/admin/graders/${editing.id}`, body);
        message.success("已更新");
      } else {
        await api.post("/api/admin/graders", {
          username: v.username,
          password: v.password,
          display_name: v.display_name ?? "",
          subjects: v.subjects,
        });
        message.success("已创建");
      }
      setModalOpen(false);
      load();
    } catch (e: unknown) {
      const msg =
        (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || "操作失败";
      message.error(typeof msg === "string" ? msg : "操作失败");
    }
  };
  return (
    <Card
      title="考评员管理"
      extra={
        <Button type="primary" onClick={openCreate}>
          新增考评员
        </Button>
      }
    >
      <Table
        rowKey="id"
        loading={loading}
        dataSource={items}
        scroll={{ x: 720 }}
        columns={[
          { title: "账号", dataIndex: "username" },
          { title: "姓名", dataIndex: "display_name" },
          {
            title: "关联科目",
            dataIndex: "subjects",
            render: (subs: string[]) =>
              subs?.length
                ? subs.map((s) => <Tag key={s}>{s}</Tag>)
                : <span style={{ color: "#999" }}>未配置</span>,
          },
          {
            title: "状态",
            dataIndex: "is_active",
            render: (v: boolean) => (v ? "启用" : "停用"),
          },
          { title: "创建时间", dataIndex: "created_at" },
          {
            title: "操作",
            render: (_, row) => (
              <Space>
                <Button type="link" onClick={() => openEdit(row)}>编辑</Button>
                <Button
                  type="link"
                  danger
                  onClick={() => {
                    Modal.confirm({
                      title: "删除该考评员?",
                      content: `账号:${row.username}`,
                      onOk: async () => {
                        await api.delete(`/api/admin/graders/${row.id}`);
                        message.success("已删除");
                        load();
                      },
                    });
                  }}
                >
                  删除
                </Button>
              </Space>
            ),
          },
        ]}
      />
      <Modal
        title={editing ? "编辑考评员" : "新增考评员"}
        open={modalOpen}
        onCancel={() => setModalOpen(false)}
        onOk={submit}
        destroyOnClose
      >
        <Form form={form} layout="vertical">
          <Form.Item name="username" label="账号" rules={[{ required: true, min: 2 }]}>
            <Input autoComplete="off" />
          </Form.Item>
          <Form.Item
            name="password"
            label={editing ? "新密码(留空不修改)" : "密码"}
            rules={editing ? [] : [{ required: true, min: 6 }]}
          >
            <Input.Password autoComplete="new-password" />
          </Form.Item>
          <Form.Item name="display_name" label="姓名">
            <Input />
          </Form.Item>
          <Form.Item
            name="subjects"
            label="关联科目"
            rules={[{ required: true, message: "请至少选择一个科目" }]}
          >
            <Select
              mode="multiple"
              placeholder="选择可查看成绩的科目"
              options={subjectOptions.map((s) => ({ label: s, value: s }))}
              optionFilterProp="label"
            />
          </Form.Item>
          {editing && (
            <Form.Item name="is_active" label="启用" valuePropName="checked">
              <Switch />
            </Form.Item>
          )}
        </Form>
      </Modal>
    </Card>
  );
}
frontend/src/pages/AdminImportPage.tsx
New file
@@ -0,0 +1,163 @@
import { useEffect, useState } from "react";
import { Alert, Button, Card, Form, Input, Table, Typography, Upload, message, Modal, Tag } from "antd";
import { UploadOutlined } from "@ant-design/icons";
import { api } from "../api";
type Batch = {
  id: number;
  subject_name: string;
  exam_id: number;
  exam_ids: number[];
  zip_filename: string;
  imported_at: string;
  status: string;
  attempt_count: number;
  paper_name?: string;
};
export default function AdminImportPage() {
  const [batches, setBatches] = useState<Batch[]>([]);
  const [file, setFile] = useState<File | null>(null);
  const [loading, setLoading] = useState(false);
  const load = () => api.get<{ items: Batch[] }>("/api/admin/import/batches").then((r) => setBatches(r.data.items));
  useEffect(() => {
    load();
  }, []);
  return (
    <div>
      <Card title="导入数据" style={{ marginBottom: 16 }}>
        <Alert
          type="info"
          showIcon
          style={{ marginBottom: 16 }}
          message="导入要求说明"
          description={
            <Typography.Paragraph style={{ marginBottom: 0 }}>
              <ul style={{ margin: "8px 0 0", paddingLeft: 20 }}>
                <li>请上传 <strong>ZIP 压缩包</strong>,内含导出 SQL(仅 <code>.sql</code> 文件,UTF-8 编码)。</li>
                <li>
                  必须包含 5 类数据(文件名需能识别,推荐与样例一致):
                  <ul>
                    <li>考生:<code>examination_examinee</code>(勿与答案表混淆)</li>
                    <li>考试考生 / 成绩:<code>examination_exam_examinee</code></li>
                    <li>试卷:<code>examination_paper_source</code></li>
                    <li>试题明细:<code>examination_paper_item</code></li>
                    <li>考生答案:<code>examination_examinee_exam_answer</code></li>
                  </ul>
                </li>
                <li>
                  一个 ZIP 可包含<strong>多个 exam_id</strong>(同卷多场次):ZIP 内须为<strong>同一份试卷</strong>(相同{" "}
                  <code>external_paper_id + revision</code>),系统将共用一套试题判分。
                </li>
                <li>
                  若 ZIP 内含<strong>多份不同试卷</strong>,须额外提供 <code>examination_exam</code> SQL,用{" "}
                  <code>paper_source_id</code> 标明每场考试使用的试卷;否则无法导入。
                </li>
                <li>
                  每个 <code>exam_id</code> 在全库仅能导入一次;已存在的试卷(相同{" "}
                  <code>external_paper_id + revision</code>)会复用题库,仅新增场次与成绩。
                </li>
                <li>
                  <strong>科目名称</strong>由管理员填写,用于成绩列表筛选;清除请用下方「导入历史」按批次操作(会删除该 ZIP 对应的全部场次数据)。
                </li>
                <li>导入后将按试题明细与答案自动判分,并以明细汇总结果更新总分(可与原 SQL 中 score 对照)。</li>
              </ul>
            </Typography.Paragraph>
          }
        />
        <Form
          layout="vertical"
          onFinish={async (v) => {
            if (!file) {
              message.warning("请选择 ZIP 文件");
              return;
            }
            const fd = new FormData();
            fd.append("subject_name", v.subject_name);
            fd.append("file", file);
            setLoading(true);
            try {
              const { data } = await api.post("/api/admin/import", fd);
              message.success("导入成功");
              Modal.info({ title: "导入报告", content: JSON.stringify(data.report, null, 2), width: 600 });
              setFile(null);
              load();
            } catch (e: unknown) {
              const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || "导入失败";
              message.error(msg);
            } finally {
              setLoading(false);
            }
          }}
        >
          <Form.Item name="subject_name" label="科目名称" rules={[{ required: true }]}>
            <Input placeholder="导入后用于成绩列表科目筛选" />
          </Form.Item>
          <Form.Item label="ZIP 文件" required>
            <Upload
              beforeUpload={(f) => {
                setFile(f);
                return false;
              }}
              maxCount={1}
              onRemove={() => setFile(null)}
            >
              <Button icon={<UploadOutlined />}>选择文件</Button>
            </Upload>
          </Form.Item>
          <Button type="primary" htmlType="submit" loading={loading}>开始导入</Button>
        </Form>
      </Card>
      <Card title="导入历史">
        <Table
          rowKey="id"
          scroll={{ x: 960 }}
          dataSource={batches}
          columns={[
            { title: "科目", dataIndex: "subject_name" },
            {
              title: "exam_id",
              dataIndex: "exam_ids",
              render: (ids: number[] | undefined, row) => {
                const list = ids?.length ? ids : row.exam_id != null ? [row.exam_id] : [];
                return list.map((id) => <Tag key={id}>{id}</Tag>);
              },
            },
            { title: "试卷", dataIndex: "paper_name", ellipsis: true },
            { title: "文件", dataIndex: "zip_filename", ellipsis: true },
            { title: "导入时间", dataIndex: "imported_at" },
            { title: "考生数", dataIndex: "attempt_count" },
            {
              title: "操作",
              render: (_, row) => {
                const ids = row.exam_ids?.length ? row.exam_ids : row.exam_id != null ? [row.exam_id] : [];
                return (
                  <Button
                    danger
                    type="link"
                    onClick={() => {
                      Modal.confirm({
                        title: "清除本次导入的全部数据?",
                        content: `科目:${row.subject_name},exam_id:${ids.join("、") || "—"}。将删除该批次下全部场次与成绩,不可恢复。`,
                        onOk: async () => {
                          await api.delete(`/api/admin/import/batches/${row.id}`);
                          message.success("已清除");
                          load();
                        },
                      });
                    }}
                  >
                    清除
                  </Button>
                );
              },
            },
          ]}
        />
      </Card>
    </div>
  );
}
frontend/src/pages/AdminSystemSettingsPage.tsx
New file
@@ -0,0 +1,91 @@
import { useEffect, useState } from "react";
import { Button, Card, Form, Input, Upload, message, Image, Space } from "antd";
import { UploadOutlined } from "@ant-design/icons";
import { api } from "../api";
import { logoSrc, useSystemSettings } from "../systemSettings";
export default function AdminSystemSettingsPage() {
  const { settings, refresh, logoCacheKey } = useSystemSettings();
  const [form] = Form.useForm<{ system_name: string }>();
  const [saving, setSaving] = useState(false);
  useEffect(() => {
    form.setFieldsValue({ system_name: settings.system_name });
  }, [settings.system_name, form]);
  const logo = logoSrc(settings.logo_url, logoCacheKey);
  return (
    <Card title="系统设置">
      <Form
        form={form}
        layout="vertical"
        style={{ maxWidth: 480 }}
        onFinish={async (v) => {
          setSaving(true);
          try {
            await api.put("/api/admin/settings", { system_name: v.system_name });
            message.success("已保存");
            await refresh();
          } catch (e: unknown) {
            const msg =
              (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || "保存失败";
            message.error(msg);
          } finally {
            setSaving(false);
          }
        }}
      >
        <Form.Item name="system_name" label="系统名称" rules={[{ required: true, max: 128 }]}>
          <Input placeholder="显示在顶栏、登录页与浏览器标题" />
        </Form.Item>
        <Button type="primary" htmlType="submit" loading={saving}>保存名称</Button>
      </Form>
      <Card type="inner" title="系统 Logo" style={{ marginTop: 24, maxWidth: 480 }}>
        {logo && (
          <div style={{ marginBottom: 16 }}>
            <Image src={logo} alt="Logo" style={{ maxHeight: 64, maxWidth: 200, objectFit: "contain" }} />
          </div>
        )}
        <Space wrap>
          <Upload
            accept=".png,.jpg,.jpeg,.webp,.svg,.gif"
            showUploadList={false}
            beforeUpload={async (file) => {
              const fd = new FormData();
              fd.append("file", file);
              try {
                await api.post("/api/admin/settings/logo", fd);
                message.success("Logo 已更新");
                await refresh();
              } catch (e: unknown) {
                const msg =
                  (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || "上传失败";
                message.error(msg);
              }
              return false;
            }}
          >
            <Button icon={<UploadOutlined />}>上传 Logo</Button>
          </Upload>
          {logo && (
            <Button
              danger
              onClick={async () => {
                await api.delete("/api/admin/settings/logo");
                message.success("已移除 Logo");
                await refresh();
              }}
            >
              移除 Logo
            </Button>
          )}
        </Space>
        <p style={{ color: "#8c8c8c", fontSize: 12, marginTop: 12 }}>
          建议透明底 PNG/WebP,高度约 32–48px,最大 2MB。
        </p>
      </Card>
    </Card>
  );
}
frontend/src/pages/AttemptReviewPage.css
New file
@@ -0,0 +1,392 @@
.attempt-review {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
  height: 100%;
  max-height: 100%;
  overflow: hidden;
  box-sizing: border-box;
  background: #fff;
}
.attempt-review__center-right {
  flex: 1;
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
  position: relative;
}
.attempt-review__center-right-mask {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255, 255, 255, 0.72);
  pointer-events: none;
}
.attempt-review__center-right-placeholder {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 200px;
}
.attempt-review__index-detail-row {
  flex: 1;
  min-height: 0;
  display: flex;
  gap: 12px;
}
.attempt-review__header {
  flex-shrink: 0;
  margin-bottom: 12px;
}
.attempt-review__split {
  flex: 1;
  min-height: 0;
  display: flex;
  gap: 12px;
}
.attempt-review__roster-col {
  flex: 0 0 200px;
  width: 200px;
  min-height: 0;
  display: flex;
}
.attempt-review__roster {
  display: flex;
  flex-direction: column;
  width: 100%;
  min-height: 0;
  height: 100%;
  background: #fff;
  border: 1px solid #f0f0f0;
  border-radius: 8px;
  overflow: hidden;
}
.attempt-review__roster-form {
  flex-shrink: 0;
  padding: 8px 8px 4px;
  border-bottom: 1px solid #f0f0f0;
}
.attempt-review__roster-form .attempt-review__roster-field {
  margin-bottom: 6px;
}
.attempt-review__roster-form .ant-form-item-row {
  flex-wrap: nowrap;
  align-items: center;
}
.attempt-review__roster-form .ant-form-item-label {
  padding-bottom: 0;
  text-align: right;
}
.attempt-review__roster-form .ant-form-item-label > label {
  font-size: 12px;
  height: 22px;
  justify-content: flex-end;
}
.attempt-review__roster-form .ant-form-item-control {
  min-width: 0;
}
.attempt-review__roster-list {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 4px;
}
.attempt-review__roster-item {
  display: block;
  width: 100%;
  text-align: left;
  border: 1px solid transparent;
  border-radius: 6px;
  background: #fafafa;
  padding: 8px 8px;
  margin-bottom: 4px;
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s;
}
.attempt-review__roster-item:hover {
  background: #f0f5ff;
}
.attempt-review__roster-item--active {
  background: #e6f4ff;
  border-color: #1677ff;
}
.attempt-review__roster-name {
  font-size: 13px;
  font-weight: 600;
  color: #262626;
  margin-bottom: 2px;
}
.attempt-review__roster-meta {
  display: flex;
  justify-content: space-between;
  gap: 4px;
  font-size: 11px;
  color: #8c8c8c;
}
.attempt-review__roster-subject {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 100px;
}
.attempt-review__roster-hint {
  text-align: center;
  font-size: 12px;
  color: #bfbfbf;
  padding: 8px 4px;
}
.attempt-review__index-col {
  flex: 0 0 280px;
  width: 280px;
  min-height: 0;
  display: flex;
}
.attempt-review__index-col--collapsed {
  flex: 0 0 48px;
  width: 48px;
}
.attempt-review__index-mobile-wrap {
  flex-shrink: 0;
  margin-bottom: 12px;
}
.attempt-review__detail {
  flex: 1;
  min-width: 0;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  overscroll-behavior: contain;
  background: #fff;
  border-radius: 8px;
  border: 1px solid #f0f0f0;
  padding: 12px 16px;
  -webkit-overflow-scrolling: touch;
}
.attempt-review__detail-inner {
  min-height: min-content;
  background: #fff;
}
.attempt-review__question-anchor {
  scroll-margin-top: 0;
}
.attempt-review__question-card {
  margin-bottom: 12px;
  border: 1px solid #f0f0f0;
}
.attempt-review__index {
  background: #fff;
  border-radius: 8px;
  border: 1px solid #f0f0f0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  width: 100%;
  min-height: 0;
  height: 100%;
}
.attempt-review__index-toolbar {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  flex-shrink: 0;
  padding: 8px 8px 4px;
  border-bottom: 1px solid #f0f0f0;
}
.attempt-review__index-filter {
  flex-shrink: 0;
  padding: 8px 10px 10px;
  border-bottom: 1px solid #f0f0f0;
}
.attempt-review__type-radio {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
.attempt-review__type-radio .ant-radio-button-wrapper {
  height: auto;
  line-height: 1.3;
  padding: 4px 10px;
  font-size: 12px;
}
.attempt-review__index-body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  overscroll-behavior: contain;
  padding: 8px 12px 16px;
}
.attempt-review__section {
  margin-bottom: 16px;
}
.attempt-review__section-title {
  background: #f5f5f5;
  color: #262626;
  font-size: 13px;
  font-weight: 600;
  padding: 8px 10px;
  margin: 0 0 10px;
  border-radius: 4px;
  line-height: 1.4;
}
.attempt-review__bubble-grid {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 10px 8px;
}
.attempt-review__bubble {
  width: 100%;
  aspect-ratio: 1;
  max-width: 40px;
  margin: 0 auto;
  border: 1px solid #e8e8e8;
  border-radius: 50%;
  background: #fafafa;
  color: #8c8c8c;
  font-size: 12px;
  cursor: pointer;
  padding: 0;
  box-sizing: border-box;
  transition:
    border-color 0.15s,
    background 0.15s,
    box-shadow 0.15s;
}
.attempt-review__bubble--unanswered {
  background: #f5f5f5;
  color: #bfbfbf;
  border-color: #ebebeb;
}
.attempt-review__bubble--correct {
  background: #f6ffed;
  color: #52a82a;
  border-color: #d9f0c8;
}
.attempt-review__bubble--wrong {
  background: #fffafa;
  color: #c75454;
  border-color: #f0e0e0;
}
.attempt-review__bubble--active {
  border: 2px solid #1677ff;
  box-shadow:
    0 0 0 2px rgba(22, 119, 255, 0.28),
    0 1px 4px rgba(22, 119, 255, 0.22);
  color: inherit;
  z-index: 1;
}
.attempt-review__bubble--active.attempt-review__bubble--correct {
  background: #f6ffed;
  color: #389e0d;
}
.attempt-review__bubble--active.attempt-review__bubble--wrong {
  background: #fff1f0;
  color: #cf1322;
}
.attempt-review__bubble--active.attempt-review__bubble--unanswered {
  background: #fff;
  color: #595959;
}
.attempt-review__bubble:hover:not(.attempt-review__bubble--active) {
  border-color: #d9d9d9;
}
.attempt-review__bubble--active:hover {
  border-color: #0958d9;
  box-shadow:
    0 0 0 2px rgba(9, 88, 217, 0.35),
    0 2px 6px rgba(22, 119, 255, 0.28);
}
.attempt-review__index--collapsed {
  width: 44px;
  min-width: 44px;
}
.attempt-review__index--collapsed .attempt-review__index-filter,
.attempt-review__index--collapsed .attempt-review__index-body {
  display: none;
}
.attempt-review__index-collapsed-label {
  writing-mode: vertical-rl;
  text-orientation: mixed;
  font-size: 12px;
  color: #8c8c8c;
  padding: 12px 0;
  text-align: center;
  letter-spacing: 2px;
  flex: 1;
}
.attempt-review__index-mobile {
  max-height: 240px;
}
.attempt-review__index-mobile .attempt-review__index-body {
  max-height: 160px;
}
@media (max-width: 991px) {
  .attempt-review__split {
    flex-direction: column;
  }
  .attempt-review__detail {
    flex: 1;
  }
}
frontend/src/pages/AttemptReviewPage.tsx
New file
@@ -0,0 +1,574 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
  Button,
  Card,
  Descriptions,
  Form,
  Grid,
  Input,
  Radio,
  Select,
  Spin,
  Tag,
  Typography,
} from "antd";
import { DoubleLeftOutlined, DoubleRightOutlined } from "@ant-design/icons";
import { api } from "../api";
import {
  attemptDetailPath,
  readScoreListQuery,
  type ScoreListQuery,
} from "../scoreQueryParams";
import "./AttemptReviewPage.css";
const { useBreakpoint } = Grid;
const { Title } = Typography;
const SECTION_CN = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
type Q = {
  external_item_id: string;
  part_name?: string;
  item_order?: number;
  title_html?: string;
  detail_json?: string;
  reference_answer?: string;
  user_answer?: string;
  is_correct: boolean;
  score: number;
  max_score?: number;
};
type Part = { part_name: string; questions: Q[] };
type Attempt = {
  attempt_id: string;
  examinee_name?: string;
  id_card?: string;
  score: number;
  paper_total_score?: number;
  paper_name?: string;
  subject_name?: string;
  room_id?: string;
  seat_number?: string;
  start_time?: string;
  end_time?: string;
  parts: Part[];
};
type FlatItem = { key: string; part: string; q: Q; idx: number; displayNo: number };
function stripHtml(html: string) {
  return html.replace(/<[^>]+>/g, "");
}
function isAnswered(q: Q) {
  return Boolean(q.user_answer?.trim());
}
function bubbleClassName(q: Q, isActive: boolean) {
  const classes = ["attempt-review__bubble"];
  if (!isAnswered(q)) classes.push("attempt-review__bubble--unanswered");
  else if (q.is_correct) classes.push("attempt-review__bubble--correct");
  else classes.push("attempt-review__bubble--wrong");
  if (isActive) classes.push("attempt-review__bubble--active");
  return classes.join(" ");
}
function parseOptions(detail_json?: string) {
  if (!detail_json) return [];
  try {
    const o = JSON.parse(detail_json);
    const qs = o?.item?.questions;
    if (Array.isArray(qs) && qs[0]?.options) return qs[0].options;
  } catch {
    /* ignore */
  }
  return [];
}
function scrollChildIntoContainer(container: HTMLElement, el: HTMLElement) {
  const cRect = container.getBoundingClientRect();
  const eRect = el.getBoundingClientRect();
  const nextTop = container.scrollTop + (eRect.top - cRect.top);
  container.scrollTo({ top: Math.max(0, nextTop), behavior: "auto" });
}
function QuestionIndex({
  parts,
  flat,
  activeKey,
  collapsed,
  onToggleCollapse,
  onJump,
  mobile,
}: {
  parts: Part[];
  flat: FlatItem[];
  activeKey: string | null;
  collapsed: boolean;
  onToggleCollapse: () => void;
  onJump: (key: string) => void;
  mobile?: boolean;
}) {
  const [typeFilter, setTypeFilter] = useState<string>("all");
  const visibleParts = useMemo(() => {
    if (typeFilter === "all") return parts;
    return parts.filter((p) => p.part_name === typeFilter);
  }, [parts, typeFilter]);
  return (
    <aside
      className={[
        "attempt-review__index",
        mobile ? "attempt-review__index-mobile" : "",
        collapsed && !mobile ? "attempt-review__index--collapsed" : "",
      ]
        .filter(Boolean)
        .join(" ")}
      aria-label="题目索引"
    >
      <div className="attempt-review__index-toolbar">
        {!mobile && (
          <Button
            type="text"
            size="small"
            icon={collapsed ? <DoubleRightOutlined /> : <DoubleLeftOutlined />}
            onClick={onToggleCollapse}
            aria-label={collapsed ? "展开索引侧栏" : "收起索引侧栏"}
          />
        )}
      </div>
      {collapsed && !mobile ? (
        <div className="attempt-review__index-collapsed-label">题目索引</div>
      ) : (
        <>
          <div className="attempt-review__index-filter">
            <Radio.Group
              size="small"
              value={typeFilter}
              onChange={(e) => setTypeFilter(e.target.value)}
              className="attempt-review__type-radio"
            >
              <Radio.Button value="all">全部</Radio.Button>
              {parts.map((p) => (
                <Radio.Button key={p.part_name} value={p.part_name}>{p.part_name}</Radio.Button>
              ))}
            </Radio.Group>
          </div>
          <div className="attempt-review__index-body">
            {visibleParts.map((p) => {
              const sectionIdx = parts.findIndex((x) => x.part_name === p.part_name);
              const sectionLabel = `${SECTION_CN[sectionIdx] ?? sectionIdx + 1}、${p.part_name}`;
              return (
                <section key={p.part_name} className="attempt-review__section">
                  <h4 className="attempt-review__section-title">{sectionLabel}</h4>
                  <div className="attempt-review__bubble-grid">
                    {p.questions.map((q, i) => {
                      const meta = flat.find((f) => f.key === q.external_item_id);
                      const no = meta?.displayNo ?? (q.item_order ?? i) + 1;
                      const isActive = activeKey === q.external_item_id;
                      return (
                        <button
                          key={q.external_item_id}
                          type="button"
                          className={bubbleClassName(q, isActive)}
                          onClick={() => onJump(q.external_item_id)}
                          title={`第 ${no} 题`}
                        >
                          {no}
                        </button>
                      );
                    })}
                  </div>
                </section>
              );
            })}
          </div>
        </>
      )}
    </aside>
  );
}
type ScoreListItem = {
  attempt_id: string;
  name: string;
  id_card: string;
  subject_name: string;
  score: number;
};
const ROSTER_PAGE_SIZE = 40;
function AttemptRoster({
  activeAttemptId,
  listQuery,
  onSelectAttempt,
  onSearch,
}: {
  activeAttemptId: string;
  listQuery: ScoreListQuery;
  onSelectAttempt: (attemptId: string) => void;
  onSearch: (filters: Pick<ScoreListQuery, "name" | "id_card" | "subject">) => void;
}) {
  const [form] = Form.useForm<Pick<ScoreListQuery, "name" | "id_card" | "subject">>();
  const [subjects, setSubjects] = useState<string[]>([]);
  const [items, setItems] = useState<ScoreListItem[]>([]);
  const [total, setTotal] = useState(0);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [filters, setFilters] = useState({
    name: listQuery.name,
    id_card: listQuery.id_card,
    subject: listQuery.subject,
  });
  const scrollRef = useRef<HTMLDivElement>(null);
  const loadingMoreRef = useRef(false);
  useEffect(() => {
    form.setFieldsValue({
      name: listQuery.name,
      id_card: listQuery.id_card,
      subject: listQuery.subject,
    });
    setFilters({
      name: listQuery.name,
      id_card: listQuery.id_card,
      subject: listQuery.subject,
    });
  }, [listQuery.name, listQuery.id_card, listQuery.subject, form]);
  useEffect(() => {
    api.get<{ items: string[] }>("/api/subjects").then((r) => setSubjects(r.data.items));
  }, []);
  const fetchPage = useCallback(
    async (p: number, append: boolean) => {
      if (loadingMoreRef.current) return;
      loadingMoreRef.current = true;
      setLoading(true);
      try {
        const { data: res } = await api.get("/api/scores", {
          params: { page: p, page_size: ROSTER_PAGE_SIZE, ...filters },
        });
        setTotal(res.total);
        setPage(p);
        setItems((prev) => (append ? [...prev, ...res.items] : res.items));
      } finally {
        setLoading(false);
        loadingMoreRef.current = false;
      }
    },
    [filters]
  );
  useEffect(() => {
    fetchPage(1, false);
  }, [fetchPage]);
  useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    const onScroll = () => {
      if (loadingMoreRef.current || loading) return;
      if (items.length >= total) return;
      if (el.scrollHeight - el.scrollTop - el.clientHeight < 64) {
        fetchPage(page + 1, true);
      }
    };
    el.addEventListener("scroll", onScroll);
    return () => el.removeEventListener("scroll", onScroll);
  }, [loading, items.length, total, page, fetchPage]);
  useEffect(() => {
    const el = scrollRef.current?.querySelector<HTMLElement>(
      `[data-attempt-id="${activeAttemptId}"]`
    );
    el?.scrollIntoView({ block: "nearest" });
  }, [activeAttemptId, items]);
  return (
    <aside className="attempt-review__roster">
      <Form
        form={form}
        className="attempt-review__roster-form"
        size="small"
        layout="horizontal"
        labelAlign="right"
        colon={false}
        labelCol={{ flex: "0 0 52px" }}
        wrapperCol={{ flex: "1 1 0" }}
        onFinish={(v) => onSearch(v)}
      >
        <Form.Item name="name" label="姓名" className="attempt-review__roster-field">
          <Input allowClear />
        </Form.Item>
        <Form.Item name="id_card" label="证件号" className="attempt-review__roster-field">
          <Input allowClear />
        </Form.Item>
        <Form.Item name="subject" label="科目" className="attempt-review__roster-field">
          <Select allowClear options={subjects.map((s) => ({ value: s, label: s }))} />
        </Form.Item>
        <Button type="primary" htmlType="submit" block size="small">查询</Button>
      </Form>
      <div ref={scrollRef} className="attempt-review__roster-list">
        {items.map((row) => (
          <button
            key={row.attempt_id}
            type="button"
            data-attempt-id={row.attempt_id}
            className={[
              "attempt-review__roster-item",
              row.attempt_id === activeAttemptId ? "attempt-review__roster-item--active" : "",
            ]
              .filter(Boolean)
              .join(" ")}
            onClick={() => {
              if (row.attempt_id !== activeAttemptId) onSelectAttempt(row.attempt_id);
            }}
          >
            <div className="attempt-review__roster-name">{row.name || "—"}</div>
            <div className="attempt-review__roster-meta">
              <span>{row.score} 分</span>
              <span className="attempt-review__roster-subject">{row.subject_name}</span>
            </div>
          </button>
        ))}
        {loading && <div className="attempt-review__roster-hint">加载中…</div>}
        {!loading && items.length === 0 && <div className="attempt-review__roster-hint">无数据</div>}
        {!loading && items.length > 0 && items.length >= total && (
          <div className="attempt-review__roster-hint">已加载全部</div>
        )}
      </div>
    </aside>
  );
}
export default function AttemptReviewPage() {
  const { attemptId } = useParams();
  const navigate = useNavigate();
  const [searchParams] = useSearchParams();
  const listQuery = readScoreListQuery(searchParams);
  const [data, setData] = useState<Attempt | null>(null);
  const [detailLoading, setDetailLoading] = useState(true);
  const [activeKey, setActiveKey] = useState<string | null>(null);
  const [indexCollapsed, setIndexCollapsed] = useState(false);
  const detailScrollRef = useRef<HTMLDivElement>(null);
  const screens = useBreakpoint();
  const isWide = !!screens.lg;
  const selectAttempt = useCallback(
    (id: string) => {
      if (id === attemptId) return;
      navigate(attemptDetailPath(id, listQuery), { replace: true, preventScrollReset: true });
    },
    [attemptId, listQuery, navigate]
  );
  const searchRoster = useCallback(
    (filters: Pick<ScoreListQuery, "name" | "id_card" | "subject">) => {
      navigate(
        attemptDetailPath(attemptId!, { ...filters, page: 1, page_size: listQuery.page_size }),
        { replace: true, preventScrollReset: true }
      );
    },
    [attemptId, listQuery.page_size, navigate]
  );
  useEffect(() => {
    if (!attemptId) return;
    let cancelled = false;
    setDetailLoading(true);
    setActiveKey(null);
    detailScrollRef.current?.scrollTo(0, 0);
    api
      .get<Attempt>(`/api/attempts/${attemptId}`)
      .then((r) => {
        if (!cancelled) setData(r.data);
      })
      .finally(() => {
        if (!cancelled) setDetailLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [attemptId]);
  useEffect(() => {
    const prev = document.documentElement.style.overflow;
    const prevBody = document.body.style.overflow;
    document.documentElement.style.overflow = "hidden";
    document.body.style.overflow = "hidden";
    return () => {
      document.documentElement.style.overflow = prev;
      document.body.style.overflow = prevBody;
    };
  }, []);
  const flat = useMemo(() => {
    if (!data) return [];
    const out: FlatItem[] = [];
    let n = 0;
    data.parts.forEach((p) =>
      p.questions.forEach((q, i) => {
        n += 1;
        out.push({
          key: q.external_item_id,
          part: p.part_name,
          q,
          idx: n,
          displayNo: (q.item_order ?? i) + 1,
        });
      })
    );
    return out;
  }, [data]);
  const jumpTo = useCallback((key: string) => {
    setActiveKey(key);
    requestAnimationFrame(() => {
      const container = detailScrollRef.current;
      const el = document.getElementById(`q-${key}`);
      if (!container || !el) return;
      scrollChildIntoContainer(container, el);
    });
  }, []);
  useEffect(() => {
    const container = detailScrollRef.current;
    if (!container || !data || flat.length === 0) return;
    const observer = new IntersectionObserver(
      (entries) => {
        const visible = entries
          .filter((e) => e.isIntersecting)
          .sort((a, b) => (a.boundingClientRect.top ?? 0) - (b.boundingClientRect.top ?? 0));
        const id = visible[0]?.target.id;
        if (id?.startsWith("q-")) {
          setActiveKey(id.replace("q-", ""));
        }
      },
      { root: container, rootMargin: "-8px 0px -70% 0px", threshold: 0 }
    );
    flat.forEach(({ key }) => {
      const el = document.getElementById(`q-${key}`);
      if (el) observer.observe(el);
    });
    return () => observer.disconnect();
  }, [data, flat]);
  const indexPanel = data ? (
    <QuestionIndex
      parts={data.parts}
      flat={flat}
      activeKey={activeKey}
      collapsed={indexCollapsed}
      onToggleCollapse={() => setIndexCollapsed((c) => !c)}
      onJump={jumpTo}
      mobile={!isWide}
    />
  ) : null;
  const detailPanel = data ? (
    <div className="attempt-review__detail-inner">
      {data.parts.map((p) => (
        <div key={p.part_name} id={`part-${p.part_name}`}>
          <Title level={5}>{p.part_name}</Title>
          {p.questions.map((q, i) => (
            <div key={q.external_item_id} id={`q-${q.external_item_id}`} className="attempt-review__question-anchor">
              <Card
                size="small"
                className="attempt-review__question-card"
                title={`第 ${(q.item_order ?? i) + 1} 题`}
              >
                <div dangerouslySetInnerHTML={{ __html: q.title_html || "" }} />
                <ul>
                  {parseOptions(q.detail_json).map((opt: { identifier: string; text: string }) => (
                    <li key={opt.identifier}>{opt.identifier}. {stripHtml(opt.text)}</li>
                  ))}
                </ul>
                <p>考生答案:{q.user_answer || "—"}</p>
                <p>参考答案:{q.reference_answer || "—"}</p>
                <p>
                  {q.is_correct ? <Tag color="success">正确</Tag> : <Tag color="error">错误</Tag>}
                  得分:{q.score} / {q.max_score ?? 1}
                </p>
              </Card>
            </div>
          ))}
        </div>
      ))}
    </div>
  ) : null;
  const centerRight = (
    <div className="attempt-review__center-right">
      {detailLoading && (
        <div className="attempt-review__center-right-mask" aria-busy="true">
          <Spin />
        </div>
      )}
      {data && (
        <Card className="attempt-review__header" size="small">
          <Descriptions column={{ xs: 1, sm: 2 }} size="small">
            <Descriptions.Item label="姓名">{data.examinee_name}</Descriptions.Item>
            <Descriptions.Item label="证件号">{data.id_card}</Descriptions.Item>
            <Descriptions.Item label="科目">{data.subject_name}</Descriptions.Item>
            <Descriptions.Item label="总分">{data.score} / {data.paper_total_score}</Descriptions.Item>
            <Descriptions.Item label="试卷">{data.paper_name}</Descriptions.Item>
            <Descriptions.Item label="考场">{data.room_id} {data.seat_number}</Descriptions.Item>
            <Descriptions.Item label="考试时间">{data.start_time} ~ {data.end_time}</Descriptions.Item>
          </Descriptions>
        </Card>
      )}
      {!data && detailLoading && (
        <div className="attempt-review__center-right-placeholder">
          <Spin />
        </div>
      )}
      {data && (
        <>
          {!isWide && <div className="attempt-review__index-mobile-wrap">{indexPanel}</div>}
          <div className="attempt-review__index-detail-row">
            {isWide && (
              <div
                className={
                  indexCollapsed
                    ? "attempt-review__index-col attempt-review__index-col--collapsed"
                    : "attempt-review__index-col"
                }
              >
                {indexPanel}
              </div>
            )}
            <div ref={detailScrollRef} className="attempt-review__detail">
              {detailPanel}
            </div>
          </div>
        </>
      )}
    </div>
  );
  return (
    <div className="attempt-review">
      <div className="attempt-review__split">
        {isWide && attemptId && (
          <div className="attempt-review__roster-col">
            <AttemptRoster
              activeAttemptId={attemptId}
              listQuery={listQuery}
              onSelectAttempt={selectAttempt}
              onSearch={searchRoster}
            />
          </div>
        )}
        {centerRight}
      </div>
    </div>
  );
}
frontend/src/pages/LoginPage.css
New file
@@ -0,0 +1,78 @@
.login-page {
  position: relative;
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 16px;
  box-sizing: border-box;
  background-image: url("/login-bg.webp");
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}
.login-page::before {
  content: "";
  position: fixed;
  inset: 0;
  background: rgba(15, 25, 45, 0.22);
  pointer-events: none;
  z-index: 0;
}
.login-page > * {
  position: relative;
  z-index: 1;
}
.login-glass {
  background: rgba(255, 255, 255, 0.58);
  backdrop-filter: blur(18px) saturate(1.15);
  -webkit-backdrop-filter: blur(18px) saturate(1.15);
  border: 1px solid rgba(255, 255, 255, 0.55);
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
}
.login-page__brand {
  margin-bottom: 20px;
  padding: 12px 20px;
  border-radius: 12px;
  max-width: 100%;
}
.login-page__brand span {
  color: #1677ff !important;
  text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
}
.login-page__card {
  width: 100%;
  max-width: 400px;
  border-radius: 12px;
  overflow: hidden;
}
.login-page__card.ant-card {
  background: rgba(255, 255, 255, 0.72);
  backdrop-filter: blur(20px) saturate(1.2);
  -webkit-backdrop-filter: blur(20px) saturate(1.2);
  border: 1px solid rgba(255, 255, 255, 0.65);
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
}
.login-page__card .ant-card-head {
  background: rgba(255, 255, 255, 0.35);
  border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.login-page__card .ant-card-body {
  background: transparent;
}
.login-page__card .ant-input,
.login-page__card .ant-input-affix-wrapper {
  background: rgba(255, 255, 255, 0.85);
}
frontend/src/pages/LoginPage.tsx
New file
@@ -0,0 +1,48 @@
import { useState } from "react";
import { Button, Card, Form, Input, message } from "antd";
import { useNavigate } from "react-router-dom";
import { api, setToken } from "../api";
import type { UserMe } from "../App";
import "./LoginPage.css";
import SystemBrand from "../components/SystemBrand";
export default function LoginPage({ onLogin }: { onLogin: (u: UserMe) => void }) {
  const nav = useNavigate();
  const [loading, setLoading] = useState(false);
  return (
    <div className="login-page">
      <div className="login-page__brand login-glass">
        <SystemBrand size="lg" />
      </div>
      <Card title="登录" className="login-page__card">
        <Form
          layout="vertical"
          onFinish={async (v) => {
            setLoading(true);
            try {
              const { data } = await api.post<{ access_token: string }>("/api/auth/login", v);
              setToken(data.access_token);
              const me = await api.get<UserMe>("/api/auth/me");
              onLogin(me.data);
              nav("/scores");
            } catch {
              message.error("登录失败");
            } finally {
              setLoading(false);
            }
          }}
        >
          <Form.Item name="username" label="账号" rules={[{ required: true }]}>
            <Input />
          </Form.Item>
          <Form.Item name="password" label="密码" rules={[{ required: true }]}>
            <Input.Password />
          </Form.Item>
          <Button type="primary" htmlType="submit" block loading={loading}>
            登录
          </Button>
        </Form>
      </Card>
    </div>
  );
}
frontend/src/pages/ScoreQueryPage.css
New file
@@ -0,0 +1,82 @@
.score-query-page {
  height: 100%;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
.score-query-page__card {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
.score-query-page__card > .ant-card-body {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  padding: 12px 16px 16px;
}
.score-query-page__form.ant-form-inline {
  flex-shrink: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 4px;
  margin-bottom: 12px;
}
.score-query-page__form .ant-form-item {
  margin-inline-end: 12px;
  margin-bottom: 0;
}
.score-query-page__table-wrap {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.score-query-page__table-wrap > .ant-table-wrapper {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
.score-query-page__table-wrap .ant-spin-nested-loading,
.score-query-page__table-wrap .ant-spin-container {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
.score-query-page__table-wrap .ant-table {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
.score-query-page__table-wrap .ant-table-container {
  flex: 1;
  min-height: 0;
  overflow: hidden;
}
.score-query-page__table-wrap .ant-table-body {
  overflow: auto !important;
}
.score-query-page__table-wrap .ant-table-pagination {
  flex-shrink: 0;
  margin: 8px 0 0 !important;
  padding-top: 4px;
  justify-content: flex-end;
}
frontend/src/pages/ScoreQueryPage.tsx
New file
@@ -0,0 +1,169 @@
import { useEffect, useRef, useState } from "react";
import { Button, Card, Form, Input, Select, Table } from "antd";
import { useNavigate, useSearchParams } from "react-router-dom";
import { api } from "../api";
import {
  attemptDetailPath,
  readScoreListQuery,
  writeScoreListQuery,
  type ScoreListQuery,
} from "../scoreQueryParams";
import "./ScoreQueryPage.css";
type ScoreRow = {
  examinee_id: string;
  name: string;
  id_card: string;
  subject_name: string;
  score: number;
  attempt_id: string;
  start_time?: string | null;
  end_time?: string | null;
};
export default function ScoreQueryPage() {
  const nav = useNavigate();
  const [searchParams, setSearchParams] = useSearchParams();
  const query = readScoreListQuery(searchParams);
  const [form] = Form.useForm<ScoreListQuery>();
  const tableWrapRef = useRef<HTMLDivElement>(null);
  const [subjects, setSubjects] = useState<string[]>([]);
  const [data, setData] = useState<ScoreRow[]>([]);
  const [total, setTotal] = useState(0);
  const [page, setPage] = useState(query.page ?? 1);
  const [pageSize, setPageSize] = useState(query.page_size ?? 20);
  const [filters, setFilters] = useState<Pick<ScoreListQuery, "name" | "id_card" | "subject">>({
    name: query.name,
    id_card: query.id_card,
    subject: query.subject,
  });
  const [tableScrollY, setTableScrollY] = useState(320);
  useEffect(() => {
    api.get<{ items: string[] }>("/api/subjects").then((r) => setSubjects(r.data.items));
  }, []);
  useEffect(() => {
    const q = readScoreListQuery(searchParams);
    setPage(q.page ?? 1);
    setPageSize(q.page_size ?? 20);
    setFilters({ name: q.name, id_card: q.id_card, subject: q.subject });
    form.setFieldsValue({
      name: q.name,
      id_card: q.id_card,
      subject: q.subject,
    });
  }, [searchParams, form]);
  const load = async () => {
    const { data: res } = await api.get("/api/scores", {
      params: { page, page_size: pageSize, ...filters },
    });
    setData(res.items);
    setTotal(res.total);
  };
  useEffect(() => {
    load();
  }, [page, pageSize, filters]);
  useEffect(() => {
    const el = tableWrapRef.current;
    if (!el) return;
    const update = () => {
      const h = el.clientHeight;
      setTableScrollY(Math.max(120, h - 56 - 52));
    };
    update();
    const ro = new ResizeObserver(update);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  const listQuery = (): ScoreListQuery => ({
    ...filters,
    page,
    page_size: pageSize,
  });
  const pushQuery = (q: ScoreListQuery) => {
    setSearchParams(writeScoreListQuery(q), { replace: true });
  };
  return (
    <div className="score-query-page">
      <Card bordered className="score-query-page__card">
        <Form
          form={form}
          layout="inline"
          className="score-query-page__form"
          onFinish={(v) => {
            pushQuery({ ...v, page: 1, page_size: pageSize });
          }}
        >
          <Form.Item name="name" label="姓名">
            <Input allowClear style={{ width: 120 }} />
          </Form.Item>
          <Form.Item name="id_card" label="证件号">
            <Input allowClear style={{ width: 180 }} />
          </Form.Item>
          <Form.Item name="subject" label="科目">
            <Select
              allowClear
              style={{ width: 220 }}
              options={subjects.map((s) => ({ value: s, label: s }))}
            />
          </Form.Item>
          <Form.Item>
            <Button type="primary" htmlType="submit">查询</Button>
          </Form.Item>
        </Form>
        <div ref={tableWrapRef} className="score-query-page__table-wrap">
          <Table
            rowKey="attempt_id"
            scroll={{ x: 1100, y: tableScrollY }}
            dataSource={data}
            pagination={{
              current: page,
              pageSize,
              total,
              showSizeChanger: true,
              showTotal: (t) => `共 ${t} 条`,
              position: ["bottomRight"],
              onChange: (p, ps) => {
                pushQuery({ ...filters, page: p, page_size: ps });
              },
            }}
            columns={[
              {
                title: "序号",
                width: 64,
                fixed: "left",
                render: (_, __, index) => (page - 1) * pageSize + index + 1,
              },
              { title: "姓名", dataIndex: "name", width: 100 },
              { title: "证件号", dataIndex: "id_card", width: 180 },
              { title: "科目", dataIndex: "subject_name", width: 200, ellipsis: true },
              { title: "开始考试时间", dataIndex: "start_time", width: 170, render: (v) => v || "—" },
              { title: "结束考试时间", dataIndex: "end_time", width: 170, render: (v) => v || "—" },
              { title: "成绩", dataIndex: "score", width: 80 },
              {
                title: "操作",
                width: 80,
                fixed: "right",
                render: (_, row) => (
                  <Button
                    type="link"
                    onClick={() => nav(attemptDetailPath(row.attempt_id, listQuery()))}
                  >
                    查看
                  </Button>
                ),
              },
            ]}
          />
        </div>
      </Card>
    </div>
  );
}
frontend/src/scoreQueryParams.ts
New file
@@ -0,0 +1,40 @@
export type ScoreListQuery = {
  name?: string;
  id_card?: string;
  subject?: string;
  page?: number;
  page_size?: number;
};
export function readScoreListQuery(search: string | URLSearchParams): ScoreListQuery {
  const sp = typeof search === "string" ? new URLSearchParams(search) : search;
  const page = parseInt(sp.get("page") || "1", 10);
  const page_size = parseInt(sp.get("page_size") || "20", 10);
  return {
    name: sp.get("name") || undefined,
    id_card: sp.get("id_card") || undefined,
    subject: sp.get("subject") || undefined,
    page: Number.isFinite(page) && page > 0 ? page : 1,
    page_size: Number.isFinite(page_size) && page_size > 0 ? page_size : 20,
  };
}
export function writeScoreListQuery(q: ScoreListQuery): URLSearchParams {
  const sp = new URLSearchParams();
  if (q.name) sp.set("name", q.name);
  if (q.id_card) sp.set("id_card", q.id_card);
  if (q.subject) sp.set("subject", q.subject);
  if (q.page && q.page > 1) sp.set("page", String(q.page));
  if (q.page_size && q.page_size !== 20) sp.set("page_size", String(q.page_size));
  return sp;
}
export function scoresListPath(q: ScoreListQuery = {}): string {
  const s = writeScoreListQuery(q).toString();
  return s ? `/scores?${s}` : "/scores";
}
export function attemptDetailPath(attemptId: string, q: ScoreListQuery = {}): string {
  const s = writeScoreListQuery(q).toString();
  return s ? `/attempts/${attemptId}?${s}` : `/attempts/${attemptId}`;
}
frontend/src/systemSettings.tsx
New file
@@ -0,0 +1,52 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { api } from "./api";
export type SystemSettings = {
  system_name: string;
  logo_url: string | null;
};
const DEFAULT: SystemSettings = {
  system_name: "考试阅卷系统",
  logo_url: null,
};
const SystemSettingsContext = createContext<{
  settings: SystemSettings;
  logoCacheKey: number;
  refresh: () => Promise<void>;
}>({ settings: DEFAULT, logoCacheKey: 0, refresh: async () => {} });
export function SystemSettingsProvider({ children }: { children: React.ReactNode }) {
  const [settings, setSettings] = useState<SystemSettings>(DEFAULT);
  const [logoCacheKey, setLogoCacheKey] = useState(0);
  const refresh = useCallback(async () => {
    try {
      const { data } = await api.get<SystemSettings>("/api/settings");
      setSettings(data);
      setLogoCacheKey((k) => k + 1);
      document.title = data.system_name;
    } catch {
      setSettings(DEFAULT);
    }
  }, []);
  useEffect(() => {
    refresh();
  }, [refresh]);
  const value = useMemo(() => ({ settings, logoCacheKey, refresh }), [settings, logoCacheKey, refresh]);
  return <SystemSettingsContext.Provider value={value}>{children}</SystemSettingsContext.Provider>;
}
export function useSystemSettings() {
  return useContext(SystemSettingsContext);
}
export function logoSrc(logoUrl: string | null, cacheBust?: number) {
  if (!logoUrl) return null;
  const q = cacheBust ? `?t=${cacheBust}` : "";
  return `${logoUrl}${q}`;
}
frontend/tsconfig.json
New file
@@ -0,0 +1,17 @@
{
  "compilerOptions": {
    "target": "ES2022",
    "useDefineForClassFields": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true
  },
  "include": ["src"]
}
frontend/vite.config.ts
New file
@@ -0,0 +1,24 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const allowedHosts = ["grader.gps120.com", "localhost", "127.0.0.1"];
const apiProxy = {
  "/api": { target: "http://127.0.0.1:8010", changeOrigin: true },
};
export default defineConfig({
  plugins: [react()],
  server: {
    port: 5175,
    host: "0.0.0.0",
    allowedHosts,
    proxy: apiProxy,
  },
  preview: {
    port: 5174,
    host: "0.0.0.0",
    allowedHosts,
    proxy: apiProxy,
  },
});
scripts/build-frontend.sh
New file
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/lib.sh"
if [[ ! -d "$ROOT/frontend/node_modules" ]]; then
  echo "未找到 frontend/node_modules,请先执行:cd frontend && npm install"
  exit 1
fi
echo "构建前端 (production)…"
cd "$ROOT/frontend"
npm run build >>"$LOG_DIR/frontend-build.log" 2>&1
echo "完成 → $ROOT/frontend/dist(日志 $LOG_DIR/frontend-build.log)"
scripts/lib.sh
New file
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUN_DIR="$ROOT/run"
LOG_DIR="$ROOT/logs"
BACKEND_PID="$RUN_DIR/backend.pid"
FRONTEND_PID="$RUN_DIR/frontend.pid"
BACKEND_PORT=8010
FRONTEND_PORT=5174
FRONTEND_DEV_PORT=5175
# prod:build + vite preview @5174;dev:vite dev @5175
FRONTEND_MODE="${FRONTEND_MODE:-prod}"
vite_project_pgrep() {
  pgrep -f "$ROOT/frontend/node_modules/.bin/vite" 2>/dev/null || true
}
mkdir -p "$RUN_DIR" "$LOG_DIR"
is_running() {
  local pid_file="$1"
  [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file")" 2>/dev/null
}
stop_pid_file() {
  local pid_file="$1"
  local name="$2"
  if [[ -f "$pid_file" ]]; then
    local pid
    pid="$(cat "$pid_file")"
    if kill -0 "$pid" 2>/dev/null; then
      kill "$pid" 2>/dev/null || true
      sleep 1
      kill -9 "$pid" 2>/dev/null || true
      echo "已停止 $name (PID $pid)"
    fi
    rm -f "$pid_file"
  fi
}
scripts/restart.sh
New file
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
DIR="$(dirname "$0")"
TARGET="${1:-all}"
"$DIR/stop.sh" "$TARGET"
sleep 1
"$DIR/start.sh" "$TARGET"
scripts/start.sh
New file
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
set -euo pipefail
# shellcheck source=lib.sh
source "$(dirname "$0")/lib.sh"
start_backend() {
  if is_running "$BACKEND_PID"; then
    echo "后端已在运行 (PID $(cat "$BACKEND_PID"))"
    return 0
  fi
  if [[ ! -x "$ROOT/backend/.venv/bin/uvicorn" ]]; then
    echo "未找到 backend/.venv,请先安装依赖:cd backend && python3 -m venv .venv && pip install -r requirements.txt"
    exit 1
  fi
  cd "$ROOT/backend"
  nohup .venv/bin/uvicorn app.main:app --host 127.0.0.1 --port "$BACKEND_PORT" \
    >>"$LOG_DIR/backend.log" 2>&1 &
  echo $! >"$BACKEND_PID"
  echo "后端已启动 → http://127.0.0.1:$BACKEND_PORT (PID $(cat "$BACKEND_PID"), 日志 $LOG_DIR/backend.log)"
}
_record_vite_pid() {
  sleep 1
  local vite_pid
  vite_pid="$(pgrep -f "$ROOT/frontend/node_modules/.bin/vite" | head -1 || true)"
  if [[ -n "$vite_pid" ]]; then
    echo "$vite_pid" >"$FRONTEND_PID"
  fi
}
start_frontend_dev() {
  if is_running "$FRONTEND_PID"; then
    echo "前端 dev 已在运行 (PID $(cat "$FRONTEND_PID"))"
    return 0
  fi
  if [[ ! -d "$ROOT/frontend/node_modules" ]]; then
    echo "未找到 frontend/node_modules,请先执行:cd frontend && npm install"
    exit 1
  fi
  cd "$ROOT/frontend"
  nohup npm run dev >>"$LOG_DIR/frontend-dev.log" 2>&1 &
  echo $! >"$FRONTEND_PID"
  _record_vite_pid
  echo "前端 dev → http://0.0.0.0:$FRONTEND_DEV_PORT (PID $(cat "$FRONTEND_PID" 2>/dev/null || echo ?), 日志 $LOG_DIR/frontend-dev.log)"
}
start_frontend_prod() {
  if is_running "$FRONTEND_PID"; then
    echo "前端已在运行 (PID $(cat "$FRONTEND_PID"))"
    return 0
  fi
  if [[ ! -d "$ROOT/frontend/node_modules" ]]; then
    echo "未找到 frontend/node_modules,请先执行:cd frontend && npm install"
    exit 1
  fi
  "$(dirname "$0")/build-frontend.sh"
  cd "$ROOT/frontend"
  nohup npm run preview >>"$LOG_DIR/frontend.log" 2>&1 &
  echo $! >"$FRONTEND_PID"
  _record_vite_pid
  echo "前端生产 → http://0.0.0.0:$FRONTEND_PORT (vite preview, PID $(cat "$FRONTEND_PID" 2>/dev/null || echo ?), 日志 $LOG_DIR/frontend.log)"
}
start_frontend() {
  if [[ "$FRONTEND_MODE" == "dev" ]]; then
    start_frontend_dev
  else
    start_frontend_prod
  fi
}
case "${1:-all}" in
  backend) start_backend ;;
  frontend) start_frontend ;;
  dev)
    FRONTEND_MODE=dev
    start_backend
    start_frontend_dev
    ;;
  all)
    start_backend
    start_frontend
    ;;
  *)
    echo "用法: $0 [all|dev|backend|frontend]"
    echo "  all      默认生产:build + Node vite preview @ $FRONTEND_PORT"
    echo "  dev      开发:vite dev @ $FRONTEND_DEV_PORT + 后端 $BACKEND_PORT"
    exit 1
    ;;
esac
scripts/status.sh
New file
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
source "$(dirname "$0")/lib.sh"
echo "=== score-query 服务状态 ==="
if is_running "$BACKEND_PID"; then
  echo "后端: 运行中 PID=$(cat "$BACKEND_PID") 端口=$BACKEND_PORT"
else
  echo "后端: 未运行"
fi
preview_pid="$(pgrep -f "$ROOT/frontend/node_modules/.bin/vite preview" | head -1 || true)"
dev_pid=""
if [[ -z "$preview_pid" ]]; then
  for p in $(pgrep -f "$ROOT/frontend/node_modules/.bin/vite" 2>/dev/null || true); do
    if ps -p "$p" -o args= 2>/dev/null | grep -qv preview; then
      dev_pid="$p"
      break
    fi
  done
fi
if [[ -n "$preview_pid" ]]; then
  echo "前端: 生产 preview PID=$preview_pid 端口=$FRONTEND_PORT"
elif [[ -n "$dev_pid" ]]; then
  echo "前端: 开发 dev PID=$dev_pid 端口=$FRONTEND_DEV_PORT"
elif is_running "$FRONTEND_PID"; then
  echo "前端: 运行中 PID=$(cat "$FRONTEND_PID")(未能识别 preview/dev)"
else
  echo "前端: 未运行(./scripts/start.sh 或 ./scripts/start.sh dev)"
fi
scripts/stop.sh
New file
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/lib.sh"
stop_backend() {
  stop_pid_file "$BACKEND_PID" "后端"
  pkill -f "$ROOT/backend/.venv/bin/uvicorn app.main:app" 2>/dev/null || true
  pkill -f "uvicorn app.main:app --host 127.0.0.1 --port $BACKEND_PORT" 2>/dev/null || true
  if command -v fuser >/dev/null 2>&1; then
    fuser -k "${BACKEND_PORT}/tcp" 2>/dev/null || true
  fi
}
stop_frontend() {
  stop_pid_file "$FRONTEND_PID" "前端"
  pkill -f "$ROOT/frontend/node_modules/.bin/vite" 2>/dev/null || true
}
case "${1:-all}" in
  backend) stop_backend ;;
  frontend) stop_frontend ;;
  all)
    stop_frontend
    stop_backend
    ;;
  *)
    echo "用法: $0 [all|backend|frontend]"
    exit 1
    ;;
esac
echo "完成"