From 3f96c9c6502243b06efd4bb34a189e72e3a5557c Mon Sep 17 00:00:00 2001
From: yearning <10538594+wangweifeng1999@user.noreply.gitee.com>
Date: 星期一, 28 九月 2026 19:24:55 +0800
Subject: [PATCH] 复制项目
---
frontend/src/api.ts | 23
frontend/playwright.config.ts | 15
scripts/status.sh | 30
frontend/src/components/SystemBrand.tsx | 32
frontend/src/AdminRoute.tsx | 16
frontend/src/index.css | 6
frontend/package.json | 28
frontend/src/layout/AppBreadcrumb.tsx | 72
backend/app/system_settings.py | 54
backend/app/routers/__init__.py | 1
frontend/src/layout/MainLayout.tsx | 106
backend/app/import_service.py | 501 +++
backend/app/models.py | 175 +
frontend/vite.config.ts | 24
scripts/stop.sh | 32
backend/app/main.py | 66
frontend/src/pages/ScoreQueryPage.tsx | 169 +
frontend/src/components/ChangePasswordModal.tsx | 69
frontend/src/pages/AdminImportPage.tsx | 163 +
frontend/src/pages/LoginPage.css | 78
backend/requirements.txt | 10
frontend/src/pages/AttemptReviewPage.css | 392 ++
scripts/build-frontend.sh | 13
frontend/src/App.tsx | 69
backend/app/__init__.py | 1
frontend/src/pages/AttemptReviewPage.tsx | 574 +++
backend/app/routers/auth_router.py | 42
backend/app/schemas.py | 132
README.md | 71
backend/app/database.py | 31
backend/app/grader_scope.py | 71
backend/app/routers/scores_router.py | 92
frontend/src/systemSettings.tsx | 52
backend/app/routers/attempts_router.py | 100
.gitignore | 66
scripts/lib.sh | 38
backend/app/routers/settings_router.py | 102
frontend/public/login-bg.webp | 0
backend/scripts/seed_admin.py | 40
frontend/src/scoreQueryParams.ts | 40
backend/app/sql_parser.py | 183 +
frontend/src/pages/LoginPage.tsx | 48
scripts/start.sh | 90
backend/tests/test_import_service.py | 62
frontend/src/main.tsx | 17
backend/app/routers/admin_import_router.py | 74
backend/app/config.py | 26
backend/tests/test_grading.py | 18
deploy-to-huaweicloud.html | 868 +++++
backend/scripts/import_local.py | 44
frontend/src/layout/MainLayout.css | 108
frontend/src/pages/AdminGradersPage.tsx | 201 +
frontend/src/pages/AdminSystemSettingsPage.tsx | 91
backend/app/grading.py | 69
frontend/e2e/scores.spec.ts | 58
frontend/package-lock.json | 3237 +++++++++++++++++++++
frontend/src/pages/ScoreQueryPage.css | 82
scripts/restart.sh | 7
backend/app/login_guard.py | 55
frontend/tsconfig.json | 17
backend/app/routers/admin_graders_router.py | 105
frontend/index.html | 12
backend/app/auth.py | 54
63 files changed, 9,079 insertions(+), 43 deletions(-)
diff --git a/.gitignore b/.gitignore
index 087029a..80d259b 100644
--- a/.gitignore
+++ b/.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/
diff --git a/README.md b/README.md
index ceee91a..7d87941 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,71 @@
-## app-full-stack-score-query
+# 鑰冭瘯闃呭嵎绯荤粺
-鑰冨姟绯荤粺瀹㈣棰樻壒闃呭鏍�
+SQLite + FastAPI + React (Ant Design + Vite)
+## 绔彛
+
+- API锛歚8010`锛堜粎鍚庣锛�
+- **鍓嶇鐢熶骇锛堥粯璁わ級**锛歚5174`锛圢ode `vite preview` 鎵樼 `dist`锛宍/api` 浠g悊鍒� 8010锛�
+- 鍓嶇寮�鍙戯細`5175`锛坄./scripts/start.sh dev`锛孷ite 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 # 鐢熶骇锛歜uild + preview @5174 + 鍚庣 8010
+./scripts/start.sh dev # 寮�鍙戯細Vite dev @5175 + 鍚庣 8010
+./scripts/build-frontend.sh # 浠呴噸鏂版瀯寤� dist锛堟敼浠g爜鍚庣敓浜ч渶 build 鍐� restart frontend锛�
+./scripts/stop.sh
+./scripts/restart.sh
+./scripts/status.sh
+
+# 鏃ュ織锛歭ogs/backend.log logs/frontend.log锛坧review锛� 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`銆�
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..b9d56a4
--- /dev/null
+++ b/backend/app/__init__.py
@@ -0,0 +1 @@
+# app package
diff --git a/backend/app/auth.py b/backend/app/auth.py
new file mode 100644
index 0000000..c3f02be
--- /dev/null
+++ b/backend/app/auth.py
@@ -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
diff --git a/backend/app/config.py b/backend/app/config.py
new file mode 100644
index 0000000..78b72e9
--- /dev/null
+++ b/backend/app/config.py
@@ -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()
diff --git a/backend/app/database.py b/backend/app/database.py
new file mode 100644
index 0000000..6e6651c
--- /dev/null
+++ b/backend/app/database.py
@@ -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()
diff --git a/backend/app/grader_scope.py b/backend/app/grader_scope.py
new file mode 100644
index 0000000..a46686b
--- /dev/null
+++ b/backend/app/grader_scope.py
@@ -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))
diff --git a/backend/app/grading.py b/backend/app/grading.py
new file mode 100644
index 0000000..7241828
--- /dev/null
+++ b/backend/app/grading.py
@@ -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
diff --git a/backend/app/import_service.py b/backend/app/import_service.py
new file mode 100644
index 0000000..0e8f72b
--- /dev/null
+++ b/backend/app/import_service.py
@@ -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锛圫QL 鏂囦欢涓殑璇曞嵎 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 []
diff --git a/backend/app/login_guard.py b/backend/app/login_guard.py
new file mode 100644
index 0000000..7a7f9c8
--- /dev/null
+++ b/backend/app/login_guard.py
@@ -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()
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..dfd53c4
--- /dev/null
+++ b/backend/app/main.py
@@ -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")
diff --git a/backend/app/models.py b/backend/app/models.py
new file mode 100644
index 0000000..a016419
--- /dev/null
+++ b/backend/app/models.py
@@ -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)
diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py
new file mode 100644
index 0000000..9c8ddfa
--- /dev/null
+++ b/backend/app/routers/__init__.py
@@ -0,0 +1 @@
+# routers package
diff --git a/backend/app/routers/admin_graders_router.py b/backend/app/routers/admin_graders_router.py
new file mode 100644
index 0000000..75002e5
--- /dev/null
+++ b/backend/app/routers/admin_graders_router.py
@@ -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()
diff --git a/backend/app/routers/admin_import_router.py b/backend/app/routers/admin_import_router.py
new file mode 100644
index 0000000..065e247
--- /dev/null
+++ b/backend/app/routers/admin_import_router.py
@@ -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}
diff --git a/backend/app/routers/attempts_router.py b/backend/app/routers/attempts_router.py
new file mode 100644
index 0000000..c5aad61
--- /dev/null
+++ b/backend/app/routers/attempts_router.py
@@ -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],
+ )
diff --git a/backend/app/routers/auth_router.py b/backend/app/routers/auth_router.py
new file mode 100644
index 0000000..c5f18c9
--- /dev/null
+++ b/backend/app/routers/auth_router.py
@@ -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="鍘熷瘑鐮佷笉姝g‘")
+ user.password_hash = hash_password(body.new_password)
+ db.commit()
+ return {"ok": True}
diff --git a/backend/app/routers/scores_router.py b/backend/app/routers/scores_router.py
new file mode 100644
index 0000000..e3cff3a
--- /dev/null
+++ b/backend/app/routers/scores_router.py
@@ -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)
diff --git a/backend/app/routers/settings_router.py b/backend/app/routers/settings_router.py
new file mode 100644
index 0000000..371ce26
--- /dev/null
+++ b/backend/app/routers/settings_router.py
@@ -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銆乯pg銆亀ebp銆乻vg銆乬if")
+ 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)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
new file mode 100644
index 0000000..9b116ad
--- /dev/null
+++ b/backend/app/schemas.py
@@ -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
diff --git a/backend/app/sql_parser.py b/backend/app/sql_parser.py
new file mode 100644
index 0000000..6816f06
--- /dev/null
+++ b/backend/app/sql_parser.py
@@ -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
diff --git a/backend/app/system_settings.py b/backend/app/system_settings.py
new file mode 100644
index 0000000..86db83a
--- /dev/null
+++ b/backend/app/system_settings.py
@@ -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
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..85b8520
--- /dev/null
+++ b/backend/requirements.txt
@@ -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
diff --git a/backend/scripts/import_local.py b/backend/scripts/import_local.py
new file mode 100644
index 0000000..51de670
--- /dev/null
+++ b/backend/scripts/import_local.py
@@ -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()
diff --git a/backend/scripts/seed_admin.py b/backend/scripts/seed_admin.py
new file mode 100644
index 0000000..17e090a
--- /dev/null
+++ b/backend/scripts/seed_admin.py
@@ -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()
diff --git a/backend/tests/test_grading.py b/backend/tests/test_grading.py
new file mode 100644
index 0000000..8997aac
--- /dev/null
+++ b/backend/tests/test_grading.py
@@ -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
diff --git a/backend/tests/test_import_service.py b/backend/tests/test_import_service.py
new file mode 100644
index 0000000..33f20f5
--- /dev/null
+++ b/backend/tests/test_import_service.py
@@ -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}
diff --git a/deploy-to-huaweicloud.html b/deploy-to-huaweicloud.html
new file mode 100644
index 0000000..79fe681
--- /dev/null
+++ b/deploy-to-huaweicloud.html
@@ -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">绗簩姝ワ細閰嶇疆瀹夊叏缁勶紙寮�鏀剧鍙o級</a></li>
+ <li><a href="#step3">绗笁姝ワ細杩滅▼鐧诲綍鏈嶅姟鍣�</a></li>
+ <li><a href="#step4">绗洓姝ワ細瀹夎杩愯鐜</a></li>
+ <li><a href="#step5">绗簲姝ワ細涓婁紶椤圭洰浠g爜</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>锛歋QLite 涓嶉渶瑕侀澶栦拱鏁版嵁搴�</li>
+ <li><strong>鐏垫椿</strong>锛氬悗缁彲浠ラ殢鏃跺崌绾ч厤缃�</li>
+ </ul>
+
+ <div class="tip">
+ 鏈暀绋嬮�夋嫨 <strong>鍗庝负浜戝脊鎬т簯鏈嶅姟鍣� ECS + Ubuntu 22.04</strong> 鎿嶄綔绯荤粺锛岃繖鏄渶閫氱敤銆佹枃妗f渶澶氱殑缁勫悎銆�
+ </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> 閰嶇疆瀹夊叏缁勶紙寮�鏀剧鍙o級</h2>
+
+ <p>鍗庝负浜戦粯璁ゅ彧寮�鏀句簡 22 绔彛锛圫SH 鐧诲綍鐢級锛屾垜浠渶瑕佹墜鍔ㄥ紑鏀鹃」鐩鐢ㄧ殑绔彛銆�</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>锛岄�夋嫨銆學indows PowerShell銆嶆垨銆岀粓绔�嶏紝杈撳叆锛�</p>
+ <div class="code-block">
+<span class="cmd">ssh</span> root@浣犵殑鏈嶅姟鍣ㄥ叕缃慖P
+<span class="comment"># 渚嬪锛歴sh root@123.45.67.89</span>
+ </div>
+ <p>绗竴娆¤繛鎺ヤ細鎻愮ず銆屾槸鍚︾户缁繛鎺ャ�嶏紝杈撳叆 <span class="highlight">yes</span> 鍥炶溅锛岀劧鍚庤緭鍏ュ瘑鐮侊紙瀵嗙爜涓嶄細鏄剧ず锛岃緭瀹岀洿鎺ュ洖杞︼級銆�</p>
+
+ <h3>Mac 鐢ㄦ埛锛氫娇鐢ㄧ粓绔�</h3>
+ <p>鎵撳紑銆岀粓绔�岮pp锛屽悓鏍疯緭鍏ワ細</p>
+ <div class="code-block"><span class="cmd">ssh</span> root@浣犵殑鏈嶅姟鍣ㄥ叕缃慖P</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銆丯ode.js 鍜� Git锛屽彲浠ラ儴缃查」鐩簡銆�
+ </div>
+ </div>
+
+ <!-- 绗簲姝ワ細涓婁紶浠g爜 -->
+ <div class="section" id="step5">
+ <h2><span class="step-num">5</span> 涓婁紶椤圭洰浠g爜鍒版湇鍔″櫒</h2>
+
+ <p>鏈変袱绉嶆柟寮忎笂浼犱唬鐮侊紝閫夋嫨浣犳柟渚跨殑涓�绉嶏細</p>
+
+ <h3>鏂瑰紡涓�锛欸it 鍏嬮殕锛堟帹鑽愶紝濡傛灉浠g爜鍦� Git 浠撳簱锛�</h3>
+ <p>濡傛灉浣犵殑浠g爜宸茬粡鎺ㄩ�佸埌 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"># 渚嬪锛歡it clone https://gitee.com/浣犵殑鐢ㄦ埛鍚�/score-query.git</span>
+
+<span class="cmd">cd</span> score-query
+<span class="cmd">ls</span>
+<span class="comment"># 搴旇鑳界湅鍒� backend銆乫rontend銆乻cripts 绛夌洰褰�</span>
+ </div>
+
+ <h3>鏂瑰紡浜岋細鏈湴涓婁紶锛堥�傚悎浠g爜鍦ㄦ湰鍦扮數鑴戯級</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@浣犵殑鏈嶅姟鍣↖P:/opt/score-query
+ </div>
+
+ <div class="tip">
+ 濡傛灉浣犵敤鐨勬槸 VS Code锛屽彲浠ュ畨瑁呫�孯emote - 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 楠岃瘉鏈嶅姟鏄惁姝e父</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>妫�鏌ョ鍙f槸鍚︾洃鍚細</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://浣犵殑鏈嶅姟鍣ㄥ叕缃慖P:5174
+ </div>
+
+ <div class="success">
+ 濡傛灉鑳界湅鍒扮櫥褰曢〉闈紝杈撳叆 <span class="highlight">admin / admin123</span> 鑳芥垚鍔熺櫥褰曪紝閭d箞鎭枩浣狅紝閮ㄧ讲鎴愬姛浜嗭紒馃帀
+ </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 瑙f瀽</h3>
+ <ol>
+ <li>杩涘叆銆屼簯瑙f瀽鏈嶅姟 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>浣犵殑鏈嶅姟鍣ㄥ叕缃慖P</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 鍙嶅悜浠g悊锛堟帹鑽愶級</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 璇存槑鏈嶅姟姝e父</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 鏂囨。璇存槑姝e父</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>鏀逛簡鍓嶇浠g爜锛�</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>鏀逛簡鍚庣浠g爜锛�</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>
diff --git a/frontend/e2e/scores.spec.ts b/frontend/e2e/scores.spec.ts
new file mode 100644
index 0000000..2d90481
--- /dev/null
+++ b/frontend/e2e/scores.spec.ts
@@ -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("鐧诲綍椤靛彲姝e父鎵撳紑骞舵樉绀虹櫥褰曡〃鍗�", 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();
+ });
+});
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..d0b1adc
--- /dev/null
+++ b/frontend/index.html
@@ -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>
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..1a5be56
--- /dev/null
+++ b/frontend/package-lock.json
@@ -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"
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..585e985
--- /dev/null
+++ b/frontend/package.json
@@ -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"
+ }
+}
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
new file mode 100644
index 0000000..7c26307
--- /dev/null
+++ b/frontend/playwright.config.ts
@@ -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"]],
+});
diff --git a/frontend/public/login-bg.webp b/frontend/public/login-bg.webp
new file mode 100644
index 0000000..f05d859
--- /dev/null
+++ b/frontend/public/login-bg.webp
Binary files differ
diff --git a/frontend/src/AdminRoute.tsx b/frontend/src/AdminRoute.tsx
new file mode 100644
index 0000000..94c7c50
--- /dev/null
+++ b/frontend/src/AdminRoute.tsx
@@ -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}</>;
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..db4ab1e
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -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;
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
new file mode 100644
index 0000000..15e252d
--- /dev/null
+++ b/frontend/src/api.ts
@@ -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);
+}
diff --git a/frontend/src/components/ChangePasswordModal.tsx b/frontend/src/components/ChangePasswordModal.tsx
new file mode 100644
index 0000000..bd611bc
--- /dev/null
+++ b/frontend/src/components/ChangePasswordModal.tsx
@@ -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>
+ );
+}
diff --git a/frontend/src/components/SystemBrand.tsx b/frontend/src/components/SystemBrand.tsx
new file mode 100644
index 0000000..ea3bf3b
--- /dev/null
+++ b/frontend/src/components/SystemBrand.tsx
@@ -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>
+ );
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..a58f3f1
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,6 @@
+html,
+body,
+#root {
+ height: 100%;
+ margin: 0;
+}
diff --git a/frontend/src/layout/AppBreadcrumb.tsx b/frontend/src/layout/AppBreadcrumb.tsx
new file mode 100644
index 0000000..eddfdc9
--- /dev/null
+++ b/frontend/src/layout/AppBreadcrumb.tsx
@@ -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>
+ );
+}
diff --git a/frontend/src/layout/MainLayout.css b/frontend/src/layout/MainLayout.css
new file mode 100644
index 0000000..ed0f885
--- /dev/null
+++ b/frontend/src/layout/MainLayout.css
@@ -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;
+}
diff --git a/frontend/src/layout/MainLayout.tsx b/frontend/src/layout/MainLayout.tsx
new file mode 100644
index 0000000..4e1fe92
--- /dev/null
+++ b/frontend/src/layout/MainLayout.tsx
@@ -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>
+ );
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..f582c3a
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -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>
+);
diff --git a/frontend/src/pages/AdminGradersPage.tsx b/frontend/src/pages/AdminGradersPage.tsx
new file mode 100644
index 0000000..d19acea
--- /dev/null
+++ b/frontend/src/pages/AdminGradersPage.tsx
@@ -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>
+ );
+}
diff --git a/frontend/src/pages/AdminImportPage.tsx b/frontend/src/pages/AdminImportPage.tsx
new file mode 100644
index 0000000..86e0834
--- /dev/null
+++ b/frontend/src/pages/AdminImportPage.tsx
@@ -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> 鏂囦欢锛孶TF-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}锛宔xam_id锛�${ids.join("銆�") || "鈥�"}銆傚皢鍒犻櫎璇ユ壒娆′笅鍏ㄩ儴鍦烘涓庢垚缁╋紝涓嶅彲鎭㈠銆俙,
+ onOk: async () => {
+ await api.delete(`/api/admin/import/batches/${row.id}`);
+ message.success("宸叉竻闄�");
+ load();
+ },
+ });
+ }}
+ >
+ 娓呴櫎
+ </Button>
+ );
+ },
+ },
+ ]}
+ />
+ </Card>
+ </div>
+ );
+}
diff --git a/frontend/src/pages/AdminSystemSettingsPage.tsx b/frontend/src/pages/AdminSystemSettingsPage.tsx
new file mode 100644
index 0000000..33f318c
--- /dev/null
+++ b/frontend/src/pages/AdminSystemSettingsPage.tsx
@@ -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>
+ );
+}
diff --git a/frontend/src/pages/AttemptReviewPage.css b/frontend/src/pages/AttemptReviewPage.css
new file mode 100644
index 0000000..cb70dbc
--- /dev/null
+++ b/frontend/src/pages/AttemptReviewPage.css
@@ -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;
+ }
+}
diff --git a/frontend/src/pages/AttemptReviewPage.tsx b/frontend/src/pages/AttemptReviewPage.tsx
new file mode 100644
index 0000000..7e503ff
--- /dev/null
+++ b/frontend/src/pages/AttemptReviewPage.tsx
@@ -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">姝g‘</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>
+ );
+}
diff --git a/frontend/src/pages/LoginPage.css b/frontend/src/pages/LoginPage.css
new file mode 100644
index 0000000..9d6e62d
--- /dev/null
+++ b/frontend/src/pages/LoginPage.css
@@ -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);
+}
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..884d325
--- /dev/null
+++ b/frontend/src/pages/LoginPage.tsx
@@ -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>
+ );
+}
diff --git a/frontend/src/pages/ScoreQueryPage.css b/frontend/src/pages/ScoreQueryPage.css
new file mode 100644
index 0000000..1ebbb98
--- /dev/null
+++ b/frontend/src/pages/ScoreQueryPage.css
@@ -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;
+}
diff --git a/frontend/src/pages/ScoreQueryPage.tsx b/frontend/src/pages/ScoreQueryPage.tsx
new file mode 100644
index 0000000..e3f3141
--- /dev/null
+++ b/frontend/src/pages/ScoreQueryPage.tsx
@@ -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>
+ );
+}
diff --git a/frontend/src/scoreQueryParams.ts b/frontend/src/scoreQueryParams.ts
new file mode 100644
index 0000000..90ee1da
--- /dev/null
+++ b/frontend/src/scoreQueryParams.ts
@@ -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}`;
+}
diff --git a/frontend/src/systemSettings.tsx b/frontend/src/systemSettings.tsx
new file mode 100644
index 0000000..b4b3233
--- /dev/null
+++ b/frontend/src/systemSettings.tsx
@@ -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}`;
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..388f1bb
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -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"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..d985240
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -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,
+ },
+});
+
\ No newline at end of file
diff --git a/scripts/build-frontend.sh b/scripts/build-frontend.sh
new file mode 100644
index 0000000..197d514
--- /dev/null
+++ b/scripts/build-frontend.sh
@@ -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锛�"
diff --git a/scripts/lib.sh b/scripts/lib.sh
new file mode 100644
index 0000000..612db07
--- /dev/null
+++ b/scripts/lib.sh
@@ -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锛歜uild + vite preview @5174锛沝ev锛歷ite 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
+}
diff --git a/scripts/restart.sh b/scripts/restart.sh
new file mode 100644
index 0000000..75f958f
--- /dev/null
+++ b/scripts/restart.sh
@@ -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"
diff --git a/scripts/start.sh b/scripts/start.sh
new file mode 100644
index 0000000..3bfd59a
--- /dev/null
+++ b/scripts/start.sh
@@ -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 榛樿鐢熶骇锛歜uild + Node vite preview @ $FRONTEND_PORT"
+ echo " dev 寮�鍙戯細vite dev @ $FRONTEND_DEV_PORT + 鍚庣 $BACKEND_PORT"
+ exit 1
+ ;;
+esac
diff --git a/scripts/status.sh b/scripts/status.sh
new file mode 100644
index 0000000..149e191
--- /dev/null
+++ b/scripts/status.sh
@@ -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
diff --git a/scripts/stop.sh b/scripts/stop.sh
new file mode 100644
index 0000000..0a8fc3c
--- /dev/null
+++ b/scripts/stop.sh
@@ -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 "瀹屾垚"
--
Gitblit v1.8.0