From e329d3398a32a368b871a40074843421b8b59ded Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 28 Jun 2026 11:18:58 +0800 Subject: [PATCH] Initial commit: secondary school grade archive system. Add FastAPI/React app with Docker deployment, Ubuntu one-click install, and docs for junior/senior high score tracking and mistake bank. Co-authored-by: Cursor --- .env.example | 23 + .gitignore | 8 + COPYRIGHT.md | 39 + LICENSE | 20 + NOTICE | 3 + README.md | 116 + backend/.env.example | 7 + backend/Dockerfile | 19 + backend/app/__init__.py | 0 backend/app/core/__init__.py | 0 backend/app/core/config.py | 21 + backend/app/core/database.py | 19 + backend/app/core/deps.py | 34 + backend/app/core/security.py | 31 + backend/app/main.py | 48 + backend/app/models/__init__.py | 0 backend/app/models/user.py | 130 + backend/app/routers/__init__.py | 0 backend/app/routers/auth.py | 67 + backend/app/routers/exams.py | 182 + backend/app/routers/export.py | 54 + backend/app/routers/students.py | 73 + backend/app/routers/subjects.py | 17 + backend/app/routers/wrong_questions.py | 313 ++ backend/app/schemas/__init__.py | 183 + backend/app/services/__init__.py | 0 backend/app/services/migrate.py | 20 + backend/app/services/ocr.py | 40 + backend/app/services/ollama.py | 47 + backend/app/services/school_level.py | 17 + backend/app/services/score_trend.py | 66 + backend/app/services/seed.py | 23 + backend/app/services/student_access.py | 13 + backend/requirements.txt | 16 + deploy/backup.sh | 25 + deploy/install.sh | 231 + deploy/uninstall.sh | 28 + deploy/update.sh | 29 + docker-compose.yml | 55 + docs/DEPLOY.md | 304 ++ docs/USAGE.md | 162 + frontend/.gitignore | 24 + frontend/.oxlintrc.json | 8 + frontend/Dockerfile | 11 + frontend/README.md | 32 + frontend/index.html | 15 + frontend/nginx.conf | 17 + frontend/package-lock.json | 3995 +++++++++++++++++ frontend/package.json | 34 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.css | 184 + frontend/src/App.tsx | 37 + frontend/src/api/client.ts | 117 + frontend/src/assets/hero.png | Bin 0 -> 13057 bytes frontend/src/assets/react.svg | 1 + frontend/src/assets/vite.svg | 1 + frontend/src/components/ScoreForm.tsx | 241 + frontend/src/components/ScoreOverview.tsx | 76 + frontend/src/components/TrendChart.tsx | 132 + .../src/components/WrongQuestionUpload.tsx | 93 + frontend/src/constants/school.ts | 26 + frontend/src/context/AuthContext.tsx | 65 + frontend/src/index.css | 25 + frontend/src/main.tsx | 20 + frontend/src/pages/LoginPage.tsx | 118 + frontend/src/pages/StudentDetailPage.tsx | 229 + frontend/src/pages/StudentsPage.tsx | 146 + frontend/src/pages/WrongQuestionDetail.tsx | 162 + frontend/src/types/index.ts | 101 + frontend/tsconfig.app.json | 25 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 23 + frontend/vite.config.ts | 15 + scripts/backup.ps1 | 14 + scripts/backup.sh | 4 + 76 files changed, 8506 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 COPYRIGHT.md create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/database.py create mode 100644 backend/app/core/deps.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/exams.py create mode 100644 backend/app/routers/export.py create mode 100644 backend/app/routers/students.py create mode 100644 backend/app/routers/subjects.py create mode 100644 backend/app/routers/wrong_questions.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/migrate.py create mode 100644 backend/app/services/ocr.py create mode 100644 backend/app/services/ollama.py create mode 100644 backend/app/services/school_level.py create mode 100644 backend/app/services/score_trend.py create mode 100644 backend/app/services/seed.py create mode 100644 backend/app/services/student_access.py create mode 100644 backend/requirements.txt create mode 100644 deploy/backup.sh create mode 100644 deploy/install.sh create mode 100644 deploy/uninstall.sh create mode 100644 deploy/update.sh create mode 100644 docker-compose.yml create mode 100644 docs/DEPLOY.md create mode 100644 docs/USAGE.md create mode 100644 frontend/.gitignore create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/components/ScoreForm.tsx create mode 100644 frontend/src/components/ScoreOverview.tsx create mode 100644 frontend/src/components/TrendChart.tsx create mode 100644 frontend/src/components/WrongQuestionUpload.tsx create mode 100644 frontend/src/constants/school.ts create mode 100644 frontend/src/context/AuthContext.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/StudentDetailPage.tsx create mode 100644 frontend/src/pages/StudentsPage.tsx create mode 100644 frontend/src/pages/WrongQuestionDetail.tsx create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 scripts/backup.ps1 create mode 100644 scripts/backup.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e21da20 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# 复制为 .env 后修改(一键部署脚本会自动生成) +# 部署目录默认:/opt/secondary-school-grade-archive + +# Web 对外端口(默认 23566) +WEB_PORT=23566 + +# 生产环境务必修改 +SECRET_KEY=请替换为随机字符串 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=请替换为强密码 +POSTGRES_DB=student_archive + +# 允许跨域的前端地址(部署完成后改为实际访问地址) +# 若通过反向代理访问,请自行在反向代理层处理,此处填写直连地址即可 +CORS_ORIGINS=http://127.0.0.1:23566,http://localhost:23566 + +# Ollama(错题 AI 解法,可选) +# Docker 容器内通过 host.docker.internal 访问宿主机 Ollama +OLLAMA_BASE_URL=http://host.docker.internal:11434 +OLLAMA_MODEL=qwen2.5:7b + +# 成绩波动高亮阈值(0.08 = 8%) +FLUCTUATION_THRESHOLD=0.08 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..558f1ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +uploads/ +backups/ +__pycache__/ +*.pyc +.env +!.env.example +node_modules/ +dist/ diff --git a/COPYRIGHT.md b/COPYRIGHT.md new file mode 100644 index 0000000..a90dc4d --- /dev/null +++ b/COPYRIGHT.md @@ -0,0 +1,39 @@ +# 版权与作者信息 + +## 软件名称 + +- 中文:中学成绩档案系统(初中 / 高中) +- 英文:Secondary School Grade Archive + +## 著作权人 + +| 项目 | 内容 | +|------|------| +| 作者 | **马建军** | +| 微信 | **dekun03** | +| 手机 | **18364911125** | + +## 权利声明 + +本仓库([secondary-school-grade-archive](https://git.bz121.com/dekun/secondary-school-grade-archive))中的全部源代码、部署脚本、文档及界面资源,著作权归 **马建军** 所有。 + +- 个人学习、研究可在保留版权信息的前提下使用; +- **商业使用、二次分发、闭源修改后对外提供服务等,须事先取得著作权人书面授权**; +- 禁止删除或修改软件内、文档中的版权与联系方式。 + +完整法律文本见项目根目录 [LICENSE](./LICENSE) 文件。 + +## 第三方开源组件 + +本系统基于多种开源技术构建,包括但不限于: + +- FastAPI、SQLAlchemy、PaddleOCR、React、Ant Design、ECharts、PostgreSQL、Nginx 等 + +上述组件分别适用其各自的开源许可证,与本项目的版权声明相互独立。 + +## 联系授权 + +如需获取商业授权、定制开发或技术支持,请联系: + +- 微信:**dekun03** +- 手机:**18364911125** diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6601d4a --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +中学成绩档案系统 (Secondary School Grade Archive) +Copyright (c) 2024-2026 马建军 (Ma Jianjun). All rights reserved. + +本软件及其源代码、文档、界面设计及相关资料的著作权均归 马建军 所有。 + +未经著作权人书面授权,任何单位或个人不得: + 1. 复制、修改、传播或公开发布本软件源代码; + 2. 将本软件用于商业用途或向第三方提供托管/销售; + 3. 移除或篡改本软件中的版权标识与作者信息。 + +授权使用、商业合作或技术支持,请联系: + 作者:马建军 + 微信:dekun03 + 手机:18364911125 + +本软件按「现状」提供,不提供任何明示或暗示的担保。使用本软件所产生的 +任何直接或间接损失,著作权人不承担法律责任。 + +第三方组件(如 React、FastAPI、PostgreSQL、PaddleOCR 等)各自适用其 +原项目许可证,与本版权声明无关。 diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..bc7cf3a --- /dev/null +++ b/NOTICE @@ -0,0 +1,3 @@ +# 中学成绩档案系统 +# Copyright (c) 马建军. All rights reserved. +# 微信: dekun03 手机: 18364911125 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a75815c --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# 中学成绩档案(初中 / 高中) + +Secondary School Grade Archive — 多用户 Web 系统:成绩录入、占比趋势分析、错题 OCR + 本地 AI 解法。 + +**版权所有 © 马建军** · 微信 **dekun03** · 手机 **18364911125** + +> 完整版权说明见 [COPYRIGHT.md](./COPYRIGHT.md) · 许可证见 [LICENSE](./LICENSE) + +**代码仓库:** [https://git.bz121.com/dekun/secondary-school-grade-archive.git](https://git.bz121.com/dekun/secondary-school-grade-archive.git) + +--- + +## 文档索引 + +| 文档 | 说明 | +|------|------| +| [docs/DEPLOY.md](./docs/DEPLOY.md) | **Ubuntu 一键 Docker 部署**(/opt、端口 23566) | +| [docs/USAGE.md](./docs/USAGE.md) | **用户使用说明** | +| [COPYRIGHT.md](./COPYRIGHT.md) | 版权与授权 | +| [LICENSE](./LICENSE) | 许可证全文 | + +--- + +## 功能概览 + +- 用户注册/登录,数据按账号隔离 +- 学生管理(**初中 / 高中**学段、年级、班级) +- 成绩录入:周考 / 月考 / 期末(总分、得分、占比) +- 分科曲线:上升绿、下降红、大幅波动高亮 +- 错题库:上传图片 → PaddleOCR → Ollama 生成解法 +- 成绩 CSV 导出、备份脚本 + +--- + +## Ubuntu 一键部署(生产环境) + +**要求:** root 用户 · Ubuntu · Docker · 目录 `/opt/secondary-school-grade-archive` · 端口 **23566** + +```bash +git clone https://git.bz121.com/dekun/secondary-school-grade-archive.git /opt/secondary-school-grade-archive +cd /opt/secondary-school-grade-archive +chmod +x deploy/*.sh +bash deploy/install.sh +``` + +部署完成后访问:`http://<服务器IP>:23566` + +脚本会自动:检测系统环境 → 安装 Docker(若缺失)→ 生成 `.env` → 构建并启动服务。 + +详细说明、运维命令、故障排查见 **[docs/DEPLOY.md](./docs/DEPLOY.md)**。 + +> **反向代理(HTTPS/域名)不包含在本项目中**,请自行配置 Nginx/Caddy 等,参见部署文档第 7 节。 + +--- + +## 本地开发 + +### Docker Compose(默认端口 23566) + +```bash +cp .env.example .env +docker compose --env-file .env up --build +``` + +- 前端:http://localhost:23566 +- API 健康检查:http://localhost:23566/api/health + +### 分步开发 + +```bash +# 仅数据库 +docker compose up db -d + +# 后端 +cd backend && pip install -r requirements.txt && cp .env.example .env +uvicorn app.main:app --reload --port 8000 + +# 前端 +cd frontend && npm install && npm run dev +``` + +### Ollama(错题 AI,可选) + +```bash +ollama pull qwen2.5:7b +ollama serve +``` + +--- + +## 运维快捷命令 + +```bash +cd /opt/secondary-school-grade-archive + +docker compose ps # 状态 +bash deploy/update.sh # 更新 +bash deploy/backup.sh # 备份 +bash deploy/uninstall.sh # 停止服务 +``` + +--- + +## 环境变量 + +见 [.env.example](./.env.example) + +--- + +## 技术支持 + +- **作者:** 马建军 +- **微信:** dekun03 +- **手机:** 18364911125 + +未经授权不得商业使用或去除版权信息。 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..0ca26ab --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,7 @@ +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/student_archive +SECRET_KEY=dev-secret-key-change-in-production +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 +UPLOAD_DIR=uploads +OLLAMA_BASE_URL=http://127.0.0.1:11434 +OLLAMA_MODEL=qwen2.5:7b +FLUCTUATION_THRESHOLD=0.08 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f0f7dde --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgomp1 libglib2.0-0 libsm6 libxext6 libxrender-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +ENV UPLOAD_DIR=/app/uploads +RUN mkdir -p /app/uploads + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..0913f6e --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,21 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DATABASE_URL: str = "postgresql://postgres:postgres@localhost:5432/student_archive" + SECRET_KEY: str = "change-me-in-production-use-a-long-random-string" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + ALGORITHM: str = "HS256" + UPLOAD_DIR: str = "uploads" + MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024 + OLLAMA_BASE_URL: str = "http://host.docker.internal:11434" + OLLAMA_MODEL: str = "qwen2.5:7b" + FLUCTUATION_THRESHOLD: float = 0.08 + CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000,http://localhost" + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..4b911ba --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,19 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +from app.core.config import settings + +engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..a8ba26f --- /dev/null +++ b/backend/app/core/deps.py @@ -0,0 +1,34 @@ +import uuid + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError, jwt +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.database import get_db +from app.models.user import User + +security = HTTPBearer(auto_error=False) + + +def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(security), + db: Session = Depends(get_db), +) -> User: + if credentials is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录") + token = credentials.credentials + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + user_id: str | None = payload.get("sub") + token_type: str | None = payload.get("type") + if user_id is None or token_type != "access": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效令牌") + except JWTError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效令牌") from exc + + user = db.get(User, uuid.UUID(user_id)) + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在") + return user diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..be5ce5a --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,31 @@ +from datetime import datetime, timedelta, timezone +from typing import Any + +from jose import jwt +from passlib.context import CryptContext + +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(subject: str, expires_delta: timedelta | None = None) -> str: + expire = datetime.now(timezone.utc) + ( + expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + ) + payload: dict[str, Any] = {"sub": subject, "exp": expire, "type": "access"} + return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def create_refresh_token(subject: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + payload: dict[str, Any] = {"sub": subject, "exp": expire, "type": "refresh"} + return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..677fab4 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,48 @@ +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.core.config import settings +from app.core.database import Base, SessionLocal, engine +from app.routers import auth, exams, export, students, subjects, wrong_questions +from app.services.migrate import run_migrations +from app.services.seed import seed_subjects + + +@asynccontextmanager +async def lifespan(app: FastAPI): + Path(settings.UPLOAD_DIR).mkdir(parents=True, exist_ok=True) + Base.metadata.create_all(bind=engine) + run_migrations() + db = SessionLocal() + try: + seed_subjects(db) + finally: + db.close() + yield + + +app = FastAPI(title="中学成绩档案", version="1.0.0", lifespan=lifespan) + +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, prefix="/api") +app.include_router(students.router, prefix="/api") +app.include_router(subjects.router, prefix="/api") +app.include_router(exams.router, prefix="/api") +app.include_router(wrong_questions.router, prefix="/api") +app.include_router(export.router, prefix="/api") + + +@app.get("/api/health") +def health(): + return {"status": "ok"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..6e1b2b1 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,130 @@ +import enum +import uuid +from datetime import date, datetime, timezone + +from sqlalchemy import Date, DateTime, Enum, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class ExamType(str, enum.Enum): + weekly = "weekly" + monthly = "monthly" + final = "final" + + +class WrongQuestionStatus(str, enum.Enum): + pending = "pending" + ocr_done = "ocr_done" + solved = "solved" + failed = "failed" + + +class SchoolLevel(str, enum.Enum): + junior_high = "junior_high" + senior_high = "senior_high" + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + username: Mapped[str] = mapped_column(String(64), unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String(255)) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + students: Mapped[list["Student"]] = relationship(back_populates="user", cascade="all, delete-orphan") + + +class Student(Base): + __tablename__ = "students" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) + name: Mapped[str] = mapped_column(String(64)) + school_level: Mapped[SchoolLevel] = mapped_column( + Enum(SchoolLevel), default=SchoolLevel.junior_high + ) + grade: Mapped[str | None] = mapped_column(String(32), nullable=True) + class_name: Mapped[str | None] = mapped_column(String(32), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + user: Mapped["User"] = relationship(back_populates="students") + exam_records: Mapped[list["ExamRecord"]] = relationship( + back_populates="student", cascade="all, delete-orphan" + ) + wrong_questions: Mapped[list["WrongQuestion"]] = relationship( + back_populates="student", cascade="all, delete-orphan" + ) + + +class Subject(Base): + __tablename__ = "subjects" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(32), unique=True) + + scores: Mapped[list["SubjectScore"]] = relationship(back_populates="subject") + wrong_questions: Mapped[list["WrongQuestion"]] = relationship(back_populates="subject") + + +class ExamRecord(Base): + __tablename__ = "exam_records" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + student_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("students.id"), index=True) + exam_type: Mapped[ExamType] = mapped_column(Enum(ExamType)) + exam_date: Mapped[date] = mapped_column(Date) + title: Mapped[str | None] = mapped_column(String(128), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + student: Mapped["Student"] = relationship(back_populates="exam_records") + scores: Mapped[list["SubjectScore"]] = relationship( + back_populates="exam_record", cascade="all, delete-orphan" + ) + + +class SubjectScore(Base): + __tablename__ = "subject_scores" + __table_args__ = (UniqueConstraint("exam_record_id", "subject_id", name="uq_exam_subject"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + exam_record_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("exam_records.id"), index=True + ) + subject_id: Mapped[int] = mapped_column(Integer, ForeignKey("subjects.id")) + total_score: Mapped[float] = mapped_column(Numeric(8, 2)) + obtained_score: Mapped[float] = mapped_column(Numeric(8, 2)) + ratio: Mapped[float] = mapped_column(Numeric(8, 4)) + + exam_record: Mapped["ExamRecord"] = relationship(back_populates="scores") + subject: Mapped["Subject"] = relationship(back_populates="scores") + + +class WrongQuestion(Base): + __tablename__ = "wrong_questions" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + student_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("students.id"), index=True) + subject_id: Mapped[int] = mapped_column(Integer, ForeignKey("subjects.id")) + image_path: Mapped[str] = mapped_column(String(512)) + ocr_raw_text: Mapped[str | None] = mapped_column(Text, nullable=True) + question_text: Mapped[str | None] = mapped_column(Text, nullable=True) + solution_text: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[WrongQuestionStatus] = mapped_column( + Enum(WrongQuestionStatus), default=WrongQuestionStatus.pending + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + student: Mapped["Student"] = relationship(back_populates="wrong_questions") + subject: Mapped["Subject"] = relationship(back_populates="wrong_questions") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..308651b --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,67 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from jose import JWTError, jwt +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.database import get_db +from app.core.deps import get_current_user +from app.core.security import ( + create_access_token, + create_refresh_token, + get_password_hash, + verify_password, +) +from app.models.user import User +from app.schemas import RefreshRequest, TokenResponse, UserLogin, UserOut, UserRegister + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED) +def register(data: UserRegister, db: Session = Depends(get_db)): + if db.query(User).filter(User.username == data.username).first(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已存在") + user = User(username=data.username, password_hash=get_password_hash(data.password)) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@router.post("/login", response_model=TokenResponse) +def login(data: UserLogin, db: Session = Depends(get_db)): + user = db.query(User).filter(User.username == data.username).first() + if user is None or not verify_password(data.password, user.password_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误") + return TokenResponse( + access_token=create_access_token(str(user.id)), + refresh_token=create_refresh_token(str(user.id)), + ) + + +@router.post("/refresh", response_model=TokenResponse) +def refresh(data: RefreshRequest, db: Session = Depends(get_db)): + try: + payload = jwt.decode(data.refresh_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + user_id = payload.get("sub") + token_type = payload.get("type") + if user_id is None or token_type != "refresh": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效刷新令牌") + except JWTError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效刷新令牌") from exc + + user = db.get(User, uuid.UUID(user_id)) + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在") + + return TokenResponse( + access_token=create_access_token(str(user.id)), + refresh_token=create_refresh_token(str(user.id)), + ) + + +@router.get("/me", response_model=UserOut) +def me(current_user: User = Depends(get_current_user)): + return current_user diff --git a/backend/app/routers/exams.py b/backend/app/routers/exams.py new file mode 100644 index 0000000..c45cf94 --- /dev/null +++ b/backend/app/routers/exams.py @@ -0,0 +1,182 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session, joinedload + +from app.core.database import get_db +from app.core.deps import get_current_user +from app.models.user import ExamRecord, SubjectScore, User +from app.schemas import ExamCreate, ExamOut, ExamUpdate, ScoreOut, TrendResponse +from app.services.score_trend import build_trend +from app.services.student_access import get_student_for_user + +router = APIRouter(tags=["exams"]) + + +def _score_to_out(score: SubjectScore) -> ScoreOut: + return ScoreOut( + id=score.id, + subject_id=score.subject_id, + subject_name=score.subject.name if score.subject else None, + total_score=float(score.total_score), + obtained_score=float(score.obtained_score), + ratio=float(score.ratio), + ) + + +def _exam_to_out(exam: ExamRecord) -> ExamOut: + return ExamOut( + id=exam.id, + exam_type=exam.exam_type, + exam_date=exam.exam_date, + title=exam.title, + created_at=exam.created_at, + scores=[_score_to_out(s) for s in exam.scores], + ) + + +def _apply_scores(db: Session, exam: ExamRecord, scores_data): + exam.scores.clear() + for item in scores_data: + ratio = round(item.obtained_score / item.total_score, 4) + exam.scores.append( + SubjectScore( + subject_id=item.subject_id, + total_score=item.total_score, + obtained_score=item.obtained_score, + ratio=ratio, + ) + ) + + +@router.get("/students/{student_id}/exams", response_model=list[ExamOut]) +def list_exams( + student_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + get_student_for_user(db, student_id, current_user.id) + exams = ( + db.query(ExamRecord) + .options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject)) + .filter(ExamRecord.student_id == student_id) + .order_by(ExamRecord.exam_date.desc(), ExamRecord.created_at.desc()) + .all() + ) + return [_exam_to_out(e) for e in exams] + + +@router.post("/students/{student_id}/exams", response_model=ExamOut, status_code=status.HTTP_201_CREATED) +def create_exam( + student_id: uuid.UUID, + data: ExamCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + get_student_for_user(db, student_id, current_user.id) + exam = ExamRecord( + student_id=student_id, + exam_type=data.exam_type, + exam_date=data.exam_date, + title=data.title, + ) + db.add(exam) + db.flush() + if data.scores: + _apply_scores(db, exam, data.scores) + db.commit() + db.refresh(exam) + exam = ( + db.query(ExamRecord) + .options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject)) + .filter(ExamRecord.id == exam.id) + .first() + ) + return _exam_to_out(exam) + + +@router.get("/exams/{exam_id}", response_model=ExamOut) +def get_exam( + exam_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + exam = ( + db.query(ExamRecord) + .options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject)) + .join(ExamRecord.student) + .filter(ExamRecord.id == exam_id) + .first() + ) + if exam is None or exam.student.user_id != current_user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="考试记录不存在") + return _exam_to_out(exam) + + +@router.patch("/exams/{exam_id}", response_model=ExamOut) +def update_exam( + exam_id: uuid.UUID, + data: ExamUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + exam = ( + db.query(ExamRecord) + .options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject)) + .join(ExamRecord.student) + .filter(ExamRecord.id == exam_id) + .first() + ) + if exam is None or exam.student.user_id != current_user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="考试记录不存在") + + if data.exam_type is not None: + exam.exam_type = data.exam_type + if data.exam_date is not None: + exam.exam_date = data.exam_date + if data.title is not None: + exam.title = data.title + if data.scores is not None: + _apply_scores(db, exam, data.scores) + + db.commit() + db.refresh(exam) + exam = ( + db.query(ExamRecord) + .options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject)) + .filter(ExamRecord.id == exam.id) + .first() + ) + return _exam_to_out(exam) + + +@router.delete("/exams/{exam_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_exam( + exam_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + exam = ( + db.query(ExamRecord) + .join(ExamRecord.student) + .filter(ExamRecord.id == exam_id) + .first() + ) + if exam is None or exam.student.user_id != current_user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="考试记录不存在") + db.delete(exam) + db.commit() + + +@router.get("/students/{student_id}/scores/trend", response_model=TrendResponse) +def get_score_trend( + student_id: uuid.UUID, + subject_id: int = Query(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + get_student_for_user(db, student_id, current_user.id) + try: + return build_trend(db, student_id, subject_id) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc diff --git a/backend/app/routers/export.py b/backend/app/routers/export.py new file mode 100644 index 0000000..178a1e6 --- /dev/null +++ b/backend/app/routers/export.py @@ -0,0 +1,54 @@ +import csv +import io +import uuid + +from fastapi import APIRouter, Depends +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session, joinedload + +from app.core.database import get_db +from app.core.deps import get_current_user +from app.models.user import ExamRecord, SubjectScore, User +from app.services.student_access import get_student_for_user + +router = APIRouter(tags=["export"]) + + +@router.get("/students/{student_id}/scores/export") +def export_scores_csv( + student_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + student = get_student_for_user(db, student_id, current_user.id) + exams = ( + db.query(ExamRecord) + .options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject)) + .filter(ExamRecord.student_id == student_id) + .order_by(ExamRecord.exam_date.asc()) + .all() + ) + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(["考试日期", "考试类型", "标题", "科目", "总分", "得分", "占比"]) + type_map = {"weekly": "周考", "monthly": "月考", "final": "期末"} + for exam in exams: + for score in exam.scores: + writer.writerow([ + exam.exam_date.isoformat(), + type_map.get(exam.exam_type.value, exam.exam_type.value), + exam.title or "", + score.subject.name if score.subject else "", + float(score.total_score), + float(score.obtained_score), + f"{float(score.ratio) * 100:.2f}%", + ]) + + output.seek(0) + filename = f"{student.name}_scores.csv" + return StreamingResponse( + iter([output.getvalue().encode("utf-8-sig")]), + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/backend/app/routers/students.py b/backend/app/routers/students.py new file mode 100644 index 0000000..500a438 --- /dev/null +++ b/backend/app/routers/students.py @@ -0,0 +1,73 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.deps import get_current_user +from app.models.user import Student, User +from app.schemas import StudentCreate, StudentOut, StudentUpdate +from app.services.student_access import get_student_for_user + +router = APIRouter(prefix="/students", tags=["students"]) + + +@router.get("", response_model=list[StudentOut]) +def list_students( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return ( + db.query(Student) + .filter(Student.user_id == current_user.id) + .order_by(Student.created_at.desc()) + .all() + ) + + +@router.post("", response_model=StudentOut, status_code=status.HTTP_201_CREATED) +def create_student( + data: StudentCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + student = Student(user_id=current_user.id, **data.model_dump()) + db.add(student) + db.commit() + db.refresh(student) + return student + + +@router.get("/{student_id}", response_model=StudentOut) +def get_student( + student_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return get_student_for_user(db, student_id, current_user.id) + + +@router.patch("/{student_id}", response_model=StudentOut) +def update_student( + student_id: uuid.UUID, + data: StudentUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + student = get_student_for_user(db, student_id, current_user.id) + for key, value in data.model_dump(exclude_unset=True).items(): + setattr(student, key, value) + db.commit() + db.refresh(student) + return student + + +@router.delete("/{student_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_student( + student_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + student = get_student_for_user(db, student_id, current_user.id) + db.delete(student) + db.commit() diff --git a/backend/app/routers/subjects.py b/backend/app/routers/subjects.py new file mode 100644 index 0000000..c839043 --- /dev/null +++ b/backend/app/routers/subjects.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.deps import get_current_user +from app.models.user import Subject, User +from app.schemas import SubjectOut + +router = APIRouter(prefix="/subjects", tags=["subjects"]) + + +@router.get("", response_model=list[SubjectOut]) +def list_subjects( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return db.query(Subject).order_by(Subject.id).all() diff --git a/backend/app/routers/wrong_questions.py b/backend/app/routers/wrong_questions.py new file mode 100644 index 0000000..4acb000 --- /dev/null +++ b/backend/app/routers/wrong_questions.py @@ -0,0 +1,313 @@ +import uuid +from pathlib import Path + +from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Query, UploadFile, status +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session, joinedload + +from app.core.config import settings +from app.core.database import SessionLocal, get_db +from app.core.deps import get_current_user +from app.models.user import Subject, User, WrongQuestion, WrongQuestionStatus +from app.schemas import WrongQuestionOut, WrongQuestionUpdate +from app.services import ocr as ocr_service +from app.services import ollama as ollama_service +from app.services.student_access import get_student_for_user + +router = APIRouter(tags=["wrong_questions"]) + + +def _wq_to_out(wq: WrongQuestion) -> WrongQuestionOut: + return WrongQuestionOut( + id=wq.id, + student_id=wq.student_id, + subject_id=wq.subject_id, + subject_name=wq.subject.name if wq.subject else None, + image_path=wq.image_path, + ocr_raw_text=wq.ocr_raw_text, + question_text=wq.question_text, + solution_text=wq.solution_text, + status=wq.status, + created_at=wq.created_at, + ) + + +def _process_wrong_question(question_id: uuid.UUID): + db = SessionLocal() + try: + wq = ( + db.query(WrongQuestion) + .options(joinedload(WrongQuestion.subject), joinedload(WrongQuestion.student)) + .filter(WrongQuestion.id == question_id) + .first() + ) + if wq is None: + return + + image_full = Path(settings.UPLOAD_DIR) / wq.image_path + try: + ocr_text = ocr_service.run_ocr(str(image_full)) + wq.ocr_raw_text = ocr_text or None + wq.status = WrongQuestionStatus.ocr_done if ocr_text else WrongQuestionStatus.failed + db.commit() + except Exception: + wq.status = WrongQuestionStatus.failed + db.commit() + return + + if not ocr_text: + return + + subject_name = wq.subject.name if wq.subject else "综合" + school_level = wq.student.school_level if wq.student else None + import asyncio + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + question_text = loop.run_until_complete( + ollama_service.format_question(subject_name, ocr_text, school_level) + ) + solution_text = loop.run_until_complete( + ollama_service.generate_solution(subject_name, question_text, school_level) + ) + wq.question_text = question_text + wq.solution_text = solution_text + wq.status = WrongQuestionStatus.solved + db.commit() + except Exception: + wq.status = WrongQuestionStatus.ocr_done + db.commit() + finally: + loop.close() + finally: + db.close() + + +@router.get("/students/{student_id}/wrong-questions", response_model=list[WrongQuestionOut]) +def list_wrong_questions( + student_id: uuid.UUID, + subject_id: int | None = Query(None), + q: str | None = Query(None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + get_student_for_user(db, student_id, current_user.id) + query = ( + db.query(WrongQuestion) + .options(joinedload(WrongQuestion.subject)) + .filter(WrongQuestion.student_id == student_id) + ) + if subject_id is not None: + query = query.filter(WrongQuestion.subject_id == subject_id) + if q: + pattern = f"%{q}%" + query = query.filter( + (WrongQuestion.question_text.ilike(pattern)) + | (WrongQuestion.solution_text.ilike(pattern)) + | (WrongQuestion.ocr_raw_text.ilike(pattern)) + ) + items = query.order_by(WrongQuestion.created_at.desc()).all() + return [_wq_to_out(w) for w in items] + + +@router.post( + "/students/{student_id}/wrong-questions", + response_model=WrongQuestionOut, + status_code=status.HTTP_201_CREATED, +) +async def upload_wrong_question( + student_id: uuid.UUID, + background_tasks: BackgroundTasks, + subject_id: int = Form(...), + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + get_student_for_user(db, student_id, current_user.id) + subject = db.get(Subject, subject_id) + if subject is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="科目不存在") + + content = await file.read() + if len(content) > settings.MAX_UPLOAD_SIZE: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件超过10MB限制") + + wq = WrongQuestion( + student_id=student_id, + subject_id=subject_id, + image_path="", + status=WrongQuestionStatus.pending, + ) + db.add(wq) + db.flush() + + rel_path = ocr_service.save_upload_file( + str(current_user.id), str(wq.id), file.filename or "image.jpg", content + ) + wq.image_path = rel_path + db.commit() + db.refresh(wq) + wq = ( + db.query(WrongQuestion) + .options(joinedload(WrongQuestion.subject)) + .filter(WrongQuestion.id == wq.id) + .first() + ) + + background_tasks.add_task(_process_wrong_question, wq.id) + return _wq_to_out(wq) + + +@router.get("/wrong-questions/{question_id}", response_model=WrongQuestionOut) +def get_wrong_question( + question_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + wq = ( + db.query(WrongQuestion) + .options(joinedload(WrongQuestion.subject), joinedload(WrongQuestion.student)) + .filter(WrongQuestion.id == question_id) + .first() + ) + if wq is None or wq.student.user_id != current_user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在") + return _wq_to_out(wq) + + +@router.patch("/wrong-questions/{question_id}", response_model=WrongQuestionOut) +def update_wrong_question( + question_id: uuid.UUID, + data: WrongQuestionUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + wq = ( + db.query(WrongQuestion) + .options(joinedload(WrongQuestion.subject), joinedload(WrongQuestion.student)) + .filter(WrongQuestion.id == question_id) + .first() + ) + if wq is None or wq.student.user_id != current_user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在") + + if data.subject_id is not None: + if db.get(Subject, data.subject_id) is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="科目不存在") + wq.subject_id = data.subject_id + if data.question_text is not None: + wq.question_text = data.question_text + if data.solution_text is not None: + wq.solution_text = data.solution_text + + db.commit() + db.refresh(wq) + return _wq_to_out(wq) + + +@router.delete("/wrong-questions/{question_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_wrong_question( + question_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + wq = ( + db.query(WrongQuestion) + .options(joinedload(WrongQuestion.student)) + .filter(WrongQuestion.id == question_id) + .first() + ) + if wq is None or wq.student.user_id != current_user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在") + + image_full = Path(settings.UPLOAD_DIR) / wq.image_path + db.delete(wq) + db.commit() + if image_full.exists(): + image_full.unlink() + + +@router.post("/wrong-questions/{question_id}/retry-ocr", response_model=WrongQuestionOut) +def retry_ocr( + question_id: uuid.UUID, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + wq = ( + db.query(WrongQuestion) + .options(joinedload(WrongQuestion.subject), joinedload(WrongQuestion.student)) + .filter(WrongQuestion.id == question_id) + .first() + ) + if wq is None or wq.student.user_id != current_user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在") + + wq.status = WrongQuestionStatus.pending + db.commit() + background_tasks.add_task(_process_wrong_question, wq.id) + return _wq_to_out(wq) + + +@router.post("/wrong-questions/{question_id}/regenerate-solution", response_model=WrongQuestionOut) +async def regenerate_solution( + question_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + wq = ( + db.query(WrongQuestion) + .options(joinedload(WrongQuestion.subject), joinedload(WrongQuestion.student)) + .filter(WrongQuestion.id == question_id) + .first() + ) + if wq is None or wq.student.user_id != current_user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在") + + if not wq.question_text and not wq.ocr_raw_text: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="缺少题目内容") + + subject_name = wq.subject.name if wq.subject else "综合" + school_level = wq.student.school_level if wq.student else None + question_text = wq.question_text or wq.ocr_raw_text or "" + + try: + if not wq.question_text and wq.ocr_raw_text: + wq.question_text = await ollama_service.format_question( + subject_name, wq.ocr_raw_text, school_level + ) + question_text = wq.question_text + wq.solution_text = await ollama_service.generate_solution( + subject_name, question_text, school_level + ) + wq.status = WrongQuestionStatus.solved + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Ollama 调用失败: {exc}" + ) from exc + + db.commit() + db.refresh(wq) + return _wq_to_out(wq) + + +@router.get("/wrong-questions/{question_id}/image") +def get_wrong_question_image( + question_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + wq = ( + db.query(WrongQuestion) + .options(joinedload(WrongQuestion.student)) + .filter(WrongQuestion.id == question_id) + .first() + ) + if wq is None or wq.student.user_id != current_user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在") + + image_full = Path(settings.UPLOAD_DIR) / wq.image_path + if not image_full.exists(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在") + return FileResponse(image_full) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..7a257aa --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,183 @@ +from datetime import date, datetime +from enum import Enum +from uuid import UUID + +from pydantic import BaseModel, Field, field_validator + + +class ExamTypeEnum(str, Enum): + weekly = "weekly" + monthly = "monthly" + final = "final" + + +class WrongQuestionStatusEnum(str, Enum): + pending = "pending" + ocr_done = "ocr_done" + solved = "solved" + failed = "failed" + + +class SchoolLevelEnum(str, Enum): + junior_high = "junior_high" + senior_high = "senior_high" + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class UserRegister(BaseModel): + username: str = Field(min_length=3, max_length=64) + password: str = Field(min_length=6, max_length=128) + + +class UserLogin(BaseModel): + username: str + password: str + + +class RefreshRequest(BaseModel): + refresh_token: str + + +class UserOut(BaseModel): + id: UUID + username: str + created_at: datetime + + model_config = {"from_attributes": True} + + +class StudentCreate(BaseModel): + name: str = Field(min_length=1, max_length=64) + school_level: SchoolLevelEnum = SchoolLevelEnum.junior_high + grade: str | None = None + class_name: str | None = None + + +class StudentUpdate(BaseModel): + name: str | None = None + school_level: SchoolLevelEnum | None = None + grade: str | None = None + class_name: str | None = None + + +class StudentOut(BaseModel): + id: UUID + name: str + school_level: SchoolLevelEnum + grade: str | None + class_name: str | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class SubjectOut(BaseModel): + id: int + name: str + + model_config = {"from_attributes": True} + + +class ScoreInput(BaseModel): + subject_id: int + total_score: float + obtained_score: float + + @field_validator("total_score") + @classmethod + def validate_total(cls, v: float) -> float: + if v <= 0: + raise ValueError("总分必须大于0") + return v + + @field_validator("obtained_score") + @classmethod + def validate_obtained(cls, v: float, info) -> float: + total = info.data.get("total_score") + if total is not None and v > total: + raise ValueError("得分不能大于总分") + if v < 0: + raise ValueError("得分不能为负") + return v + + +class ScoreOut(BaseModel): + id: UUID + subject_id: int + subject_name: str | None = None + total_score: float + obtained_score: float + ratio: float + + model_config = {"from_attributes": True} + + +class ExamCreate(BaseModel): + exam_type: ExamTypeEnum + exam_date: date + title: str | None = None + scores: list[ScoreInput] = [] + + +class ExamUpdate(BaseModel): + exam_type: ExamTypeEnum | None = None + exam_date: date | None = None + title: str | None = None + scores: list[ScoreInput] | None = None + + +class ExamOut(BaseModel): + id: UUID + exam_type: ExamTypeEnum + exam_date: date + title: str | None + created_at: datetime + scores: list[ScoreOut] = [] + + model_config = {"from_attributes": True} + + +class TrendPoint(BaseModel): + exam_id: UUID + exam_type: ExamTypeEnum + exam_date: date + title: str | None + ratio: float + ratio_percent: float + delta: float | None = None + delta_percent: float | None = None + is_volatile: bool = False + direction: str | None = None + + +class TrendResponse(BaseModel): + subject_id: int + subject_name: str + threshold: float + points: list[TrendPoint] + + +class WrongQuestionOut(BaseModel): + id: UUID + student_id: UUID + subject_id: int + subject_name: str | None = None + image_path: str + ocr_raw_text: str | None + question_text: str | None + solution_text: str | None + status: WrongQuestionStatusEnum + created_at: datetime + + model_config = {"from_attributes": True} + + +class WrongQuestionUpdate(BaseModel): + question_text: str | None = None + solution_text: str | None = None + subject_id: int | None = None diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/migrate.py b/backend/app/services/migrate.py new file mode 100644 index 0000000..3d2076a --- /dev/null +++ b/backend/app/services/migrate.py @@ -0,0 +1,20 @@ +from sqlalchemy import inspect, text + +from app.core.database import engine + + +def run_migrations() -> None: + """Apply lightweight schema updates for existing databases.""" + inspector = inspect(engine) + if "students" not in inspector.get_table_names(): + return + + columns = {col["name"] for col in inspector.get_columns("students")} + if "school_level" not in columns: + with engine.begin() as conn: + conn.execute( + text( + "ALTER TABLE students ADD COLUMN school_level VARCHAR(32) " + "NOT NULL DEFAULT 'junior_high'" + ) + ) diff --git a/backend/app/services/ocr.py b/backend/app/services/ocr.py new file mode 100644 index 0000000..a44c099 --- /dev/null +++ b/backend/app/services/ocr.py @@ -0,0 +1,40 @@ +from pathlib import Path + +from app.core.config import settings + +_ocr_engine = None + + +def get_ocr_engine(): + global _ocr_engine + if _ocr_engine is None: + from paddleocr import PaddleOCR + + _ocr_engine = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False) + return _ocr_engine + + +def run_ocr(image_path: str) -> str: + engine = get_ocr_engine() + result = engine.ocr(image_path, cls=True) + if not result or not result[0]: + return "" + lines = [] + for line in result[0]: + if line and len(line) >= 2: + text = line[1][0] + if text: + lines.append(text) + return "\n".join(lines) + + +def save_upload_file(user_id: str, question_id: str, filename: str, content: bytes) -> str: + ext = Path(filename).suffix.lower() or ".jpg" + if ext not in {".jpg", ".jpeg", ".png", ".webp"}: + ext = ".jpg" + user_dir = Path(settings.UPLOAD_DIR) / user_id + user_dir.mkdir(parents=True, exist_ok=True) + rel_path = f"{user_id}/{question_id}{ext}" + full_path = Path(settings.UPLOAD_DIR) / rel_path + full_path.write_bytes(content) + return rel_path diff --git a/backend/app/services/ollama.py b/backend/app/services/ollama.py new file mode 100644 index 0000000..753e7fa --- /dev/null +++ b/backend/app/services/ollama.py @@ -0,0 +1,47 @@ +import httpx + +from app.core.config import settings +from app.services.school_level import school_level_label + +QUESTION_PROMPT = """你是一位{stage}老师。以下是从试卷 OCR 识别出的文字,可能含有噪声。 +科目:{subject} +请整理出清晰的题目内容(保留题号、选项、公式),只输出题目正文,不要解释。 + +OCR 原文: +{ocr_text} +""" + +SOLUTION_PROMPT = """你是一位耐心的{stage}{subject}老师。请为以下题目给出详细解法。 +要求:步骤清晰,语言适合{stage}学生理解,指出考点和易错点。 + +题目: +{question_text} +""" + + +async def ollama_generate(prompt: str) -> str: + url = f"{settings.OLLAMA_BASE_URL.rstrip('/')}/api/generate" + payload = { + "model": settings.OLLAMA_MODEL, + "prompt": prompt, + "stream": False, + } + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post(url, json=payload) + response.raise_for_status() + data = response.json() + return (data.get("response") or "").strip() + + +async def format_question(subject: str, ocr_text: str, school_level=None) -> str: + stage = school_level_label(school_level) + prompt = QUESTION_PROMPT.format(stage=stage, subject=subject, ocr_text=ocr_text) + return await ollama_generate(prompt) + + +async def generate_solution(subject: str, question_text: str, school_level=None) -> str: + stage = school_level_label(school_level) + prompt = SOLUTION_PROMPT.format( + stage=stage, subject=subject, question_text=question_text + ) + return await ollama_generate(prompt) diff --git a/backend/app/services/school_level.py b/backend/app/services/school_level.py new file mode 100644 index 0000000..a7f4e7d --- /dev/null +++ b/backend/app/services/school_level.py @@ -0,0 +1,17 @@ +from app.models.user import SchoolLevel + +SCHOOL_LEVEL_LABELS: dict[SchoolLevel, str] = { + SchoolLevel.junior_high: "初中", + SchoolLevel.senior_high: "高中", +} + + +def school_level_label(level: SchoolLevel | str | None) -> str: + if level is None: + return "初中" + if isinstance(level, str): + try: + level = SchoolLevel(level) + except ValueError: + return "初中" + return SCHOOL_LEVEL_LABELS.get(level, "初中") diff --git a/backend/app/services/score_trend.py b/backend/app/services/score_trend.py new file mode 100644 index 0000000..d8c4663 --- /dev/null +++ b/backend/app/services/score_trend.py @@ -0,0 +1,66 @@ +from sqlalchemy.orm import Session, joinedload + +from app.core.config import settings +from app.models.user import ExamRecord, Subject, SubjectScore +from app.schemas import ExamTypeEnum, TrendPoint, TrendResponse + + +def build_trend(db: Session, student_id, subject_id: int) -> TrendResponse: + subject = db.get(Subject, subject_id) + if subject is None: + raise ValueError("科目不存在") + + scores = ( + db.query(SubjectScore) + .join(ExamRecord) + .options(joinedload(SubjectScore.exam_record)) + .filter( + ExamRecord.student_id == student_id, + SubjectScore.subject_id == subject_id, + ) + .order_by(ExamRecord.exam_date.asc(), ExamRecord.created_at.asc()) + .all() + ) + + threshold = settings.FLUCTUATION_THRESHOLD + points: list[TrendPoint] = [] + prev_ratio: float | None = None + + for score in scores: + exam = score.exam_record + ratio = float(score.ratio) + delta = None if prev_ratio is None else ratio - prev_ratio + direction = None + is_volatile = False + + if delta is not None: + if delta > 0: + direction = "up" + elif delta < 0: + direction = "down" + else: + direction = "flat" + is_volatile = abs(delta) >= threshold + + points.append( + TrendPoint( + exam_id=exam.id, + exam_type=ExamTypeEnum(exam.exam_type.value), + exam_date=exam.exam_date, + title=exam.title, + ratio=ratio, + ratio_percent=round(ratio * 100, 2), + delta=delta, + delta_percent=round(delta * 100, 2) if delta is not None else None, + is_volatile=is_volatile, + direction=direction, + ) + ) + prev_ratio = ratio + + return TrendResponse( + subject_id=subject_id, + subject_name=subject.name, + threshold=threshold, + points=points, + ) diff --git a/backend/app/services/seed.py b/backend/app/services/seed.py new file mode 100644 index 0000000..f58bc4a --- /dev/null +++ b/backend/app/services/seed.py @@ -0,0 +1,23 @@ +from sqlalchemy.orm import Session + +from app.models.user import Subject + +DEFAULT_SUBJECTS = [ + "语文", + "数学", + "英语", + "物理", + "化学", + "生物", + "历史", + "地理", + "政治", +] + + +def seed_subjects(db: Session) -> None: + existing = {s.name for s in db.query(Subject).all()} + for name in DEFAULT_SUBJECTS: + if name not in existing: + db.add(Subject(name=name)) + db.commit() diff --git a/backend/app/services/student_access.py b/backend/app/services/student_access.py new file mode 100644 index 0000000..5d1b317 --- /dev/null +++ b/backend/app/services/student_access.py @@ -0,0 +1,13 @@ +import uuid + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.user import Student + + +def get_student_for_user(db: Session, student_id: uuid.UUID, user_id: uuid.UUID) -> Student: + student = db.get(Student, student_id) + if student is None or student.user_id != user_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="学生不存在") + return student diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..8098fe9 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,16 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy==2.0.36 +psycopg2-binary==2.9.10 +alembic==1.14.0 +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.20 +httpx==0.28.1 +paddleocr==2.9.1 +paddlepaddle==2.6.2 +Pillow==11.0.0 +aiofiles==24.1.0 diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100644 index 0000000..788a899 --- /dev/null +++ b/deploy/backup.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# +# 备份 PostgreSQL 与 uploads 目录 +# 版权所有 (c) 马建军 +# +set -euo pipefail + +INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}" +BACKUP_DIR="${BACKUP_DIR:-${INSTALL_DIR}/backups}" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) + +cd "${INSTALL_DIR}" +mkdir -p "${BACKUP_DIR}" + +echo "[INFO] 备份数据库…" +docker compose --env-file .env exec -T db \ + pg_dump -U "${POSTGRES_USER:-postgres}" "${POSTGRES_DB:-student_archive}" \ + > "${BACKUP_DIR}/db_${TIMESTAMP}.sql" + +echo "[INFO] 备份 uploads…" +tar -czf "${BACKUP_DIR}/uploads_${TIMESTAMP}.tar.gz" -C "${INSTALL_DIR}" uploads/ + +echo "[INFO] 备份完成:" +echo " ${BACKUP_DIR}/db_${TIMESTAMP}.sql" +echo " ${BACKUP_DIR}/uploads_${TIMESTAMP}.tar.gz" diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100644 index 0000000..beb79d9 --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +# +# 中学成绩档案系统 — Ubuntu 一键部署脚本 +# 版权所有 (c) 马建军 微信: dekun03 手机: 18364911125 +# +# 用法(root): +# curl -fsSL .../deploy/install.sh | bash +# 或 +# bash deploy/install.sh +# +set -euo pipefail + +REPO_URL="${REPO_URL:-https://git.bz121.com/dekun/secondary-school-grade-archive.git}" +INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}" +WEB_PORT="${WEB_PORT:-23566}" +BRANCH="${BRANCH:-main}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } + +require_root() { + if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then + log_error "请使用 root 用户运行本脚本,例如: sudo bash deploy/install.sh" + exit 1 + fi +} + +check_os() { + if [[ ! -f /etc/os-release ]]; then + log_error "无法识别操作系统,本脚本仅支持 Ubuntu/Debian 系 Linux" + exit 1 + fi + # shellcheck source=/dev/null + source /etc/os-release + log_info "检测到系统: ${NAME:-Unknown} ${VERSION_ID:-}" + case "${ID:-}" in + ubuntu|debian) + ;; + *) + log_warn "当前系统为 ${ID:-unknown},未经完整测试,继续安装…" + ;; + esac +} + +check_resources() { + local mem_kb disk_kb + mem_kb=$(grep MemTotal /proc/meminfo | awk '{print $2}') + disk_kb=$(df -k "${INSTALL_DIR%/*}" 2>/dev/null | tail -1 | awk '{print $4}') + if [[ "${mem_kb:-0}" -lt 1800000 ]]; then + log_warn "内存不足 2GB,PaddleOCR 首次运行可能较慢" + fi + if [[ "${disk_kb:-0}" -lt 5242880 ]]; then + log_warn "可用磁盘空间不足 5GB,请确保有足够空间存放镜像与 OCR 模型" + fi +} + +check_port() { + if command -v ss &>/dev/null; then + if ss -tln | grep -q ":${WEB_PORT} "; then + log_error "端口 ${WEB_PORT} 已被占用,请修改 WEB_PORT 环境变量后重试" + exit 1 + fi + elif command -v netstat &>/dev/null; then + if netstat -tln | grep -q ":${WEB_PORT} "; then + log_error "端口 ${WEB_PORT} 已被占用" + exit 1 + fi + fi + log_info "端口 ${WEB_PORT} 可用" +} + +install_packages() { + log_info "安装基础依赖…" + apt-get update -qq + apt-get install -y -qq git curl ca-certificates openssl +} + +install_docker() { + if command -v docker &>/dev/null; then + log_info "Docker 已安装: $(docker --version)" + else + log_info "正在安装 Docker…" + apt-get install -y -qq apt-transport-https gnupg lsb-release + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc + chmod a+r /etc/apt/keyrings/docker.asc + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" \ + > /etc/apt/sources.list.d/docker.list + apt-get update -qq + apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin + systemctl enable docker + systemctl start docker + log_info "Docker 安装完成" + fi + + if ! docker compose version &>/dev/null; then + log_error "未检测到 docker compose 插件,请手动安装 docker-compose-plugin" + exit 1 + fi + log_info "Docker Compose: $(docker compose version)" +} + +clone_or_update_repo() { + if [[ -d "${INSTALL_DIR}/.git" ]]; then + log_info "更新代码: ${INSTALL_DIR}" + git -C "${INSTALL_DIR}" fetch origin + git -C "${INSTALL_DIR}" checkout "${BRANCH}" 2>/dev/null || true + git -C "${INSTALL_DIR}" pull --ff-only origin "${BRANCH}" || git -C "${INSTALL_DIR}" pull origin "${BRANCH}" + elif [[ -d "${INSTALL_DIR}" ]]; then + log_error "目录 ${INSTALL_DIR} 已存在但不是 git 仓库,请备份后删除或修改 INSTALL_DIR" + exit 1 + else + log_info "克隆仓库到 ${INSTALL_DIR}" + mkdir -p "$(dirname "${INSTALL_DIR}")" + git clone --branch "${BRANCH}" "${REPO_URL}" "${INSTALL_DIR}" || git clone "${REPO_URL}" "${INSTALL_DIR}" + fi +} + +generate_env() { + local env_file="${INSTALL_DIR}/.env" + local server_ip + server_ip=$(hostname -I 2>/dev/null | awk '{print $1}') + server_ip="${server_ip:-127.0.0.1}" + + if [[ -f "${env_file}" ]]; then + log_info "保留已有 .env 配置" + # shellcheck source=/dev/null + source "${env_file}" + WEB_PORT="${WEB_PORT:-23566}" + return + fi + + log_info "生成 .env 配置文件" + local secret pg_pass + secret=$(openssl rand -hex 32) + pg_pass=$(openssl rand -hex 16) + + cat > "${env_file}" </dev/null || true + docker compose --env-file .env up -d --build +} + +wait_healthy() { + local i max=60 + log_info "等待 API 就绪…" + for ((i=1; i<=max; i++)); do + if docker compose --env-file "${INSTALL_DIR}/.env" exec -T api \ + python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')" &>/dev/null; then + log_info "API 健康检查通过" + return 0 + fi + sleep 3 + done + log_warn "API 启动超时,请执行: cd ${INSTALL_DIR} && docker compose logs api" +} + +print_summary() { + local ip + ip=$(hostname -I 2>/dev/null | awk '{print $1}') + ip="${ip:-127.0.0.1}" + # shellcheck source=/dev/null + source "${INSTALL_DIR}/.env" + + echo "" + echo "==========================================" + echo " 中学成绩档案系统 部署完成" + echo " 版权所有 (c) 马建军" + echo "==========================================" + echo "" + echo " 访问地址: http://${ip}:${WEB_PORT}" + echo " 本地访问: http://127.0.0.1:${WEB_PORT}" + echo " 安装目录: ${INSTALL_DIR}" + echo "" + echo " 常用命令:" + echo " 查看状态: cd ${INSTALL_DIR} && docker compose ps" + echo " 查看日志: cd ${INSTALL_DIR} && docker compose logs -f" + echo " 停止服务: cd ${INSTALL_DIR} && docker compose down" + echo " 更新版本: bash ${INSTALL_DIR}/deploy/update.sh" + echo " 数据备份: bash ${INSTALL_DIR}/deploy/backup.sh" + echo "" + echo " 反向代理(Nginx/Caddy 等)请自行配置,本项目不包含。" + echo " 详见文档: ${INSTALL_DIR}/docs/DEPLOY.md" + echo "" + echo " 技术支持: 微信 dekun03 手机 18364911125" + echo "==========================================" +} + +main() { + echo "" + log_info "中学成绩档案系统 — 一键部署开始" + require_root + check_os + check_resources + check_port + install_packages + install_docker + clone_or_update_repo + generate_env + start_services + wait_healthy + print_summary +} + +main "$@" diff --git a/deploy/uninstall.sh b/deploy/uninstall.sh new file mode 100644 index 0000000..f2b656d --- /dev/null +++ b/deploy/uninstall.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# +# 停止并移除容器(默认保留数据卷与 uploads) +# 版权所有 (c) 马建军 +# +set -euo pipefail + +INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}" +REMOVE_VOLUMES="${REMOVE_VOLUMES:-0}" + +cd "${INSTALL_DIR}" || exit 1 + +echo "将停止 Docker 服务…" +if [[ "${REMOVE_VOLUMES}" == "1" ]]; then + echo "警告: 将删除数据库卷(所有成绩数据会丢失)" + read -r -p "确认删除数据卷? 输入 yes: " ans + if [[ "${ans}" == "yes" ]]; then + docker compose --env-file .env down -v + else + echo "已取消" + exit 1 + fi +else + docker compose --env-file .env down + echo "数据卷与 ${INSTALL_DIR}/uploads 已保留" +fi + +echo "卸载完成。源码目录 ${INSTALL_DIR} 未自动删除,如需删除请手动 rm -rf" diff --git a/deploy/update.sh b/deploy/update.sh new file mode 100644 index 0000000..2eb222d --- /dev/null +++ b/deploy/update.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# 更新已部署实例:拉取代码并重建容器 +# 版权所有 (c) 马建军 +# +set -euo pipefail + +INSTALL_DIR="${INSTALL_DIR:-/opt/secondary-school-grade-archive}" +BRANCH="${BRANCH:-main}" + +GREEN='\033[0;32m' +NC='\033[0m' +log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } + +if [[ ! -d "${INSTALL_DIR}/.git" ]]; then + echo "未找到部署目录 ${INSTALL_DIR},请先运行 deploy/install.sh" + exit 1 +fi + +cd "${INSTALL_DIR}" +log_info "拉取最新代码…" +git fetch origin +git checkout "${BRANCH}" 2>/dev/null || true +git pull origin "${BRANCH}" + +log_info "重建并重启服务…" +docker compose --env-file .env up -d --build + +log_info "更新完成。访问端口见 .env 中 WEB_PORT" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..44b748f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-student_archive} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - appnet + + api: + build: ./backend + restart: unless-stopped + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-student_archive} + SECRET_KEY: ${SECRET_KEY:-dev-secret-key-change-in-production} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:23566} + UPLOAD_DIR: /app/uploads + OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen2.5:7b} + FLUCTUATION_THRESHOLD: ${FLUCTUATION_THRESHOLD:-0.08} + volumes: + - ./uploads:/app/uploads + depends_on: + db: + condition: service_healthy + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - appnet + + web: + build: ./frontend + restart: unless-stopped + ports: + - "${WEB_PORT:-23566}:80" + depends_on: + - api + networks: + - appnet + +volumes: + pgdata: + +networks: + appnet: + driver: bridge diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..adbe352 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,304 @@ +# Ubuntu 部署文档 + +> **中学成绩档案系统**(Secondary School Grade Archive) +> 版权所有 © 马建军 · 微信 **dekun03** · 手机 **18364911125** +> 代码仓库:[https://git.bz121.com/dekun/secondary-school-grade-archive.git](https://git.bz121.com/dekun/secondary-school-grade-archive.git) + +--- + +## 1. 部署概述 + +| 项目 | 说明 | +|------|------| +| 目标系统 | Ubuntu 20.04 / 22.04 / 24.04(推荐 22.04+) | +| 运行用户 | **root**(一键脚本要求) | +| 安装目录 | **`/opt/secondary-school-grade-archive`** | +| 部署方式 | Docker Compose | +| 对外端口 | **`23566`**(HTTP,可在 `.env` 修改 `WEB_PORT`) | +| 反向代理 | **不包含在本项目中**,需用户自行配置 Nginx/Caddy 等 | + +### 1.1 架构说明 + +``` +浏览器 ──► :23566 (Nginx/Web) ──► API (内部) ──► PostgreSQL (内部) + │ + └──► Ollama (宿主机,可选) + └──► uploads/ (宿主机挂载) +``` + +- **Web**:Nginx 提供前端静态文件,并将 `/api` 反向代理到后端容器 +- **API**:FastAPI,不直接暴露端口到宿主机 +- **PostgreSQL**:仅 Docker 内网访问,不映射宿主机端口 +- **Ollama**:可选,运行在宿主机,容器通过 `host.docker.internal` 访问 + +--- + +## 2. 环境要求 + +### 2.1 硬件(建议) + +| 资源 | 最低 | 推荐 | +|------|------|------| +| CPU | 2 核 | 4 核+ | +| 内存 | 2 GB | 4 GB+(启用 OCR 建议 8 GB) | +| 磁盘 | 10 GB | 20 GB+ | + +### 2.2 软件 + +- Ubuntu Server(64 位) +- 可访问互联网(拉取 Docker 镜像与 Git 仓库) +- 防火墙放行 **23566/TCP**(若需外网访问) + +### 2.3 端口 + +| 端口 | 用途 | 是否必须对外开放 | +|------|------|------------------| +| **23566** | Web 前端 + API(经 Nginx 统一入口) | 是 | +| 11434 | Ollama(宿主机,错题 AI 解法) | 仅本机,可不对外 | +| 5432 | PostgreSQL | **否**(已关闭对外映射) | + +--- + +## 3. 一键部署(推荐) + +以 **root** 登录 Ubuntu 服务器后执行: + +```bash +# 方式 A:克隆后执行(仓库已有代码时) +git clone https://git.bz121.com/dekun/secondary-school-grade-archive.git /opt/secondary-school-grade-archive +cd /opt/secondary-school-grade-archive +chmod +x deploy/*.sh +bash deploy/install.sh +``` + +```bash +# 方式 B:仅下载安装脚本(仓库已推送 install.sh 后) +export WEB_PORT=23566 +export INSTALL_DIR=/opt/secondary-school-grade-archive +curl -fsSL https://git.bz121.com/dekun/secondary-school-grade-archive/raw/branch/main/deploy/install.sh -o /tmp/install.sh +chmod +x /tmp/install.sh +bash /tmp/install.sh +``` + +### 3.1 脚本自动完成的事项 + +1. 检测是否为 root 用户 +2. 检测 Ubuntu/Debian 系操作系统 +3. 检测内存、磁盘(不足时警告) +4. 检测 **23566** 端口是否占用 +5. 安装 `git`、`curl`、`openssl` 等基础工具 +6. 若未安装 Docker,自动安装 **Docker CE + Compose 插件** +7. 克隆/更新代码到 `/opt/secondary-school-grade-archive` +8. 自动生成 `.env`(随机 `SECRET_KEY`、数据库密码) +9. `docker compose up -d --build` 构建并启动 +10. 等待 API 健康检查通过后输出访问地址 + +### 3.2 部署成功后的访问 + +``` +http://<服务器IP>:23566 +``` + +首次使用请在页面 **注册** 账号,然后登录添加学生。 + +--- + +## 4. 环境变量(`.env`) + +部署后配置文件位于: + +``` +/opt/secondary-school-grade-archive/.env +``` + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `WEB_PORT` | `23566` | Web 对外端口 | +| `SECRET_KEY` | 自动生成 | JWT 密钥,生产环境请勿泄露 | +| `POSTGRES_PASSWORD` | 自动生成 | 数据库密码 | +| `CORS_ORIGINS` | 含服务器 IP | 前端跨域白名单 | +| `OLLAMA_BASE_URL` | `http://host.docker.internal:11434` | Ollama 地址 | +| `OLLAMA_MODEL` | `qwen2.5:7b` | 模型名称 | +| `FLUCTUATION_THRESHOLD` | `0.08` | 成绩波动高亮阈值(8%) | + +修改 `.env` 后重启: + +```bash +cd /opt/secondary-school-grade-archive +docker compose --env-file .env up -d +``` + +修改 `WEB_PORT` 后需重新创建容器: + +```bash +docker compose --env-file .env up -d --force-recreate web +``` + +--- + +## 5. 常用运维命令 + +```bash +cd /opt/secondary-school-grade-archive + +# 查看运行状态 +docker compose ps + +# 查看日志 +docker compose logs -f +docker compose logs -f api +docker compose logs -f web + +# 停止服务 +docker compose down + +# 启动服务 +docker compose --env-file .env up -d + +# 更新版本(拉代码 + 重建) +bash deploy/update.sh + +# 数据备份 +bash deploy/backup.sh + +# 卸载容器(保留数据) +bash deploy/uninstall.sh + +# 卸载并删除数据库卷(危险) +REMOVE_VOLUMES=1 bash deploy/uninstall.sh +``` + +--- + +## 6. Ollama 可选配置(错题 AI 解法) + +错题上传后的「整理题目 / 生成解法」依赖宿主机 Ollama。若不安装,OCR 仍可用,AI 解法需手动填写。 + +```bash +# 安装 Ollama(参考 https://ollama.com) +curl -fsSL https://ollama.com/install.sh | sh + +# 拉取模型 +ollama pull qwen2.5:7b + +# 确认服务运行 +curl http://127.0.0.1:11434/api/tags +``` + +确保 `.env` 中: + +``` +OLLAMA_BASE_URL=http://host.docker.internal:11434 +OLLAMA_MODEL=qwen2.5:7b +``` + +--- + +## 7. 反向代理(用户自行配置) + +**本项目不包含 HTTPS、域名、反向代理配置。** 若需通过域名访问或启用 HTTPS,请在宿主机自行配置 Nginx/Caddy/Traefik 等。 + +### 7.1 原则 + +- 反向代理将流量转发到 **`http://127.0.0.1:23566`** +- 代理需支持 **WebSocket**(若未来扩展)及 **大文件上传**(错题图片最大 10MB) +- 代理 `/api` 与 `/` 均应转发到同一 upstream(本项目 Nginx 已统一处理) + +### 7.2 Nginx 示例(仅供参考,不包含在项目中) + +```nginx +server { + listen 443 ssl http2; + server_name grade.example.com; + + ssl_certificate /path/to/fullchain.pem; + ssl_certificate_key /path/to/privkey.pem; + client_max_body_size 10M; + + location / { + proxy_pass http://127.0.0.1:23566; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +使用 HTTPS 反向代理后,请同步修改 `.env` 中的 `CORS_ORIGINS`,加入 `https://grade.example.com`。 + +--- + +## 8. 防火墙示例(UFW) + +```bash +ufw allow 22/tcp +ufw allow 23566/tcp +ufw enable +``` + +--- + +## 9. 故障排查 + +| 现象 | 处理 | +|------|------| +| 无法访问 23566 | `docker compose ps` 确认 web 运行;`ufw status` 检查防火墙 | +| 注册/登录 502 | `docker compose logs api` 查看后端;确认 db 健康 | +| OCR 很慢/失败 | 首次运行需下载 PaddleOCR 模型;查看 api 日志 | +| AI 解法失败 | 确认宿主机 Ollama 运行;`curl host.docker.internal:11434` 从 api 容器内测试 | +| 端口被占用 | 修改 `.env` 中 `WEB_PORT` 或释放占用进程 | + +进入 API 容器调试: + +```bash +docker compose exec api bash +``` + +--- + +## 10. 数据备份与恢复 + +### 备份 + +```bash +bash /opt/secondary-school-grade-archive/deploy/backup.sh +``` + +生成文件位于 `backups/`: + +- `db_YYYYMMDD_HHMMSS.sql` — 数据库 +- `uploads_YYYYMMDD_HHMMSS.tar.gz` — 错题图片 + +### 恢复数据库(示例) + +```bash +cd /opt/secondary-school-grade-archive +docker compose exec -T db psql -U postgres student_archive < backups/db_XXXXXX.sql +``` + +--- + +## 11. 版权与授权 + +本软件著作权归 **马建军** 所有。部署和使用须遵守 [LICENSE](../LICENSE) 与 [COPYRIGHT.md](../COPYRIGHT.md)。 + +- 微信:**dekun03** +- 手机:**18364911125** + +未经授权不得用于商业分发或去除版权信息。 + +--- + +## 12. 自定义安装参数 + +```bash +# 自定义端口 +WEB_PORT=23566 bash deploy/install.sh + +# 自定义目录 +INSTALL_DIR=/opt/my-grade-app bash deploy/install.sh + +# 指定分支 +BRANCH=main bash deploy/install.sh +``` diff --git a/docs/USAGE.md b/docs/USAGE.md new file mode 100644 index 0000000..f461852 --- /dev/null +++ b/docs/USAGE.md @@ -0,0 +1,162 @@ +# 使用说明 + +> **中学成绩档案系统**(初中 / 高中) +> 版权所有 © 马建军 · 微信 **dekun03** · 手机 **18364911125** + +--- + +## 1. 系统简介 + +本系统面向**初中、高中**学生,提供: + +- 多用户账号,数据互相隔离 +- 学生档案管理(学段、年级、班级) +- 成绩录入:周考、月考、期末 +- 分科成绩占比曲线(上升绿色、下降红色、大幅波动高亮) +- 错题库:拍照上传 → OCR 识别 → AI 生成解法(可编辑) +- 成绩 CSV 导出 + +部署方式见 [DEPLOY.md](./DEPLOY.md)。 + +--- + +## 2. 快速上手 + +### 2.1 登录与注册 + +1. 浏览器打开 `http://<服务器IP>:23566` +2. 首次使用点击 **注册**,设置用户名(≥3 字符)和密码(≥6 字符) +3. 注册成功后自动登录 + +> 系统无默认管理员账号,首个注册用户即为普通用户,数据仅本人可见。 + +### 2.2 添加学生 + +1. 首页点击 **添加学生** +2. 填写: + - **姓名**(必填) + - **学段**:初中 / 高中 + - **年级**:初一~初三 或 高一~高三 + - **班级**:如「3班」(可选) +3. 保存后在卡片上可看到学段标签 + +### 2.3 录入成绩 + +进入学生详情 → **成绩录入** 标签: + +1. 点击 **录入成绩** +2. 选择 **考试类型**:周考 / 月考 / 期末 +3. 选择 **考试日期** +4. 在表格中填写各科 **总分**、**得分**(未考科目可留空) +5. 系统自动计算 **占比** +6. 保存后在列表中可编辑或删除 + +**校验规则:** + +- 总分必须 > 0 +- 得分不能大于总分 +- 至少录入一科成绩 + +--- + +## 3. 成绩分析 + +### 3.1 成绩总览 + +**成绩总览** 标签以表格展示历次考试各科得分与占比。 + +- 顶部 **波动预警** 会标记相邻两次占比变化 ≥ 8% 的考试 +- 可横向滚动查看所有科目 + +### 3.2 分科曲线 + +**分科曲线** 标签: + +1. 选择科目 +2. 查看占比(%)随时间变化折线图 + +**图例说明:** + +| 元素 | 含义 | +|------|------| +| 绿色线段 | 较上一次占比 **上升** | +| 红色线段 | 较上一次占比 **下降** | +| 橙色大圆点 | **大幅波动**(变化 ≥ 8%) | +| Tooltip | 显示日期、考试类型、占比、较上次变化 | + +--- + +## 4. 错题库 + +### 4.1 上传错题 + +进入 **错题库** 标签: + +1. 选择 **科目** +2. 点击 **上传错题图片**(支持 jpg/png/webp,最大 10MB) +3. 上传后后台自动:**OCR 识别 → AI 整理题目 → 生成解法** +4. 处理状态:处理中 → 已识别 → 已生成解法 + +> AI 解法依赖服务器上的 **Ollama**。未配置时仍可 OCR,解法需手动填写。 + +### 4.2 查看与编辑 + +点击错题卡片打开详情: + +- **左侧**:原始试卷图片、OCR 原文 +- **右侧**:识别题目、解法(均可编辑) +- **保存编辑**:手动修正后点击保存 +- **重新 OCR**:识别不准时可重试 +- **重新生成解法**:基于当前题目重新调用 AI + +> 解法标注「AI 生成,请核对」,使用前请人工确认。 + +### 4.3 筛选与搜索 + +- 按 **科目** 筛选 +- **搜索** 题目/解法关键词 + +--- + +## 5. 数据导出 + +学生详情页点击 **导出 CSV**,下载该生全部成绩记录,可用 Excel 打开。 + +--- + +## 6. 预置科目 + +语文、数学、英语、物理、化学、生物、历史、地理、政治。 + +--- + +## 7. 常见问题 + +**Q:忘记密码怎么办?** +A:当前版本无找回密码功能,需管理员在数据库中重置或重新注册(生产环境建议后续增加找回流程)。 + +**Q:多人能否共用一台服务器?** +A:可以。每人注册独立账号,数据互不可见。 + +**Q:能否同时管理初中和高中孩子?** +A:可以。添加学生时分别选择学段即可。 + +**Q:换手机/电脑能否访问?** +A:可以。使用同一服务器地址与账号登录即可。 + +**Q:HTTPS 和域名怎么配置?** +A:本项目不包含反向代理配置,请参考 [DEPLOY.md 第 7 节](./DEPLOY.md#7-反向代理用户自行配置) 自行设置。 + +--- + +## 8. 技术支持与版权 + +| 项目 | 内容 | +|------|------| +| 作者 | 马建军 | +| 微信 | dekun03 | +| 手机 | 18364911125 | +| 仓库 | [secondary-school-grade-archive](https://git.bz121.com/dekun/secondary-school-grade-archive.git) | + +使用本软件须遵守 [LICENSE](../LICENSE) 与 [COPYRIGHT.md](../COPYRIGHT.md)。 +商业使用或二次分发请联系作者取得授权。 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..77ceded --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..521a219 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + + + 中学成绩档案 + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..161227f --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://api:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + client_max_body_size 10M; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ad5d587 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3995 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@ant-design/icons": "^6.3.2", + "antd": "^6.5.0", + "axios": "^1.18.1", + "dayjs": "^1.11.21", + "echarts": "^6.1.0", + "echarts-for-react": "^3.0.6", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-markdown": "^10.1.0", + "react-router-dom": "^7.18.0", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "oxlint": "^1.69.0", + "typescript": "~6.0.2", + "vite": "^8.1.0" + } + }, + "node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", + "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz", + "integrity": "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^2.1.2", + "@babel/runtime": "^7.23.2", + "@rc-component/util": "^1.4.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.3.2.tgz", + "integrity": "sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/icons-svg": "^4.5.0", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz", + "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz", + "integrity": "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "clsx": "^2.1.1", + "json2mq": "^0.2.0", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.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/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.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/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.71.0.tgz", + "integrity": "sha512-ImGmd1njEg4FEJH03jhRnveEegtO3czCtfptvaHivKAZQIYATbVFBrrzbaYMYv0oJioTnxZAZVSyV+oL7W8S2g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.71.0.tgz", + "integrity": "sha512-4A5BEexBrwY1YFF8Kiq/lp/wQPRG79G3BWIE1FuWaM5MvmpYSd+7ZySVcKkHdwo0UDzdQGddp6pD9mpctMqLnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.71.0.tgz", + "integrity": "sha512-9wJA9GJulLwS2usU3CEisI/ESDO1n1z9eyTCvApMDrAkbJ1ve0mORgTMjcWWsKxkzkeZ2N/Gpra5IQE7x8tYgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.71.0.tgz", + "integrity": "sha512-PlLCjS06V0PeJMAJwzjrExw1sYNW9Gch3JtNlcwwZDXGlTYDuwHNN89zYH8LTXFfgkVtsYvs2nv0FqrzyuFDzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.71.0.tgz", + "integrity": "sha512-Lhil7bWre0ncxbUoDoxfS0JzpTz17BRQKW7iwoAUY8GJ66+WwJEfYPCFJ1P0WgVZR5/O/b3Q2pENlHOjeXLOGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.71.0.tgz", + "integrity": "sha512-Oo9/L58PYD3RC0x05d2upAPLllHytTjHQGsnC06P6Ynn7jKkp5mdImQxXdJ3+FnBaKspNpGogzgVsi6g872LiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.71.0.tgz", + "integrity": "sha512-mSHfyfgJrEbyIR29ejaeS50BdPk+GoNPlC1dckpDiUZbJAIel68sjSMdOt4WY0/gva+ECC7FNITQkxMJU+vSBw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.71.0.tgz", + "integrity": "sha512-n9yY4M2tiy3aij4AqtlnspzpfdpeT5JQfK2/w2d8oyp5W0FRwOb1dIeX99nORNcxGr08iD9bH8N5XFz3I2iy8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.71.0.tgz", + "integrity": "sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.71.0.tgz", + "integrity": "sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.71.0.tgz", + "integrity": "sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.71.0.tgz", + "integrity": "sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.71.0.tgz", + "integrity": "sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.71.0.tgz", + "integrity": "sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.71.0.tgz", + "integrity": "sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.71.0.tgz", + "integrity": "sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.71.0.tgz", + "integrity": "sha512-bd5kI8spYwTm3BILDtGhi73zoup5dw8MlPQNT8YB3BD5UIsjNe3K9/4ctrzQMX4SZMoK5HgzVLkLJzacEXB7fA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.71.0.tgz", + "integrity": "sha512-W4HvOHGzVLHcrmFu+bMrJlho+/yrlX5ZNdJZqGe8MEldkQG+RHYhxxad9P4jvWAYFmIqUA5i9DQ8QsJqSU9GIw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.71.0.tgz", + "integrity": "sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-6.0.0.tgz", + "integrity": "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/cascader": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.17.0.tgz", + "integrity": "sha512-3cVNG0zrQF1PoXq262L3wGCU+/YLEC1mGSVHDl577dQmA0ZKkXFbY6nwyXo+beCcM7buo49t24jkr+QZdL7O8w==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.8.0", + "@rc-component/tree": "~1.3.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/checkbox": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/checkbox/-/checkbox-2.0.0.tgz", + "integrity": "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/collapse": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/collapse/-/collapse-1.2.0.tgz", + "integrity": "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-3.1.1.tgz", + "integrity": "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-2.0.2.tgz", + "integrity": "sha512-uiGpAlblCNlziHPwj4S4Iy/oemeuz/hR03mbiEjTCXwsqOIN3BOzsRMyDwpyO5Fm0vIEEJRUf9ZtbRLbhksuTA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dialog": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/dialog/-/dialog-1.10.0.tgz", + "integrity": "sha512-eDukNlz9vNszAGv7i3zKXdxEd3wgVmNxuJijYt8zvTh17QwTu8KK/bdURRd/lU4qaMzhO1HKKmMrwOnkaw0BvQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.3.3", + "@rc-component/portal": "^2.1.0", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/drawer": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@rc-component/drawer/-/drawer-1.4.2.tgz", + "integrity": "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.1.3", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dropdown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/dropdown/-/dropdown-1.0.2.tgz", + "integrity": "sha512-6PY2ecUSYhDPhkNHHb4wfeAya04WhpmUSKzdR60G+kMNVUCX2vjT/AgTS0Lz0I/K6xrPMJ3enQbwVpeN3sHCgg==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/@rc-component/form": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.5.tgz", + "integrity": "sha512-d24EYtvUOBhxEtSd/EqIu9DaMuqrWF2IRIvAFCTM6NQ/GJIYNr8DvEpUSUlv2uPxEJ0ZPwYQ+wwlGIAaiHvdrw==", + "license": "MIT", + "dependencies": { + "@rc-component/async-validator": "^6.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/image": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@rc-component/image/-/image-1.9.0.tgz", + "integrity": "sha512-khF7w7xkBH5B1bsBcI1FSUZdkyd1aqpl2eYyILCqCzzQH3XdfehGUaZTnptyaJJfs09/R5hv9jXWyazOMFIClQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/portal": "^2.1.2", + "@rc-component/util": "^1.10.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/input": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/input/-/input-1.3.1.tgz", + "integrity": "sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig==", + "license": "MIT", + "dependencies": { + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/input-number": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@rc-component/input-number/-/input-number-1.6.2.tgz", + "integrity": "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==", + "license": "MIT", + "dependencies": { + "@rc-component/mini-decimal": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mentions": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/mentions/-/mentions-1.10.0.tgz", + "integrity": "sha512-CI1njYUVY0NjHtLhNoVmXlJyy568Sfep9Wsak6vmGjtT6uazx98djGYlCXz2xkHhEm73g91Y3MTvzUyE5avI7w==", + "license": "MIT", + "dependencies": { + "@rc-component/input": "~1.3.0", + "@rc-component/menu": "~1.4.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/menu": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@rc-component/menu/-/menu-1.4.1.tgz", + "integrity": "sha512-3GsVRoQ4cnF/AoIQ4P+Z1haBfgfBPQfLT1RJY3Nu4DzOnheTslfCiGSPj7bv/cLj5sW5pHqN25dDXGP3JELAlQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "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/motion": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@rc-component/motion/-/motion-1.3.3.tgz", + "integrity": "sha512-Xh3IszxvlSv3/PLYFyC2UZi9LNB83yOnkB/LNmRzaypZLvkhqUIPS7MQpGZcCMWrNsXV2p6YTSWbSGvFpEle9A==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-2.0.1.tgz", + "integrity": "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/notification": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@rc-component/notification/-/notification-2.0.7.tgz", + "integrity": "sha512-nqZzpf6BPdaj+3ILx7si79LLmqPKyUmQoXa+/9gg0SkH0v1DbD66oJgRMSBEVnd/zUT3D4gwxWIHUKebYf2ZXQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/overflow": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/overflow/-/overflow-1.0.1.tgz", + "integrity": "sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/pagination": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.4.0.tgz", + "integrity": "sha512-CW1g7P9V8u+e8JQdUsl2RWg+GCsoee0mtJjZUCCxn/vb3jzOwDKm6hAdwddHCVBfWJ58eGUBZz3IvnU8rRktjw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/picker": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@rc-component/picker/-/picker-1.11.0.tgz", + "integrity": "sha512-6qXGKtoJvO8sUd17m5cyNEbEJub0zflCHnaZTBBmj63DPRZYc0WEHN8rp6hFSl+yMCJS/dJY5G+1fQ8bLCuD7A==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/trigger": "^3.6.15", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.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-component/portal": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-2.2.1.tgz", + "integrity": "sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/progress": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/progress/-/progress-1.0.2.tgz", + "integrity": "sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-2.0.0.tgz", + "integrity": "sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw==", + "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/rate": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/rate/-/rate-1.0.1.tgz", + "integrity": "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/resize-observer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/resize-observer/-/resize-observer-1.1.2.tgz", + "integrity": "sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/segmented": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/segmented/-/segmented-1.3.0.tgz", + "integrity": "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/select": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.8.2.tgz", + "integrity": "sha512-HQ9zuYqjfZTlcEMWlU1GAPBajd2OHIMVHyjZSGVTCVARwkfCgvXZMTEn0cduy3L+ejAKkaZluOQvxovZoaJaQw==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/slider": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/slider/-/slider-1.1.1.tgz", + "integrity": "sha512-LSzgWGYDgeCDgR4r1XlU29gbYws6HpLnvJd/uMhLeW/vQgxldeR+Wb4uzHDCHiYEbr1bnEHWdjkPxjJRHxuiig==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/steps": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rc-component/steps/-/steps-1.2.2.tgz", + "integrity": "sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/switch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rc-component/switch/-/switch-1.0.3.tgz", + "integrity": "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/table": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.10.2.tgz", + "integrity": "sha512-b3PjqB9Gp25p5t/zq+9QrbXbodkptT8/zvLmwgd2FNPUUtaYyDnQqfxeD5a7ao8E8lpinLHsi2u2vdfPhyNvAw==", + "license": "MIT", + "dependencies": { + "@rc-component/context": "^2.0.1", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tabs": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@rc-component/tabs/-/tabs-1.11.0.tgz", + "integrity": "sha512-hA/drZYOVa/MMIb4M2fWf3yaTyTG4qVuIABmghvEhyfw2nBob5VTH69lMCDjSVKmgODjO6nWlCV+gVn3xBrj5Q==", + "license": "MIT", + "dependencies": { + "@rc-component/dropdown": "~1.0.0", + "@rc-component/menu": "~1.4.0", + "@rc-component/motion": "^1.1.3", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tooltip": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tooltip/-/tooltip-1.4.0.tgz", + "integrity": "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.7.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-2.4.0.tgz", + "integrity": "sha512-aui4r4TqmTzwaBgcQxHYep8kM8PTjZFufjokObpy35KfFeZ0k9ArquWFZqegQlH24P14t+F0qO0mGTgzlav1yg==", + "license": "MIT", + "dependencies": { + "@rc-component/portal": "^2.2.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.7.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tree": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.3.2.tgz", + "integrity": "sha512-bJFj46wEkpBPnWyTm18XmgAgNQ/4YvprxMOPPY2a6rmhGJYxLuNKEFiL5Qej4Qctu9wHJm8WW+v2SYskafE0kA==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/tree-select": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.11.0.tgz", + "integrity": "sha512-EhS0X0wtUhBfK4S5TlpSY3MR9ndPMGgujtt1PJW3Ej+ToAlnS/6ohYURtCoXBYGqazUwHmgQGVUDsfpVwhWPkg==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.8.0", + "@rc-component/tree": "~1.3.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/trigger": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.9.1.tgz", + "integrity": "sha512-LNsYvz60mrLJ/kRvKcHE7boUvcQfVMCfRqZ71x3Fo9AOiZ1KKIEqkzMA8DNvz2V3Bcvir/vwQNn7JF1NPODQ7Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.2.0", + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/upload": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/upload/-/upload-1.1.1.tgz", + "integrity": "sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.11.1.tgz", + "integrity": "sha512-awVlI3ub2vqfqkYxOBc/uQ0efm3jw0wcrhtO/YWLyZfxiKXczKwNbVuhlnyxytDt7H9pbbVQiqr+O6MLATtRYg==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/virtual-list": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.2.0.tgz", + "integrity": "sha512-iavRm1Jo4GDbASQwdGa7jFyk93RvSOo9xHyBT4QL1pgFJj/Fdf1G+3RErH7/7BmAMvx2AkF62mjGYxDbXsK9TQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "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": "6.5.0", + "resolved": "https://registry.npmjs.org/antd/-/antd-6.5.0.tgz", + "integrity": "sha512-9zbVc9UukfGuqCvIAov01nlpDQWfARNmZQyt21ZhqLX7ilXmi4cdkp12xA48WEmXRXwZvno8A03qQuGE9JG8fg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/cssinjs": "^2.1.2", + "@ant-design/cssinjs-utils": "^2.1.2", + "@ant-design/fast-color": "^3.0.1", + "@ant-design/icons": "^6.3.1", + "@ant-design/react-slick": "~2.0.0", + "@babel/runtime": "^7.29.2", + "@rc-component/cascader": "~1.17.0", + "@rc-component/checkbox": "~2.0.0", + "@rc-component/collapse": "~1.2.0", + "@rc-component/color-picker": "~3.1.1", + "@rc-component/dialog": "~1.10.0", + "@rc-component/drawer": "~1.4.2", + "@rc-component/dropdown": "~1.0.2", + "@rc-component/form": "~1.8.5", + "@rc-component/image": "~1.9.0", + "@rc-component/input": "~1.3.1", + "@rc-component/input-number": "~1.6.2", + "@rc-component/mentions": "~1.10.0", + "@rc-component/menu": "~1.4.1", + "@rc-component/motion": "^1.3.3", + "@rc-component/mutate-observer": "^2.0.1", + "@rc-component/notification": "~2.0.7", + "@rc-component/pagination": "~1.4.0", + "@rc-component/picker": "~1.11.0", + "@rc-component/progress": "~1.0.2", + "@rc-component/qrcode": "~2.0.0", + "@rc-component/rate": "~1.0.1", + "@rc-component/resize-observer": "^1.1.2", + "@rc-component/segmented": "~1.3.0", + "@rc-component/select": "~1.8.2", + "@rc-component/slider": "~1.1.1", + "@rc-component/steps": "~1.2.2", + "@rc-component/switch": "~1.0.3", + "@rc-component/table": "~1.10.2", + "@rc-component/tabs": "~1.11.0", + "@rc-component/tooltip": "~1.4.0", + "@rc-component/tour": "~2.4.0", + "@rc-component/tree": "~1.3.2", + "@rc-component/tree-select": "~1.11.0", + "@rc-component/trigger": "^3.9.1", + "@rc-component/upload": "~1.1.1", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1", + "dayjs": "^1.11.11", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.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.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "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/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/echarts-for-react": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz", + "integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "size-sensor": "^1.0.1" + }, + "peerDependencies": { + "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", + "react": "^15.0.0 || >=16.0.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "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/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "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/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/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "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.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "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/oxlint": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.71.0.tgz", + "integrity": "sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.71.0", + "@oxlint/binding-android-arm64": "1.71.0", + "@oxlint/binding-darwin-arm64": "1.71.0", + "@oxlint/binding-darwin-x64": "1.71.0", + "@oxlint/binding-freebsd-x64": "1.71.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.71.0", + "@oxlint/binding-linux-arm-musleabihf": "1.71.0", + "@oxlint/binding-linux-arm64-gnu": "1.71.0", + "@oxlint/binding-linux-arm64-musl": "1.71.0", + "@oxlint/binding-linux-ppc64-gnu": "1.71.0", + "@oxlint/binding-linux-riscv64-gnu": "1.71.0", + "@oxlint/binding-linux-riscv64-musl": "1.71.0", + "@oxlint/binding-linux-s390x-gnu": "1.71.0", + "@oxlint/binding-linux-x64-gnu": "1.71.0", + "@oxlint/binding-linux-x64-musl": "1.71.0", + "@oxlint/binding-openharmony-arm64": "1.71.0", + "@oxlint/binding-win32-arm64-msvc": "1.71.0", + "@oxlint/binding-win32-ia32-msvc": "1.71.0", + "@oxlint/binding-win32-x64-msvc": "1.71.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.22.1", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "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.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "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-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-router": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", + "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", + "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/size-sensor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz", + "integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==", + "license": "ISC" + }, + "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/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "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/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "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/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..04bf79a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,34 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@ant-design/icons": "^6.3.2", + "antd": "^6.5.0", + "axios": "^1.18.1", + "dayjs": "^1.11.21", + "echarts": "^6.1.0", + "echarts-for-react": "^3.0.6", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-markdown": "^10.1.0", + "react-router-dom": "^7.18.0", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "oxlint": "^1.69.0", + "typescript": "~6.0.2", + "vite": "^8.1.0" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..6364494 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,37 @@ +import { Navigate, Route, Routes } from 'react-router-dom' +import { useAuth } from './context/AuthContext' +import LoginPage from './pages/LoginPage' +import StudentDetailPage from './pages/StudentDetailPage' +import StudentsPage from './pages/StudentsPage' + +function PrivateRoute({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth() + if (loading) return null + if (!user) return + return <>{children} +} + +export default function App() { + return ( + + } /> + + + + } + /> + + + + } + /> + } /> + + ) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..0183f9a --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,117 @@ +import axios from 'axios' +import type { + Exam, + ScoreInput, + SchoolLevel, + Student, + Subject, + TokenResponse, + TrendResponse, + User, + WrongQuestion, +} from '../types' +import type { ExamType } from '../types' + +const api = axios.create({ + baseURL: '/api', +}) + +api.interceptors.request.use((config) => { + const token = localStorage.getItem('access_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +api.interceptors.response.use( + (res) => res, + async (error) => { + const original = error.config + if (error.response?.status === 401 && !original._retry) { + original._retry = true + const refresh = localStorage.getItem('refresh_token') + if (refresh) { + try { + const { data } = await axios.post('/api/auth/refresh', { + refresh_token: refresh, + }) + localStorage.setItem('access_token', data.access_token) + localStorage.setItem('refresh_token', data.refresh_token) + original.headers.Authorization = `Bearer ${data.access_token}` + return api(original) + } catch { + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + window.location.href = '/login' + } + } + } + return Promise.reject(error) + }, +) + +export const authApi = { + register: (username: string, password: string) => + api.post('/auth/register', { username, password }), + login: (username: string, password: string) => + api.post('/auth/login', { username, password }), + me: () => api.get('/auth/me'), +} + +export const studentApi = { + list: () => api.get('/students'), + create: (data: { + name: string + school_level?: SchoolLevel + grade?: string + class_name?: string + }) => api.post('/students', data), + get: (id: string) => api.get(`/students/${id}`), + update: (id: string, data: Partial) => api.patch(`/students/${id}`, data), + remove: (id: string) => api.delete(`/students/${id}`), +} + +export const subjectApi = { + list: () => api.get('/subjects'), +} + +export const examApi = { + list: (studentId: string) => api.get(`/students/${studentId}/exams`), + create: ( + studentId: string, + data: { exam_type: ExamType; exam_date: string; title?: string; scores: ScoreInput[] }, + ) => api.post(`/students/${studentId}/exams`, data), + update: ( + examId: string, + data: Partial<{ exam_type: ExamType; exam_date: string; title: string; scores: ScoreInput[] }>, + ) => api.patch(`/exams/${examId}`, data), + remove: (examId: string) => api.delete(`/exams/${examId}`), + trend: (studentId: string, subjectId: number) => + api.get(`/students/${studentId}/scores/trend`, { + params: { subject_id: subjectId }, + }), + exportCsv: (studentId: string) => + api.get(`/students/${studentId}/scores/export`, { responseType: 'blob' }), +} + +export const wrongQuestionApi = { + list: (studentId: string, params?: { subject_id?: number; q?: string }) => + api.get(`/students/${studentId}/wrong-questions`, { params }), + upload: (studentId: string, subjectId: number, file: File) => { + const form = new FormData() + form.append('subject_id', String(subjectId)) + form.append('file', file) + return api.post(`/students/${studentId}/wrong-questions`, form) + }, + get: (id: string) => api.get(`/wrong-questions/${id}`), + update: (id: string, data: Partial) => + api.patch(`/wrong-questions/${id}`, data), + remove: (id: string) => api.delete(`/wrong-questions/${id}`), + retryOcr: (id: string) => api.post(`/wrong-questions/${id}/retry-ocr`), + regenerate: (id: string) => + api.post(`/wrong-questions/${id}/regenerate-solution`), + imageUrl: (id: string) => `/api/wrong-questions/${id}/image`, +} + +export default api diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 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 P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/ScoreForm.tsx b/frontend/src/components/ScoreForm.tsx new file mode 100644 index 0000000..cd5b7c4 --- /dev/null +++ b/frontend/src/components/ScoreForm.tsx @@ -0,0 +1,241 @@ +import { DeleteOutlined, EditOutlined } from '@ant-design/icons' +import { Button, DatePicker, Form, Input, InputNumber, Modal, Select, Space, Table, message } from 'antd' +import dayjs from 'dayjs' +import { useEffect, useState } from 'react' +import { examApi } from '../api/client' +import type { Exam, ExamType, ScoreInput, Subject } from '../types' +import { EXAM_TYPE_LABELS } from '../types' + +interface Props { + studentId: string + subjects: Subject[] + exams: Exam[] + onRefresh: () => void +} + +export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Props) { + const [modalOpen, setModalOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (modalOpen && editing) { + form.setFieldsValue({ + exam_type: editing.exam_type, + exam_date: dayjs(editing.exam_date), + title: editing.title, + scores: subjects.map((s) => { + const found = editing.scores.find((sc) => sc.subject_id === s.id) + return found + ? { subject_id: s.id, total_score: found.total_score, obtained_score: found.obtained_score } + : { subject_id: s.id, total_score: undefined, obtained_score: undefined } + }), + }) + } else if (modalOpen) { + form.setFieldsValue({ + exam_type: 'weekly', + exam_date: dayjs(), + scores: subjects.map((s) => ({ subject_id: s.id })), + }) + } + }, [modalOpen, editing, subjects, form]) + + const openCreate = () => { + setEditing(null) + setModalOpen(true) + } + + const openEdit = (exam: Exam) => { + setEditing(exam) + setModalOpen(true) + } + + const handleSubmit = async () => { + try { + const values = await form.validateFields() + const scores: ScoreInput[] = (values.scores || []) + .map((s: ScoreInput, idx: number) => ({ + subject_id: subjects[idx]?.id ?? s.subject_id, + total_score: s.total_score, + obtained_score: s.obtained_score, + })) + .filter( + (s: ScoreInput) => + s.subject_id != null && + s.total_score != null && + s.obtained_score != null && + s.total_score > 0, + ) + .map((s: ScoreInput) => ({ + subject_id: s.subject_id, + total_score: Number(s.total_score), + obtained_score: Number(s.obtained_score), + })) + + if (scores.length === 0) { + message.warning('请至少录入一科成绩') + return + } + + setLoading(true) + const payload = { + exam_type: values.exam_type as ExamType, + exam_date: values.exam_date.format('YYYY-MM-DD'), + title: values.title || undefined, + scores, + } + + if (editing) { + await examApi.update(editing.id, payload) + message.success('已更新') + } else { + await examApi.create(studentId, payload) + message.success('已添加') + } + setModalOpen(false) + onRefresh() + } catch { + /* validation */ + } finally { + setLoading(false) + } + } + + const handleDelete = async (exam: Exam) => { + Modal.confirm({ + title: '确认删除该考试记录?', + onOk: async () => { + await examApi.remove(exam.id) + message.success('已删除') + onRefresh() + }, + }) + } + + const columns = [ + { title: '日期', dataIndex: 'exam_date', key: 'exam_date', width: 110 }, + { + title: '类型', + dataIndex: 'exam_type', + key: 'exam_type', + width: 80, + render: (t: ExamType) => EXAM_TYPE_LABELS[t], + }, + { title: '标题', dataIndex: 'title', key: 'title', ellipsis: true }, + { + title: '科目数', + key: 'count', + width: 80, + render: (_: unknown, r: Exam) => r.scores.length, + }, + { + title: '平均占比', + key: 'avg', + width: 100, + render: (_: unknown, r: Exam) => { + if (!r.scores.length) return '-' + const avg = r.scores.reduce((a, s) => a + s.ratio, 0) / r.scores.length + return `${(avg * 100).toFixed(1)}%` + }, + }, + { + title: '操作', + key: 'action', + width: 120, + render: (_: unknown, r: Exam) => ( + + + + + setModalOpen(false)} + onOk={handleSubmit} + confirmLoading={loading} + width={720} + destroyOnHidden + > +
+ + + + + + + + {(fields) => ( +
({ ...f, subject: subjects[i] }))} + rowKey="key" + columns={[ + { + title: '科目', + render: (_, row) => ( + <> + + {row.subject?.name} + + ), + }, + { + title: '总分', + render: (_, row) => ( + + + + ), + }, + { + title: '得分', + render: (_, row) => ( + + + + ), + }, + { + title: '占比', + render: (_, row) => { + const total = form.getFieldValue(['scores', row.name, 'total_score']) + const obtained = form.getFieldValue(['scores', row.name, 'obtained_score']) + if (total > 0 && obtained != null) { + return `${((obtained / total) * 100).toFixed(1)}%` + } + return '-' + }, + }, + ]} + /> + )} + + + + + ) +} diff --git a/frontend/src/components/ScoreOverview.tsx b/frontend/src/components/ScoreOverview.tsx new file mode 100644 index 0000000..07e94bb --- /dev/null +++ b/frontend/src/components/ScoreOverview.tsx @@ -0,0 +1,76 @@ +import { Table, Tag } from 'antd' +import type { Exam } from '../types' +import { EXAM_TYPE_LABELS } from '../types' + +interface Props { + exams: Exam[] + subjectNames: Record +} + +export default function ScoreOverview({ exams, subjectNames }: Props) { + const subjectIds = new Set() + exams.forEach((e) => e.scores.forEach((s) => subjectIds.add(s.subject_id))) + const sortedSubjects = [...subjectIds].sort((a, b) => a - b) + + const columns = [ + { title: '日期', dataIndex: 'exam_date', key: 'date', width: 110, fixed: 'left' as const }, + { + title: '类型', + dataIndex: 'exam_type', + key: 'type', + width: 80, + render: (t: keyof typeof EXAM_TYPE_LABELS) => ( + {EXAM_TYPE_LABELS[t]} + ), + }, + ...sortedSubjects.map((sid) => ({ + title: subjectNames[sid] || `科目${sid}`, + key: `s${sid}`, + width: 100, + render: (_: unknown, exam: Exam) => { + const score = exam.scores.find((s) => s.subject_id === sid) + if (!score) return '-' + return `${score.obtained_score}/${score.total_score} (${(score.ratio * 100).toFixed(1)}%)` + }, + })), + ] + + const volatileExams = exams.filter((exam) => { + return exam.scores.some((s) => { + const allScores = exams + .filter((e) => e.exam_date <= exam.exam_date) + .flatMap((e) => e.scores.filter((sc) => sc.subject_id === s.subject_id)) + .sort((a, b) => { + const ea = exams.find((e) => e.scores.includes(a)) + const eb = exams.find((e) => e.scores.includes(b)) + return (ea?.exam_date || '').localeCompare(eb?.exam_date || '') + }) + const idx = allScores.findIndex((sc) => sc.id === s.id) + if (idx <= 0) return false + return Math.abs(allScores[idx].ratio - allScores[idx - 1].ratio) >= 0.08 + }) + }) + + return ( +
+ {volatileExams.length > 0 && ( +
+ 波动预警: + {volatileExams.slice(0, 5).map((e) => ( + + {e.exam_date} {EXAM_TYPE_LABELS[e.exam_type]} + + ))} +
+ )} +
b.exam_date.localeCompare(a.exam_date))} + pagination={{ pageSize: 15 }} + scroll={{ x: 'max-content' }} + size="small" + /> + + ) +} diff --git a/frontend/src/components/TrendChart.tsx b/frontend/src/components/TrendChart.tsx new file mode 100644 index 0000000..2b33f6f --- /dev/null +++ b/frontend/src/components/TrendChart.tsx @@ -0,0 +1,132 @@ +import ReactECharts from 'echarts-for-react' +import type { TrendPoint } from '../types' +import { EXAM_TYPE_LABELS } from '../types' + +interface Props { + points: TrendPoint[] + subjectName: string + threshold: number +} + +const COLORS = { + up: '#52c41a', + down: '#ff4d4f', + flat: '#8c8c8c', + volatile: '#fa8c16', +} + +export default function TrendChart({ points, subjectName, threshold }: Props) { + if (points.length === 0) { + return
暂无成绩数据
+ } + + const dates = points.map((p) => p.exam_date) + const values = points.map((p) => p.ratio_percent) + + const lineSeries = points.slice(1).map((point, i) => { + let color = COLORS.flat + if (point.direction === 'up') color = COLORS.up + if (point.direction === 'down') color = COLORS.down + + return { + type: 'line' as const, + data: dates.map((_, idx) => (idx === i || idx === i + 1 ? values[idx] : null)), + connectNulls: false, + showSymbol: false, + lineStyle: { width: 3, color }, + tooltip: { show: false }, + silent: true, + } + }) + + const markPoints = points + .map((point, i) => ({ point, i })) + .filter(({ point }) => point.is_volatile) + .map(({ i }) => ({ + coord: [dates[i], values[i]], + symbol: 'circle', + symbolSize: 18, + itemStyle: { + color: COLORS.volatile, + borderColor: '#fff', + borderWidth: 2, + }, + label: { show: false }, + })) + + const option = { + title: { + text: `${subjectName} 成绩占比趋势`, + left: 'center', + textStyle: { fontSize: 16 }, + }, + tooltip: { + trigger: 'axis', + formatter: (params: Array<{ dataIndex: number; value: number }>) => { + const idx = params[0]?.dataIndex ?? 0 + const p = points[idx] + if (!p) return '' + const typeLabel = EXAM_TYPE_LABELS[p.exam_type] + let html = `${p.exam_date} (${typeLabel})
占比: ${p.ratio_percent}%` + if (p.title) html += `
${p.title}` + if (p.delta_percent !== null) { + const sign = p.delta_percent > 0 ? '+' : '' + html += `
较上次: ${sign}${p.delta_percent}%` + if (p.is_volatile) html += ' [大幅波动]' + } + return html + }, + }, + grid: { left: 50, right: 30, top: 60, bottom: 50 }, + xAxis: { + type: 'category', + data: dates, + axisLabel: { rotate: 30 }, + }, + yAxis: { + type: 'value', + name: '占比 (%)', + min: 0, + max: 100, + }, + series: [ + { + type: 'line', + data: values, + symbol: 'circle', + symbolSize: (_val: number, params: { dataIndex: number }) => + points[params.dataIndex]?.is_volatile ? 14 : 8, + itemStyle: { + color: (params: { dataIndex: number }) => { + const p = points[params.dataIndex] + if (p?.is_volatile) return COLORS.volatile + if (p?.direction === 'up') return COLORS.up + if (p?.direction === 'down') return COLORS.down + return '#1677ff' + }, + }, + lineStyle: { opacity: 0 }, + markPoint: markPoints.length ? { data: markPoints } : undefined, + z: 10, + }, + ...lineSeries, + ], + legend: { + bottom: 0, + data: [ + { name: '上升', itemStyle: { color: COLORS.up } }, + { name: '下降', itemStyle: { color: COLORS.down } }, + { name: '大幅波动', itemStyle: { color: COLORS.volatile } }, + ], + }, + } + + return ( +
+ +

+ 波动阈值: {(threshold * 100).toFixed(0)}%,超过此变化幅度将高亮显示 +

+
+ ) +} diff --git a/frontend/src/components/WrongQuestionUpload.tsx b/frontend/src/components/WrongQuestionUpload.tsx new file mode 100644 index 0000000..ea23f4d --- /dev/null +++ b/frontend/src/components/WrongQuestionUpload.tsx @@ -0,0 +1,93 @@ +import { ReloadOutlined, UploadOutlined } from '@ant-design/icons' +import { Button, Input, Select, Space, Upload, message } from 'antd' +import { useState } from 'react' +import { wrongQuestionApi } from '../api/client' +import type { Subject } from '../types' + +interface Props { + studentId: string + subjects: Subject[] + onUploaded: () => void +} + +export default function WrongQuestionUpload({ studentId, subjects, onUploaded }: Props) { + const [subjectId, setSubjectId] = useState(subjects[0]?.id) + const [uploading, setUploading] = useState(false) + + const handleUpload = async (file: File) => { + if (!subjectId) { + message.warning('请选择科目') + return false + } + setUploading(true) + try { + await wrongQuestionApi.upload(studentId, subjectId, file) + message.success('上传成功,正在 OCR 识别并生成解法…') + onUploaded() + } catch { + message.error('上传失败') + } finally { + setUploading(false) + } + return false + } + + return ( + + ({ value: s.id, label: s.name }))} + /> + onSearchChange(e.target.value)} + onSearch={onRefresh} + style={{ width: 220 }} + allowClear + /> + + + ) +} diff --git a/frontend/src/constants/school.ts b/frontend/src/constants/school.ts new file mode 100644 index 0000000..82cd9f4 --- /dev/null +++ b/frontend/src/constants/school.ts @@ -0,0 +1,26 @@ +import type { SchoolLevel } from '../types' + +export type { SchoolLevel } + +export const SCHOOL_LEVEL_LABELS: Record = { + junior_high: '初中', + senior_high: '高中', +} + +export const GRADE_OPTIONS: Record = { + junior_high: ['初一', '初二', '初三'], + senior_high: ['高一', '高二', '高三'], +} + +export function formatStudentMeta(student: { + school_level: SchoolLevel + grade?: string | null + class_name?: string | null +}): string { + const parts = [ + SCHOOL_LEVEL_LABELS[student.school_level], + student.grade, + student.class_name, + ].filter(Boolean) + return parts.length ? parts.join(' · ') : '未设置学段年级' +} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..4646a98 --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -0,0 +1,65 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' +import { authApi } from '../api/client' +import type { User } from '../types' + +interface AuthContextValue { + user: User | null + loading: boolean + login: (username: string, password: string) => Promise + register: (username: string, password: string) => Promise + logout: () => void +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const token = localStorage.getItem('access_token') + if (!token) { + setLoading(false) + return + } + authApi + .me() + .then((res) => setUser(res.data)) + .catch(() => { + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + }) + .finally(() => setLoading(false)) + }, []) + + const login = async (username: string, password: string) => { + const { data } = await authApi.login(username, password) + localStorage.setItem('access_token', data.access_token) + localStorage.setItem('refresh_token', data.refresh_token) + const me = await authApi.me() + setUser(me.data) + } + + const register = async (username: string, password: string) => { + await authApi.register(username, password) + await login(username, password) + } + + const logout = () => { + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + setUser(null) + } + + return ( + + {children} + + ) +} + +export function useAuth() { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used within AuthProvider') + return ctx +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..3d11c35 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,25 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, + sans-serif; + background: #f5f5f5; + -webkit-font-smoothing: antialiased; +} + +#root { + min-height: 100vh; +} + +a { + color: inherit; +} + +@media (max-width: 576px) { + .ant-table { + font-size: 12px; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..275d41b --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,20 @@ +import { ConfigProvider } from 'antd' +import zhCN from 'antd/locale/zh_CN' +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import { AuthProvider } from './context/AuthContext' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + + + + + + + , +) diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..379256a --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,118 @@ +import { LockOutlined, UserOutlined } from '@ant-design/icons' +import { Button, Card, Form, Input, Tabs, Typography, message } from 'antd' +import { Navigate, useNavigate } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' + +export default function LoginPage() { + const { user, login, register, loading } = useAuth() + const navigate = useNavigate() + const [loginForm] = Form.useForm() + const [registerForm] = Form.useForm() + + if (!loading && user) return + + const onLogin = async (values: { username: string; password: string }) => { + try { + await login(values.username, values.password) + message.success('登录成功') + navigate('/') + } catch { + message.error('用户名或密码错误') + } + } + + const onRegister = async (values: { username: string; password: string; confirm: string }) => { + if (values.password !== values.confirm) { + message.error('两次密码不一致') + return + } + try { + await register(values.username, values.password) + message.success('注册成功') + navigate('/') + } catch { + message.error('注册失败,用户名可能已存在') + } + } + + return ( +
+ + + 成绩录入 · 趋势分析 · 错题管理 + + + + } placeholder="用户名" /> + + + } placeholder="密码" /> + + + + ), + }, + { + key: 'register', + label: '注册', + children: ( +
+ + } placeholder="用户名" /> + + + } placeholder="密码" /> + + + } placeholder="确认密码" /> + + + + ), + }, + ]} + /> + + © 马建军 · 微信 dekun03 · 18364911125 + +
+
+ ) +} diff --git a/frontend/src/pages/StudentDetailPage.tsx b/frontend/src/pages/StudentDetailPage.tsx new file mode 100644 index 0000000..578c714 --- /dev/null +++ b/frontend/src/pages/StudentDetailPage.tsx @@ -0,0 +1,229 @@ +import { ArrowLeftOutlined, DownloadOutlined } from '@ant-design/icons' +import { Button, Select, Space, Spin, Tabs, Tag, Typography, message } from 'antd' +import { useCallback, useEffect, useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import { examApi, studentApi, subjectApi, wrongQuestionApi } from '../api/client' +import ScoreForm from '../components/ScoreForm' +import ScoreOverview from '../components/ScoreOverview' +import TrendChart from '../components/TrendChart' +import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQuestionUpload' +import { formatStudentMeta, SCHOOL_LEVEL_LABELS } from '../constants/school' +import type { Exam, Student, Subject, TrendResponse, WrongQuestion } from '../types' +import { STATUS_LABELS } from '../types' +import WrongQuestionDetail from './WrongQuestionDetail' + +export default function StudentDetailPage() { + const { id } = useParams<{ id: string }>() + const [student, setStudent] = useState(null) + const [subjects, setSubjects] = useState([]) + const [exams, setExams] = useState([]) + const [trend, setTrend] = useState(null) + const [selectedSubject, setSelectedSubject] = useState() + const [wrongQuestions, setWrongQuestions] = useState([]) + const [wqSubjectFilter, setWqSubjectFilter] = useState() + const [wqSearch, setWqSearch] = useState('') + const [selectedWq, setSelectedWq] = useState(null) + const [loading, setLoading] = useState(true) + + const subjectNames = Object.fromEntries(subjects.map((s) => [s.id, s.name])) + + const loadExams = useCallback(async () => { + if (!id) return + const { data } = await examApi.list(id) + setExams(data) + }, [id]) + + const loadTrend = useCallback(async () => { + if (!id || !selectedSubject) return + const { data } = await examApi.trend(id, selectedSubject) + setTrend(data) + }, [id, selectedSubject]) + + const loadWrongQuestions = useCallback(async () => { + if (!id) return + const { data } = await wrongQuestionApi.list(id, { + subject_id: wqSubjectFilter, + q: wqSearch || undefined, + }) + setWrongQuestions(data) + }, [id, wqSubjectFilter, wqSearch]) + + useEffect(() => { + if (!id) return + const init = async () => { + setLoading(true) + try { + const [studentRes, subjectRes] = await Promise.all([ + studentApi.get(id), + subjectApi.list(), + ]) + setStudent(studentRes.data) + setSubjects(subjectRes.data) + if (subjectRes.data.length) setSelectedSubject(subjectRes.data[0].id) + await loadExams() + await loadWrongQuestions() + } finally { + setLoading(false) + } + } + init() + }, [id, loadExams, loadWrongQuestions]) + + useEffect(() => { + loadTrend() + }, [loadTrend]) + + const handleExport = async () => { + if (!id) return + try { + const { data } = await examApi.exportCsv(id) + const url = URL.createObjectURL(data) + const a = document.createElement('a') + a.href = url + a.download = `${student?.name || 'student'}_scores.csv` + a.click() + URL.revokeObjectURL(url) + } catch { + message.error('导出失败') + } + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (!student) return 学生不存在 + + return ( +
+ + + + + + {student.name} + + + {SCHOOL_LEVEL_LABELS[student.school_level]} + + {formatStudentMeta(student)} + + + + + ), + }, + { + key: 'overview', + label: '成绩总览', + children: , + }, + { + key: 'trend', + label: '分科曲线', + children: ( +
+ + + + ({ + value: g, + label: g, + }))} + /> + + + + + + +
+ ) +} diff --git a/frontend/src/pages/WrongQuestionDetail.tsx b/frontend/src/pages/WrongQuestionDetail.tsx new file mode 100644 index 0000000..bc2bece --- /dev/null +++ b/frontend/src/pages/WrongQuestionDetail.tsx @@ -0,0 +1,162 @@ +import { Alert, Button, Col, Input, Modal, Row, Space, Spin, Typography, message } from 'antd' +import { useEffect, useState } from 'react' +import ReactMarkdown from 'react-markdown' +import { wrongQuestionApi } from '../api/client' +import type { WrongQuestion } from '../types' +import { STATUS_LABELS } from '../types' + +interface Props { + questionId: string + open: boolean + onClose: () => void + onUpdated: () => void +} + +export default function WrongQuestionDetail({ questionId, open, onClose, onUpdated }: Props) { + const [wq, setWq] = useState(null) + const [loading, setLoading] = useState(false) + const [questionText, setQuestionText] = useState('') + const [solutionText, setSolutionText] = useState('') + const [saving, setSaving] = useState(false) + const [regenerating, setRegenerating] = useState(false) + + const load = async () => { + setLoading(true) + try { + const { data } = await wrongQuestionApi.get(questionId) + setWq(data) + setQuestionText(data.question_text || '') + setSolutionText(data.solution_text || '') + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (open && questionId) load() + }, [open, questionId]) + + const handleSave = async () => { + setSaving(true) + try { + await wrongQuestionApi.update(questionId, { + question_text: questionText, + solution_text: solutionText, + }) + message.success('已保存') + onUpdated() + } finally { + setSaving(false) + } + } + + const handleRegenerate = async () => { + setRegenerating(true) + try { + const { data } = await wrongQuestionApi.regenerate(questionId) + setWq(data) + setQuestionText(data.question_text || '') + setSolutionText(data.solution_text || '') + message.success('解法已重新生成') + onUpdated() + } catch { + message.error('生成失败,请确认 Ollama 已启动') + } finally { + setRegenerating(false) + } + } + + const handleRetryOcr = async () => { + await wrongQuestionApi.retryOcr(questionId) + message.info('已重新识别,请稍后刷新') + onUpdated() + onClose() + } + + return ( + + + + + + } + > + + {wq && ( + <> + 状态:{STATUS_LABELS[wq.status]} + {wq.solution_text && ( + + )} + +
+ 原题 + {wq.ocr_raw_text && ( +
+ OCR 原文 +
+                      {wq.ocr_raw_text}
+                    
+
+ )} + + + 识别题目(可编辑) + setQuestionText(e.target.value)} + style={{ marginTop: 8, marginBottom: 16 }} + /> + 解法 + setSolutionText(e.target.value)} + style={{ marginTop: 8, marginBottom: 12 }} + /> + {solutionText && ( +
+ + 预览 + + {solutionText} +
+ )} + + + + )} + + + ) +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..65f9023 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,101 @@ +export interface TokenResponse { + access_token: string + refresh_token: string + token_type: string +} + +export interface User { + id: string + username: string + created_at: string +} + +export type SchoolLevel = 'junior_high' | 'senior_high' + +export interface Student { + id: string + name: string + school_level: SchoolLevel + grade: string | null + class_name: string | null + created_at: string +} + +export interface Subject { + id: number + name: string +} + +export interface Score { + id: string + subject_id: number + subject_name?: string + total_score: number + obtained_score: number + ratio: number +} + +export type ExamType = 'weekly' | 'monthly' | 'final' + +export interface Exam { + id: string + exam_type: ExamType + exam_date: string + title: string | null + created_at: string + scores: Score[] +} + +export interface ScoreInput { + subject_id: number + total_score: number + obtained_score: number +} + +export interface TrendPoint { + exam_id: string + exam_type: ExamType + exam_date: string + title: string | null + ratio: number + ratio_percent: number + delta: number | null + delta_percent: number | null + is_volatile: boolean + direction: 'up' | 'down' | 'flat' | null +} + +export interface TrendResponse { + subject_id: number + subject_name: string + threshold: number + points: TrendPoint[] +} + +export type WrongQuestionStatus = 'pending' | 'ocr_done' | 'solved' | 'failed' + +export interface WrongQuestion { + id: string + student_id: string + subject_id: number + subject_name?: string + image_path: string + ocr_raw_text: string | null + question_text: string | null + solution_text: string | null + status: WrongQuestionStatus + created_at: string +} + +export const EXAM_TYPE_LABELS: Record = { + weekly: '周考', + monthly: '月考', + final: '期末', +} + +export const STATUS_LABELS: Record = { + pending: '处理中', + ocr_done: '已识别', + solved: '已生成解法', + failed: '失败', +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..7f42e5f --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..1406734 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, +}) diff --git a/scripts/backup.ps1 b/scripts/backup.ps1 new file mode 100644 index 0000000..cab9c21 --- /dev/null +++ b/scripts/backup.ps1 @@ -0,0 +1,14 @@ +$ErrorActionPreference = "Stop" +$BackupDir = if ($args[0]) { $args[0] } else { ".\backups" } +$Timestamp = Get-Date -Format "yyyyMMdd_HHmmss" +New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null + +Write-Host "Backing up database..." +docker compose exec -T db pg_dump -U postgres student_archive | Out-File -Encoding utf8 "$BackupDir\db_$Timestamp.sql" + +Write-Host "Backing up uploads..." +Compress-Archive -Path "uploads\*" -DestinationPath "$BackupDir\uploads_$Timestamp.zip" -Force + +Write-Host "Backup complete:" +Write-Host " $BackupDir\db_$Timestamp.sql" +Write-Host " $BackupDir\uploads_$Timestamp.zip" diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100644 index 0000000..d36e682 --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# 兼容入口,请优先使用 deploy/backup.sh +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec bash "${SCRIPT_DIR}/../deploy/backup.sh" "$@"