| | |
| | | # 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/ |
| | |
| | | ## app-full-stack-score-query |
| | | # 考试阅卷系统 |
| | | |
| | | 考务系统客观题批阅复核 |
| | | SQLite + FastAPI + React (Ant Design + Vite) |
| | | |
| | | ## 端口 |
| | | |
| | | - API:`8010`(仅后端) |
| | | - **前端生产(默认)**:`5174`(Node `vite preview` 托管 `dist`,`/api` 代理到 8010) |
| | | - 前端开发:`5175`(`./scripts/start.sh dev`,Vite HMR) |
| | | |
| | | ## 访问域名 |
| | | |
| | | 已默认允许 CORS 来源:`https://grader.gps120.com`、`http://grader.gps120.com`(及本地开发地址)。 |
| | | |
| | | 生产访问:将 `grader.gps120.com` 反代到本机 **`5174`**(或直连 `http://服务器:5174`),无需 Nginx 也可由 Node 提供页面。若需改 CORS: |
| | | |
| | | ```bash |
| | | export CORS_ORIGINS="https://grader.gps120.com,http://grader.gps120.com" |
| | | ``` |
| | | |
| | | Vite 开发已配置 `allowedHosts` 含 `grader.gps120.com`(经隧道/反代访问 dev 时)。 |
| | | |
| | | ## 服务启停(后台运行,关闭终端不退出) |
| | | |
| | | ```bash |
| | | # 首次安装后 |
| | | cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt |
| | | cd ../frontend && npm install |
| | | |
| | | # 启动 / 停止 / 重启 / 状态 |
| | | ./scripts/start.sh # 生产:build + preview @5174 + 后端 8010 |
| | | ./scripts/start.sh dev # 开发:Vite dev @5175 + 后端 8010 |
| | | ./scripts/build-frontend.sh # 仅重新构建 dist(改代码后生产需 build 再 restart frontend) |
| | | ./scripts/stop.sh |
| | | ./scripts/restart.sh |
| | | ./scripts/status.sh |
| | | |
| | | # 日志:logs/backend.log logs/frontend.log(preview) logs/frontend-dev.log logs/frontend-build.log |
| | | # PID: run/backend.pid run/frontend.pid |
| | | ``` |
| | | |
| | | 单独控制:`./scripts/start.sh backend` | `frontend` |
| | | |
| | | ## 后端(手动调试) |
| | | |
| | | ```bash |
| | | cd backend |
| | | source .venv/bin/activate |
| | | python scripts/seed_admin.py |
| | | uvicorn app.main:app --host 127.0.0.1 --port 8010 |
| | | ``` |
| | | |
| | | 默认账号:`admin` / `admin123`(管理员),`grader` / `grader123`(阅卷员) |
| | | |
| | | ## 本地导入样例 |
| | | |
| | | ```bash |
| | | source backend/.venv/bin/activate |
| | | python backend/scripts/import_local.py "data/碳排放核查员" "碳排放核查员三级理论" |
| | | ``` |
| | | |
| | | ## 前端(手动调试) |
| | | |
| | | ```bash |
| | | cd frontend |
| | | npm run dev |
| | | ``` |
| | | |
| | | ## ZIP 格式 |
| | | |
| | | ZIP 内须包含 5 类 SQL:`examination_examinee`、`examination_exam_examinee`、`examination_paper_source`、`examination_paper_item`、`examination_examinee_exam_answer`。 |
| New file |
| | |
| | | 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 |
| New file |
| | |
| | | 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() |
| New file |
| | |
| | | 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() |
| New file |
| | |
| | | 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)) |
| New file |
| | |
| | | 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 |
| New file |
| | |
| | | import hashlib |
| | | import io |
| | | import json |
| | | import zipfile |
| | | from pathlib import Path |
| | | from typing import Any |
| | | |
| | | from fastapi import HTTPException |
| | | from sqlalchemy import delete, func, select |
| | | from sqlalchemy.orm import Session |
| | | |
| | | from app.grading import answers_json_to_map, grade_item |
| | | from app.models import ( |
| | | ExamPaperMap, |
| | | ExaminationExamExaminee, |
| | | ExaminationExaminee, |
| | | ExaminationExamineeExamAnswer, |
| | | ExaminationExamineeExamAnswerItem, |
| | | ExaminationPaperItem, |
| | | ExaminationPaperSource, |
| | | ImportBatch, |
| | | ) |
| | | from app.sql_parser import find_file_content, rows_from_sql |
| | | |
| | | |
| | | def load_zip_files(zip_bytes: bytes) -> dict[str, bytes]: |
| | | out: dict[str, bytes] = {} |
| | | with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: |
| | | for info in zf.infolist(): |
| | | if info.is_dir(): |
| | | continue |
| | | if info.filename.lower().endswith(".sql"): |
| | | out[info.filename] = zf.read(info) |
| | | return out |
| | | |
| | | |
| | | def load_dir_sql(dir_path: Path) -> dict[str, bytes]: |
| | | out: dict[str, bytes] = {} |
| | | for p in dir_path.rglob("*.sql"): |
| | | out[str(p.relative_to(dir_path))] = p.read_bytes() |
| | | return out |
| | | |
| | | |
| | | def _require_sql_kind(files: dict[str, bytes], kind: str, label: str) -> str: |
| | | text = find_file_content(files, kind) |
| | | if not text: |
| | | raise HTTPException(400, detail=f"ZIP 缺少 {label} SQL 文件") |
| | | return text |
| | | |
| | | |
| | | def _paper_identity(row: dict[str, Any]) -> tuple[str, str]: |
| | | return (str(row.get("external_paper_id", "")), str(row.get("revision", ""))) |
| | | |
| | | |
| | | def _collect_exam_ids(exam_rows: list[dict[str, Any]]) -> list[int]: |
| | | ids = sorted({int(r["exam_id"]) for r in exam_rows if r.get("exam_id") is not None}) |
| | | if not ids: |
| | | raise HTTPException(400, detail="考试考生 SQL 中未找到有效的 exam_id") |
| | | return ids |
| | | |
| | | |
| | | def _parse_exam_to_paper_sql_map(exam_sql: str) -> dict[int, int]: |
| | | out: dict[int, int] = {} |
| | | for r in rows_from_sql(exam_sql): |
| | | eid = r.get("id") |
| | | if eid is None: |
| | | continue |
| | | psid = r.get("paper_source_id") |
| | | if psid is None: |
| | | psid = r.get("paper_id") |
| | | if psid is None: |
| | | continue |
| | | out[int(eid)] = int(psid) |
| | | return out |
| | | |
| | | |
| | | def _resolve_exam_paper_map( |
| | | exam_ids: list[int], |
| | | paper_rows: list[dict[str, Any]], |
| | | files: dict[str, bytes], |
| | | ) -> dict[int, int]: |
| | | """exam_id -> paper_source_id(SQL 文件中的试卷 id)。""" |
| | | keys = {_paper_identity(r) for r in paper_rows} |
| | | sql_paper_ids = {int(r["id"]) for r in paper_rows if r.get("id") is not None} |
| | | |
| | | if len(keys) <= 1: |
| | | if not paper_rows: |
| | | raise HTTPException(400, detail="试卷 SQL 无有效数据") |
| | | canonical = int(paper_rows[0]["id"]) |
| | | return {eid: canonical for eid in exam_ids} |
| | | |
| | | exam_text = find_file_content(files, "exam") |
| | | if not exam_text: |
| | | raise HTTPException( |
| | | 400, |
| | | detail="ZIP 含多份试卷时,必须提供 examination_exam SQL,用于关联每场考试的试卷", |
| | | ) |
| | | meta = _parse_exam_to_paper_sql_map(exam_text) |
| | | if not meta: |
| | | raise HTTPException(400, detail="examination_exam SQL 无有效数据或未包含 paper_source_id") |
| | | |
| | | result: dict[int, int] = {} |
| | | for eid in exam_ids: |
| | | ps_sql = meta.get(eid) |
| | | if ps_sql is None: |
| | | raise HTTPException(400, detail=f"examination_exam 中缺少 exam_id={eid} 的试卷关联") |
| | | if ps_sql not in sql_paper_ids: |
| | | raise HTTPException( |
| | | 400, |
| | | detail=f"exam_id={eid} 关联的 paper_source_id={ps_sql} 不在 ZIP 试卷 SQL 中", |
| | | ) |
| | | result[eid] = ps_sql |
| | | return result |
| | | |
| | | |
| | | def _get_or_create_paper_source( |
| | | db: Session, |
| | | batch: ImportBatch, |
| | | row: dict[str, Any], |
| | | ) -> int: |
| | | ext_id, revision = _paper_identity(row) |
| | | existing = db.scalar( |
| | | select(ExaminationPaperSource.id).where( |
| | | ExaminationPaperSource.external_paper_id == ext_id, |
| | | ExaminationPaperSource.revision == revision, |
| | | ) |
| | | ) |
| | | if existing: |
| | | return int(existing) |
| | | |
| | | ps = ExaminationPaperSource( |
| | | id=int(row["id"]), |
| | | external_paper_id=ext_id, |
| | | revision=revision, |
| | | sha256=str(row.get("sha256") or ""), |
| | | paper_name=str(row.get("paper_name") or ""), |
| | | occupation=str(row.get("occupation") or ""), |
| | | level=str(row.get("level") or ""), |
| | | subject=int(row["subject"]) if row.get("subject") is not None else None, |
| | | struct_json=str(row.get("struct_json") or ""), |
| | | item_count=int(row["item_count"]) if row.get("item_count") is not None else None, |
| | | import_batch_id=batch.id, |
| | | ) |
| | | db.add(ps) |
| | | db.flush() |
| | | return int(ps.id) |
| | | |
| | | |
| | | def _items_for_grading( |
| | | db: Session, |
| | | batch: ImportBatch, |
| | | sql_ps_id: int, |
| | | db_ps_id: int, |
| | | item_rows: list[dict[str, Any]], |
| | | ) -> dict[str, ExaminationPaperItem]: |
| | | ps = db.get(ExaminationPaperSource, db_ps_id) |
| | | if not ps: |
| | | raise HTTPException(500, detail=f"试卷 {db_ps_id} 不存在") |
| | | |
| | | if ps.import_batch_id != batch.id: |
| | | items = list( |
| | | db.scalars( |
| | | select(ExaminationPaperItem).where(ExaminationPaperItem.paper_source_id == db_ps_id) |
| | | ) |
| | | ) |
| | | return {it.external_item_id: it for it in items} |
| | | |
| | | items_by_ext: dict[str, ExaminationPaperItem] = {} |
| | | total_max = 0.0 |
| | | for r in item_rows: |
| | | if int(r.get("paper_source_id") or 0) != sql_ps_id: |
| | | continue |
| | | ms = float(r.get("max_score") or 1) |
| | | total_max += ms |
| | | it = ExaminationPaperItem( |
| | | id=int(r["id"]), |
| | | paper_source_id=db_ps_id, |
| | | external_item_id=str(r["external_item_id"]), |
| | | section_ref=str(r.get("section_ref") or ""), |
| | | part_ref=str(r.get("part_ref") or ""), |
| | | part_name=str(r.get("part_name") or ""), |
| | | item_type=int(r["item_type"]) if r.get("item_type") is not None else None, |
| | | item_order=int(r["item_order"]) if r.get("item_order") is not None else None, |
| | | title_html=str(r.get("title_html") or ""), |
| | | detail_json=str(r.get("detail_json") or ""), |
| | | max_score=ms, |
| | | reference_answer=str(r.get("reference_answer") or ""), |
| | | answer_rules=str(r.get("answer_rules") or ""), |
| | | ) |
| | | db.add(it) |
| | | items_by_ext[it.external_item_id] = it |
| | | ps.total_score = total_max |
| | | return items_by_ext |
| | | |
| | | |
| | | def run_import( |
| | | db: Session, |
| | | subject_name: str, |
| | | zip_filename: str, |
| | | file_bytes: bytes, |
| | | user_id: int | None, |
| | | ) -> dict[str, Any]: |
| | | files = load_zip_files(file_bytes) |
| | | sha = hashlib.sha256(file_bytes).hexdigest() |
| | | |
| | | examinee_sql = _require_sql_kind(files, "examinee", "考生") |
| | | exam_examinee_sql = _require_sql_kind(files, "exam_examinee", "考试考生") |
| | | paper_sql = _require_sql_kind(files, "paper_source", "试卷") |
| | | item_sql = _require_sql_kind(files, "paper_item", "试题") |
| | | answer_sql = _require_sql_kind(files, "answer", "考生答案") |
| | | |
| | | paper_rows = rows_from_sql(paper_sql) |
| | | if not paper_rows: |
| | | raise HTTPException(400, detail="试卷 SQL 无有效数据") |
| | | |
| | | exam_rows = rows_from_sql(exam_examinee_sql) |
| | | if not exam_rows: |
| | | raise HTTPException(400, detail="考试考生 SQL 无有效数据") |
| | | |
| | | exam_ids = _collect_exam_ids(exam_rows) |
| | | for eid in exam_ids: |
| | | if db.scalar(select(ExamPaperMap.id).where(ExamPaperMap.exam_id == eid)): |
| | | raise HTTPException(409, detail=f"exam_id {eid} 已存在,请先清除对应导入批次") |
| | | |
| | | exam_to_sql_paper = _resolve_exam_paper_map(exam_ids, paper_rows, files) |
| | | |
| | | paper_row_by_sql_id = {int(r["id"]): r for r in paper_rows if r.get("id") is not None} |
| | | needed_sql_paper_ids = {exam_to_sql_paper[e] for e in exam_ids} |
| | | for sql_id in needed_sql_paper_ids: |
| | | if sql_id not in paper_row_by_sql_id: |
| | | raise HTTPException(400, detail=f"试卷 SQL 缺少 id={sql_id}") |
| | | |
| | | warnings: list[dict[str, Any]] = [] |
| | | report: dict[str, Any] = {"warnings": warnings, "counts": {}} |
| | | |
| | | primary_paper_row = paper_row_by_sql_id[exam_to_sql_paper[exam_ids[0]]] |
| | | ext_id, revision = _paper_identity(primary_paper_row) |
| | | |
| | | try: |
| | | batch = ImportBatch( |
| | | subject_name=subject_name.strip(), |
| | | zip_filename=zip_filename, |
| | | sha256=sha, |
| | | status="success", |
| | | imported_by=user_id, |
| | | external_paper_id=ext_id, |
| | | revision=revision, |
| | | exam_id=exam_ids[0], |
| | | exam_ids_json=json.dumps(exam_ids, ensure_ascii=False), |
| | | occupation=str(primary_paper_row.get("occupation") or ""), |
| | | level=str(primary_paper_row.get("level") or ""), |
| | | subject_code=int(primary_paper_row["subject"]) |
| | | if primary_paper_row.get("subject") is not None |
| | | else None, |
| | | ) |
| | | db.add(batch) |
| | | db.flush() |
| | | |
| | | sql_to_db_paper: dict[int, int] = {} |
| | | for sql_id in needed_sql_paper_ids: |
| | | sql_to_db_paper[sql_id] = _get_or_create_paper_source( |
| | | db, batch, paper_row_by_sql_id[sql_id] |
| | | ) |
| | | |
| | | batch.paper_source_id = sql_to_db_paper[exam_to_sql_paper[exam_ids[0]]] |
| | | |
| | | item_rows = rows_from_sql(item_sql) |
| | | grading_items: dict[int, dict[str, ExaminationPaperItem]] = {} |
| | | for sql_id in set(exam_to_sql_paper.values()): |
| | | db_ps = sql_to_db_paper[sql_id] |
| | | grading_items[db_ps] = _items_for_grading(db, batch, sql_id, db_ps, item_rows) |
| | | |
| | | for r in rows_from_sql(examinee_sql): |
| | | eid = int(r["id"]) |
| | | existing = db.get(ExaminationExaminee, eid) |
| | | if existing: |
| | | existing.username = r.get("username") |
| | | existing.nickname = r.get("nickname") |
| | | existing.id_card = r.get("id_card") |
| | | existing.mobile = r.get("mobile") |
| | | existing.sex = int(r["sex"]) if r.get("sex") is not None else None |
| | | else: |
| | | db.add( |
| | | ExaminationExaminee( |
| | | id=eid, |
| | | username=r.get("username"), |
| | | nickname=r.get("nickname"), |
| | | id_card=r.get("id_card"), |
| | | mobile=r.get("mobile"), |
| | | sex=int(r["sex"]) if r.get("sex") is not None else None, |
| | | deleted=int(r.get("deleted") or 0), |
| | | ) |
| | | ) |
| | | db.flush() |
| | | |
| | | answer_rows = {int(r["exam_examinee_id"]): r for r in rows_from_sql(answer_sql)} |
| | | |
| | | for r in exam_rows: |
| | | aid = int(r["id"]) |
| | | row_exam_id = int(r["exam_id"]) |
| | | imported_score = float(r.get("score") or 0) |
| | | sql_ps = exam_to_sql_paper[row_exam_id] |
| | | db_ps = sql_to_db_paper[sql_ps] |
| | | items_by_ext = grading_items[db_ps] |
| | | |
| | | attempt = ExaminationExamExaminee( |
| | | id=aid, |
| | | exam_id=row_exam_id, |
| | | examinee_id=int(r["examinee_id"]), |
| | | room_id=r.get("room_id"), |
| | | seat_number=r.get("seat_number"), |
| | | status=int(r["status"]) if r.get("status") is not None else None, |
| | | score=0, |
| | | imported_score=imported_score, |
| | | level_str=r.get("level_str"), |
| | | start_time=r.get("start_time"), |
| | | end_time=r.get("end_time"), |
| | | subject=int(r["subject"]) if r.get("subject") is not None else None, |
| | | import_batch_id=batch.id, |
| | | ) |
| | | db.add(attempt) |
| | | |
| | | ar = answer_rows.get(aid) |
| | | if not ar: |
| | | raise HTTPException(400, detail=f"缺少考生答案 exam_examinee_id={aid}") |
| | | answers_str = str(ar.get("answers") or "[]") |
| | | db.add( |
| | | ExaminationExamineeExamAnswer( |
| | | id=int(ar["id"]), |
| | | exam_examinee_id=aid, |
| | | answers=answers_str, |
| | | deleted=int(ar.get("deleted") or 0), |
| | | ) |
| | | ) |
| | | amap = answers_json_to_map(answers_str) |
| | | calc = 0.0 |
| | | for ext, item in items_by_ext.items(): |
| | | ua = amap.get(ext, "") |
| | | ok, sc = grade_item( |
| | | ua, |
| | | item.reference_answer or "", |
| | | item.answer_rules, |
| | | item.item_type, |
| | | item.max_score, |
| | | ) |
| | | calc += sc |
| | | db.add( |
| | | ExaminationExamineeExamAnswerItem( |
| | | exam_examinee_id=aid, |
| | | external_item_id=ext, |
| | | answer_raw=ua, |
| | | is_correct=ok, |
| | | score=sc, |
| | | ) |
| | | ) |
| | | attempt.score = calc |
| | | if abs(calc - imported_score) > 0.01: |
| | | warnings.append( |
| | | { |
| | | "exam_examinee_id": aid, |
| | | "imported_score": imported_score, |
| | | "calculated_score": calc, |
| | | } |
| | | ) |
| | | |
| | | for eid in exam_ids: |
| | | db.add( |
| | | ExamPaperMap( |
| | | exam_id=eid, |
| | | paper_source_id=sql_to_db_paper[exam_to_sql_paper[eid]], |
| | | import_batch_id=batch.id, |
| | | ) |
| | | ) |
| | | |
| | | report["counts"] = { |
| | | "examinees": len(rows_from_sql(examinee_sql)), |
| | | "attempts": len(exam_rows), |
| | | "items": len(item_rows), |
| | | "exam_id": exam_ids[0], |
| | | "exam_ids": exam_ids, |
| | | "papers": len(set(sql_to_db_paper.values())), |
| | | } |
| | | batch.report_json = json.dumps(report, ensure_ascii=False) |
| | | db.commit() |
| | | db.refresh(batch) |
| | | return {"batch_id": batch.id, "report": report} |
| | | except HTTPException: |
| | | db.rollback() |
| | | raise |
| | | except Exception as e: |
| | | db.rollback() |
| | | raise HTTPException(500, detail=f"导入失败: {e}") from e |
| | | |
| | | |
| | | def _paper_owned_by_batch(db: Session, ps_id: int, batch_id: int) -> bool: |
| | | ps = db.get(ExaminationPaperSource, ps_id) |
| | | return ps is not None and ps.import_batch_id == batch_id |
| | | |
| | | |
| | | def _paper_still_referenced(db: Session, ps_id: int, batch_id: int) -> bool: |
| | | other_maps = db.scalar( |
| | | select(func.count()) |
| | | .select_from(ExamPaperMap) |
| | | .where( |
| | | ExamPaperMap.paper_source_id == ps_id, |
| | | ExamPaperMap.import_batch_id != batch_id, |
| | | ) |
| | | ) |
| | | return (other_maps or 0) > 0 |
| | | |
| | | |
| | | def purge_batch(db: Session, batch_id: int) -> dict[str, int]: |
| | | batch = db.get(ImportBatch, batch_id) |
| | | if not batch: |
| | | raise HTTPException(404, detail="导入批次不存在") |
| | | |
| | | counts: dict[str, int] = {} |
| | | attempt_ids = list( |
| | | db.scalars( |
| | | select(ExaminationExamExaminee.id).where( |
| | | ExaminationExamExaminee.import_batch_id == batch_id |
| | | ) |
| | | ) |
| | | ) |
| | | examinee_ids_to_check: set[int] = set() |
| | | if attempt_ids: |
| | | examinee_ids_to_check = set( |
| | | db.scalars( |
| | | select(ExaminationExamExaminee.examinee_id).where( |
| | | ExaminationExamExaminee.id.in_(attempt_ids) |
| | | ) |
| | | ) |
| | | ) |
| | | counts["answer_item"] = db.execute( |
| | | delete(ExaminationExamineeExamAnswerItem).where( |
| | | ExaminationExamineeExamAnswerItem.exam_examinee_id.in_(attempt_ids) |
| | | ) |
| | | ).rowcount |
| | | counts["exam_answer"] = db.execute( |
| | | delete(ExaminationExamineeExamAnswer).where( |
| | | ExaminationExamineeExamAnswer.exam_examinee_id.in_(attempt_ids) |
| | | ) |
| | | ).rowcount |
| | | counts["exam_examinee"] = db.execute( |
| | | delete(ExaminationExamExaminee).where(ExaminationExamExaminee.import_batch_id == batch_id) |
| | | ).rowcount |
| | | |
| | | paper_ids_to_check = set( |
| | | db.scalars( |
| | | select(ExamPaperMap.paper_source_id).where(ExamPaperMap.import_batch_id == batch_id) |
| | | ) |
| | | ) |
| | | if batch.paper_source_id: |
| | | paper_ids_to_check.add(batch.paper_source_id) |
| | | |
| | | counts["exam_paper_map"] = db.execute( |
| | | delete(ExamPaperMap).where(ExamPaperMap.import_batch_id == batch_id) |
| | | ).rowcount |
| | | |
| | | for ps_id in paper_ids_to_check: |
| | | if not _paper_owned_by_batch(db, ps_id, batch_id): |
| | | continue |
| | | if _paper_still_referenced(db, ps_id, batch_id): |
| | | continue |
| | | counts["paper_item"] = counts.get("paper_item", 0) + ( |
| | | db.execute( |
| | | delete(ExaminationPaperItem).where(ExaminationPaperItem.paper_source_id == ps_id) |
| | | ).rowcount |
| | | or 0 |
| | | ) |
| | | counts["paper_source"] = counts.get("paper_source", 0) + ( |
| | | db.execute(delete(ExaminationPaperSource).where(ExaminationPaperSource.id == ps_id)).rowcount |
| | | or 0 |
| | | ) |
| | | |
| | | if examinee_ids_to_check: |
| | | for eid in examinee_ids_to_check: |
| | | ref = db.scalar( |
| | | select(func.count()) |
| | | .select_from(ExaminationExamExaminee) |
| | | .where(ExaminationExamExaminee.examinee_id == eid) |
| | | ) |
| | | if ref == 0: |
| | | db.execute(delete(ExaminationExaminee).where(ExaminationExaminee.id == eid)) |
| | | counts["import_batch"] = db.execute(delete(ImportBatch).where(ImportBatch.id == batch_id)).rowcount |
| | | |
| | | db.commit() |
| | | return counts |
| | | |
| | | |
| | | def parse_batch_exam_ids(batch: ImportBatch) -> list[int]: |
| | | if batch.exam_ids_json: |
| | | try: |
| | | raw = json.loads(batch.exam_ids_json) |
| | | if isinstance(raw, list) and raw: |
| | | return [int(x) for x in raw] |
| | | except (json.JSONDecodeError, TypeError, ValueError): |
| | | pass |
| | | if batch.exam_id is not None: |
| | | return [int(batch.exam_id)] |
| | | return [] |
| New file |
| | |
| | | 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() |
| New file |
| | |
| | | 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") |
| New file |
| | |
| | | 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) |
| New file |
| | |
| | | # routers package |
| New file |
| | |
| | | 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() |
| New file |
| | |
| | | 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} |
| New file |
| | |
| | | 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], |
| | | ) |
| New file |
| | |
| | | from fastapi import APIRouter, Depends, HTTPException |
| | | from sqlalchemy.orm import Session |
| | | |
| | | from app.auth import create_access_token, get_current_user, hash_password, verify_password |
| | | from app.database import get_db |
| | | from app.login_guard import clear_login_lock, ensure_login_allowed, record_login_failure |
| | | from app.models import User |
| | | from app.schemas import ChangePasswordRequest, LoginRequest, TokenResponse, UserMe |
| | | |
| | | router = APIRouter(prefix="/api/auth", tags=["auth"]) |
| | | |
| | | |
| | | @router.post("/login", response_model=TokenResponse) |
| | | def login(body: LoginRequest, db: Session = Depends(get_db)): |
| | | ensure_login_allowed(db, body.username) |
| | | user = db.query(User).filter(User.username == body.username).first() |
| | | if not user or not user.is_active or not verify_password(body.password, user.password_hash): |
| | | record_login_failure(db, body.username) |
| | | raise HTTPException(401, detail="用户名或密码错误") |
| | | clear_login_lock(db, body.username) |
| | | token = create_access_token(user.id, user.role) |
| | | return TokenResponse(access_token=token) |
| | | |
| | | |
| | | @router.get("/me", response_model=UserMe) |
| | | def me(user: User = Depends(get_current_user)): |
| | | return user |
| | | |
| | | |
| | | @router.post("/change-password") |
| | | def change_password( |
| | | body: ChangePasswordRequest, |
| | | db: Session = Depends(get_db), |
| | | user: User = Depends(get_current_user), |
| | | ): |
| | | if body.old_password == body.new_password: |
| | | raise HTTPException(400, detail="新密码不能与旧密码相同") |
| | | if not verify_password(body.old_password, user.password_hash): |
| | | raise HTTPException(400, detail="原密码不正确") |
| | | user.password_hash = hash_password(body.new_password) |
| | | db.commit() |
| | | return {"ok": True} |
| New file |
| | |
| | | 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) |
| New file |
| | |
| | | from pathlib import Path |
| | | |
| | | from fastapi import APIRouter, Depends, File, HTTPException, UploadFile |
| | | from fastapi.responses import FileResponse |
| | | from sqlalchemy.orm import Session |
| | | |
| | | from app.auth import require_admin |
| | | from app.database import get_db |
| | | from app.models import User |
| | | from app.schemas import SystemSettingsOut, SystemSettingsUpdate |
| | | from app.system_settings import ( |
| | | clear_logo_files, |
| | | ensure_upload_dir, |
| | | find_logo_file, |
| | | get_or_create_settings, |
| | | logo_path, |
| | | public_logo_url, |
| | | ) |
| | | |
| | | router = APIRouter(prefix="/api", tags=["settings"]) |
| | | |
| | | ALLOWED_LOGO_EXT = {".png", ".jpg", ".jpeg", ".webp", ".svg", ".gif"} |
| | | MAX_LOGO_BYTES = 2 * 1024 * 1024 |
| | | |
| | | |
| | | def _settings_out(db: Session) -> SystemSettingsOut: |
| | | row = get_or_create_settings(db) |
| | | return SystemSettingsOut(system_name=row.system_name, logo_url=public_logo_url(row.logo_ext or None)) |
| | | |
| | | |
| | | @router.get("/settings", response_model=SystemSettingsOut) |
| | | def get_settings(db: Session = Depends(get_db)): |
| | | return _settings_out(db) |
| | | |
| | | |
| | | @router.get("/settings/logo") |
| | | def get_logo(db: Session = Depends(get_db)): |
| | | row = get_or_create_settings(db) |
| | | path = find_logo_file(row.logo_ext or None) |
| | | if not path: |
| | | raise HTTPException(404, detail="未设置 Logo") |
| | | media = { |
| | | ".png": "image/png", |
| | | ".jpg": "image/jpeg", |
| | | ".jpeg": "image/jpeg", |
| | | ".webp": "image/webp", |
| | | ".svg": "image/svg+xml", |
| | | ".gif": "image/gif", |
| | | }.get(path.suffix.lower(), "application/octet-stream") |
| | | return FileResponse(path, media_type=media) |
| | | |
| | | |
| | | @router.put("/admin/settings", response_model=SystemSettingsOut) |
| | | def update_settings( |
| | | body: SystemSettingsUpdate, |
| | | db: Session = Depends(get_db), |
| | | _: User = Depends(require_admin), |
| | | ): |
| | | row = get_or_create_settings(db) |
| | | row.system_name = body.system_name.strip() |
| | | db.commit() |
| | | db.refresh(row) |
| | | return _settings_out(db) |
| | | |
| | | |
| | | @router.post("/admin/settings/logo", response_model=SystemSettingsOut) |
| | | async def upload_logo( |
| | | file: UploadFile = File(...), |
| | | db: Session = Depends(get_db), |
| | | _: User = Depends(require_admin), |
| | | ): |
| | | if not file.filename: |
| | | raise HTTPException(400, detail="请选择文件") |
| | | ext = Path(file.filename).suffix.lower() |
| | | if ext not in ALLOWED_LOGO_EXT: |
| | | raise HTTPException(400, detail="仅支持 png、jpg、webp、svg、gif") |
| | | content = await file.read() |
| | | if len(content) > MAX_LOGO_BYTES: |
| | | raise HTTPException(400, detail="Logo 不能超过 2MB") |
| | | ensure_upload_dir() |
| | | clear_logo_files() |
| | | ext_clean = ext.lstrip(".") |
| | | dest = logo_path(ext_clean) |
| | | dest.write_bytes(content) |
| | | row = get_or_create_settings(db) |
| | | row.logo_ext = ext_clean |
| | | db.commit() |
| | | db.refresh(row) |
| | | return _settings_out(db) |
| | | |
| | | |
| | | @router.delete("/admin/settings/logo", response_model=SystemSettingsOut) |
| | | def delete_logo( |
| | | db: Session = Depends(get_db), |
| | | _: User = Depends(require_admin), |
| | | ): |
| | | clear_logo_files() |
| | | row = get_or_create_settings(db) |
| | | row.logo_ext = "" |
| | | db.commit() |
| | | db.refresh(row) |
| | | return _settings_out(db) |
| New file |
| | |
| | | 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 |
| New file |
| | |
| | | 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 |
| New file |
| | |
| | | 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 |
| New file |
| | |
| | | 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 |
| New file |
| | |
| | | #!/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() |
| New file |
| | |
| | | #!/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() |
| New file |
| | |
| | | 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 |
| New file |
| | |
| | | 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} |
| New file |
| | |
| | | <!-- Generated by Trae Work --> |
| | | <!DOCTYPE html> |
| | | <html lang="zh-CN"> |
| | | <head> |
| | | <meta charset="UTF-8"> |
| | | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| | | <title>考试阅卷系统 - 华为云部署新手教程</title> |
| | | <style> |
| | | * { margin: 0; padding: 0; box-sizing: border-box; } |
| | | body { |
| | | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; |
| | | background: #f5f7fa; |
| | | color: #333; |
| | | line-height: 1.8; |
| | | } |
| | | .container { |
| | | max-width: 960px; |
| | | margin: 0 auto; |
| | | padding: 40px 20px; |
| | | } |
| | | h1 { |
| | | font-size: 32px; |
| | | color: #1a1a2e; |
| | | margin-bottom: 10px; |
| | | text-align: center; |
| | | } |
| | | .subtitle { |
| | | text-align: center; |
| | | color: #666; |
| | | margin-bottom: 40px; |
| | | font-size: 16px; |
| | | } |
| | | .toc { |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | padding: 24px 32px; |
| | | margin-bottom: 32px; |
| | | box-shadow: 0 2px 12px rgba(0,0,0,0.06); |
| | | } |
| | | .toc h2 { |
| | | font-size: 18px; |
| | | margin-bottom: 12px; |
| | | color: #1a1a2e; |
| | | } |
| | | .toc ol { |
| | | padding-left: 24px; |
| | | } |
| | | .toc li { |
| | | margin: 6px 0; |
| | | } |
| | | .toc a { |
| | | color: #0052d9; |
| | | text-decoration: none; |
| | | } |
| | | .toc a:hover { |
| | | text-decoration: underline; |
| | | } |
| | | .section { |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | padding: 32px; |
| | | margin-bottom: 24px; |
| | | box-shadow: 0 2px 12px rgba(0,0,0,0.06); |
| | | } |
| | | .section h2 { |
| | | font-size: 24px; |
| | | color: #1a1a2e; |
| | | margin-bottom: 16px; |
| | | padding-bottom: 12px; |
| | | border-bottom: 2px solid #e8eaed; |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | } |
| | | .section h2 .step-num { |
| | | background: linear-gradient(135deg, #0052d9, #36cffb); |
| | | color: #fff; |
| | | width: 36px; |
| | | height: 36px; |
| | | border-radius: 50%; |
| | | display: inline-flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | font-size: 16px; |
| | | font-weight: bold; |
| | | } |
| | | .section h3 { |
| | | font-size: 18px; |
| | | color: #1a1a2e; |
| | | margin: 20px 0 10px; |
| | | } |
| | | .section p { |
| | | margin: 10px 0; |
| | | color: #444; |
| | | } |
| | | .section ul, .section ol { |
| | | padding-left: 24px; |
| | | margin: 10px 0; |
| | | } |
| | | .section li { |
| | | margin: 6px 0; |
| | | color: #444; |
| | | } |
| | | .code-block { |
| | | background: #1e1e2e; |
| | | color: #cdd6f4; |
| | | padding: 16px 20px; |
| | | border-radius: 8px; |
| | | margin: 12px 0; |
| | | font-family: "Fira Code", "Consolas", monospace; |
| | | font-size: 14px; |
| | | overflow-x: auto; |
| | | line-height: 1.6; |
| | | } |
| | | .code-block .comment { color: #6c7086; } |
| | | .code-block .cmd { color: #a6e3a1; } |
| | | .code-block .var { color: #f9e2af; } |
| | | .tip { |
| | | background: #ecf5ff; |
| | | border-left: 4px solid #409eff; |
| | | padding: 12px 16px; |
| | | border-radius: 4px; |
| | | margin: 12px 0; |
| | | color: #333; |
| | | } |
| | | .tip::before { |
| | | content: "💡 提示:"; |
| | | font-weight: bold; |
| | | color: #409eff; |
| | | } |
| | | .warning { |
| | | background: #fdf6ec; |
| | | border-left: 4px solid #e6a23c; |
| | | padding: 12px 16px; |
| | | border-radius: 4px; |
| | | margin: 12px 0; |
| | | color: #333; |
| | | } |
| | | .warning::before { |
| | | content: "⚠️ 注意:"; |
| | | font-weight: bold; |
| | | color: #e6a23c; |
| | | } |
| | | .success { |
| | | background: #f0f9eb; |
| | | border-left: 4px solid #67c23a; |
| | | padding: 12px 16px; |
| | | border-radius: 4px; |
| | | margin: 12px 0; |
| | | color: #333; |
| | | } |
| | | .success::before { |
| | | content: "✅ 成功:"; |
| | | font-weight: bold; |
| | | color: #67c23a; |
| | | } |
| | | .table-wrapper { |
| | | overflow-x: auto; |
| | | margin: 16px 0; |
| | | } |
| | | table { |
| | | width: 100%; |
| | | border-collapse: collapse; |
| | | font-size: 14px; |
| | | } |
| | | th, td { |
| | | border: 1px solid #e8eaed; |
| | | padding: 10px 14px; |
| | | text-align: left; |
| | | } |
| | | th { |
| | | background: #f5f7fa; |
| | | font-weight: 600; |
| | | color: #1a1a2e; |
| | | } |
| | | tr:nth-child(even) { |
| | | background: #fafbfc; |
| | | } |
| | | .highlight { |
| | | background: #fffbe6; |
| | | padding: 2px 6px; |
| | | border-radius: 3px; |
| | | font-family: monospace; |
| | | font-size: 13px; |
| | | } |
| | | .architecture { |
| | | background: #f8f9fc; |
| | | border-radius: 8px; |
| | | padding: 24px; |
| | | margin: 16px 0; |
| | | text-align: center; |
| | | } |
| | | .arch-box { |
| | | display: inline-block; |
| | | background: #fff; |
| | | border: 2px solid #0052d9; |
| | | color: #0052d9; |
| | | padding: 12px 24px; |
| | | border-radius: 8px; |
| | | margin: 8px; |
| | | font-weight: 600; |
| | | } |
| | | .arch-arrow { |
| | | display: inline-block; |
| | | color: #999; |
| | | font-size: 24px; |
| | | margin: 0 8px; |
| | | vertical-align: middle; |
| | | } |
| | | .faq-item { |
| | | border: 1px solid #e8eaed; |
| | | border-radius: 8px; |
| | | margin: 10px 0; |
| | | overflow: hidden; |
| | | } |
| | | .faq-q { |
| | | background: #f5f7fa; |
| | | padding: 12px 16px; |
| | | font-weight: 600; |
| | | color: #1a1a2e; |
| | | } |
| | | .faq-a { |
| | | padding: 12px 16px; |
| | | color: #555; |
| | | } |
| | | .footer { |
| | | text-align: center; |
| | | color: #999; |
| | | padding: 20px; |
| | | font-size: 14px; |
| | | } |
| | | </style> |
| | | </head> |
| | | <body> |
| | | <div class="container"> |
| | | <h1>🚀 考试阅卷系统 - 华为云部署新手教程</h1> |
| | | <p class="subtitle">从购买服务器到上线运行,一步步带你完成部署</p> |
| | | |
| | | <div class="toc"> |
| | | <h2>📑 目录</h2> |
| | | <ol> |
| | | <li><a href="#overview">项目概览与部署方案</a></li> |
| | | <li><a href="#step1">第一步:购买华为云 ECS 服务器</a></li> |
| | | <li><a href="#step2">第二步:配置安全组(开放端口)</a></li> |
| | | <li><a href="#step3">第三步:远程登录服务器</a></li> |
| | | <li><a href="#step4">第四步:安装运行环境</a></li> |
| | | <li><a href="#step5">第五步:上传项目代码</a></li> |
| | | <li><a href="#step6">第六步:安装项目依赖</a></li> |
| | | <li><a href="#step7">第七步:初始化数据库与管理员</a></li> |
| | | <li><a href="#step8">第八步:启动服务并验证</a></li> |
| | | <li><a href="#step9">第九步:配置开机自启(systemd)</a></li> |
| | | <li><a href="#step10">第十步:绑定域名(可选)</a></li> |
| | | <li><a href="#faq">常见问题 FAQ</a></li> |
| | | </ol> |
| | | </div> |
| | | |
| | | <!-- 0. 项目概览 --> |
| | | <div class="section" id="overview"> |
| | | <h2><span class="step-num">📌</span> 项目概览与部署方案</h2> |
| | | |
| | | <h3>项目技术栈</h3> |
| | | <div class="table-wrapper"> |
| | | <table> |
| | | <tr><th>层级</th><th>技术</th><th>端口</th></tr> |
| | | <tr><td>前端</td><td>React + TypeScript + Vite + Ant Design</td><td>5174(生产)</td></tr> |
| | | <tr><td>后端</td><td>Python FastAPI + SQLAlchemy + Uvicorn</td><td>8010</td></tr> |
| | | <tr><td>数据库</td><td>SQLite(文件型,无需单独部署)</td><td>-</td></tr> |
| | | </table> |
| | | </div> |
| | | |
| | | <h3>部署架构</h3> |
| | | <div class="architecture"> |
| | | <div class="arch-box">用户浏览器</div> |
| | | <div class="arch-arrow">→</div> |
| | | <div class="arch-box">ECS 云服务器</div> |
| | | <div style="margin-top:12px;"> |
| | | <div class="arch-box" style="border-color:#67c23a;color:#67c23a;">前端 :5174</div> |
| | | <div class="arch-arrow">→</div> |
| | | <div class="arch-box" style="border-color:#e6a23c;color:#e6a23a;">后端 :8010</div> |
| | | <div class="arch-arrow">→</div> |
| | | <div class="arch-box" style="border-color:#909399;color:#909399;">SQLite 文件</div> |
| | | </div> |
| | | </div> |
| | | |
| | | <h3>为什么选 ECS?</h3> |
| | | <ul> |
| | | <li><strong>最简单</strong>:一台服务器搞定全部,适合新手</li> |
| | | <li><strong>成本低</strong>:SQLite 不需要额外买数据库</li> |
| | | <li><strong>灵活</strong>:后续可以随时升级配置</li> |
| | | </ul> |
| | | |
| | | <div class="tip"> |
| | | 本教程选择 <strong>华为云弹性云服务器 ECS + Ubuntu 22.04</strong> 操作系统,这是最通用、文档最多的组合。 |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 第一步:购买 ECS --> |
| | | <div class="section" id="step1"> |
| | | <h2><span class="step-num">1</span> 购买华为云 ECS 服务器</h2> |
| | | |
| | | <h3>1.1 注册华为云账号</h3> |
| | | <p>访问 <a href="https://www.huaweicloud.com/" target="_blank">华为云官网</a>,点击右上角「注册」,按照提示完成注册和实名认证。</p> |
| | | |
| | | <h3>1.2 购买 ECS</h3> |
| | | <ol> |
| | | <li>登录后,在顶部搜索框输入「弹性云服务器 ECS」,点击进入</li> |
| | | <li>点击「购买弹性云服务器」</li> |
| | | <li>按照以下配置选择:</li> |
| | | </ol> |
| | | |
| | | <div class="table-wrapper"> |
| | | <table> |
| | | <tr><th>配置项</th><th>推荐选择</th><th>说明</th></tr> |
| | | <tr><td>计费模式</td><td>按需计费 / 包年包月</td><td>新手先用按需计费试试,稳定后转包月更便宜</td></tr> |
| | | <tr><td>区域</td><td>选离你最近的(如:北京四、上海一)</td><td>影响访问速度</td></tr> |
| | | <tr><td>CPU 架构</td><td>x86 计算</td><td>兼容性最好</td></tr> |
| | | <tr><td>规格</td><td>通用计算型 | s6.large.2(2核4G)</td><td>💡 最低 2核2G 也能跑,但推荐 2核4G 更流畅</td></tr> |
| | | <tr><td>镜像</td><td>公共镜像 → Ubuntu → Ubuntu 22.04 64bit</td><td>本教程基于此系统</td></tr> |
| | | <tr><td>系统盘</td><td>通用型 SSD,40GB</td><td>足够用了</td></tr> |
| | | <tr><td>带宽</td><td>按流量计费,5Mbit/s</td><td>小流量场景省钱</td></tr> |
| | | </table> |
| | | </div> |
| | | |
| | | <h3>1.3 设置登录凭证</h3> |
| | | <p>在「高级配置」中设置登录方式:</p> |
| | | <ul> |
| | | <li><strong>密码方式(推荐新手)</strong>:设置 root 用户密码,请牢记!</li> |
| | | <li>密钥对方式:更安全,但需要生成密钥对</li> |
| | | </ul> |
| | | |
| | | <div class="warning"> |
| | | 请务必记住你的服务器 <strong>公网 IP 地址</strong> 和 <strong>root 密码</strong>,后面会反复用到。 |
| | | </div> |
| | | |
| | | <p>确认配置无误后,点击「立即购买」→ 「去支付」,等待一两分钟服务器就创建好了。</p> |
| | | </div> |
| | | |
| | | <!-- 第二步:配置安全组 --> |
| | | <div class="section" id="step2"> |
| | | <h2><span class="step-num">2</span> 配置安全组(开放端口)</h2> |
| | | |
| | | <p>华为云默认只开放了 22 端口(SSH 登录用),我们需要手动开放项目要用的端口。</p> |
| | | |
| | | <h3>2.1 进入安全组配置</h3> |
| | | <ol> |
| | | <li>在 ECS 控制台,找到你的服务器,点击服务器名称进入详情</li> |
| | | <li>找到「安全组」标签页</li> |
| | | <li>点击安全组名称(如 default)进入配置</li> |
| | | </ol> |
| | | |
| | | <h3>2.2 添加入方向规则</h3> |
| | | <p>点击「添加规则」,依次添加以下规则:</p> |
| | | |
| | | <div class="table-wrapper"> |
| | | <table> |
| | | <tr><th>优先级</th><th>策略</th><th>协议端口</th><th>类型</th><th>源地址</th><th>说明</th></tr> |
| | | <tr><td>1</td><td>允许</td><td>TCP:22</td><td>IPv4</td><td>0.0.0.0/0</td><td>SSH 远程登录</td></tr> |
| | | <tr><td>1</td><td>允许</td><td>TCP:5174</td><td>IPv4</td><td>0.0.0.0/0</td><td>前端访问端口</td></tr> |
| | | <tr><td>1</td><td>允许</td><td>TCP:8010</td><td>IPv4</td><td>0.0.0.0/0</td><td>后端 API 端口</td></tr> |
| | | <tr><td>1</td><td>允许</td><td>TCP:80</td><td>IPv4</td><td>0.0.0.0/0</td><td>HTTP(后面绑域名用)</td></tr> |
| | | <tr><td>1</td><td>允许</td><td>TCP:443</td><td>IPv4</td><td>0.0.0.0/0</td><td>HTTPS(后面绑域名用)</td></tr> |
| | | </table> |
| | | </div> |
| | | |
| | | <div class="tip"> |
| | | 源地址填 <span class="highlight">0.0.0.0/0</span> 表示允许所有 IP 访问。如果只有你自己用,可以填你电脑的公网 IP,更安全。 |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 第三步:登录服务器 --> |
| | | <div class="section" id="step3"> |
| | | <h2><span class="step-num">3</span> 远程登录服务器</h2> |
| | | |
| | | <h3>Windows 用户:使用 PowerShell</h3> |
| | | <p>按下 <kbd>Win</kbd> + <kbd>X</kbd>,选择「Windows PowerShell」或「终端」,输入:</p> |
| | | <div class="code-block"> |
| | | <span class="cmd">ssh</span> root@你的服务器公网IP |
| | | <span class="comment"># 例如:ssh root@123.45.67.89</span> |
| | | </div> |
| | | <p>第一次连接会提示「是否继续连接」,输入 <span class="highlight">yes</span> 回车,然后输入密码(密码不会显示,输完直接回车)。</p> |
| | | |
| | | <h3>Mac 用户:使用终端</h3> |
| | | <p>打开「终端」App,同样输入:</p> |
| | | <div class="code-block"><span class="cmd">ssh</span> root@你的服务器公网IP</div> |
| | | |
| | | <div class="success"> |
| | | 登录成功后,你会看到类似 <span class="highlight">root@ecs-xxx:~#</span> 的提示符,说明已经进入服务器了! |
| | | </div> |
| | | |
| | | <div class="tip"> |
| | | 也可以使用华为云控制台的「远程登录」功能(VNC),在 ECS 详情页点击「远程登录」按钮即可。 |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 第四步:安装环境 --> |
| | | <div class="section" id="step4"> |
| | | <h2><span class="step-num">4</span> 安装运行环境</h2> |
| | | |
| | | <p>登录服务器后,依次执行以下命令。每输完一行按回车执行,等待完成再继续。</p> |
| | | |
| | | <h3>4.1 更新系统包</h3> |
| | | <div class="code-block"> |
| | | <span class="cmd">apt</span> update && <span class="cmd">apt</span> upgrade -y |
| | | </div> |
| | | <p>这一步可能需要几分钟,耐心等待。</p> |
| | | |
| | | <h3>4.2 安装 Python 3 和 pip</h3> |
| | | <div class="code-block"> |
| | | <span class="cmd">apt</span> install -y python3 python3-venv python3-pip git |
| | | </div> |
| | | <p>验证安装:</p> |
| | | <div class="code-block"> |
| | | <span class="cmd">python3</span> --version |
| | | <span class="comment"># 输出类似 Python 3.10.x 就对了</span> |
| | | </div> |
| | | |
| | | <h3>4.3 安装 Node.js</h3> |
| | | <p>前端构建需要 Node.js,我们安装 LTS 版本:</p> |
| | | <div class="code-block"> |
| | | <span class="comment"># 下载 Node.js 安装脚本</span> |
| | | <span class="cmd">curl</span> -fsSL https://deb.nodesource.com/setup_20.x | bash - |
| | | |
| | | <span class="comment"># 安装 Node.js</span> |
| | | <span class="cmd">apt</span> install -y nodejs |
| | | |
| | | <span class="comment"># 验证安装</span> |
| | | <span class="cmd">node</span> --version |
| | | <span class="cmd">npm</span> --version |
| | | <span class="comment"># 输出 v20.x.x 和 10.x.x 就对了</span> |
| | | </div> |
| | | |
| | | <div class="success"> |
| | | 环境安装完成!现在有了 Python、Node.js 和 Git,可以部署项目了。 |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 第五步:上传代码 --> |
| | | <div class="section" id="step5"> |
| | | <h2><span class="step-num">5</span> 上传项目代码到服务器</h2> |
| | | |
| | | <p>有两种方式上传代码,选择你方便的一种:</p> |
| | | |
| | | <h3>方式一:Git 克隆(推荐,如果代码在 Git 仓库)</h3> |
| | | <p>如果你的代码已经推送到 Gitee / GitHub / GitLab 等平台:</p> |
| | | <div class="code-block"> |
| | | <span class="cmd">cd</span> /opt |
| | | <span class="cmd">git</span> clone 你的仓库地址 score-query |
| | | <span class="comment"># 例如:git clone https://gitee.com/你的用户名/score-query.git</span> |
| | | |
| | | <span class="cmd">cd</span> score-query |
| | | <span class="cmd">ls</span> |
| | | <span class="comment"># 应该能看到 backend、frontend、scripts 等目录</span> |
| | | </div> |
| | | |
| | | <h3>方式二:本地上传(适合代码在本地电脑)</h3> |
| | | <p><strong>Windows 用户:</strong>下载 <a href="https://winscp.net/" target="_blank">WinSCP</a> 或使用 <a href="https://www.putty.org/" target="_blank">PuTTY</a> 的 pscp</p> |
| | | <p><strong>Mac 用户:</strong>在本地终端执行:</p> |
| | | <div class="code-block"> |
| | | <span class="cmd">scp</span> -r /本地项目路径 root@你的服务器IP:/opt/score-query |
| | | </div> |
| | | |
| | | <div class="tip"> |
| | | 如果你用的是 VS Code,可以安装「Remote - SSH」插件,直接在 VS Code 里编辑服务器上的文件,非常方便! |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 第六步:安装依赖 --> |
| | | <div class="section" id="step6"> |
| | | <h2><span class="step-num">6</span> 安装项目依赖</h2> |
| | | |
| | | <p>假设项目在 <span class="highlight">/opt/score-query</span> 目录下:</p> |
| | | |
| | | <h3>6.1 安装后端 Python 依赖</h3> |
| | | <div class="code-block"> |
| | | <span class="cmd">cd</span> /opt/score-query/backend |
| | | |
| | | <span class="comment"># 创建虚拟环境(推荐,隔离依赖)</span> |
| | | <span class="cmd">python3</span> -m venv .venv |
| | | |
| | | <span class="comment"># 激活虚拟环境并安装依赖</span> |
| | | <span class="cmd">source</span> .venv/bin/activate |
| | | <span class="cmd">pip</span> install -r requirements.txt |
| | | |
| | | <span class="comment"># 等待安装完成,验证一下</span> |
| | | <span class="cmd">python</span> -c "import fastapi; print('FastAPI 版本:', fastapi.__version__)" |
| | | </div> |
| | | |
| | | <h3>6.2 安装前端 Node.js 依赖</h3> |
| | | <div class="code-block"> |
| | | <span class="cmd">cd</span> /opt/score-query/frontend |
| | | |
| | | <span class="comment"># 安装依赖,可能需要几分钟</span> |
| | | <span class="cmd">npm</span> install |
| | | |
| | | <span class="comment"># 验证</span> |
| | | <span class="cmd">npm</span> run build |
| | | <span class="comment"># 如果能成功构建出 dist 目录就说明没问题</span> |
| | | </div> |
| | | |
| | | <div class="warning"> |
| | | 如果 <span class="highlight">npm install</span> 很慢,可以切换到国内镜像:<br> |
| | | <span class="highlight">npm config set registry https://registry.npmmirror.com</span> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 第七步:初始化 --> |
| | | <div class="section" id="step7"> |
| | | <h2><span class="step-num">7</span> 初始化数据库与管理员</h2> |
| | | |
| | | <h3>7.1 创建数据目录</h3> |
| | | <div class="code-block"> |
| | | <span class="cmd">cd</span> /opt/score-query/backend |
| | | <span class="cmd">mkdir</span> -p data uploads |
| | | </div> |
| | | |
| | | <h3>7.2 初始化管理员账号</h3> |
| | | <div class="code-block"> |
| | | <span class="cmd">cd</span> /opt/score-query/backend |
| | | <span class="cmd">source</span> .venv/bin/activate |
| | | <span class="cmd">python</span> scripts/seed_admin.py |
| | | </div> |
| | | |
| | | <div class="success"> |
| | | 默认账号:<br> |
| | | 管理员:<span class="highlight">admin</span> / 密码:<span class="highlight">admin123</span><br> |
| | | 阅卷员:<span class="highlight">grader</span> / 密码:<span class="highlight">grader123</span><br> |
| | | <strong>上线后请务必修改默认密码!</strong> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 第八步:启动服务 --> |
| | | <div class="section" id="step8"> |
| | | <h2><span class="step-num">8</span> 启动服务并验证</h2> |
| | | |
| | | <h3>8.1 使用项目自带脚本启动</h3> |
| | | <p>项目已经提供了方便的启动脚本:</p> |
| | | <div class="code-block"> |
| | | <span class="cmd">cd</span> /opt/score-query |
| | | |
| | | <span class="comment"># 给脚本添加执行权限(首次需要)</span> |
| | | <span class="cmd">chmod</span> +x scripts/*.sh |
| | | |
| | | <span class="comment"># 启动全部服务(生产模式)</span> |
| | | ./scripts/start.sh |
| | | </div> |
| | | |
| | | <p>启动成功后,你会看到类似输出:</p> |
| | | <div class="code-block"> |
| | | 后端已启动 → http://127.0.0.1:8010 |
| | | 前端生产 → http://0.0.0.0:5174 |
| | | </div> |
| | | |
| | | <h3>8.2 验证服务是否正常</h3> |
| | | <p>检查进程是否在运行:</p> |
| | | <div class="code-block"> |
| | | ./scripts/status.sh |
| | | <span class="comment"># 或</span> |
| | | <span class="cmd">ps</span> aux | grep uvicorn |
| | | <span class="cmd">ps</span> aux | grep vite |
| | | </div> |
| | | |
| | | <p>检查端口是否监听:</p> |
| | | <div class="code-block"> |
| | | <span class="cmd">ss</span> -tlnp | grep -E '5174|8010' |
| | | </div> |
| | | |
| | | <h3>8.3 在浏览器访问</h3> |
| | | <p>打开浏览器,访问:</p> |
| | | <div class="code-block"> |
| | | http://你的服务器公网IP:5174 |
| | | </div> |
| | | |
| | | <div class="success"> |
| | | 如果能看到登录页面,输入 <span class="highlight">admin / admin123</span> 能成功登录,那么恭喜你,部署成功了!🎉 |
| | | </div> |
| | | |
| | | <h3>8.4 常用命令</h3> |
| | | <div class="table-wrapper"> |
| | | <table> |
| | | <tr><th>命令</th><th>作用</th></tr> |
| | | <tr><td><span class="highlight">./scripts/start.sh</span></td><td>启动全部服务(生产模式)</td></tr> |
| | | <tr><td><span class="highlight">./scripts/stop.sh</span></td><td>停止全部服务</td></tr> |
| | | <tr><td><span class="highlight">./scripts/restart.sh</span></td><td>重启全部服务</td></tr> |
| | | <tr><td><span class="highlight">./scripts/status.sh</span></td><td>查看服务状态</td></tr> |
| | | <tr><td><span class="highlight">./scripts/build-frontend.sh</span></td><td>重新构建前端</td></tr> |
| | | </table> |
| | | </div> |
| | | |
| | | <h3>8.5 查看日志</h3> |
| | | <div class="code-block"> |
| | | <span class="comment"># 后端日志</span> |
| | | <span class="cmd">tail</span> -f logs/backend.log |
| | | |
| | | <span class="comment"># 前端日志</span> |
| | | <span class="cmd">tail</span> -f logs/frontend.log |
| | | </div> |
| | | <p>按 <kbd>Ctrl</kbd> + <kbd>C</kbd> 退出日志查看。</p> |
| | | </div> |
| | | |
| | | <!-- 第九步:开机自启 --> |
| | | <div class="section" id="step9"> |
| | | <h2><span class="step-num">9</span> 配置开机自启(强烈推荐)</h2> |
| | | |
| | | <p>前面的方式启动后,如果服务器重启,服务不会自动恢复。我们配置 systemd 服务来实现开机自启。</p> |
| | | |
| | | <h3>9.1 创建后端服务</h3> |
| | | <div class="code-block"> |
| | | <span class="cmd">cat</span> > /etc/systemd/system/score-backend.service << 'EOF' |
| | | [Unit] |
| | | Description=Score Query Backend (FastAPI) |
| | | After=network.target |
| | | |
| | | [Service] |
| | | Type=simple |
| | | User=root |
| | | WorkingDirectory=/opt/score-query/backend |
| | | ExecStart=/opt/score-query/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8010 |
| | | Restart=always |
| | | RestartSec=5 |
| | | Environment=PYTHONUNBUFFERED=1 |
| | | |
| | | [Install] |
| | | WantedBy=multi-user.target |
| | | EOF |
| | | </div> |
| | | |
| | | <h3>9.2 创建前端服务</h3> |
| | | <div class="code-block"> |
| | | <span class="cmd">cat</span> > /etc/systemd/system/score-frontend.service << 'EOF' |
| | | [Unit] |
| | | Description=Score Query Frontend (Vite Preview) |
| | | After=network.target |
| | | |
| | | [Service] |
| | | Type=simple |
| | | User=root |
| | | WorkingDirectory=/opt/score-query/frontend |
| | | ExecStart=/usr/bin/npm run preview |
| | | Restart=always |
| | | RestartSec=5 |
| | | |
| | | [Install] |
| | | WantedBy=multi-user.target |
| | | EOF |
| | | </div> |
| | | |
| | | <h3>9.3 启用并启动服务</h3> |
| | | <div class="code-block"> |
| | | <span class="comment"># 先停止之前的脚本启动的服务</span> |
| | | <span class="cmd">cd</span> /opt/score-query && ./scripts/stop.sh |
| | | |
| | | <span class="comment"># 重新加载 systemd 配置</span> |
| | | <span class="cmd">systemctl</span> daemon-reload |
| | | |
| | | <span class="comment"># 设置开机自启</span> |
| | | <span class="cmd">systemctl</span> enable score-backend score-frontend |
| | | |
| | | <span class="comment"># 启动服务</span> |
| | | <span class="cmd">systemctl</span> start score-backend score-frontend |
| | | |
| | | <span class="comment"># 查看状态</span> |
| | | <span class="cmd">systemctl</span> status score-backend |
| | | <span class="cmd">systemctl</span> status score-frontend |
| | | </div> |
| | | |
| | | <div class="success"> |
| | | 看到 <span class="highlight">active (running)</span> 就说明配置成功了!服务器重启后服务会自动启动。 |
| | | </div> |
| | | |
| | | <h3>9.4 systemd 常用命令</h3> |
| | | <div class="table-wrapper"> |
| | | <table> |
| | | <tr><th>命令</th><th>作用</th></tr> |
| | | <tr><td>systemctl start 服务名</td><td>启动服务</td></tr> |
| | | <tr><td>systemctl stop 服务名</td><td>停止服务</td></tr> |
| | | <tr><td>systemctl restart 服务名</td><td>重启服务</td></tr> |
| | | <tr><td>systemctl status 服务名</td><td>查看服务状态</td></tr> |
| | | <tr><td>journalctl -u 服务名 -f</td><td>查看服务日志</td></tr> |
| | | </table> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 第十步:绑定域名 --> |
| | | <div class="section" id="step10"> |
| | | <h2><span class="step-num">10</span> 绑定域名(可选进阶)</h2> |
| | | |
| | | <p>用 IP 访问不太方便,也不专业。我们可以绑定一个域名。</p> |
| | | |
| | | <h3>10.1 购买域名</h3> |
| | | <p>在华为云「域名注册」服务购买一个域名,例如 <span class="highlight">yourdomain.com</span>。</p> |
| | | |
| | | <h3>10.2 配置 DNS 解析</h3> |
| | | <ol> |
| | | <li>进入「云解析服务 DNS」</li> |
| | | <li>找到你的域名,点击「解析」</li> |
| | | <li>添加记录:</li> |
| | | </ol> |
| | | <div class="table-wrapper"> |
| | | <table> |
| | | <tr><th>主机记录</th><th>类型</th><th>值</th></tr> |
| | | <tr><td>grader(或 www)</td><td>A</td><td>你的服务器公网IP</td></tr> |
| | | </table> |
| | | </div> |
| | | <p>等待几分钟解析生效。</p> |
| | | |
| | | <h3>10.3 修改 CORS 配置</h3> |
| | | <p>因为项目有 CORS 跨域限制,需要把你的域名加到允许列表中。创建环境变量配置:</p> |
| | | <div class="code-block"> |
| | | <span class="cmd">cat</span> > /opt/score-query/.env << 'EOF' |
| | | CORS_ORIGINS="http://grader.yourdomain.com,https://grader.yourdomain.com,http://localhost:5174" |
| | | JWT_SECRET="这里改成一个随机的长字符串,比如 mashuang123456!@#" |
| | | EOF |
| | | </div> |
| | | |
| | | <p>然后修改 systemd 服务加载这个环境变量:</p> |
| | | <div class="code-block"> |
| | | <span class="cmd">sed</span> -i '/ExecStart/i EnvironmentFile=/opt/score-query/.env' /etc/systemd/system/score-backend.service |
| | | <span class="cmd">systemctl</span> daemon-reload |
| | | <span class="cmd">systemctl</span> restart score-backend |
| | | </div> |
| | | |
| | | <h3>10.4 修改前端 vite 配置中的 allowedHosts</h3> |
| | | <p>编辑 <span class="highlight">/opt/score-query/frontend/vite.config.ts</span>,把你的域名加到 <span class="highlight">allowedHosts</span> 数组中,然后重新构建:</p> |
| | | <div class="code-block"> |
| | | <span class="cmd">cd</span> /opt/score-query/frontend |
| | | <span class="cmd">npm</span> run build |
| | | <span class="cmd">systemctl</span> restart score-frontend |
| | | </div> |
| | | |
| | | <h3>10.5 配置 Nginx 反向代理(推荐)</h3> |
| | | <p>用 Nginx 做反向代理,可以直接用 80 端口访问,不需要加端口号:</p> |
| | | |
| | | <div class="code-block"> |
| | | <span class="comment"># 安装 Nginx</span> |
| | | <span class="cmd">apt</span> install -y nginx |
| | | |
| | | <span class="comment"># 创建配置文件</span> |
| | | <span class="cmd">cat</span> > /etc/nginx/sites-available/score-query << 'EOF' |
| | | server { |
| | | listen 80; |
| | | server_name grader.yourdomain.com; |
| | | |
| | | # 前端静态文件(也可以继续用 vite preview,这里演示直接由 Nginx 托管) |
| | | location / { |
| | | proxy_pass http://127.0.0.1:5174; |
| | | proxy_set_header Host $host; |
| | | proxy_set_header X-Real-IP $remote_addr; |
| | | } |
| | | } |
| | | EOF |
| | | |
| | | <span class="comment"># 启用站点</span> |
| | | <span class="cmd">ln</span> -s /etc/nginx/sites-available/score-query /etc/nginx/sites-enabled/ |
| | | <span class="cmd">rm</span> /etc/nginx/sites-enabled/default |
| | | |
| | | <span class="comment"># 测试配置</span> |
| | | <span class="cmd">nginx</span> -t |
| | | |
| | | <span class="comment"># 重载 Nginx</span> |
| | | <span class="cmd">systemctl</span> reload nginx |
| | | </div> |
| | | |
| | | <p>现在就可以用 <span class="highlight">http://grader.yourdomain.com</span> 直接访问了!</p> |
| | | |
| | | <div class="tip"> |
| | | 如果需要 HTTPS,可以用 Let's Encrypt 免费证书,安装 certbot 后执行:<br> |
| | | <span class="highlight">apt install -y certbot python3-certbot-nginx && certbot --nginx</span> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- FAQ --> |
| | | <div class="section" id="faq"> |
| | | <h2><span class="step-num">❓</span> 常见问题 FAQ</h2> |
| | | |
| | | <div class="faq-item"> |
| | | <div class="faq-q">Q1: 浏览器访问不了怎么办?</div> |
| | | <div class="faq-a"> |
| | | <p>按以下顺序排查:</p> |
| | | <ol> |
| | | <li>确认安全组是否开放了 5174 端口</li> |
| | | <li>确认服务是否在运行:<span class="highlight">systemctl status score-backend score-frontend</span></li> |
| | | <li>在服务器本地测试:<span class="highlight">curl http://127.0.0.1:5174</span>,能返回 HTML 说明服务正常</li> |
| | | <li>如果本地通、外网不通,基本是安全组或防火墙的问题</li> |
| | | <li>检查系统防火墙:<span class="highlight">ufw status</span>,如果开了就 <span class="highlight">ufw allow 5174</span></li> |
| | | </ol> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="faq-item"> |
| | | <div class="faq-q">Q2: 前端页面打开了,但登录/接口报错?</div> |
| | | <div class="faq-a"> |
| | | <p>大概率是后端没启动或跨域配置问题:</p> |
| | | <ol> |
| | | <li>确认后端在运行:<span class="highlight">systemctl status score-backend</span></li> |
| | | <li>本地测试后端:<span class="highlight">curl http://127.0.0.1:8010/docs</span>,能看到 Swagger 文档说明正常</li> |
| | | <li>如果是跨域错误(浏览器 F12 控制台有 CORS 字样),检查 CORS_ORIGINS 配置是否包含你的访问地址</li> |
| | | </ol> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="faq-item"> |
| | | <div class="faq-q">Q3: SQLite 数据库文件在哪?怎么备份?</div> |
| | | <div class="faq-a"> |
| | | <p>数据库文件在 <span class="highlight">/opt/score-query/backend/data/app.db</span>,直接复制这个文件就是备份。建议定期备份:</p> |
| | | <div class="code-block"><span class="cmd">cp</span> /opt/score-query/backend/data/app.db /opt/score-query/backend/data/app.db.bak.$(date +%Y%m%d)</div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="faq-item"> |
| | | <div class="faq-q">Q4: 修改了代码怎么更新?</div> |
| | | <div class="faq-a"> |
| | | <p>分两种情况:</p> |
| | | <ul> |
| | | <li><strong>改了前端代码:</strong>重新构建 → 重启前端 |
| | | <div class="code-block"><span class="cmd">cd</span> /opt/score-query/frontend && npm run build && systemctl restart score-frontend</div> |
| | | </li> |
| | | <li><strong>改了后端代码:</strong>直接重启后端 |
| | | <div class="code-block"><span class="cmd">systemctl</span> restart score-backend</div> |
| | | </li> |
| | | </ul> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="faq-item"> |
| | | <div class="faq-q">Q5: 服务器太卡了怎么办?</div> |
| | | <div class="faq-a"> |
| | | <p>可以在华为云控制台「变更规格」升级配置。也可以优化:</p> |
| | | <ul> |
| | | <li>用 Nginx 直接托管前端静态文件,关掉 vite preview(节省内存)</li> |
| | | <li>开启 gzip 压缩</li> |
| | | <li>如果数据量大,可以考虑把 SQLite 换成 MySQL</li> |
| | | </ul> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="faq-item"> |
| | | <div class="faq-q">Q6: 忘记管理员密码怎么办?</div> |
| | | <div class="faq-a"> |
| | | <p>重新运行 seed_admin.py 脚本会重置默认账号密码:</p> |
| | | <div class="code-block"> |
| | | <span class="cmd">cd</span> /opt/score-query/backend |
| | | <span class="cmd">source</span> .venv/bin/activate |
| | | <span class="cmd">python</span> scripts/seed_admin.py |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="faq-item"> |
| | | <div class="faq-q">Q7: 用脚本启动还是 systemd 启动?</div> |
| | | <div class="faq-a"> |
| | | <p>推荐用 <strong>systemd</strong>,因为:</p> |
| | | <ul> |
| | | <li>开机自动启动,服务器重启不怕</li> |
| | | <li>进程崩溃自动重启</li> |
| | | <li>统一的日志管理(journalctl)</li> |
| | | <li>更专业、更稳定</li> |
| | | </ul> |
| | | <p>脚本方式适合临时调试使用。</p> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="footer"> |
| | | <p>📚 考试阅卷系统华为云部署教程 | 祝你部署顺利!🎉</p> |
| | | </div> |
| | | </div> |
| | | </body> |
| | | </html> |
| New file |
| | |
| | | import { test, expect } from "@playwright/test"; |
| | | |
| | | const BASE = process.env.E2E_BASE_URL || "https://grader.gps120.com"; |
| | | const USER = process.env.E2E_USER || "admin"; |
| | | const PASS = process.env.E2E_PASS || "admin123"; |
| | | |
| | | async function login(page: import("@playwright/test").Page) { |
| | | await page.getByLabel("账号").fill(USER); |
| | | await page.getByLabel("密码").fill(PASS); |
| | | await page.locator("button.ant-btn-primary").click(); |
| | | await expect(page).toHaveURL(/\/scores/, { timeout: 15_000 }); |
| | | } |
| | | |
| | | test.describe("考试阅卷系统 E2E", () => { |
| | | test("登录页可正常打开并显示登录表单", async ({ page }) => { |
| | | const errors: string[] = []; |
| | | page.on("pageerror", (e) => errors.push(e.message)); |
| | | |
| | | await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" }); |
| | | await expect(page.locator(".ant-card").filter({ hasText: "登录" })).toBeVisible({ timeout: 30_000 }); |
| | | await expect(page.getByLabel("账号")).toBeVisible(); |
| | | await expect(page.getByLabel("密码")).toBeVisible(); |
| | | expect(errors).toEqual([]); |
| | | }); |
| | | |
| | | test("未登录访问 /scores 会进入登录并成功查询成绩与答卷详情", async ({ page }) => { |
| | | await page.goto(`${BASE}/scores`, { waitUntil: "domcontentloaded" }); |
| | | await expect(page.locator(".ant-card").filter({ hasText: "登录" })).toBeVisible({ timeout: 30_000 }); |
| | | await login(page); |
| | | |
| | | await expect(page.locator(".app-breadcrumb").getByText("成绩查询")).toBeVisible(); |
| | | const table = page.locator(".ant-table"); |
| | | await expect(table).toBeVisible({ timeout: 15_000 }); |
| | | const dataRow = page.locator("tr[data-row-key]").first(); |
| | | await expect(dataRow).toBeVisible({ timeout: 15_000 }); |
| | | |
| | | await dataRow.getByRole("button", { name: /查\s*看/ }).click(); |
| | | await expect(page).toHaveURL(/\/attempts\/\d+/, { timeout: 15_000 }); |
| | | await expect(page.locator('aside[aria-label="题目索引"]')).toBeVisible({ timeout: 30_000 }); |
| | | }); |
| | | |
| | | test("管理员可访问数据导入、考评员、系统设置", async ({ page }) => { |
| | | await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" }); |
| | | await login(page); |
| | | |
| | | await page.getByRole("menuitem", { name: "数据导入" }).click(); |
| | | await expect(page).toHaveURL(/\/admin\/import/); |
| | | await expect(page.locator(".ant-card").filter({ hasText: "导入数据" })).toBeVisible(); |
| | | |
| | | await page.getByRole("menuitem", { name: "考评员管理" }).click(); |
| | | await expect(page).toHaveURL(/\/admin\/graders/); |
| | | await expect(page.locator(".ant-card").filter({ hasText: "考评员管理" })).toBeVisible(); |
| | | |
| | | await page.getByRole("menuitem", { name: "系统设置" }).click(); |
| | | await expect(page).toHaveURL(/\/admin\/settings/); |
| | | await expect(page.locator(".ant-card").filter({ hasText: "系统设置" })).toBeVisible(); |
| | | }); |
| | | }); |
| New file |
| | |
| | | <!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> |
| New file |
| | |
| | | { |
| | | "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" |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | { |
| | | "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" |
| | | } |
| | | } |
| New file |
| | |
| | | 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"]], |
| | | }); |
| New file |
| | |
| | | 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}</>; |
| | | } |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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); |
| | | } |
| New file |
| | |
| | | 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> |
| | | ); |
| | | } |
| New file |
| | |
| | | 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> |
| | | ); |
| | | } |
| New file |
| | |
| | | html, |
| | | body, |
| | | #root { |
| | | height: 100%; |
| | | margin: 0; |
| | | } |
| New file |
| | |
| | | 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> |
| | | ); |
| | | } |
| New file |
| | |
| | | .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; |
| | | } |
| New file |
| | |
| | | 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> |
| | | ); |
| | | } |
| New file |
| | |
| | | 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> |
| | | ); |
| New file |
| | |
| | | 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> |
| | | ); |
| | | } |
| New file |
| | |
| | | import { useEffect, useState } from "react"; |
| | | import { Alert, Button, Card, Form, Input, Table, Typography, Upload, message, Modal, Tag } from "antd"; |
| | | import { UploadOutlined } from "@ant-design/icons"; |
| | | import { api } from "../api"; |
| | | |
| | | type Batch = { |
| | | id: number; |
| | | subject_name: string; |
| | | exam_id: number; |
| | | exam_ids: number[]; |
| | | zip_filename: string; |
| | | imported_at: string; |
| | | status: string; |
| | | attempt_count: number; |
| | | paper_name?: string; |
| | | }; |
| | | |
| | | export default function AdminImportPage() { |
| | | const [batches, setBatches] = useState<Batch[]>([]); |
| | | const [file, setFile] = useState<File | null>(null); |
| | | const [loading, setLoading] = useState(false); |
| | | |
| | | const load = () => api.get<{ items: Batch[] }>("/api/admin/import/batches").then((r) => setBatches(r.data.items)); |
| | | |
| | | useEffect(() => { |
| | | load(); |
| | | }, []); |
| | | |
| | | return ( |
| | | <div> |
| | | <Card title="导入数据" style={{ marginBottom: 16 }}> |
| | | <Alert |
| | | type="info" |
| | | showIcon |
| | | style={{ marginBottom: 16 }} |
| | | message="导入要求说明" |
| | | description={ |
| | | <Typography.Paragraph style={{ marginBottom: 0 }}> |
| | | <ul style={{ margin: "8px 0 0", paddingLeft: 20 }}> |
| | | <li>请上传 <strong>ZIP 压缩包</strong>,内含导出 SQL(仅 <code>.sql</code> 文件,UTF-8 编码)。</li> |
| | | <li> |
| | | 必须包含 5 类数据(文件名需能识别,推荐与样例一致): |
| | | <ul> |
| | | <li>考生:<code>examination_examinee</code>(勿与答案表混淆)</li> |
| | | <li>考试考生 / 成绩:<code>examination_exam_examinee</code></li> |
| | | <li>试卷:<code>examination_paper_source</code></li> |
| | | <li>试题明细:<code>examination_paper_item</code></li> |
| | | <li>考生答案:<code>examination_examinee_exam_answer</code></li> |
| | | </ul> |
| | | </li> |
| | | <li> |
| | | 一个 ZIP 可包含<strong>多个 exam_id</strong>(同卷多场次):ZIP 内须为<strong>同一份试卷</strong>(相同{" "} |
| | | <code>external_paper_id + revision</code>),系统将共用一套试题判分。 |
| | | </li> |
| | | <li> |
| | | 若 ZIP 内含<strong>多份不同试卷</strong>,须额外提供 <code>examination_exam</code> SQL,用{" "} |
| | | <code>paper_source_id</code> 标明每场考试使用的试卷;否则无法导入。 |
| | | </li> |
| | | <li> |
| | | 每个 <code>exam_id</code> 在全库仅能导入一次;已存在的试卷(相同{" "} |
| | | <code>external_paper_id + revision</code>)会复用题库,仅新增场次与成绩。 |
| | | </li> |
| | | <li> |
| | | <strong>科目名称</strong>由管理员填写,用于成绩列表筛选;清除请用下方「导入历史」按批次操作(会删除该 ZIP 对应的全部场次数据)。 |
| | | </li> |
| | | <li>导入后将按试题明细与答案自动判分,并以明细汇总结果更新总分(可与原 SQL 中 score 对照)。</li> |
| | | </ul> |
| | | </Typography.Paragraph> |
| | | } |
| | | /> |
| | | <Form |
| | | layout="vertical" |
| | | onFinish={async (v) => { |
| | | if (!file) { |
| | | message.warning("请选择 ZIP 文件"); |
| | | return; |
| | | } |
| | | const fd = new FormData(); |
| | | fd.append("subject_name", v.subject_name); |
| | | fd.append("file", file); |
| | | setLoading(true); |
| | | try { |
| | | const { data } = await api.post("/api/admin/import", fd); |
| | | message.success("导入成功"); |
| | | Modal.info({ title: "导入报告", content: JSON.stringify(data.report, null, 2), width: 600 }); |
| | | setFile(null); |
| | | load(); |
| | | } catch (e: unknown) { |
| | | const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || "导入失败"; |
| | | message.error(msg); |
| | | } finally { |
| | | setLoading(false); |
| | | } |
| | | }} |
| | | > |
| | | <Form.Item name="subject_name" label="科目名称" rules={[{ required: true }]}> |
| | | <Input placeholder="导入后用于成绩列表科目筛选" /> |
| | | </Form.Item> |
| | | <Form.Item label="ZIP 文件" required> |
| | | <Upload |
| | | beforeUpload={(f) => { |
| | | setFile(f); |
| | | return false; |
| | | }} |
| | | maxCount={1} |
| | | onRemove={() => setFile(null)} |
| | | > |
| | | <Button icon={<UploadOutlined />}>选择文件</Button> |
| | | </Upload> |
| | | </Form.Item> |
| | | <Button type="primary" htmlType="submit" loading={loading}>开始导入</Button> |
| | | </Form> |
| | | </Card> |
| | | <Card title="导入历史"> |
| | | <Table |
| | | rowKey="id" |
| | | scroll={{ x: 960 }} |
| | | dataSource={batches} |
| | | columns={[ |
| | | { title: "科目", dataIndex: "subject_name" }, |
| | | { |
| | | title: "exam_id", |
| | | dataIndex: "exam_ids", |
| | | render: (ids: number[] | undefined, row) => { |
| | | const list = ids?.length ? ids : row.exam_id != null ? [row.exam_id] : []; |
| | | return list.map((id) => <Tag key={id}>{id}</Tag>); |
| | | }, |
| | | }, |
| | | { title: "试卷", dataIndex: "paper_name", ellipsis: true }, |
| | | { title: "文件", dataIndex: "zip_filename", ellipsis: true }, |
| | | { title: "导入时间", dataIndex: "imported_at" }, |
| | | { title: "考生数", dataIndex: "attempt_count" }, |
| | | { |
| | | title: "操作", |
| | | render: (_, row) => { |
| | | const ids = row.exam_ids?.length ? row.exam_ids : row.exam_id != null ? [row.exam_id] : []; |
| | | return ( |
| | | <Button |
| | | danger |
| | | type="link" |
| | | onClick={() => { |
| | | Modal.confirm({ |
| | | title: "清除本次导入的全部数据?", |
| | | content: `科目:${row.subject_name},exam_id:${ids.join("、") || "—"}。将删除该批次下全部场次与成绩,不可恢复。`, |
| | | onOk: async () => { |
| | | await api.delete(`/api/admin/import/batches/${row.id}`); |
| | | message.success("已清除"); |
| | | load(); |
| | | }, |
| | | }); |
| | | }} |
| | | > |
| | | 清除 |
| | | </Button> |
| | | ); |
| | | }, |
| | | }, |
| | | ]} |
| | | /> |
| | | </Card> |
| | | </div> |
| | | ); |
| | | } |
| New file |
| | |
| | | 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> |
| | | ); |
| | | } |
| New file |
| | |
| | | .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; |
| | | } |
| | | } |
| New file |
| | |
| | | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| | | import { useNavigate, useParams, useSearchParams } from "react-router-dom"; |
| | | import { |
| | | Button, |
| | | Card, |
| | | Descriptions, |
| | | Form, |
| | | Grid, |
| | | Input, |
| | | Radio, |
| | | Select, |
| | | Spin, |
| | | Tag, |
| | | Typography, |
| | | } from "antd"; |
| | | import { DoubleLeftOutlined, DoubleRightOutlined } from "@ant-design/icons"; |
| | | import { api } from "../api"; |
| | | import { |
| | | attemptDetailPath, |
| | | readScoreListQuery, |
| | | type ScoreListQuery, |
| | | } from "../scoreQueryParams"; |
| | | import "./AttemptReviewPage.css"; |
| | | |
| | | const { useBreakpoint } = Grid; |
| | | const { Title } = Typography; |
| | | |
| | | const SECTION_CN = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十"]; |
| | | |
| | | type Q = { |
| | | external_item_id: string; |
| | | part_name?: string; |
| | | item_order?: number; |
| | | title_html?: string; |
| | | detail_json?: string; |
| | | reference_answer?: string; |
| | | user_answer?: string; |
| | | is_correct: boolean; |
| | | score: number; |
| | | max_score?: number; |
| | | }; |
| | | |
| | | type Part = { part_name: string; questions: Q[] }; |
| | | |
| | | type Attempt = { |
| | | attempt_id: string; |
| | | examinee_name?: string; |
| | | id_card?: string; |
| | | score: number; |
| | | paper_total_score?: number; |
| | | paper_name?: string; |
| | | subject_name?: string; |
| | | room_id?: string; |
| | | seat_number?: string; |
| | | start_time?: string; |
| | | end_time?: string; |
| | | parts: Part[]; |
| | | }; |
| | | |
| | | type FlatItem = { key: string; part: string; q: Q; idx: number; displayNo: number }; |
| | | |
| | | function stripHtml(html: string) { |
| | | return html.replace(/<[^>]+>/g, ""); |
| | | } |
| | | |
| | | function isAnswered(q: Q) { |
| | | return Boolean(q.user_answer?.trim()); |
| | | } |
| | | |
| | | function bubbleClassName(q: Q, isActive: boolean) { |
| | | const classes = ["attempt-review__bubble"]; |
| | | if (!isAnswered(q)) classes.push("attempt-review__bubble--unanswered"); |
| | | else if (q.is_correct) classes.push("attempt-review__bubble--correct"); |
| | | else classes.push("attempt-review__bubble--wrong"); |
| | | if (isActive) classes.push("attempt-review__bubble--active"); |
| | | return classes.join(" "); |
| | | } |
| | | |
| | | function parseOptions(detail_json?: string) { |
| | | if (!detail_json) return []; |
| | | try { |
| | | const o = JSON.parse(detail_json); |
| | | const qs = o?.item?.questions; |
| | | if (Array.isArray(qs) && qs[0]?.options) return qs[0].options; |
| | | } catch { |
| | | /* ignore */ |
| | | } |
| | | return []; |
| | | } |
| | | |
| | | function scrollChildIntoContainer(container: HTMLElement, el: HTMLElement) { |
| | | const cRect = container.getBoundingClientRect(); |
| | | const eRect = el.getBoundingClientRect(); |
| | | const nextTop = container.scrollTop + (eRect.top - cRect.top); |
| | | container.scrollTo({ top: Math.max(0, nextTop), behavior: "auto" }); |
| | | } |
| | | |
| | | function QuestionIndex({ |
| | | parts, |
| | | flat, |
| | | activeKey, |
| | | collapsed, |
| | | onToggleCollapse, |
| | | onJump, |
| | | mobile, |
| | | }: { |
| | | parts: Part[]; |
| | | flat: FlatItem[]; |
| | | activeKey: string | null; |
| | | collapsed: boolean; |
| | | onToggleCollapse: () => void; |
| | | onJump: (key: string) => void; |
| | | mobile?: boolean; |
| | | }) { |
| | | const [typeFilter, setTypeFilter] = useState<string>("all"); |
| | | |
| | | const visibleParts = useMemo(() => { |
| | | if (typeFilter === "all") return parts; |
| | | return parts.filter((p) => p.part_name === typeFilter); |
| | | }, [parts, typeFilter]); |
| | | |
| | | return ( |
| | | <aside |
| | | className={[ |
| | | "attempt-review__index", |
| | | mobile ? "attempt-review__index-mobile" : "", |
| | | collapsed && !mobile ? "attempt-review__index--collapsed" : "", |
| | | ] |
| | | .filter(Boolean) |
| | | .join(" ")} |
| | | aria-label="题目索引" |
| | | > |
| | | <div className="attempt-review__index-toolbar"> |
| | | {!mobile && ( |
| | | <Button |
| | | type="text" |
| | | size="small" |
| | | icon={collapsed ? <DoubleRightOutlined /> : <DoubleLeftOutlined />} |
| | | onClick={onToggleCollapse} |
| | | aria-label={collapsed ? "展开索引侧栏" : "收起索引侧栏"} |
| | | /> |
| | | )} |
| | | </div> |
| | | {collapsed && !mobile ? ( |
| | | <div className="attempt-review__index-collapsed-label">题目索引</div> |
| | | ) : ( |
| | | <> |
| | | <div className="attempt-review__index-filter"> |
| | | <Radio.Group |
| | | size="small" |
| | | value={typeFilter} |
| | | onChange={(e) => setTypeFilter(e.target.value)} |
| | | className="attempt-review__type-radio" |
| | | > |
| | | <Radio.Button value="all">全部</Radio.Button> |
| | | {parts.map((p) => ( |
| | | <Radio.Button key={p.part_name} value={p.part_name}>{p.part_name}</Radio.Button> |
| | | ))} |
| | | </Radio.Group> |
| | | </div> |
| | | <div className="attempt-review__index-body"> |
| | | {visibleParts.map((p) => { |
| | | const sectionIdx = parts.findIndex((x) => x.part_name === p.part_name); |
| | | const sectionLabel = `${SECTION_CN[sectionIdx] ?? sectionIdx + 1}、${p.part_name}`; |
| | | return ( |
| | | <section key={p.part_name} className="attempt-review__section"> |
| | | <h4 className="attempt-review__section-title">{sectionLabel}</h4> |
| | | <div className="attempt-review__bubble-grid"> |
| | | {p.questions.map((q, i) => { |
| | | const meta = flat.find((f) => f.key === q.external_item_id); |
| | | const no = meta?.displayNo ?? (q.item_order ?? i) + 1; |
| | | const isActive = activeKey === q.external_item_id; |
| | | return ( |
| | | <button |
| | | key={q.external_item_id} |
| | | type="button" |
| | | className={bubbleClassName(q, isActive)} |
| | | onClick={() => onJump(q.external_item_id)} |
| | | title={`第 ${no} 题`} |
| | | > |
| | | {no} |
| | | </button> |
| | | ); |
| | | })} |
| | | </div> |
| | | </section> |
| | | ); |
| | | })} |
| | | </div> |
| | | </> |
| | | )} |
| | | </aside> |
| | | ); |
| | | } |
| | | |
| | | type ScoreListItem = { |
| | | attempt_id: string; |
| | | name: string; |
| | | id_card: string; |
| | | subject_name: string; |
| | | score: number; |
| | | }; |
| | | |
| | | const ROSTER_PAGE_SIZE = 40; |
| | | |
| | | function AttemptRoster({ |
| | | activeAttemptId, |
| | | listQuery, |
| | | onSelectAttempt, |
| | | onSearch, |
| | | }: { |
| | | activeAttemptId: string; |
| | | listQuery: ScoreListQuery; |
| | | onSelectAttempt: (attemptId: string) => void; |
| | | onSearch: (filters: Pick<ScoreListQuery, "name" | "id_card" | "subject">) => void; |
| | | }) { |
| | | const [form] = Form.useForm<Pick<ScoreListQuery, "name" | "id_card" | "subject">>(); |
| | | const [subjects, setSubjects] = useState<string[]>([]); |
| | | const [items, setItems] = useState<ScoreListItem[]>([]); |
| | | const [total, setTotal] = useState(0); |
| | | const [page, setPage] = useState(1); |
| | | const [loading, setLoading] = useState(false); |
| | | const [filters, setFilters] = useState({ |
| | | name: listQuery.name, |
| | | id_card: listQuery.id_card, |
| | | subject: listQuery.subject, |
| | | }); |
| | | const scrollRef = useRef<HTMLDivElement>(null); |
| | | const loadingMoreRef = useRef(false); |
| | | |
| | | useEffect(() => { |
| | | form.setFieldsValue({ |
| | | name: listQuery.name, |
| | | id_card: listQuery.id_card, |
| | | subject: listQuery.subject, |
| | | }); |
| | | setFilters({ |
| | | name: listQuery.name, |
| | | id_card: listQuery.id_card, |
| | | subject: listQuery.subject, |
| | | }); |
| | | }, [listQuery.name, listQuery.id_card, listQuery.subject, form]); |
| | | |
| | | useEffect(() => { |
| | | api.get<{ items: string[] }>("/api/subjects").then((r) => setSubjects(r.data.items)); |
| | | }, []); |
| | | |
| | | const fetchPage = useCallback( |
| | | async (p: number, append: boolean) => { |
| | | if (loadingMoreRef.current) return; |
| | | loadingMoreRef.current = true; |
| | | setLoading(true); |
| | | try { |
| | | const { data: res } = await api.get("/api/scores", { |
| | | params: { page: p, page_size: ROSTER_PAGE_SIZE, ...filters }, |
| | | }); |
| | | setTotal(res.total); |
| | | setPage(p); |
| | | setItems((prev) => (append ? [...prev, ...res.items] : res.items)); |
| | | } finally { |
| | | setLoading(false); |
| | | loadingMoreRef.current = false; |
| | | } |
| | | }, |
| | | [filters] |
| | | ); |
| | | |
| | | useEffect(() => { |
| | | fetchPage(1, false); |
| | | }, [fetchPage]); |
| | | |
| | | useEffect(() => { |
| | | const el = scrollRef.current; |
| | | if (!el) return; |
| | | const onScroll = () => { |
| | | if (loadingMoreRef.current || loading) return; |
| | | if (items.length >= total) return; |
| | | if (el.scrollHeight - el.scrollTop - el.clientHeight < 64) { |
| | | fetchPage(page + 1, true); |
| | | } |
| | | }; |
| | | el.addEventListener("scroll", onScroll); |
| | | return () => el.removeEventListener("scroll", onScroll); |
| | | }, [loading, items.length, total, page, fetchPage]); |
| | | |
| | | useEffect(() => { |
| | | const el = scrollRef.current?.querySelector<HTMLElement>( |
| | | `[data-attempt-id="${activeAttemptId}"]` |
| | | ); |
| | | el?.scrollIntoView({ block: "nearest" }); |
| | | }, [activeAttemptId, items]); |
| | | |
| | | return ( |
| | | <aside className="attempt-review__roster"> |
| | | <Form |
| | | form={form} |
| | | className="attempt-review__roster-form" |
| | | size="small" |
| | | layout="horizontal" |
| | | labelAlign="right" |
| | | colon={false} |
| | | labelCol={{ flex: "0 0 52px" }} |
| | | wrapperCol={{ flex: "1 1 0" }} |
| | | onFinish={(v) => onSearch(v)} |
| | | > |
| | | <Form.Item name="name" label="姓名" className="attempt-review__roster-field"> |
| | | <Input allowClear /> |
| | | </Form.Item> |
| | | <Form.Item name="id_card" label="证件号" className="attempt-review__roster-field"> |
| | | <Input allowClear /> |
| | | </Form.Item> |
| | | <Form.Item name="subject" label="科目" className="attempt-review__roster-field"> |
| | | <Select allowClear options={subjects.map((s) => ({ value: s, label: s }))} /> |
| | | </Form.Item> |
| | | <Button type="primary" htmlType="submit" block size="small">查询</Button> |
| | | </Form> |
| | | <div ref={scrollRef} className="attempt-review__roster-list"> |
| | | {items.map((row) => ( |
| | | <button |
| | | key={row.attempt_id} |
| | | type="button" |
| | | data-attempt-id={row.attempt_id} |
| | | className={[ |
| | | "attempt-review__roster-item", |
| | | row.attempt_id === activeAttemptId ? "attempt-review__roster-item--active" : "", |
| | | ] |
| | | .filter(Boolean) |
| | | .join(" ")} |
| | | onClick={() => { |
| | | if (row.attempt_id !== activeAttemptId) onSelectAttempt(row.attempt_id); |
| | | }} |
| | | > |
| | | <div className="attempt-review__roster-name">{row.name || "—"}</div> |
| | | <div className="attempt-review__roster-meta"> |
| | | <span>{row.score} 分</span> |
| | | <span className="attempt-review__roster-subject">{row.subject_name}</span> |
| | | </div> |
| | | </button> |
| | | ))} |
| | | {loading && <div className="attempt-review__roster-hint">加载中…</div>} |
| | | {!loading && items.length === 0 && <div className="attempt-review__roster-hint">无数据</div>} |
| | | {!loading && items.length > 0 && items.length >= total && ( |
| | | <div className="attempt-review__roster-hint">已加载全部</div> |
| | | )} |
| | | </div> |
| | | </aside> |
| | | ); |
| | | } |
| | | |
| | | export default function AttemptReviewPage() { |
| | | const { attemptId } = useParams(); |
| | | const navigate = useNavigate(); |
| | | const [searchParams] = useSearchParams(); |
| | | const listQuery = readScoreListQuery(searchParams); |
| | | const [data, setData] = useState<Attempt | null>(null); |
| | | const [detailLoading, setDetailLoading] = useState(true); |
| | | const [activeKey, setActiveKey] = useState<string | null>(null); |
| | | const [indexCollapsed, setIndexCollapsed] = useState(false); |
| | | const detailScrollRef = useRef<HTMLDivElement>(null); |
| | | const screens = useBreakpoint(); |
| | | const isWide = !!screens.lg; |
| | | |
| | | const selectAttempt = useCallback( |
| | | (id: string) => { |
| | | if (id === attemptId) return; |
| | | navigate(attemptDetailPath(id, listQuery), { replace: true, preventScrollReset: true }); |
| | | }, |
| | | [attemptId, listQuery, navigate] |
| | | ); |
| | | |
| | | const searchRoster = useCallback( |
| | | (filters: Pick<ScoreListQuery, "name" | "id_card" | "subject">) => { |
| | | navigate( |
| | | attemptDetailPath(attemptId!, { ...filters, page: 1, page_size: listQuery.page_size }), |
| | | { replace: true, preventScrollReset: true } |
| | | ); |
| | | }, |
| | | [attemptId, listQuery.page_size, navigate] |
| | | ); |
| | | |
| | | useEffect(() => { |
| | | if (!attemptId) return; |
| | | let cancelled = false; |
| | | setDetailLoading(true); |
| | | setActiveKey(null); |
| | | detailScrollRef.current?.scrollTo(0, 0); |
| | | api |
| | | .get<Attempt>(`/api/attempts/${attemptId}`) |
| | | .then((r) => { |
| | | if (!cancelled) setData(r.data); |
| | | }) |
| | | .finally(() => { |
| | | if (!cancelled) setDetailLoading(false); |
| | | }); |
| | | return () => { |
| | | cancelled = true; |
| | | }; |
| | | }, [attemptId]); |
| | | |
| | | useEffect(() => { |
| | | const prev = document.documentElement.style.overflow; |
| | | const prevBody = document.body.style.overflow; |
| | | document.documentElement.style.overflow = "hidden"; |
| | | document.body.style.overflow = "hidden"; |
| | | return () => { |
| | | document.documentElement.style.overflow = prev; |
| | | document.body.style.overflow = prevBody; |
| | | }; |
| | | }, []); |
| | | |
| | | const flat = useMemo(() => { |
| | | if (!data) return []; |
| | | const out: FlatItem[] = []; |
| | | let n = 0; |
| | | data.parts.forEach((p) => |
| | | p.questions.forEach((q, i) => { |
| | | n += 1; |
| | | out.push({ |
| | | key: q.external_item_id, |
| | | part: p.part_name, |
| | | q, |
| | | idx: n, |
| | | displayNo: (q.item_order ?? i) + 1, |
| | | }); |
| | | }) |
| | | ); |
| | | return out; |
| | | }, [data]); |
| | | |
| | | const jumpTo = useCallback((key: string) => { |
| | | setActiveKey(key); |
| | | requestAnimationFrame(() => { |
| | | const container = detailScrollRef.current; |
| | | const el = document.getElementById(`q-${key}`); |
| | | if (!container || !el) return; |
| | | scrollChildIntoContainer(container, el); |
| | | }); |
| | | }, []); |
| | | |
| | | useEffect(() => { |
| | | const container = detailScrollRef.current; |
| | | if (!container || !data || flat.length === 0) return; |
| | | |
| | | const observer = new IntersectionObserver( |
| | | (entries) => { |
| | | const visible = entries |
| | | .filter((e) => e.isIntersecting) |
| | | .sort((a, b) => (a.boundingClientRect.top ?? 0) - (b.boundingClientRect.top ?? 0)); |
| | | const id = visible[0]?.target.id; |
| | | if (id?.startsWith("q-")) { |
| | | setActiveKey(id.replace("q-", "")); |
| | | } |
| | | }, |
| | | { root: container, rootMargin: "-8px 0px -70% 0px", threshold: 0 } |
| | | ); |
| | | |
| | | flat.forEach(({ key }) => { |
| | | const el = document.getElementById(`q-${key}`); |
| | | if (el) observer.observe(el); |
| | | }); |
| | | return () => observer.disconnect(); |
| | | }, [data, flat]); |
| | | |
| | | const indexPanel = data ? ( |
| | | <QuestionIndex |
| | | parts={data.parts} |
| | | flat={flat} |
| | | activeKey={activeKey} |
| | | collapsed={indexCollapsed} |
| | | onToggleCollapse={() => setIndexCollapsed((c) => !c)} |
| | | onJump={jumpTo} |
| | | mobile={!isWide} |
| | | /> |
| | | ) : null; |
| | | |
| | | const detailPanel = data ? ( |
| | | <div className="attempt-review__detail-inner"> |
| | | {data.parts.map((p) => ( |
| | | <div key={p.part_name} id={`part-${p.part_name}`}> |
| | | <Title level={5}>{p.part_name}</Title> |
| | | {p.questions.map((q, i) => ( |
| | | <div key={q.external_item_id} id={`q-${q.external_item_id}`} className="attempt-review__question-anchor"> |
| | | <Card |
| | | size="small" |
| | | className="attempt-review__question-card" |
| | | title={`第 ${(q.item_order ?? i) + 1} 题`} |
| | | > |
| | | <div dangerouslySetInnerHTML={{ __html: q.title_html || "" }} /> |
| | | <ul> |
| | | {parseOptions(q.detail_json).map((opt: { identifier: string; text: string }) => ( |
| | | <li key={opt.identifier}>{opt.identifier}. {stripHtml(opt.text)}</li> |
| | | ))} |
| | | </ul> |
| | | <p>考生答案:{q.user_answer || "—"}</p> |
| | | <p>参考答案:{q.reference_answer || "—"}</p> |
| | | <p> |
| | | {q.is_correct ? <Tag color="success">正确</Tag> : <Tag color="error">错误</Tag>} |
| | | 得分:{q.score} / {q.max_score ?? 1} |
| | | </p> |
| | | </Card> |
| | | </div> |
| | | ))} |
| | | </div> |
| | | ))} |
| | | </div> |
| | | ) : null; |
| | | |
| | | const centerRight = ( |
| | | <div className="attempt-review__center-right"> |
| | | {detailLoading && ( |
| | | <div className="attempt-review__center-right-mask" aria-busy="true"> |
| | | <Spin /> |
| | | </div> |
| | | )} |
| | | {data && ( |
| | | <Card className="attempt-review__header" size="small"> |
| | | <Descriptions column={{ xs: 1, sm: 2 }} size="small"> |
| | | <Descriptions.Item label="姓名">{data.examinee_name}</Descriptions.Item> |
| | | <Descriptions.Item label="证件号">{data.id_card}</Descriptions.Item> |
| | | <Descriptions.Item label="科目">{data.subject_name}</Descriptions.Item> |
| | | <Descriptions.Item label="总分">{data.score} / {data.paper_total_score}</Descriptions.Item> |
| | | <Descriptions.Item label="试卷">{data.paper_name}</Descriptions.Item> |
| | | <Descriptions.Item label="考场">{data.room_id} {data.seat_number}</Descriptions.Item> |
| | | <Descriptions.Item label="考试时间">{data.start_time} ~ {data.end_time}</Descriptions.Item> |
| | | </Descriptions> |
| | | </Card> |
| | | )} |
| | | {!data && detailLoading && ( |
| | | <div className="attempt-review__center-right-placeholder"> |
| | | <Spin /> |
| | | </div> |
| | | )} |
| | | {data && ( |
| | | <> |
| | | {!isWide && <div className="attempt-review__index-mobile-wrap">{indexPanel}</div>} |
| | | <div className="attempt-review__index-detail-row"> |
| | | {isWide && ( |
| | | <div |
| | | className={ |
| | | indexCollapsed |
| | | ? "attempt-review__index-col attempt-review__index-col--collapsed" |
| | | : "attempt-review__index-col" |
| | | } |
| | | > |
| | | {indexPanel} |
| | | </div> |
| | | )} |
| | | <div ref={detailScrollRef} className="attempt-review__detail"> |
| | | {detailPanel} |
| | | </div> |
| | | </div> |
| | | </> |
| | | )} |
| | | </div> |
| | | ); |
| | | |
| | | return ( |
| | | <div className="attempt-review"> |
| | | <div className="attempt-review__split"> |
| | | {isWide && attemptId && ( |
| | | <div className="attempt-review__roster-col"> |
| | | <AttemptRoster |
| | | activeAttemptId={attemptId} |
| | | listQuery={listQuery} |
| | | onSelectAttempt={selectAttempt} |
| | | onSearch={searchRoster} |
| | | /> |
| | | </div> |
| | | )} |
| | | {centerRight} |
| | | </div> |
| | | </div> |
| | | ); |
| | | } |
| New file |
| | |
| | | .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); |
| | | } |
| New file |
| | |
| | | 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> |
| | | ); |
| | | } |
| New file |
| | |
| | | .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; |
| | | } |
| New file |
| | |
| | | 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> |
| | | ); |
| | | } |
| New file |
| | |
| | | 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}`; |
| | | } |
| New file |
| | |
| | | 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}`; |
| | | } |
| New file |
| | |
| | | { |
| | | "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"] |
| | | } |
| New file |
| | |
| | | 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,
|
| | | },
|
| | | });
|
| | |
|
| New file |
| | |
| | | #!/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)" |
| New file |
| | |
| | | #!/usr/bin/env bash |
| | | ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| | | RUN_DIR="$ROOT/run" |
| | | LOG_DIR="$ROOT/logs" |
| | | BACKEND_PID="$RUN_DIR/backend.pid" |
| | | FRONTEND_PID="$RUN_DIR/frontend.pid" |
| | | BACKEND_PORT=8010 |
| | | FRONTEND_PORT=5174 |
| | | FRONTEND_DEV_PORT=5175 |
| | | # prod:build + vite preview @5174;dev:vite dev @5175 |
| | | FRONTEND_MODE="${FRONTEND_MODE:-prod}" |
| | | |
| | | vite_project_pgrep() { |
| | | pgrep -f "$ROOT/frontend/node_modules/.bin/vite" 2>/dev/null || true |
| | | } |
| | | |
| | | mkdir -p "$RUN_DIR" "$LOG_DIR" |
| | | |
| | | is_running() { |
| | | local pid_file="$1" |
| | | [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file")" 2>/dev/null |
| | | } |
| | | |
| | | stop_pid_file() { |
| | | local pid_file="$1" |
| | | local name="$2" |
| | | if [[ -f "$pid_file" ]]; then |
| | | local pid |
| | | pid="$(cat "$pid_file")" |
| | | if kill -0 "$pid" 2>/dev/null; then |
| | | kill "$pid" 2>/dev/null || true |
| | | sleep 1 |
| | | kill -9 "$pid" 2>/dev/null || true |
| | | echo "已停止 $name (PID $pid)" |
| | | fi |
| | | rm -f "$pid_file" |
| | | fi |
| | | } |
| New file |
| | |
| | | #!/usr/bin/env bash |
| | | set -euo pipefail |
| | | DIR="$(dirname "$0")" |
| | | TARGET="${1:-all}" |
| | | "$DIR/stop.sh" "$TARGET" |
| | | sleep 1 |
| | | "$DIR/start.sh" "$TARGET" |
| New file |
| | |
| | | #!/usr/bin/env bash |
| | | set -euo pipefail |
| | | # shellcheck source=lib.sh |
| | | source "$(dirname "$0")/lib.sh" |
| | | |
| | | start_backend() { |
| | | if is_running "$BACKEND_PID"; then |
| | | echo "后端已在运行 (PID $(cat "$BACKEND_PID"))" |
| | | return 0 |
| | | fi |
| | | if [[ ! -x "$ROOT/backend/.venv/bin/uvicorn" ]]; then |
| | | echo "未找到 backend/.venv,请先安装依赖:cd backend && python3 -m venv .venv && pip install -r requirements.txt" |
| | | exit 1 |
| | | fi |
| | | cd "$ROOT/backend" |
| | | nohup .venv/bin/uvicorn app.main:app --host 127.0.0.1 --port "$BACKEND_PORT" \ |
| | | >>"$LOG_DIR/backend.log" 2>&1 & |
| | | echo $! >"$BACKEND_PID" |
| | | echo "后端已启动 → http://127.0.0.1:$BACKEND_PORT (PID $(cat "$BACKEND_PID"), 日志 $LOG_DIR/backend.log)" |
| | | } |
| | | |
| | | _record_vite_pid() { |
| | | sleep 1 |
| | | local vite_pid |
| | | vite_pid="$(pgrep -f "$ROOT/frontend/node_modules/.bin/vite" | head -1 || true)" |
| | | if [[ -n "$vite_pid" ]]; then |
| | | echo "$vite_pid" >"$FRONTEND_PID" |
| | | fi |
| | | } |
| | | |
| | | start_frontend_dev() { |
| | | if is_running "$FRONTEND_PID"; then |
| | | echo "前端 dev 已在运行 (PID $(cat "$FRONTEND_PID"))" |
| | | return 0 |
| | | fi |
| | | if [[ ! -d "$ROOT/frontend/node_modules" ]]; then |
| | | echo "未找到 frontend/node_modules,请先执行:cd frontend && npm install" |
| | | exit 1 |
| | | fi |
| | | cd "$ROOT/frontend" |
| | | nohup npm run dev >>"$LOG_DIR/frontend-dev.log" 2>&1 & |
| | | echo $! >"$FRONTEND_PID" |
| | | _record_vite_pid |
| | | echo "前端 dev → http://0.0.0.0:$FRONTEND_DEV_PORT (PID $(cat "$FRONTEND_PID" 2>/dev/null || echo ?), 日志 $LOG_DIR/frontend-dev.log)" |
| | | } |
| | | |
| | | start_frontend_prod() { |
| | | if is_running "$FRONTEND_PID"; then |
| | | echo "前端已在运行 (PID $(cat "$FRONTEND_PID"))" |
| | | return 0 |
| | | fi |
| | | if [[ ! -d "$ROOT/frontend/node_modules" ]]; then |
| | | echo "未找到 frontend/node_modules,请先执行:cd frontend && npm install" |
| | | exit 1 |
| | | fi |
| | | "$(dirname "$0")/build-frontend.sh" |
| | | cd "$ROOT/frontend" |
| | | nohup npm run preview >>"$LOG_DIR/frontend.log" 2>&1 & |
| | | echo $! >"$FRONTEND_PID" |
| | | _record_vite_pid |
| | | echo "前端生产 → http://0.0.0.0:$FRONTEND_PORT (vite preview, PID $(cat "$FRONTEND_PID" 2>/dev/null || echo ?), 日志 $LOG_DIR/frontend.log)" |
| | | } |
| | | |
| | | start_frontend() { |
| | | if [[ "$FRONTEND_MODE" == "dev" ]]; then |
| | | start_frontend_dev |
| | | else |
| | | start_frontend_prod |
| | | fi |
| | | } |
| | | |
| | | case "${1:-all}" in |
| | | backend) start_backend ;; |
| | | frontend) start_frontend ;; |
| | | dev) |
| | | FRONTEND_MODE=dev |
| | | start_backend |
| | | start_frontend_dev |
| | | ;; |
| | | all) |
| | | start_backend |
| | | start_frontend |
| | | ;; |
| | | *) |
| | | echo "用法: $0 [all|dev|backend|frontend]" |
| | | echo " all 默认生产:build + Node vite preview @ $FRONTEND_PORT" |
| | | echo " dev 开发:vite dev @ $FRONTEND_DEV_PORT + 后端 $BACKEND_PORT" |
| | | exit 1 |
| | | ;; |
| | | esac |
| New file |
| | |
| | | #!/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 |
| New file |
| | |
| | | #!/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 "完成" |