commit b564ca4797e0674eea16a528f5e7f3d1553d6187 Author: liumangmang Date: Tue May 12 17:51:53 2026 +0800 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..806a985 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# ===== 必填 ===== +# 管理员账号(首次启动自动创建) +ADMIN_EMAIL=admin@smartup.local +ADMIN_PASSWORD=changeme123 + +# ===== 推荐配置 ===== +# JWT 签名密钥(生产环境请替换): openssl rand -hex 32 +JWT_SECRET=change-me-in-production + +# ===== 可选 ===== +# 监听端口(默认 8899) +SERVER_PORT=8899 +# 绑定地址(默认 0.0.0.0) +BIND_HOST=0.0.0.0 +# 时区 +TZ=Asia/Shanghai +# 连续失败多少次判定为 unhealthy(默认 3) +UNHEALTHY_THRESHOLD=3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24a9422 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +.env +.env.* +!.env.example + +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +venv/ + +node_modules/ +dist/ +build/ +.vite/ + +backend/static/ +backend/data/ + +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..67f3a66 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# ---- Stage 1: Build frontend ---- +FROM node:20-alpine AS frontend-build +WORKDIR /frontend +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ . +RUN npm run build + +# ---- Stage 2: Python backend ---- +FROM python:3.12-slim +WORKDIR /app + +# Install deps +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy backend source +COPY backend/ . + +# Copy built frontend into backend/static +COPY --from=frontend-build /frontend/dist ./static + +# Data directory for SQLite +RUN mkdir -p /app/data + +ENV PYTHONPATH=/app +ENV DATABASE_URL=sqlite:////app/data/app.db + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..09ef384 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# SmartUp — API 上游管理与 Webhook 通知系统 + +SmartUp 是一个独立的 Web 后台,用于管理多个 API 上游的分组倍率监听,并通过 Webhook(通用 JSON / 钉钉机器人)发送变更通知。 + +## 功能 + +- **上游管理**:支持 none / bearer / api_key / 邮箱密码 四种认证方式 +- **定时检测**:每个上游独立配置检测间隔,APScheduler 后台运行 +- **倍率快照**:检测变化后保存快照,diff 比对历史 +- **Webhook 通知**:支持通用 JSON 和钉钉机器人(带签名) +- **通知日志**:记录每次发送结果,支持筛选查看 + +## 技术栈 + +- 后端:FastAPI + SQLite + APScheduler +- 前端:Vue 3 + Element Plus + Vite +- 部署:Docker Compose 单容器 + +## 快速部署 + +### 1. 准备配置文件 + +```bash +cp .env.example .env +# 编辑 .env,至少填写 ADMIN_PASSWORD 和 JWT_SECRET +``` + +### 2. 启动 + +```bash +docker compose up -d --build +``` + +首次启动时自动: +- 创建 SQLite 数据库(`./data/app.db`) +- 初始化管理员账号 + +### 3. 访问 + +打开浏览器:http://localhost:8899 + +默认账号:`.env` 中配置的 `ADMIN_EMAIL` / `ADMIN_PASSWORD` + +## 本地开发 + +### 后端 + +```bash +cd backend +python -m venv venv && source venv/bin/activate +pip install -r requirements.txt + +# 创建 .env(可复制根目录的 .env.example) +cat > .env << 'EOF' +ADMIN_EMAIL=admin@smartup.local +ADMIN_PASSWORD=dev123 +JWT_SECRET=dev-secret +DATABASE_URL=sqlite:///./data/app.db +EOF + +mkdir -p data +uvicorn app.main:app --reload --port 8000 +``` + +### 前端 + +```bash +cd frontend +npm install +npm run dev # 代理到 localhost:8000 +``` + +## 数据备份 + +SQLite 数据库位于 `./data/app.db`,直接复制即可备份: + +```bash +cp ./data/app.db ./data/app.db.$(date +%Y%m%d) +``` + +## 环境变量 + +| 变量 | 说明 | 默认值 | +|------|------|--------| +| `ADMIN_EMAIL` | 管理员邮箱 | `admin@smartup.local` | +| `ADMIN_PASSWORD` | 管理员密码(必填) | — | +| `JWT_SECRET` | JWT 签名密钥 | `change-me-in-production` | +| `SERVER_PORT` | 宿主机端口 | `8899` | +| `TZ` | 时区 | `Asia/Shanghai` | +| `UNHEALTHY_THRESHOLD` | 连续失败多少次标记为异常 | `3` | + +## 目录结构 + +``` +SmartUp/ +├── backend/ # FastAPI 应用 +│ └── app/ +│ ├── models/ # SQLAlchemy ORM +│ ├── schemas/ # Pydantic schemas +│ ├── routers/ # API 路由 +│ ├── services/ # 业务逻辑(client/scheduler/webhook) +│ └── utils/ # JWT / 钉钉签名 +├── frontend/ # Vue 3 前端 +│ └── src/ +│ ├── views/ # 页面组件 +│ ├── api/ # Axios 封装 +│ └── stores/ # Pinia 状态 +├── data/ # SQLite 数据目录(Docker volume) +├── Dockerfile # 多阶段构建 +├── docker-compose.yml +└── .env.example +``` diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..b9d56a4 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# app package diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..3f76f1b --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,20 @@ +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + admin_email: str = "admin@smartup.local" + admin_password: str = "changeme" + jwt_secret: str = "change-me-in-production" + jwt_expire_hours: int = 24 + database_url: str = "sqlite:////app/data/app.db" + tz: str = "Asia/Shanghai" + # consecutive failures before upstream goes unhealthy + unhealthy_threshold: int = 3 + + model_config = {"env_file": ".env", "case_sensitive": False} + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..b15fcec --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,30 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase +from app.config import get_settings + +settings = get_settings() + +engine = create_engine( + settings.database_url, + connect_args={"check_same_thread": False}, +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """Create all tables.""" + # import models so SQLAlchemy registers them + from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page # noqa: F401 + Base.metadata.create_all(bind=engine) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..ddfd694 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,93 @@ +"""SmartUp FastAPI application entry point.""" +import logging +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + +from app.config import get_settings +from app.database import init_db +from app.models.admin_user import AdminUser +from app.database import SessionLocal +from app.utils.auth import hash_password +from app.services.scheduler import start_scheduler, stop_scheduler +from app.routers import auth, upstreams, webhooks, logs, custom_pages + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") +logger = logging.getLogger(__name__) + + +def _init_admin() -> None: + settings = get_settings() + if not settings.admin_password: + logger.warning("ADMIN_PASSWORD not set, skip admin init") + return + db = SessionLocal() + try: + exists = db.query(AdminUser).filter(AdminUser.email == settings.admin_email).first() + if not exists: + user = AdminUser( + email=settings.admin_email, + password_hash=hash_password(settings.admin_password), + ) + db.add(user) + db.commit() + logger.info("admin user created: %s", settings.admin_email) + else: + logger.info("admin user already exists: %s", settings.admin_email) + finally: + db.close() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + _init_admin() + start_scheduler() + yield + stop_scheduler() + + +app = FastAPI( + title="SmartUp", + description="API 上游管理与 Webhook 通知系统", + version="1.0.0", + lifespan=lifespan, + docs_url="/api/docs", + redoc_url="/api/redoc", + openapi_url="/api/openapi.json", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# API routers +app.include_router(auth.router) +app.include_router(upstreams.router) +app.include_router(webhooks.router) +app.include_router(logs.router) +app.include_router(custom_pages.router) + + +@app.get("/healthz") +def health(): + return {"status": "ok"} + + +# Serve frontend static files +STATIC_DIR = Path(__file__).parent.parent / "static" +if STATIC_DIR.exists(): + app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="assets") + + @app.get("/{full_path:path}") + def serve_spa(full_path: str): + index = STATIC_DIR / "index.html" + return FileResponse(str(index)) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..3c159a6 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1 @@ +# models package diff --git a/backend/app/models/admin_user.py b/backend/app/models/admin_user.py new file mode 100644 index 0000000..959a6b0 --- /dev/null +++ b/backend/app/models/admin_user.py @@ -0,0 +1,16 @@ +from datetime import datetime, timezone +from sqlalchemy import Integer, String, DateTime +from sqlalchemy.orm import mapped_column, Mapped +from app.database import Base + + +class AdminUser(Base): + __tablename__ = "admin_users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) + ) diff --git a/backend/app/models/custom_page.py b/backend/app/models/custom_page.py new file mode 100644 index 0000000..36114e5 --- /dev/null +++ b/backend/app/models/custom_page.py @@ -0,0 +1,25 @@ +"""Custom embedded pages model.""" +from datetime import datetime, timezone +from typing import Optional +from sqlalchemy import Integer, String, Boolean, DateTime, Text +from sqlalchemy.orm import mapped_column, Mapped +from app.database import Base + + +class CustomPage(Base): + __tablename__ = "custom_pages" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + url: Mapped[str] = mapped_column(String(2048), nullable=False) + icon: Mapped[str] = mapped_column(String(64), default="Link") + sort_order: Mapped[int] = mapped_column(Integer, default=0) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + use_proxy: Mapped[bool] = mapped_column(Boolean, default=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/app/models/notification_log.py b/backend/app/models/notification_log.py new file mode 100644 index 0000000..01f40f9 --- /dev/null +++ b/backend/app/models/notification_log.py @@ -0,0 +1,21 @@ +from datetime import datetime, timezone +from typing import Optional +from sqlalchemy import Integer, String, DateTime, Text, ForeignKey +from sqlalchemy.orm import mapped_column, Mapped +from app.database import Base + + +class NotificationLog(Base): + __tablename__ = "notification_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + webhook_config_id: Mapped[int] = mapped_column( + Integer, ForeignKey("webhook_configs.id", ondelete="CASCADE"), index=True + ) + webhook_name: Mapped[str] = mapped_column(String(255), default="") + event_type: Mapped[str] = mapped_column(String(64), nullable=False) + payload_json: Mapped[str] = mapped_column(Text, default="{}") + # success | failed + status: Mapped[str] = mapped_column(String(16), nullable=False) + response_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), index=True) diff --git a/backend/app/models/snapshot.py b/backend/app/models/snapshot.py new file mode 100644 index 0000000..b24a7be --- /dev/null +++ b/backend/app/models/snapshot.py @@ -0,0 +1,13 @@ +from datetime import datetime, timezone +from sqlalchemy import Integer, Text, DateTime, ForeignKey +from sqlalchemy.orm import mapped_column, Mapped +from app.database import Base + + +class UpstreamRateSnapshot(Base): + __tablename__ = "upstream_rate_snapshots" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + upstream_id: Mapped[int] = mapped_column(Integer, ForeignKey("upstreams.id", ondelete="CASCADE"), index=True) + snapshot_json: Mapped[str] = mapped_column(Text, nullable=False) + captured_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), index=True) diff --git a/backend/app/models/upstream.py b/backend/app/models/upstream.py new file mode 100644 index 0000000..935b52a --- /dev/null +++ b/backend/app/models/upstream.py @@ -0,0 +1,32 @@ +from datetime import datetime, timezone +from typing import Optional +from sqlalchemy import Integer, String, Boolean, DateTime, Text +from sqlalchemy.orm import mapped_column, Mapped +from app.database import Base + + +class Upstream(Base): + __tablename__ = "upstreams" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + base_url: Mapped[str] = mapped_column(String(512), nullable=False) + api_prefix: Mapped[str] = mapped_column(String(128), default="/api/v1") + # none | bearer | api_key | login_password + auth_type: Mapped[str] = mapped_column(String(32), default="login_password") + # JSON: {"email":"..","password":".."} or {"token":".."} etc. + auth_config_json: Mapped[str] = mapped_column(Text, default="{}") + rate_endpoint: Mapped[str] = mapped_column(String(256), default="/groups/rates") + groups_endpoint: Mapped[str] = mapped_column(String(256), default="/groups/available") + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + check_interval_seconds: Mapped[int] = mapped_column(Integer, default=600) + timeout_seconds: Mapped[int] = mapped_column(Integer, default=30) + # unknown | healthy | unhealthy + last_status: Mapped[str] = mapped_column(String(32), default="unknown") + last_checked_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + consecutive_failures: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) + ) diff --git a/backend/app/models/webhook_config.py b/backend/app/models/webhook_config.py new file mode 100644 index 0000000..353a8fd --- /dev/null +++ b/backend/app/models/webhook_config.py @@ -0,0 +1,22 @@ +from datetime import datetime, timezone +from sqlalchemy import Integer, String, Boolean, DateTime, Text +from sqlalchemy.orm import mapped_column, Mapped +from app.database import Base + + +class WebhookConfig(Base): + __tablename__ = "webhook_configs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + # generic | dingtalk + type: Mapped[str] = mapped_column(String(32), default="generic") + url: Mapped[str] = mapped_column(String(1024), nullable=False) + secret: Mapped[str] = mapped_column(String(512), default="") + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + # JSON array: ["upstream_rate_changed","upstream_unhealthy","upstream_recovered"] + events_json: Mapped[str] = mapped_column(Text, default='["upstream_rate_changed"]') + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) + ) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..9c8ddfa --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1 @@ +# routers package diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..3b68218 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,28 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from app.database import get_db +from app.models.admin_user import AdminUser +from app.schemas.auth import LoginRequest, TokenResponse, UserInfo +from app.utils.auth import verify_password, create_access_token, get_current_user + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/login", response_model=TokenResponse) +def login(req: LoginRequest, db: Session = Depends(get_db)): + user = db.query(AdminUser).filter(AdminUser.email == req.email).first() + if not user or not verify_password(req.password, user.password_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误") + token = create_access_token(user.email) + return TokenResponse(access_token=token) + + +@router.get("/me", response_model=UserInfo) +def me(current_user: AdminUser = Depends(get_current_user)): + return UserInfo(email=current_user.email) + + +@router.post("/logout") +def logout(): + # JWT is stateless — client discards token + return {"message": "logged out"} diff --git a/backend/app/routers/custom_pages.py b/backend/app/routers/custom_pages.py new file mode 100644 index 0000000..8c3752d --- /dev/null +++ b/backend/app/routers/custom_pages.py @@ -0,0 +1,178 @@ +"""Custom pages CRUD router + transparent iframe proxy.""" +from __future__ import annotations + +import re +from datetime import datetime, timezone +from typing import List, Optional +from urllib.parse import urljoin, urlparse, urlencode, quote + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import Response +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.custom_page import CustomPage +from app.utils.auth import get_current_user, get_user_from_token_param + +router = APIRouter(prefix="/api/custom-pages", tags=["custom-pages"]) + +# Headers that prevent iframe embedding — strip them from proxied responses +_STRIP_RESPONSE_HEADERS = { + "x-frame-options", + "content-security-policy", + "content-security-policy-report-only", +} +# Headers we should NOT forward to the upstream (hop-by-hop + host) +_STRIP_REQUEST_HEADERS = {"host", "connection", "transfer-encoding", "te", + "trailers", "upgrade", "proxy-authorization"} + + +# ---- Schemas ---- +class CustomPageCreate(BaseModel): + name: str + url: str + icon: str = "Link" + sort_order: int = 0 + enabled: bool = True + use_proxy: bool = False + description: Optional[str] = None + + +class CustomPageUpdate(BaseModel): + name: Optional[str] = None + url: Optional[str] = None + icon: Optional[str] = None + sort_order: Optional[int] = None + enabled: Optional[bool] = None + use_proxy: Optional[bool] = None + description: Optional[str] = None + + +class CustomPageResponse(BaseModel): + id: int + name: str + url: str + icon: str + sort_order: int + enabled: bool + use_proxy: bool + description: Optional[str] + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +# ---- CRUD Endpoints ---- + +@router.get("", response_model=List[CustomPageResponse]) +def list_pages(db: Session = Depends(get_db), _=Depends(get_current_user)): + return db.query(CustomPage).order_by(CustomPage.sort_order, CustomPage.id).all() + + +@router.post("", response_model=CustomPageResponse, status_code=201) +def create_page(body: CustomPageCreate, db: Session = Depends(get_db), _=Depends(get_current_user)): + page = CustomPage(**body.model_dump()) + db.add(page) + db.commit() + db.refresh(page) + return page + + +@router.put("/{pid}", response_model=CustomPageResponse) +def update_page(pid: int, body: CustomPageUpdate, db: Session = Depends(get_db), _=Depends(get_current_user)): + page = db.query(CustomPage).filter(CustomPage.id == pid).first() + if not page: + raise HTTPException(404, "page not found") + for k, v in body.model_dump(exclude_none=True).items(): + setattr(page, k, v) + page.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(page) + return page + + +@router.delete("/{pid}", status_code=204) +def delete_page(pid: int, db: Session = Depends(get_db), _=Depends(get_current_user)): + page = db.query(CustomPage).filter(CustomPage.id == pid).first() + if not page: + raise HTTPException(404, "page not found") + db.delete(page) + db.commit() + + +# ---- Frame Proxy (simple: strip X-Frame-Options / CSP, pass through content) ---- + +_STRIP_RESP = { + "x-frame-options", + "content-security-policy", + "content-security-policy-report-only", +} +_STRIP_REQ = { + "host", "connection", "transfer-encoding", "te", + "trailers", "upgrade", "proxy-authorization", "authorization", +} + + +@router.api_route("/frame-proxy", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"]) +async def frame_proxy( + request: Request, + url: str = Query(..., description="Target URL to proxy"), + token: Optional[str] = Query(default=None), + _=Depends(get_user_from_token_param), +): + """ + Simple transparent proxy: strips X-Frame-Options and CSP headers so the + response can be embedded in an iframe. + + NOTE: For full SPA (React/Vue) sites, install the 'Requestly' browser + extension and set a rule to remove X-Frame-Options on the target domain — + that works reliably without any server-side complexity. + """ + if not url.startswith(("http://", "https://")): + raise HTTPException(400, "Only http/https URLs are allowed") + + # Forward browser headers (cookies, language, accept, etc.) + fwd: dict[str, str] = {} + for k, v in request.headers.items(): + if k.lower() in _STRIP_REQ or k.lower().startswith("x-forwarded"): + continue + fwd[k] = v + fwd["user-agent"] = ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + ) + fwd.setdefault("accept", "text/html,application/xhtml+xml,*/*;q=0.8") + + body = await request.body() if request.method not in ("GET", "HEAD") else None + + try: + async with httpx.AsyncClient(follow_redirects=True, timeout=30) as client: + resp = await client.request( + method=request.method, + url=url, + headers=fwd, + content=body, + ) + except httpx.RequestError as exc: + raise HTTPException(502, f"Proxy error: {exc}") + + # Pass through content unchanged — just strip the iframe-blocking headers + out: dict[str, str] = {} + for k, v in resp.headers.items(): + kl = k.lower() + if kl in _STRIP_RESP: + continue + if kl in ("content-encoding", "transfer-encoding", "content-length"): + continue + out[k] = v + + return Response( + content=resp.content, + status_code=resp.status_code, + media_type=resp.headers.get("content-type"), + headers=out, + ) + diff --git a/backend/app/routers/logs.py b/backend/app/routers/logs.py new file mode 100644 index 0000000..94cc4a4 --- /dev/null +++ b/backend/app/routers/logs.py @@ -0,0 +1,46 @@ +"""Notification logs listing with filters.""" +from __future__ import annotations + +import json +from typing import List, Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.notification_log import NotificationLog +from app.schemas.log import NotificationLogResponse +from app.utils.auth import get_current_user + +router = APIRouter(prefix="/api/notification-logs", tags=["logs"]) + + +def _to_response(log: NotificationLog) -> NotificationLogResponse: + return NotificationLogResponse( + id=log.id, + webhook_config_id=log.webhook_config_id, + webhook_name=log.webhook_name, + event_type=log.event_type, + payload=json.loads(log.payload_json or "{}"), + status=log.status, + response_text=log.response_text, + created_at=log.created_at, + ) + + +@router.get("", response_model=List[NotificationLogResponse]) +def list_logs( + status: Optional[str] = Query(None), + event_type: Optional[str] = Query(None), + limit: int = Query(100, le=500), + offset: int = Query(0), + db: Session = Depends(get_db), + _=Depends(get_current_user), +): + q = db.query(NotificationLog) + if status: + q = q.filter(NotificationLog.status == status) + if event_type: + q = q.filter(NotificationLog.event_type == event_type) + logs = q.order_by(NotificationLog.created_at.desc()).offset(offset).limit(limit).all() + return [_to_response(log) for log in logs] diff --git a/backend/app/routers/upstreams.py b/backend/app/routers/upstreams.py new file mode 100644 index 0000000..e3abcdc --- /dev/null +++ b/backend/app/routers/upstreams.py @@ -0,0 +1,282 @@ +"""Upstream management CRUD + test + check-now + snapshots.""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.admin_user import AdminUser +from app.models.upstream import Upstream +from app.models.snapshot import UpstreamRateSnapshot +from app.schemas.upstream import ( + UpstreamCreate, UpstreamUpdate, UpstreamResponse, SnapshotResponse, TestResult +) +from app.services.upstream_client import UpstreamClient, UpstreamError, build_snapshot +from app.services.snapshot_service import diff_snapshots +from app.services import scheduler as sched_svc +from app.services import webhook_service +from app.utils.auth import get_current_user + +router = APIRouter(prefix="/api/upstreams", tags=["upstreams"]) + +MASK = "***" +SECRET_KEYS = {"password", "token", "key", "secret"} + + +def _mask_auth_config(auth_type: str, cfg: dict) -> dict: + masked = {} + for k, v in cfg.items(): + if k.lower() in SECRET_KEYS and v: + masked[k] = MASK + else: + masked[k] = v + return masked + + +def _to_response(u: Upstream) -> UpstreamResponse: + cfg = json.loads(u.auth_config_json or "{}") + return UpstreamResponse( + id=u.id, + name=u.name, + base_url=u.base_url, + api_prefix=u.api_prefix, + auth_type=u.auth_type, + auth_config_masked=_mask_auth_config(u.auth_type, cfg), + rate_endpoint=u.rate_endpoint, + groups_endpoint=u.groups_endpoint, + enabled=u.enabled, + check_interval_seconds=u.check_interval_seconds, + timeout_seconds=u.timeout_seconds, + last_status=u.last_status, + last_checked_at=u.last_checked_at, + last_error=u.last_error, + created_at=u.created_at, + updated_at=u.updated_at, + ) + + +@router.get("", response_model=List[UpstreamResponse]) +def list_upstreams(db: Session = Depends(get_db), _=Depends(get_current_user)): + return [_to_response(u) for u in db.query(Upstream).order_by(Upstream.id).all()] + + +@router.post("", response_model=UpstreamResponse, status_code=201) +def create_upstream( + body: UpstreamCreate, + db: Session = Depends(get_db), + _=Depends(get_current_user), +): + u = Upstream( + name=body.name, + base_url=body.base_url.rstrip("/"), + api_prefix=body.api_prefix, + auth_type=body.auth_type, + auth_config_json=json.dumps(body.auth_config, ensure_ascii=False), + rate_endpoint=body.rate_endpoint, + groups_endpoint=body.groups_endpoint, + enabled=body.enabled, + check_interval_seconds=body.check_interval_seconds, + timeout_seconds=body.timeout_seconds, + ) + db.add(u) + db.commit() + db.refresh(u) + sched_svc.refresh_upstream(u.id, u.check_interval_seconds, u.enabled) + return _to_response(u) + + +@router.get("/{uid}", response_model=UpstreamResponse) +def get_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)): + u = db.query(Upstream).filter(Upstream.id == uid).first() + if not u: + raise HTTPException(404, "upstream not found") + return _to_response(u) + + +@router.put("/{uid}", response_model=UpstreamResponse) +def update_upstream( + uid: int, + body: UpstreamUpdate, + db: Session = Depends(get_db), + _=Depends(get_current_user), +): + u = db.query(Upstream).filter(Upstream.id == uid).first() + if not u: + raise HTTPException(404, "upstream not found") + data = body.model_dump(exclude_none=True) + if "auth_config" in data: + # merge with existing config to avoid overwriting masked fields + existing = json.loads(u.auth_config_json or "{}") + incoming = data.pop("auth_config") + for k, v in incoming.items(): + if v != MASK: # don't overwrite with mask placeholder + existing[k] = v + u.auth_config_json = json.dumps(existing, ensure_ascii=False) + if "base_url" in data: + data["base_url"] = data["base_url"].rstrip("/") + for k, v in data.items(): + setattr(u, k, v) + u.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(u) + sched_svc.refresh_upstream(u.id, u.check_interval_seconds, u.enabled) + return _to_response(u) + + +@router.delete("/{uid}", status_code=204) +def delete_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)): + u = db.query(Upstream).filter(Upstream.id == uid).first() + if not u: + raise HTTPException(404, "upstream not found") + sched_svc.refresh_upstream(uid, 0, False) # remove job + db.delete(u) + db.commit() + + +@router.post("/{uid}/test", response_model=TestResult) +def test_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)): + u = db.query(Upstream).filter(Upstream.id == uid).first() + if not u: + raise HTTPException(404, "upstream not found") + auth_config = json.loads(u.auth_config_json or "{}") + client = UpstreamClient( + base_url=u.base_url, + api_prefix=u.api_prefix, + auth_type=u.auth_type, + auth_config=auth_config, + timeout=float(u.timeout_seconds), + ) + try: + client.login() + groups = client.get_available_groups(u.groups_endpoint) + return TestResult(success=True, message=f"连接成功,获取到 {len(groups)} 个分组") + except Exception as exc: + return TestResult(success=False, message="连接失败", detail=str(exc)) + + +@router.post("/{uid}/check-now", response_model=TestResult) +def check_now(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)): + u = db.query(Upstream).filter(Upstream.id == uid).first() + if not u: + raise HTTPException(404, "upstream not found") + auth_config = json.loads(u.auth_config_json or "{}") + client = UpstreamClient( + base_url=u.base_url, + api_prefix=u.api_prefix, + auth_type=u.auth_type, + auth_config=auth_config, + timeout=float(u.timeout_seconds), + ) + try: + client.login() + groups = client.get_available_groups(u.groups_endpoint) + raw_rates = client.get_group_rates(u.rate_endpoint) + snapshot = build_snapshot(u.id, u.base_url, u.api_prefix, groups, raw_rates) + except Exception as exc: + u.consecutive_failures = (u.consecutive_failures or 0) + 1 + u.last_error = str(exc) + u.last_checked_at = datetime.now(timezone.utc) + db.commit() + return TestResult(success=False, message="检测失败", detail=str(exc)) + + prev_row = ( + db.query(UpstreamRateSnapshot) + .filter(UpstreamRateSnapshot.upstream_id == uid) + .order_by(UpstreamRateSnapshot.captured_at.desc()) + .first() + ) + previous = json.loads(prev_row.snapshot_json) if prev_row else None + changes = diff_snapshots(previous, snapshot) + + new_row = UpstreamRateSnapshot( + upstream_id=uid, + snapshot_json=json.dumps(snapshot, ensure_ascii=False), + captured_at=datetime.now(timezone.utc), + ) + db.add(new_row) + was_unhealthy = u.last_status == "unhealthy" + u.last_status = "healthy" + u.last_checked_at = datetime.now(timezone.utc) + u.last_error = None + u.consecutive_failures = 0 + db.commit() + + if was_unhealthy: + webhook_service.send_status_event(db, u.id, u.name, u.base_url, "upstream_recovered") + if changes: + webhook_service.send_rate_changed(db, u.id, u.name, u.base_url, changes) + + msg = f"检测成功,{len(groups)} 个分组" + if changes: + msg += f",发现 {len(changes)} 处倍率变化" + elif previous is None: + msg += ",初始化快照完成" + else: + msg += ",无变化" + return TestResult(success=True, message=msg) + + +@router.get("/{uid}/snapshots/latest", response_model=SnapshotResponse) +def latest_snapshot(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)): + row = ( + db.query(UpstreamRateSnapshot) + .filter(UpstreamRateSnapshot.upstream_id == uid) + .order_by(UpstreamRateSnapshot.captured_at.desc()) + .first() + ) + if not row: + raise HTTPException(404, "no snapshot found") + return SnapshotResponse( + id=row.id, + upstream_id=row.upstream_id, + snapshot=json.loads(row.snapshot_json), + captured_at=row.captured_at, + ) + + +from fastapi import Query as QueryParam + +@router.get("/{uid}/snapshots", response_model=List[SnapshotResponse]) +def list_snapshots( + uid: int, + limit: int = QueryParam(20, le=100), + offset: int = QueryParam(0), + db: Session = Depends(get_db), + _=Depends(get_current_user), +): + """Return paginated snapshot history with diff vs previous snapshot embedded.""" + rows = ( + db.query(UpstreamRateSnapshot) + .filter(UpstreamRateSnapshot.upstream_id == uid) + .order_by(UpstreamRateSnapshot.captured_at.desc()) + .offset(offset) + .limit(limit + 1) # fetch one extra to get the "previous" for diffing + .all() + ) + # We need the snapshot just before each one to compute changes count. + # rows are desc order; rows[i+1] is older than rows[i] + results = [] + for i, row in enumerate(rows[:limit]): + snap = json.loads(row.snapshot_json) + # try to diff against the next row (which is older) + changes_count: int | None = None + if i + 1 < len(rows): + older = json.loads(rows[i + 1].snapshot_json) + from app.services.snapshot_service import diff_snapshots + ch = diff_snapshots(older, snap) + changes_count = len(ch) + groups_count = len(snap.get("groups", {})) + # embed lightweight summary into snapshot dict so frontend can display it + snap["_groups_count"] = groups_count + snap["_changes_count"] = changes_count # None means first ever snapshot + results.append(SnapshotResponse( + id=row.id, + upstream_id=row.upstream_id, + snapshot=snap, + captured_at=row.captured_at, + )) + return results diff --git a/backend/app/routers/webhooks.py b/backend/app/routers/webhooks.py new file mode 100644 index 0000000..74ebbe5 --- /dev/null +++ b/backend/app/routers/webhooks.py @@ -0,0 +1,110 @@ +"""Webhook configuration CRUD + test notification.""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.webhook_config import WebhookConfig +from app.schemas.webhook import WebhookCreate, WebhookUpdate, WebhookResponse +from app.services.webhook_service import send_test_notification +from app.utils.auth import get_current_user + +router = APIRouter(prefix="/api/webhooks", tags=["webhooks"]) + +MASK = "***" + + +def _to_response(w: WebhookConfig) -> WebhookResponse: + return WebhookResponse( + id=w.id, + name=w.name, + type=w.type, + url=w.url, + secret_masked=MASK if w.secret else "", + enabled=w.enabled, + events=json.loads(w.events_json or "[]"), + created_at=w.created_at, + updated_at=w.updated_at, + ) + + +@router.get("", response_model=List[WebhookResponse]) +def list_webhooks(db: Session = Depends(get_db), _=Depends(get_current_user)): + return [_to_response(w) for w in db.query(WebhookConfig).order_by(WebhookConfig.id).all()] + + +@router.post("", response_model=WebhookResponse, status_code=201) +def create_webhook( + body: WebhookCreate, + db: Session = Depends(get_db), + _=Depends(get_current_user), +): + w = WebhookConfig( + name=body.name, + type=body.type, + url=body.url, + secret=body.secret, + enabled=body.enabled, + events_json=json.dumps(body.events, ensure_ascii=False), + ) + db.add(w) + db.commit() + db.refresh(w) + return _to_response(w) + + +@router.get("/{wid}", response_model=WebhookResponse) +def get_webhook(wid: int, db: Session = Depends(get_db), _=Depends(get_current_user)): + w = db.query(WebhookConfig).filter(WebhookConfig.id == wid).first() + if not w: + raise HTTPException(404, "webhook not found") + return _to_response(w) + + +@router.put("/{wid}", response_model=WebhookResponse) +def update_webhook( + wid: int, + body: WebhookUpdate, + db: Session = Depends(get_db), + _=Depends(get_current_user), +): + w = db.query(WebhookConfig).filter(WebhookConfig.id == wid).first() + if not w: + raise HTTPException(404, "webhook not found") + data = body.model_dump(exclude_none=True) + if "events" in data: + w.events_json = json.dumps(data.pop("events"), ensure_ascii=False) + if "secret" in data: + if data["secret"] != MASK: # only update if not mask placeholder + w.secret = data.pop("secret") + else: + data.pop("secret") + for k, v in data.items(): + setattr(w, k, v) + w.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(w) + return _to_response(w) + + +@router.delete("/{wid}", status_code=204) +def delete_webhook(wid: int, db: Session = Depends(get_db), _=Depends(get_current_user)): + w = db.query(WebhookConfig).filter(WebhookConfig.id == wid).first() + if not w: + raise HTTPException(404, "webhook not found") + db.delete(w) + db.commit() + + +@router.post("/{wid}/test") +def test_webhook(wid: int, db: Session = Depends(get_db), _=Depends(get_current_user)): + w = db.query(WebhookConfig).filter(WebhookConfig.id == wid).first() + if not w: + raise HTTPException(404, "webhook not found") + ok, msg = send_test_notification(db, w) + return {"success": ok, "message": msg} diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..78ee4f2 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +# schemas package diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..035734f --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, EmailStr + + +class LoginRequest(BaseModel): + email: str + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +class UserInfo(BaseModel): + email: str diff --git a/backend/app/schemas/log.py b/backend/app/schemas/log.py new file mode 100644 index 0000000..a883c72 --- /dev/null +++ b/backend/app/schemas/log.py @@ -0,0 +1,16 @@ +from datetime import datetime +from typing import Optional, Any +from pydantic import BaseModel + + +class NotificationLogResponse(BaseModel): + id: int + webhook_config_id: int + webhook_name: str + event_type: str + payload: dict[str, Any] + status: str + response_text: Optional[str] + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/upstream.py b/backend/app/schemas/upstream.py new file mode 100644 index 0000000..54a5fb1 --- /dev/null +++ b/backend/app/schemas/upstream.py @@ -0,0 +1,80 @@ +from datetime import datetime +from typing import Optional, Any +from pydantic import BaseModel + + +class AuthConfigBearer(BaseModel): + token: str = "" + + +class AuthConfigApiKey(BaseModel): + key: str = "" + header: str = "Authorization" + + +class AuthConfigLoginPassword(BaseModel): + email: str = "" + password: str = "" + login_path: str = "/auth/login" + + +class UpstreamCreate(BaseModel): + name: str + base_url: str + api_prefix: str = "/api/v1" + auth_type: str = "login_password" # none | bearer | api_key | login_password + auth_config: dict[str, Any] = {} + rate_endpoint: str = "/groups/rates" + groups_endpoint: str = "/groups/available" + enabled: bool = True + check_interval_seconds: int = 600 + timeout_seconds: int = 30 + + +class UpstreamUpdate(BaseModel): + name: Optional[str] = None + base_url: Optional[str] = None + api_prefix: Optional[str] = None + auth_type: Optional[str] = None + auth_config: Optional[dict[str, Any]] = None + rate_endpoint: Optional[str] = None + groups_endpoint: Optional[str] = None + enabled: Optional[bool] = None + check_interval_seconds: Optional[int] = None + timeout_seconds: Optional[int] = None + + +class UpstreamResponse(BaseModel): + id: int + name: str + base_url: str + api_prefix: str + auth_type: str + auth_config_masked: dict[str, Any] # secrets replaced with *** + rate_endpoint: str + groups_endpoint: str + enabled: bool + check_interval_seconds: int + timeout_seconds: int + last_status: str + last_checked_at: Optional[datetime] + last_error: Optional[str] + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class SnapshotResponse(BaseModel): + id: int + upstream_id: int + snapshot: dict[str, Any] + captured_at: datetime + + model_config = {"from_attributes": True} + + +class TestResult(BaseModel): + success: bool + message: str + detail: Optional[str] = None diff --git a/backend/app/schemas/webhook.py b/backend/app/schemas/webhook.py new file mode 100644 index 0000000..7f1b940 --- /dev/null +++ b/backend/app/schemas/webhook.py @@ -0,0 +1,37 @@ +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel + +ALLOWED_EVENTS = ["upstream_rate_changed", "upstream_unhealthy", "upstream_recovered"] + + +class WebhookCreate(BaseModel): + name: str + type: str = "generic" # generic | dingtalk + url: str + secret: str = "" + enabled: bool = True + events: List[str] = ["upstream_rate_changed"] + + +class WebhookUpdate(BaseModel): + name: Optional[str] = None + type: Optional[str] = None + url: Optional[str] = None + secret: Optional[str] = None + enabled: Optional[bool] = None + events: Optional[List[str]] = None + + +class WebhookResponse(BaseModel): + id: int + name: str + type: str + url: str + secret_masked: str # always "***" if set, else "" + enabled: bool + events: List[str] + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..0274469 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +# services package diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py new file mode 100644 index 0000000..9f1308b --- /dev/null +++ b/backend/app/services/scheduler.py @@ -0,0 +1,156 @@ +"""APScheduler background scheduler for upstream checks.""" +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone + +from apscheduler.schedulers.background import BackgroundScheduler +from sqlalchemy.orm import Session + +from app.database import SessionLocal +from app.models.upstream import Upstream +from app.models.snapshot import UpstreamRateSnapshot +from app.services.upstream_client import UpstreamClient, UpstreamError, build_snapshot +from app.services.snapshot_service import diff_snapshots +from app.services import webhook_service +from app.config import get_settings + +logger = logging.getLogger(__name__) + +_scheduler = BackgroundScheduler(timezone="UTC") + + +def get_scheduler() -> BackgroundScheduler: + return _scheduler + + +def _check_upstream(upstream_id: int) -> None: + """Full upstream check executed by scheduler (runs in thread).""" + settings = get_settings() + db: Session = SessionLocal() + try: + upstream = db.query(Upstream).filter(Upstream.id == upstream_id).first() + if not upstream or not upstream.enabled: + _remove_job(upstream_id) + return + + auth_config = json.loads(upstream.auth_config_json or "{}") + client = UpstreamClient( + base_url=upstream.base_url, + api_prefix=upstream.api_prefix, + auth_type=upstream.auth_type, + auth_config=auth_config, + timeout=float(upstream.timeout_seconds), + ) + + was_unhealthy = upstream.last_status == "unhealthy" + + try: + client.login() + groups = client.get_available_groups(upstream.groups_endpoint) + raw_rates = client.get_group_rates(upstream.rate_endpoint) + snapshot = build_snapshot( + upstream.id, upstream.base_url, upstream.api_prefix, groups, raw_rates + ) + except Exception as exc: + # failure path + upstream.consecutive_failures = (upstream.consecutive_failures or 0) + 1 + upstream.last_error = str(exc) + upstream.last_checked_at = datetime.now(timezone.utc) + threshold = settings.unhealthy_threshold + if upstream.consecutive_failures >= threshold and upstream.last_status != "unhealthy": + upstream.last_status = "unhealthy" + db.commit() + webhook_service.send_status_event( + db, upstream.id, upstream.name, upstream.base_url, + "upstream_unhealthy", str(exc) + ) + else: + db.commit() + logger.warning("upstream %s check failed: %s", upstream.name, exc) + return + + # success path + prev_snapshot_row = ( + db.query(UpstreamRateSnapshot) + .filter(UpstreamRateSnapshot.upstream_id == upstream_id) + .order_by(UpstreamRateSnapshot.captured_at.desc()) + .first() + ) + previous = json.loads(prev_snapshot_row.snapshot_json) if prev_snapshot_row else None + changes = diff_snapshots(previous, snapshot) + + # save new snapshot + new_row = UpstreamRateSnapshot( + upstream_id=upstream_id, + snapshot_json=json.dumps(snapshot, ensure_ascii=False), + captured_at=datetime.now(timezone.utc), + ) + db.add(new_row) + + # update upstream status + upstream.last_status = "healthy" + upstream.last_checked_at = datetime.now(timezone.utc) + upstream.last_error = None + upstream.consecutive_failures = 0 + db.commit() + + if was_unhealthy: + webhook_service.send_status_event( + db, upstream.id, upstream.name, upstream.base_url, "upstream_recovered" + ) + + if changes: + webhook_service.send_rate_changed( + db, upstream.id, upstream.name, upstream.base_url, changes + ) + logger.info("upstream %s: %d rate change(s)", upstream.name, len(changes)) + else: + logger.debug("upstream %s: no changes", upstream.name) + + finally: + db.close() + + +def _remove_job(upstream_id: int) -> None: + job_id = f"upstream_{upstream_id}" + if _scheduler.get_job(job_id): + _scheduler.remove_job(job_id) + + +def refresh_upstream(upstream_id: int, interval_seconds: int = 0, enabled: bool = True) -> None: + """Add/update/remove a scheduler job for the given upstream.""" + job_id = f"upstream_{upstream_id}" + if not enabled or interval_seconds <= 0: + _remove_job(upstream_id) + return + _scheduler.add_job( + _check_upstream, + "interval", + seconds=interval_seconds, + id=job_id, + args=[upstream_id], + replace_existing=True, + coalesce=True, + max_instances=1, + ) + logger.info("scheduler job %s set to %ds interval", job_id, interval_seconds) + + +def start_scheduler() -> None: + """Start scheduler and load all enabled upstreams.""" + _scheduler.start() + db: Session = SessionLocal() + try: + upstreams = db.query(Upstream).filter(Upstream.enabled == True).all() + for u in upstreams: + refresh_upstream(u.id, u.check_interval_seconds, u.enabled) + logger.info("scheduler started with %d upstream job(s)", len(upstreams)) + finally: + db.close() + + +def stop_scheduler() -> None: + if _scheduler.running: + _scheduler.shutdown(wait=False) diff --git a/backend/app/services/snapshot_service.py b/backend/app/services/snapshot_service.py new file mode 100644 index 0000000..d36fd5d --- /dev/null +++ b/backend/app/services/snapshot_service.py @@ -0,0 +1,39 @@ +"""Snapshot diff logic.""" +from typing import Any, Optional + + +def diff_snapshots( + previous: Optional[dict[str, Any]], + current: dict[str, Any], +) -> list[dict[str, Any]]: + """Return list of rate changes between previous and current snapshots. + Returns empty list if previous is None (first check).""" + if not previous: + return [] + old_groups: dict[str, Any] = previous.get("groups") or {} + new_groups: dict[str, Any] = current.get("groups") or {} + changes: list[dict[str, Any]] = [] + for gid, new_g in sorted(new_groups.items()): + if not isinstance(new_g, dict): + continue + old_g = old_groups.get(gid) + old_rate = old_g.get("rate") if isinstance(old_g, dict) else None + new_rate = new_g.get("rate") + if old_rate != new_rate: + changes.append({ + "group_id": gid, + "group_name": new_g.get("group_name", ""), + "platform": new_g.get("platform", ""), + "old_rate": old_rate, + "new_rate": new_rate, + }) + for gid, old_g in sorted(old_groups.items()): + if gid not in new_groups and isinstance(old_g, dict): + changes.append({ + "group_id": gid, + "group_name": old_g.get("group_name", ""), + "platform": old_g.get("platform", ""), + "old_rate": old_g.get("rate"), + "new_rate": None, + }) + return changes diff --git a/backend/app/services/upstream_client.py b/backend/app/services/upstream_client.py new file mode 100644 index 0000000..6071d9b --- /dev/null +++ b/backend/app/services/upstream_client.py @@ -0,0 +1,217 @@ +"""Upstream HTTP client — ported from monitor_ai98pro_group_rates.py.""" +from __future__ import annotations + +import json +from decimal import Decimal, InvalidOperation +from typing import Any, Optional +from urllib.parse import urljoin +import httpx + + +class UpstreamError(RuntimeError): + pass + + +def _find_token(value: Any) -> str: + if isinstance(value, str) and value.count(".") >= 2: + return value + if isinstance(value, dict): + for key in ("token", "access_token", "accessToken", "jwt", "auth_token", "authToken"): + candidate = value.get(key) + if isinstance(candidate, str) and candidate: + return candidate + for key in ("data", "result", "user", "session"): + tok = _find_token(value.get(key)) + if tok: + return tok + return "" + + +def _unwrap_list(value: Any) -> Optional[list[dict[str, Any]]]: + if isinstance(value, list): + return [i for i in value if isinstance(i, dict)] + if isinstance(value, dict): + for key in ("data", "items", "groups", "available_groups", "availableGroups"): + nested = value.get(key) + if isinstance(nested, list): + return [i for i in nested if isinstance(i, dict)] + return None + + +def _decimal_str(value: Any) -> str: + if value is None or value == "": + return "" + try: + d = Decimal(str(value)) + except (InvalidOperation, ValueError): + return str(value) + n = d.normalize() + if n == n.to_integral(): + return str(n.quantize(Decimal("1"))) + return format(n, "f") + + +def _group_id(group: dict[str, Any]) -> str: + for key in ("id", "group_id", "groupId"): + v = group.get(key) + if v is not None: + return str(v) + name = str(group.get("name") or group.get("group_name") or "") + platform = str(group.get("platform") or "") + return f"{platform}:{name}" + + +def _rate_from_group(group: dict[str, Any]) -> str: + for key in ( + "user_rate_multiplier", "userRateMultiplier", + "effective_rate_multiplier", "effectiveRateMultiplier", + "rate_multiplier", "rateMultiplier", + ): + r = _decimal_str(group.get(key)) + if r: + return r + return "" + + +def _extract_rates_map(raw: Any) -> dict[str, str]: + if raw is None: + return {} + if isinstance(raw, dict): + candidates = raw + for key in ("data", "rates", "group_rates", "groupRates"): + nested = raw.get(key) + if isinstance(nested, dict): + candidates = nested + break + result: dict[str, str] = {} + for k, v in candidates.items(): + if isinstance(v, dict): + r = _decimal_str( + v.get("rate_multiplier") or v.get("rateMultiplier") + or v.get("user_rate_multiplier") or v.get("userRateMultiplier") + ) + else: + r = _decimal_str(v) + if r: + result[str(k)] = r + return result + if isinstance(raw, list): + result = {} + for item in raw: + if not isinstance(item, dict): + continue + gid = _group_id(item) + rate = _rate_from_group(item) + if gid and rate: + result[gid] = rate + return result + return {} + + +def build_snapshot(upstream_id: int, base_url: str, api_prefix: str, + groups: list[dict[str, Any]], raw_rates: Any) -> dict[str, Any]: + from datetime import datetime, timezone + override_rates = _extract_rates_map(raw_rates) + entries: dict[str, dict[str, Any]] = {} + for g in groups: + gid = _group_id(g) + default_rate = _rate_from_group(g) + effective_rate = override_rates.get(gid, default_rate) + entries[gid] = { + "group_id": gid, + "group_name": g.get("name") or g.get("group_name") or "", + "platform": g.get("platform") or "", + "rate": effective_rate, + "default_rate": default_rate, + "override_rate": override_rates.get(gid, ""), + } + return { + "upstream_id": upstream_id, + "base_url": base_url.rstrip("/"), + "api_prefix": api_prefix, + "captured_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), + "groups": entries, + } + + +class UpstreamClient: + """Sync HTTP client that handles all auth types.""" + + def __init__( + self, + base_url: str, + api_prefix: str, + auth_type: str, + auth_config: dict[str, Any], + timeout: float = 30.0, + ) -> None: + self.base_url = base_url.rstrip("/") + self.api_prefix = api_prefix.strip("/") + self.auth_type = auth_type + self.auth_config = auth_config + self.timeout = timeout + self._token: str = "" + + def _url(self, path: str) -> str: + prefix = f"/{self.api_prefix}" if self.api_prefix else "" + return f"{self.base_url}{prefix}/{path.lstrip('/')}" + + def _headers(self, auth: bool = True) -> dict[str, str]: + headers: dict[str, str] = { + "Accept": "application/json", + "User-Agent": "SmartUp/1.0", + } + if not auth: + return headers + if self.auth_type == "bearer": + token = self.auth_config.get("token", "") + if token: + headers["Authorization"] = f"Bearer {token}" + elif self.auth_type == "api_key": + key = self.auth_config.get("key", "") + header = self.auth_config.get("header", "Authorization") + if key: + headers[header] = key + elif self.auth_type == "login_password" and self._token: + headers["Authorization"] = f"Bearer {self._token}" + return headers + + def _request(self, method: str, path: str, body: Any = None, auth: bool = True) -> Any: + url = self._url(path) + with httpx.Client(timeout=self.timeout) as client: + if body is not None: + resp = client.request(method, url, json=body, headers=self._headers(auth)) + else: + resp = client.request(method, url, headers=self._headers(auth)) + resp.raise_for_status() + ct = resp.headers.get("content-type", "") + if not resp.content: + return None + text = resp.text + if "application/json" not in ct and text.lstrip().startswith("<"): + raise UpstreamError(f"{method} {path} returned HTML, not JSON") + return resp.json() + + def login(self) -> None: + if self.auth_type != "login_password": + return + email = self.auth_config.get("email", "") + password = self.auth_config.get("password", "") + login_path = self.auth_config.get("login_path", "/auth/login") + if not email or not password: + raise UpstreamError("login_password auth requires email and password in auth_config") + resp = self._request("POST", login_path, {"email": email, "password": password}, auth=False) + token = _find_token(resp) + if not token: + raise UpstreamError("login succeeded but no token found in response") + self._token = token + + def get_available_groups(self, endpoint: str) -> list[dict[str, Any]]: + resp = self._request("GET", endpoint) + groups = _unwrap_list(resp) + if groups is None: + raise UpstreamError(f"{endpoint} did not return a list") + return groups + + def get_group_rates(self, endpoint: str) -> Any: + return self._request("GET", endpoint) diff --git a/backend/app/services/webhook_service.py b/backend/app/services/webhook_service.py new file mode 100644 index 0000000..bcd51ec --- /dev/null +++ b/backend/app/services/webhook_service.py @@ -0,0 +1,158 @@ +"""Send webhook notifications and write notification logs.""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any + +import httpx +from sqlalchemy.orm import Session + +from app.models.webhook_config import WebhookConfig +from app.models.notification_log import NotificationLog +from app.utils.dingtalk import ( + dingtalk_signed_url, + format_dingtalk_rate_changed, + format_dingtalk_status, +) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + + +def _log( + db: Session, + webhook: WebhookConfig, + event_type: str, + payload: dict[str, Any], + status: str, + response_text: str, +) -> None: + entry = NotificationLog( + webhook_config_id=webhook.id, + webhook_name=webhook.name, + event_type=event_type, + payload_json=json.dumps(payload, ensure_ascii=False), + status=status, + response_text=response_text[:2000] if response_text else None, + ) + db.add(entry) + db.commit() + + +def _send_generic(url: str, payload: dict[str, Any], timeout: float = 15.0) -> str: + resp = httpx.post( + url, + json=payload, + headers={"Content-Type": "application/json", "User-Agent": "SmartUp/1.0"}, + timeout=timeout, + ) + resp.raise_for_status() + return resp.text[:500] + + +def _send_dingtalk(url: str, secret: str, payload: dict[str, Any], timeout: float = 15.0) -> str: + signed = dingtalk_signed_url(url, secret) if secret else url + resp = httpx.post( + signed, + json=payload, + headers={"Content-Type": "application/json", "User-Agent": "SmartUp/1.0"}, + timeout=timeout, + ) + resp.raise_for_status() + result = resp.json() + if result.get("errcode", 0) != 0: + raise RuntimeError(f"DingTalk error: {resp.text[:300]}") + return resp.text[:500] + + +def send_rate_changed( + db: Session, + upstream_id: int, + upstream_name: str, + base_url: str, + changes: list[dict[str, Any]], +) -> None: + webhooks = ( + db.query(WebhookConfig) + .filter(WebhookConfig.enabled == True) + .all() + ) + changed_at = _now_iso() + generic_payload = { + "event": "upstream_rate_changed", + "upstream": {"id": upstream_id, "name": upstream_name, "base_url": base_url}, + "changed_at": changed_at, + "changes": changes, + } + for wh in webhooks: + events = json.loads(wh.events_json or "[]") + if "upstream_rate_changed" not in events: + continue + try: + if wh.type == "dingtalk": + msg = format_dingtalk_rate_changed(upstream_name, changed_at, changes) + resp_text = _send_dingtalk(wh.url, wh.secret, msg) + else: + resp_text = _send_generic(wh.url, generic_payload) + _log(db, wh, "upstream_rate_changed", generic_payload, "success", resp_text) + except Exception as exc: + _log(db, wh, "upstream_rate_changed", generic_payload, "failed", str(exc)) + + +def send_status_event( + db: Session, + upstream_id: int, + upstream_name: str, + base_url: str, + event: str, + error: str = "", +) -> None: + webhooks = ( + db.query(WebhookConfig) + .filter(WebhookConfig.enabled == True) + .all() + ) + changed_at = _now_iso() + generic_payload = { + "event": event, + "upstream": {"id": upstream_id, "name": upstream_name, "base_url": base_url}, + "changed_at": changed_at, + "error": error, + } + for wh in webhooks: + events = json.loads(wh.events_json or "[]") + if event not in events: + continue + try: + if wh.type == "dingtalk": + msg = format_dingtalk_status(upstream_name, event, changed_at, error) + resp_text = _send_dingtalk(wh.url, wh.secret, msg) + else: + resp_text = _send_generic(wh.url, generic_payload) + _log(db, wh, event, generic_payload, "success", resp_text) + except Exception as exc: + _log(db, wh, event, generic_payload, "failed", str(exc)) + + +def send_test_notification(db: Session, webhook: WebhookConfig) -> tuple[bool, str]: + payload = { + "event": "test", + "message": "SmartUp webhook test notification", + "sent_at": _now_iso(), + } + try: + if webhook.type == "dingtalk": + msg = { + "msgtype": "text", + "text": {"content": "✅ SmartUp webhook 测试通知\n配置正常,连接成功。"}, + } + resp_text = _send_dingtalk(webhook.url, webhook.secret, msg) + else: + resp_text = _send_generic(webhook.url, payload) + _log(db, webhook, "test", payload, "success", resp_text) + return True, "发送成功" + except Exception as exc: + _log(db, webhook, "test", payload, "failed", str(exc)) + return False, str(exc) diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..db3e327 --- /dev/null +++ b/backend/app/utils/__init__.py @@ -0,0 +1 @@ +# utils package diff --git a/backend/app/utils/auth.py b/backend/app/utils/auth.py new file mode 100644 index 0000000..c9313dc --- /dev/null +++ b/backend/app/utils/auth.py @@ -0,0 +1,74 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional +from jose import JWTError, jwt +import bcrypt +from fastapi import Depends, HTTPException, Query, Request, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from app.config import get_settings +from app.database import get_db +from app.models.admin_user import AdminUser + +ALGORITHM = "HS256" +bearer_scheme = HTTPBearer(auto_error=False) + + +def hash_password(password: str) -> str: + pw = password.encode("utf-8")[:72] + return bcrypt.hashpw(pw, bcrypt.gensalt()).decode("utf-8") + + +def verify_password(plain: str, hashed: str) -> bool: + pw = plain.encode("utf-8")[:72] + return bcrypt.checkpw(pw, hashed.encode("utf-8")) + + +def create_access_token(email: str, expires_hours: Optional[int] = None) -> str: + settings = get_settings() + hours = expires_hours or settings.jwt_expire_hours + expire = datetime.now(timezone.utc) + timedelta(hours=hours) + data = {"sub": email, "exp": expire} + return jwt.encode(data, settings.jwt_secret, algorithm=ALGORITHM) + + +def decode_token(token: str) -> Optional[str]: + settings = get_settings() + try: + payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM]) + return payload.get("sub") + except JWTError: + return None + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), + db: Session = Depends(get_db), +) -> AdminUser: + token = credentials.credentials if credentials else None + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + email = decode_token(token) + if not email: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + user = db.query(AdminUser).filter(AdminUser.email == email).first() + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + return user + + +def get_user_from_token_param( + token: Optional[str] = Query(default=None), + credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), + db: Session = Depends(get_db), +) -> AdminUser: + """Accept JWT from ?token= query param (for iframe src) OR Authorization header.""" + raw = token or (credentials.credentials if credentials else None) + if not raw: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + email = decode_token(raw) + if not email: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + user = db.query(AdminUser).filter(AdminUser.email == email).first() + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + return user diff --git a/backend/app/utils/dingtalk.py b/backend/app/utils/dingtalk.py new file mode 100644 index 0000000..6164416 --- /dev/null +++ b/backend/app/utils/dingtalk.py @@ -0,0 +1,59 @@ +"""DingTalk webhook signing and message formatting (ported from monitor script).""" +import base64 +import hashlib +import hmac +import json +import time +from typing import Any +from urllib.parse import quote_plus + + +def dingtalk_signed_url(webhook_url: str, secret: str) -> str: + timestamp = str(int(time.time() * 1000)) + string_to_sign = f"{timestamp}\n{secret}".encode("utf-8") + digest = hmac.new(secret.encode("utf-8"), string_to_sign, hashlib.sha256).digest() + sign = quote_plus(base64.b64encode(digest).decode("utf-8")) + sep = "&" if "?" in webhook_url else "?" + return f"{webhook_url}{sep}timestamp={timestamp}&sign={sign}" + + +def format_dingtalk_rate_changed(upstream_name: str, changed_at: str, changes: list[dict[str, Any]]) -> dict[str, Any]: + lines = [ + f"### 📊 {upstream_name} 分组倍率变更", + "", + f"- **时间**:{changed_at}", + f"- **变化数量**:{len(changes)}", + "", + ] + for ch in changes: + name = ch.get("group_name") or ch.get("group_id") or "unknown" + platform = ch.get("platform") or "-" + old = ch.get("old_rate") + new = ch.get("new_rate") + lines.append(f"- `{name}` ({platform}):`{old}` → `{new}`") + return { + "msgtype": "markdown", + "markdown": { + "title": f"{upstream_name} 分组倍率变更", + "text": "\n".join(lines), + }, + } + + +def format_dingtalk_status(upstream_name: str, event: str, changed_at: str, error: str = "") -> dict[str, Any]: + emoji = "🔴" if event == "upstream_unhealthy" else "🟢" + label = "服务异常" if event == "upstream_unhealthy" else "服务恢复" + lines = [ + f"### {emoji} {upstream_name} {label}", + "", + f"- **时间**:{changed_at}", + ] + if error: + lines.append(f"- **错误**:{error}") + return { + "msgtype": "markdown", + "markdown": { + "title": f"{upstream_name} {label}", + "text": "\n".join(lines), + }, + } diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..a150354 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.115.5 +uvicorn[standard]==0.32.1 +sqlalchemy==2.0.36 +python-jose[cryptography]==3.3.0 +bcrypt==4.2.1 +httpx==0.28.1 +apscheduler==3.10.4 +python-dotenv==1.0.1 +pydantic-settings==2.6.1 +python-multipart==0.0.20 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..330001a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + smartup: + build: + context: . + dockerfile: Dockerfile + container_name: smartup + restart: unless-stopped + ports: + - "${BIND_HOST:-0.0.0.0}:${SERVER_PORT:-8899}:8000" + volumes: + - ./data:/app/data + environment: + - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@smartup.local} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:?ADMIN_PASSWORD is required} + - JWT_SECRET=${JWT_SECRET:-change-me-in-production} + - DATABASE_URL=sqlite:////app/data/app.db + - TZ=${TZ:-Asia/Shanghai} + - UNHEALTHY_THRESHOLD=${UNHEALTHY_THRESHOLD:-3} + healthcheck: + test: ["CMD", "wget", "-q", "-T", "5", "-O", "/dev/null", "http://localhost:8000/healthz"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c043a25 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + SmartUp 管理后台 + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..385c568 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2049 @@ +{ + "name": "smartup-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "smartup-frontend", + "version": "1.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.7.9", + "dayjs": "^1.11.13", + "element-plus": "^2.8.8", + "pinia": "^2.2.6", + "vue": "^3.5.13", + "vue-router": "^4.4.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.7.2", + "vite": "^6.0.3", + "vue-tsc": "^2.1.10" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.14", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "vue": "3.5.34" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.14.0", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.0.tgz", + "integrity": "sha512-POgH+TtoreaEKWqYYAVQyE6i8rQMEFqAEublyF29dBA5yASWPLKY6EzfeqBTr2Uv26mPss4vSrMrNPyaK7LX5w==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.8" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/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.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/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/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "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/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.8", + "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.2.8.tgz", + "integrity": "sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..94a293b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "smartup-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.13", + "vue-router": "^4.4.5", + "pinia": "^2.2.6", + "element-plus": "^2.8.8", + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.7.9", + "dayjs": "^1.11.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.7.2", + "vite": "^6.0.3", + "vue-tsc": "^2.1.10" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..1ca87d0 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..2994757 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,150 @@ +import axios from 'axios' +import router from '@/router' + +export const api = axios.create({ + baseURL: '/', + timeout: 30000, +}) + +api.interceptors.response.use( + (r) => r, + (err) => { + if (err.response?.status === 401) { + localStorage.removeItem('smartup_token') + localStorage.removeItem('smartup_email') + router.push('/login') + } + return Promise.reject(err) + } +) + +// ——— Auth ——— +export const authApi = { + login: (email: string, password: string) => + api.post<{ access_token: string }>('/api/auth/login', { email, password }), + me: () => api.get<{ email: string }>('/api/auth/me'), +} + +// ——— Upstreams ——— +export interface UpstreamData { + id: number + name: string + base_url: string + api_prefix: string + auth_type: string + auth_config_masked: Record + rate_endpoint: string + groups_endpoint: string + enabled: boolean + check_interval_seconds: number + timeout_seconds: number + last_status: string + last_checked_at: string | null + last_error: string | null + created_at: string + updated_at: string +} + +export interface UpstreamForm { + name: string + base_url: string + api_prefix: string + auth_type: string + auth_config: Record + rate_endpoint: string + groups_endpoint: string + enabled: boolean + check_interval_seconds: number + timeout_seconds: number +} + +export const upstreamsApi = { + list: () => api.get('/api/upstreams'), + create: (data: UpstreamForm) => api.post('/api/upstreams', data), + update: (id: number, data: Partial) => api.put(`/api/upstreams/${id}`, data), + delete: (id: number) => api.delete(`/api/upstreams/${id}`), + test: (id: number) => api.post<{ success: boolean; message: string; detail?: string }>(`/api/upstreams/${id}/test`), + checkNow: (id: number) => api.post<{ success: boolean; message: string }>(`/api/upstreams/${id}/check-now`), + latestSnapshot: (id: number) => api.get(`/api/upstreams/${id}/snapshots/latest`), + listSnapshots: (id: number, limit = 20, offset = 0) => + api.get(`/api/upstreams/${id}/snapshots`, { params: { limit, offset } }), +} + +// ——— Webhooks ——— +export interface WebhookData { + id: number + name: string + type: string + url: string + secret_masked: string + enabled: boolean + events: string[] + created_at: string + updated_at: string +} + +export interface WebhookForm { + name: string + type: string + url: string + secret: string + enabled: boolean + events: string[] +} + +export const webhooksApi = { + list: () => api.get('/api/webhooks'), + create: (data: WebhookForm) => api.post('/api/webhooks', data), + update: (id: number, data: Partial) => api.put(`/api/webhooks/${id}`, data), + delete: (id: number) => api.delete(`/api/webhooks/${id}`), + test: (id: number) => api.post<{ success: boolean; message: string }>(`/api/webhooks/${id}/test`), +} + +// ——— Logs ——— +export interface LogData { + id: number + webhook_config_id: number + webhook_name: string + event_type: string + payload: Record + status: string + response_text: string | null + created_at: string +} + +export const logsApi = { + list: (params?: { status?: string; event_type?: string; limit?: number; offset?: number }) => + api.get('/api/notification-logs', { params }), +} + +// ——— Custom Pages ——— +export interface CustomPageData { + id: number + name: string + url: string + icon: string + sort_order: number + enabled: boolean + use_proxy: boolean + description: string | null + created_at: string + updated_at: string +} + +export interface CustomPageForm { + name: string + url: string + icon: string + sort_order: number + enabled: boolean + use_proxy: boolean + description?: string +} + +export const customPagesApi = { + list: () => api.get('/api/custom-pages'), + listPublic: () => axios.get('/api/custom-pages/public'), + create: (data: CustomPageForm) => api.post('/api/custom-pages', data), + update: (id: number, data: Partial) => api.put(`/api/custom-pages/${id}`, data), + delete: (id: number) => api.delete(`/api/custom-pages/${id}`), +} diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css new file mode 100644 index 0000000..c44e297 --- /dev/null +++ b/frontend/src/assets/main.css @@ -0,0 +1,66 @@ +/* SmartUp — Global CSS */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + +:root { + --bg-base: #0d1117; + --bg-surface: #161b22; + --bg-card: #1c2128; + --bg-elevated: #22272e; + --border-color: #30363d; + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --text-muted: #6e7681; + --color-primary: #6366f1; + --color-primary-hover: #818cf8; + --color-success: #22c55e; + --color-danger: #ef4444; + --color-warning: #f59e0b; + --color-info: #38bdf8; + --sidebar-width: 220px; + --topbar-height: 56px; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body, #app { + height: 100%; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + background-color: var(--bg-base); + color: var(--text-primary); + -webkit-font-smoothing: antialiased; +} + +/* Element Plus dark mode overrides */ +.el-table { --el-table-bg-color: var(--bg-card); --el-table-tr-bg-color: var(--bg-card); --el-table-header-bg-color: var(--bg-elevated); --el-table-border-color: var(--border-color); --el-table-text-color: var(--text-primary); --el-table-header-text-color: var(--text-secondary); } +.el-dialog { --el-dialog-bg-color: var(--bg-surface); --el-dialog-border-color: var(--border-color); } +.el-drawer { --el-drawer-bg-color: var(--bg-surface); } +.el-form-item__label { color: var(--text-secondary); } +.el-input__wrapper { background-color: var(--bg-elevated) !important; box-shadow: 0 0 0 1px var(--border-color) inset !important; } +.el-input__inner { color: var(--text-primary) !important; } +.el-select-dropdown { background-color: var(--bg-elevated); border-color: var(--border-color); } +.el-select-dropdown__item { color: var(--text-primary); } +.el-select-dropdown__item.hover, .el-select-dropdown__item:hover { background-color: var(--bg-surface); } +.el-tag { border-radius: 6px; } +.el-button--primary { background-color: var(--color-primary); border-color: var(--color-primary); } +.el-button--primary:hover { background-color: var(--color-primary-hover); border-color: var(--color-primary-hover); } + +/* Status badges */ +.status-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 10px; + border-radius: 20px; + font-size: 12px; + font-weight: 500; +} +.status-badge.healthy { background: rgba(34,197,94,0.15); color: var(--color-success); } +.status-badge.unhealthy { background: rgba(239,68,68,0.15); color: var(--color-danger); } +.status-badge.unknown { background: rgba(110,118,129,0.2); color: var(--text-muted); } +.status-badge.enabled { background: rgba(99,102,241,0.15); color: var(--color-primary); } +.status-badge.disabled { background: rgba(110,118,129,0.15); color: var(--text-muted); } + +/* Scrollbar */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; } diff --git a/frontend/src/components/AppLayout.vue b/frontend/src/components/AppLayout.vue new file mode 100644 index 0000000..067bbd4 --- /dev/null +++ b/frontend/src/components/AppLayout.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..eea619c --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,21 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import 'element-plus/theme-chalk/dark/css-vars.css' +import * as ElIcons from '@element-plus/icons-vue' +import App from './App.vue' +import router from './router' +import './assets/main.css' + +const app = createApp(App) + +// Register all Element Plus icons globally +for (const [name, component] of Object.entries(ElIcons)) { + app.component(name, component) +} + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus) +app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..d17c894 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,40 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/login', + component: () => import('@/views/Login.vue'), + meta: { requiresAuth: false }, + }, + { + path: '/', + component: () => import('@/components/AppLayout.vue'), + meta: { requiresAuth: true }, + redirect: '/upstreams', + children: [ + { path: 'upstreams', component: () => import('@/views/Upstreams.vue') }, + { path: 'webhooks', component: () => import('@/views/Webhooks.vue') }, + { path: 'logs', component: () => import('@/views/NotificationLogs.vue') }, + { path: 'custom-pages', component: () => import('@/views/CustomPages.vue') }, + { path: 'page/:id', component: () => import('@/views/PageViewer.vue') }, + ], + }, + { path: '/:pathMatch(.*)*', redirect: '/' }, + ], +}) + +router.beforeEach((to, _from, next) => { + const auth = useAuthStore() + if (to.meta.requiresAuth && !auth.token) { + next('/login') + } else if (to.path === '/login' && auth.token) { + next('/upstreams') + } else { + next() + } +}) + +export default router diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..2ad59af --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,31 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { api } from '@/api' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem('smartup_token') || '') + const email = ref(localStorage.getItem('smartup_email') || '') + + function setToken(t: string, e: string) { + token.value = t + email.value = e + localStorage.setItem('smartup_token', t) + localStorage.setItem('smartup_email', e) + api.defaults.headers.common['Authorization'] = `Bearer ${t}` + } + + function clear() { + token.value = '' + email.value = '' + localStorage.removeItem('smartup_token') + localStorage.removeItem('smartup_email') + delete api.defaults.headers.common['Authorization'] + } + + // Restore token on load + if (token.value) { + api.defaults.headers.common['Authorization'] = `Bearer ${token.value}` + } + + return { token, email, setToken, clear } +}) diff --git a/frontend/src/views/CustomPages.vue b/frontend/src/views/CustomPages.vue new file mode 100644 index 0000000..47fe3b1 --- /dev/null +++ b/frontend/src/views/CustomPages.vue @@ -0,0 +1,325 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..97738af --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/frontend/src/views/NotificationLogs.vue b/frontend/src/views/NotificationLogs.vue new file mode 100644 index 0000000..cfdda51 --- /dev/null +++ b/frontend/src/views/NotificationLogs.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/frontend/src/views/PageViewer.vue b/frontend/src/views/PageViewer.vue new file mode 100644 index 0000000..24854b9 --- /dev/null +++ b/frontend/src/views/PageViewer.vue @@ -0,0 +1,208 @@ +