commit e0b830540648755316577ec90116619169637ad8 Author: Lxy Date: Sat May 9 00:54:38 2026 +0800 feat: 初始化代码 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f976b4a --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Tushare API Token (必填,到 https://tushare.pro 注册获取) +TUSHARE_TOKEN=your_tushare_token_here + +# 数据库配置(默认值即可) +DB_HOST=postgres +DB_PORT=5432 +DB_NAME=futures_data +DB_USER=futures +DB_PASSWORD=futures123 + +# Redis 配置 +REDIS_HOST=redis +REDIS_PORT=6379 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd1a6cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +env/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Environment variables +.env +.env.local +.env.*.local + +# Database +*.db +*.sqlite3 + +# Logs +*.log +logs/ + +# Frontend +node_modules/ +dist/ +build/ +.npm +.yarn + +# Docker +.dockerignore + +# Misc +.DS_Store +Thumbs.db diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..3a78773 --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple + +COPY app/ ./app/ + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..df90705 --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install --registry=https://registry.npmmirror.com + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "run", "dev"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc76af4 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# 期货统一数据平台 + +期货行情统一数据平台,对接多个数据源(Tushare/CTP等),提供统一的数据出口。 + +## 功能 + +- **数据源管理**: 可配置启用/禁用的数据源,优先级管理 +- **历史K线**: 日K/周K/60m/30m/15m/5m 数据存储与查询 +- **合约管理**: 合约信息同步与查询 +- **管理后台**: 数据源监控、接口测试、K线展示 +- **RESTful API**: 统一的数据接口供其他系统调用 + +## 快速开始 + +### 1. 配置环境变量 + +```bash +cp .env.example .env +# 编辑 .env,填入你的 Tushare Token +``` + +### 2. Docker 启动 + +```bash +docker compose up -d --build +``` + +启动后访问: +- 管理后台: http://localhost:3000 +- 后端API: http://localhost:8000 +- API文档: http://localhost:8000/docs + +### 3. 初始配置 + +1. 打开 http://localhost:3000 +2. 进入"数据源监控"页面 +3. 点击 tushare 行的"配置"按钮,填入你的 Tushare Token +4. 点击"测试连接"确认 +5. 开启"启用"开关 + +## 项目结构 + +``` +├── backend/ # 后端 (FastAPI + SQLAlchemy) +│ ├── app/ +│ │ ├── main.py # 入口 & API路由 +│ │ ├── config.py # 配置 +│ │ ├── database.py # 数据库连接 +│ │ ├── models/ # 数据库模型 +│ │ ├── schemas/ # Pydantic 模型 +│ │ ├── services/ # 业务逻辑 +│ │ │ ├── datasource/ # 数据源适配器 +│ │ │ ├── kline_service.py +│ │ │ └── contract_service.py +│ │ └── api/ +│ └── requirements.txt +├── frontend/ # 前端 (Vue3 + Element Plus) +│ ├── src/ +│ │ ├── views/ # 页面 +│ │ └── api/ # API调用 +│ └── package.json +├── docker-compose.yml +└── .env +``` + +## API 接口 + +### 合约 +- `GET /api/v1/contracts` - 合约列表 +- `GET /api/v1/contracts/{symbol}` - 合约详情 +- `POST /api/v1/contracts/sync` - 同步合约 + +### K线 +- `GET /api/v1/kline?symbol=rb2401&period=daily&limit=100` - 查询K线 +- `POST /api/v1/kline/sync` - 同步K线数据 + +### 数据源 +- `GET /api/v1/datasources` - 数据源列表 +- `PUT /api/v1/datasources/{name}` - 更新配置 +- `POST /api/v1/datasources/{name}/test` - 测试连接 + +## 技术栈 + +| 层次 | 技术 | +|------|------| +| 后端 | Python / FastAPI / SQLAlchemy | +| 数据库 | PostgreSQL + TimescaleDB | +| 缓存 | Redis | +| 前端 | Vue3 / Element Plus / ECharts | +| 部署 | Docker Compose | diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..152414b --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,50 @@ +from pydantic_settings import BaseSettings +from typing import Optional + + +class Settings(BaseSettings): + # 项目信息 + PROJECT_NAME: str = "期货统一数据平台" + VERSION: str = "0.1.0" + API_PREFIX: str = "/api/v1" + + # 数据库配置 + DB_HOST: str = "postgres" + DB_PORT: int = 5432 + DB_NAME: str = "futures_data" + DB_USER: str = "futures" + DB_PASSWORD: str = "futures123" + DATABASE_URL: str = "" # 直接指定完整 URL,支持 sqlite:/// + + @property + def database_url(self) -> str: + if self.DATABASE_URL: + return self.DATABASE_URL + return f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + + # Redis 配置 + REDIS_HOST: str = "redis" + REDIS_PORT: int = 6379 + REDIS_DB: int = 0 + REDIS_PASSWORD: Optional[str] = None + + @property + def REDIS_URL(self) -> str: + if self.REDIS_PASSWORD: + return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" + return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" + + # Tushare 配置 + TUSHARE_TOKEN: str = "" + + # 服务配置 + HOST: str = "0.0.0.0" + PORT: int = 8000 + DEBUG: bool = True + + class Config: + env_file = ".env" + case_sensitive = True + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..a7278ff --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,23 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase +from app.config import settings + +db_url = settings.database_url +connect_args = {} +if db_url.startswith("sqlite"): + connect_args["check_same_thread"] = False + +engine = create_engine(db_url, connect_args=connect_args, pool_pre_ping=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..09ef25b --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,339 @@ +from fastapi import FastAPI, Depends, HTTPException, Query +from fastapi.middleware.cors import CORSMiddleware +from typing import Optional +from datetime import datetime +import logging + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) + +from app.config import settings +from app.database import get_db, engine, Base +from app.schemas import ( + KlineRequest, KlineResponse, KlineItem, + ContractInfo as ContractSchema, ContractListResponse, + DataSourceConfigItem, DataSourceConfigUpdate, DataSourceCreate, + ApiResponse, HealthResponse, DataSourceStatus, +) +from app.services.kline_service import kline_service +from app.services.contract_service import contract_service +from app.services.datasource.manager import DataSourceManager +from app.models import DataSourceConfig + +logger = logging.getLogger(__name__) + + +app = FastAPI( + title=settings.PROJECT_NAME, + version=settings.VERSION, + docs_url="/docs", + redoc_url="/redoc", +) + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# ========== 启动事件 ========== +@app.on_event("startup") +async def startup(): + # 创建数据库表 + Base.metadata.create_all(bind=engine) + # 加载数据源配置 + DataSourceManager.load_enabled_sources() + # 初始化默认数据源配置(如果不存在) + _init_default_datasource() + + +def _init_default_datasource(): + """初始化默认的数据源配置(如果不存在)""" + from app.database import SessionLocal + db = SessionLocal() + try: + # 初始化 Tushare + existing = db.query(DataSourceConfig).filter( + DataSourceConfig.source_name == "tushare" + ).first() + if not existing: + import json + cfg = DataSourceConfig( + source_name="tushare", + display_name="Tushare", + is_enabled=False, + config_json=json.dumps({"token": ""}), + priority=1, + status="unknown", + ) + db.add(cfg) + + # 初始化 Akshare + existing_ak = db.query(DataSourceConfig).filter( + DataSourceConfig.source_name == "akshare" + ).first() + if not existing_ak: + import json + cfg_ak = DataSourceConfig( + source_name="akshare", + display_name="AKShare", + is_enabled=False, + config_json=json.dumps({"max_retries": 3}), + priority=2, + status="unknown", + ) + db.add(cfg_ak) + + db.commit() + finally: + db.close() + + +# ========== 健康检查 ========== +@app.get("/api/health", response_model=HealthResponse) +async def health_check(): + services = {} + try: + from sqlalchemy import text + from app.database import engine + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + services["database"] = "ok" + except Exception as e: + services["database"] = f"error: {str(e)}" + + try: + import redis + r = redis.from_url(settings.REDIS_URL) + r.ping() + services["redis"] = "ok" + except Exception as e: + services["redis"] = "not configured" # Redis 非必须 + + status = "healthy" if all(v == "ok" for v in services.values()) else "degraded" + + return HealthResponse( + status=status, + services=services, + version=settings.VERSION, + ) + + +# ========== 合约接口 ========== +@app.get("/api/v1/contracts", response_model=ContractListResponse) +async def list_contracts( + exchange: Optional[str] = Query(None, description="交易所代码"), + product: Optional[str] = Query(None, description="品种代码"), + is_active: Optional[bool] = Query(None, description="是否活跃"), +): + contracts = contract_service.get_contracts( + exchange=exchange, product=product, is_active=is_active + ) + return ContractListResponse( + total=len(contracts), + items=[ContractSchema.model_validate(c) for c in contracts], + ) + + +@app.get("/api/v1/contracts/{symbol}", response_model=ContractSchema) +async def get_contract(symbol: str): + contract = contract_service.get_contract(symbol) + if not contract: + raise HTTPException(status_code=404, detail="合约不存在") + return ContractSchema.model_validate(contract) + + +@app.post("/api/v1/contracts/sync") +async def sync_contracts(): + """从数据源同步合约列表""" + try: + count = contract_service.sync_contracts() + return {"code": 0, "message": "同步成功", "data": {"synced": count}} + except Exception as e: + return {"code": 1, "message": f"同步失败: {str(e)}", "data": None} + + +# ========== K线接口 ========== +@app.get("/api/v1/kline", response_model=KlineResponse) +async def get_kline( + symbol: str = Query(..., description="合约代码"), + period: str = Query("daily", description="周期: daily/weekly/5m/15m/30m/60m"), + start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"), + end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"), + limit: int = Query(500, ge=1, le=5000, description="返回条数"), +): + logger.info(f"[API-查询K线] 请求参数: symbol={symbol}, period={period}, start_date={start_date}, end_date={end_date}, limit={limit}") + items = kline_service.get_kline( + symbol=symbol, + period=period, + start_date=start_date, + end_date=end_date, + limit=limit, + ) + logger.info(f"[API-查询K线] 返回 {len(items)} 条记录") + return KlineResponse( + symbol=symbol, + period=period, + total=len(items), + items=[KlineItem(**item) for item in items], + ) + + +@app.post("/api/v1/kline/sync") +async def sync_kline(req: KlineRequest): + """从数据源同步K线数据""" + logger.info(f"[API-同步K线] 请求参数: symbol={req.symbol}, period={req.period}, start_date={req.start_date}, end_date={req.end_date}") + try: + start = req.start_date or "2020-01-01" + end = req.end_date or datetime.now().strftime("%Y-%m-%d") + logger.info(f"[API-同步K线] 使用日期范围: {start} ~ {end}") + + if req.period == "daily": + count = kline_service.sync_daily(req.symbol, start, end) + elif req.period == "weekly": + count = kline_service.sync_weekly(req.symbol, start, end) + else: + count = kline_service.sync_intraday(req.symbol, req.period, start, end) + + logger.info(f"[API-同步K线] 同步成功,共同步 {count} 条记录") + return {"code": 0, "message": "同步成功", "data": {"synced": count}} + except Exception as e: + logger.error(f"[API-同步K线] 同步失败: {e}", exc_info=True) + return {"code": 1, "message": f"同步失败: {str(e)}", "data": None} + + +# ========== 数据源管理接口 ========== +@app.get("/api/v1/datasources") +async def list_datasources(): + """获取所有数据源状态""" + sources = DataSourceManager.get_all_sources_status() + return {"code": 0, "data": sources} + + +@app.post("/api/v1/datasources") +async def create_datasource(req: DataSourceCreate): + """创建数据源配置""" + from app.database import SessionLocal + db = SessionLocal() + try: + existing = db.query(DataSourceConfig).filter( + DataSourceConfig.source_name == req.source_name + ).first() + if existing: + return {"code": 1, "message": "数据源已存在"} + + cfg = DataSourceConfig( + source_name=req.source_name, + display_name=req.display_name or req.source_name, + is_enabled=False, + config_json=req.config_json or {}, + priority=req.priority, + status="unknown", + ) + db.add(cfg) + db.commit() + return {"code": 0, "message": "创建成功", "data": {"id": cfg.id}} + except Exception as e: + db.rollback() + return {"code": 1, "message": f"创建失败: {str(e)}"} + finally: + db.close() + + +@app.put("/api/v1/datasources/{source_name}") +async def update_datasource(source_name: str, req: DataSourceConfigUpdate): + """更新数据源配置""" + from app.database import SessionLocal + db = SessionLocal() + try: + cfg = db.query(DataSourceConfig).filter( + DataSourceConfig.source_name == source_name + ).first() + if not cfg: + return {"code": 1, "message": "数据源不存在"} + + if req.is_enabled is not None: + cfg.is_enabled = req.is_enabled + if req.config_json is not None: + import json + cfg.config_json = json.dumps(req.config_json) + if req.priority is not None: + cfg.priority = req.priority + + db.commit() + + # 重新加载数据源 + DataSourceManager.load_enabled_sources() + + return {"code": 0, "message": "更新成功"} + except Exception as e: + db.rollback() + return {"code": 1, "message": f"更新失败: {str(e)}"} + finally: + db.close() + + +@app.post("/api/v1/datasources/{source_name}/test") +async def test_datasource(source_name: str): + """测试数据源连接""" + source = DataSourceManager.get_source(source_name) + if not source: + # 尝试创建临时实例测试 + from app.database import SessionLocal + import json + db = SessionLocal() + try: + cfg = db.query(DataSourceConfig).filter( + DataSourceConfig.source_name == source_name + ).first() + if not cfg: + return {"code": 1, "message": "数据源不存在"} + + config = json.loads(cfg.config_json) if cfg.config_json else {} + + # 动态获取数据源类 + source_class = DataSourceManager._source_map.get(source_name) + if not source_class: + return {"code": 1, "message": "不支持的数据源类型"} + + source = source_class(config) + finally: + db.close() + + ok, msg = source.health_check() + if ok: + # 更新状态 + from app.database import SessionLocal + db = SessionLocal() + try: + cfg = db.query(DataSourceConfig).filter( + DataSourceConfig.source_name == source_name + ).first() + if cfg: + cfg.status = "ok" + cfg.error_msg = None + db.commit() + finally: + db.close() + return {"code": 0, "message": "连接成功", "data": {"status": "ok"}} + else: + from app.database import SessionLocal + db = SessionLocal() + try: + cfg = db.query(DataSourceConfig).filter( + DataSourceConfig.source_name == source_name + ).first() + if cfg: + cfg.status = "error" + cfg.error_msg = msg + db.commit() + finally: + db.close() + return {"code": 1, "message": f"连接失败: {msg}", "data": {"status": "error"}} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..ea736d8 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,113 @@ +from app.database import Base +from sqlalchemy import Column, Integer, String, Float, DateTime, BigInteger, Boolean, Text, Index +from datetime import datetime + + +class ContractInfo(Base): + """期货合约信息表""" + __tablename__ = "contract_info" + + id = Column(Integer, primary_key=True, autoincrement=True) + symbol = Column(String(20), unique=True, nullable=False, comment="合约代码,如 rb2401") + exchange = Column(String(10), nullable=False, comment="交易所代码: SHFE/DCE/CZCE/INE/CFFEX") + name = Column(String(50), comment="合约名称") + product = Column(String(20), comment="品种代码,如 rb") + multiplier = Column(Integer, default=10, comment="合约乘数") + price_tick = Column(Float, comment="最小变动价位") + limit_up_ratio = Column(Float, comment="涨停板比例") + limit_down_ratio = Column(Float, comment="跌停板比例") + expire_date = Column(DateTime, comment="到期日") + is_active = Column(Boolean, default=True, comment="是否活跃") + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + __table_args__ = ( + Index("idx_contract_product", "product"), + Index("idx_contract_exchange", "exchange"), + ) + + +class KlineDaily(Base): + """日K线表""" + __tablename__ = "kline_daily" + + id = Column(Integer, primary_key=True, autoincrement=True) + symbol = Column(String(20), nullable=False) + trade_date = Column(DateTime, nullable=False) + open = Column(Float) + high = Column(Float) + low = Column(Float) + close = Column(Float) + volume = Column(BigInteger, comment="成交量") + turnover = Column(Float, comment="成交额") + open_interest = Column(BigInteger, comment="持仓量") + settle = Column(Float, comment="结算价") + pre_settle = Column(Float, comment="昨结算") + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + __table_args__ = ( + Index("idx_kline_daily_symbol_date", "symbol", "trade_date", unique=True), + ) + + +class KlineWeekly(Base): + """周K线表""" + __tablename__ = "kline_weekly" + + id = Column(Integer, primary_key=True, autoincrement=True) + symbol = Column(String(20), nullable=False) + trade_date = Column(DateTime, nullable=False, comment="周最后一天") + open = Column(Float) + high = Column(Float) + low = Column(Float) + close = Column(Float) + volume = Column(BigInteger) + turnover = Column(Float) + open_interest = Column(BigInteger) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + __table_args__ = ( + Index("idx_kline_weekly_symbol_date", "symbol", "trade_date", unique=True), + ) + + +class KlineIntraday(Base): + """分钟级K线表(5m/15m/30m/60m共用,通过period区分)""" + __tablename__ = "kline_intraday" + + id = Column(Integer, primary_key=True, autoincrement=True) + symbol = Column(String(20), nullable=False) + period = Column(String(10), nullable=False, comment="周期: 5m/15m/30m/60m") + trade_time = Column(DateTime, nullable=False) + open = Column(Float) + high = Column(Float) + low = Column(Float) + close = Column(Float) + volume = Column(BigInteger) + turnover = Column(Float) + open_interest = Column(BigInteger) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + __table_args__ = ( + Index("idx_kline_intraday_symbol_period_time", "symbol", "period", "trade_time", unique=True), + ) + + +class DataSourceConfig(Base): + """数据源配置表""" + __tablename__ = "data_source_config" + + id = Column(Integer, primary_key=True, autoincrement=True) + source_name = Column(String(30), unique=True, nullable=False, comment="数据源名称: tushare/ctp") + display_name = Column(String(50), comment="显示名称") + is_enabled = Column(Boolean, default=False, comment="是否启用") + config_json = Column(Text, comment="JSON格式的配置信息") + priority = Column(Integer, default=0, comment="优先级,数字越小优先级越高") + last_sync_time = Column(DateTime, comment="最后同步时间") + status = Column(String(20), default="unknown", comment="状态: ok/error/unknown") + error_msg = Column(Text, comment="错误信息") + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..06bfb50 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,102 @@ +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime + + +# ========== 合约相关 ========== +class ContractInfo(BaseModel): + id: int + symbol: str + exchange: str + name: Optional[str] = None + product: Optional[str] = None + multiplier: Optional[int] = None + price_tick: Optional[float] = None + is_active: Optional[bool] = None + + class Config: + from_attributes = True + + +class ContractListResponse(BaseModel): + total: int + items: List[ContractInfo] + + +# ========== K线相关 ========== +class KlineItem(BaseModel): + trade_time: datetime + open: Optional[float] = None + high: Optional[float] = None + low: Optional[float] = None + close: Optional[float] = None + volume: Optional[int] = None + turnover: Optional[float] = None + open_interest: Optional[int] = None + + class Config: + from_attributes = True + + +class KlineRequest(BaseModel): + symbol: str + period: str = "daily" # daily/weekly/5m/15m/30m/60m + start_date: Optional[str] = None # YYYY-MM-DD or YYYY-MM-DD HH:MM:SS + end_date: Optional[str] = None + limit: int = 500 # 默认返回最近500条 + + +class KlineResponse(BaseModel): + symbol: str + period: str + total: int + items: List[KlineItem] + + +# ========== 数据源相关 ========== +class DataSourceConfigItem(BaseModel): + id: int + source_name: str + display_name: Optional[str] = None + is_enabled: bool + priority: int + status: str + error_msg: Optional[str] = None + last_sync_time: Optional[datetime] = None + + class Config: + from_attributes = True + + +class DataSourceConfigUpdate(BaseModel): + is_enabled: Optional[bool] = None + config_json: Optional[dict] = None + priority: Optional[int] = None + + +class DataSourceCreate(BaseModel): + source_name: str + display_name: Optional[str] = None + config_json: Optional[dict] = None + priority: int = 0 + + +class DataSourceStatus(BaseModel): + source_name: str + is_enabled: bool + status: str + error_msg: Optional[str] = None + last_sync_time: Optional[datetime] = None + + +# ========== 通用 ========== +class ApiResponse(BaseModel): + code: int = 0 + message: str = "ok" + data: Optional[dict] = None + + +class HealthResponse(BaseModel): + status: str + services: dict + version: str diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/contract_service.py b/backend/app/services/contract_service.py new file mode 100644 index 0000000..cf02a7d --- /dev/null +++ b/backend/app/services/contract_service.py @@ -0,0 +1,110 @@ +from typing import List, Optional +from sqlalchemy.orm import Session +from app.models import ContractInfo +from app.services.datasource.manager import DataSourceManager +from app.database import SessionLocal + + +class ContractService: + """合约信息服务""" + + def __init__(self): + self.manager = DataSourceManager() + + def sync_contracts(self) -> int: + """从数据源同步合约列表到数据库""" + source = self.manager.get_primary_source() + if not source: + raise Exception("没有可用的数据源") + + # Tushare 需要遍历所有交易所 + exchanges = ["CFFEX", "SHFE", "DCE", "CZCE", "INE", "GFEX"] + all_contracts = [] + + for ex in exchanges: + try: + contracts = source.get_contract_list(exchange=ex) + all_contracts.extend(contracts) + except Exception: + continue # 某个交易所失败不影响其他 + + # 去重:基于 symbol + seen_symbols = set() + unique_contracts = [] + for c in all_contracts: + if c["symbol"] not in seen_symbols: + seen_symbols.add(c["symbol"]) + unique_contracts.append(c) + all_contracts = unique_contracts + + db = SessionLocal() + count = 0 + try: + for c in all_contracts: + contract = db.query(ContractInfo).filter( + ContractInfo.symbol == c["symbol"] + ).first() + + if contract: + contract.exchange = c["exchange"] + contract.name = c["name"] + contract.product = c["product"] + contract.multiplier = c["multiplier"] + contract.price_tick = c["price_tick"] + contract.expire_date = c["expire_date"] + contract.is_active = c["is_active"] + else: + contract = ContractInfo( + symbol=c["symbol"], + exchange=c["exchange"], + name=c["name"], + product=c["product"], + multiplier=c["multiplier"], + price_tick=c["price_tick"], + expire_date=c["expire_date"], + is_active=c["is_active"], + ) + db.add(contract) + count += 1 + + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() + + return count + + def get_contracts( + self, + exchange: Optional[str] = None, + product: Optional[str] = None, + is_active: Optional[bool] = None + ) -> List[ContractInfo]: + """查询合约列表""" + db = SessionLocal() + try: + query = db.query(ContractInfo) + if exchange: + query = query.filter(ContractInfo.exchange == exchange) + if product: + query = query.filter(ContractInfo.product == product) + if is_active is not None: + query = query.filter(ContractInfo.is_active == is_active) + + query = query.order_by(ContractInfo.symbol) + return query.all() + finally: + db.close() + + def get_contract(self, symbol: str) -> Optional[ContractInfo]: + """查询单个合约""" + db = SessionLocal() + try: + return db.query(ContractInfo).filter(ContractInfo.symbol == symbol).first() + finally: + db.close() + + +contract_service = ContractService() diff --git a/backend/app/services/datasource/__init__.py b/backend/app/services/datasource/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/datasource/akshare.py b/backend/app/services/datasource/akshare.py new file mode 100644 index 0000000..b754590 --- /dev/null +++ b/backend/app/services/datasource/akshare.py @@ -0,0 +1,318 @@ +import akshare as ak +import pandas as pd +import random +import time +import logging +import requests +from typing import List, Optional, Dict +from datetime import datetime +from app.services.datasource.base import DataSourceBase + +logger = logging.getLogger(__name__) + + +class SmartRequester: + """ + 反爬综合管理器:集成 Headers 伪装、拟人化延时、重试机制 + (IP 代理部分暂按文档要求空缺,后续扩展) + """ + + def __init__(self, max_retries: int = 3): + self.max_retries = max_retries + self.session = requests.Session() + + # User-Agent 池 + self.user_agents = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.42", + ] + + def get_random_headers(self, referer: str = "https://finance.sina.com.cn/") -> Dict[str, str]: + """生成随机请求头,模拟真实浏览器""" + return { + "User-Agent": random.choice(self.user_agents), + "Referer": referer, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + } + + def request(self, url, referer="https://finance.sina.com.cn/", method="GET", **kwargs) -> Optional[requests.Response]: + """执行智能请求""" + last_error = None + + for attempt in range(self.max_retries): + try: + # 1. 拟人化延时 + if attempt == 0: + time.sleep(random.uniform(0.5, 1.5)) + else: + delay = (2 ** attempt) + random.uniform(1, 3) + logging.warning(f"第 {attempt+1} 次重试,等待 {delay:.1f} 秒...") + time.sleep(delay) + + # 2. 轮换 Headers + headers = kwargs.pop("headers", {}) + random_headers = self.get_random_headers(referer=referer) + headers.update(random_headers) + kwargs["headers"] = headers + + # 3. 发送请求 + logging.debug(f"请求: {method} {url}") + response = self.session.request(method, url, timeout=10, **kwargs) + + # 4. 检查状态 + if response.status_code == 200: + logging.info("✅ 请求成功") + return response + elif response.status_code == 403: + logging.warning("⚠️ 收到 403 Forbidden,将尝试重试") + raise requests.exceptions.HTTPError("403 Forbidden") + else: + response.raise_for_status() + + except Exception as e: + last_error = e + logging.warning(f"❌ 请求失败: {str(e)}") + + logging.error(f"🚫 所有 {self.max_retries} 次尝试均失败") + return None + + +class AkshareSource(DataSourceBase): + """AKShare 数据源适配器""" + + def __init__(self, config: dict): + super().__init__(config) + self.name = "akshare" + self.requester = SmartRequester(max_retries=config.get("max_retries", 3)) + + def initialize(self) -> bool: + """初始化检查""" + try: + ak.__version__ + self._initialized = True + return True + except Exception as e: + self._initialized = False + logging.error(f"AkshareSource 初始化失败: {e}") + return False + + def get_contract_list(self, exchange: Optional[str] = None) -> List[dict]: + """获取期货合约列表""" + if not self._initialized: + self.initialize() + + results = [] + try: + exchanges_to_fetch = ["CZCE", "DCE", "SHFE", "INE", "CFFEX", "GFEX"] + if exchange: + exchanges_to_fetch = [exchange] + + for ex in exchanges_to_fetch: + try: + func_name = f"futures_contract_info_{ex.lower()}" + if hasattr(ak, func_name): + df = getattr(ak, func_name)() + else: + continue + + if df is not None and not df.empty: + # 统一列名映射 + col_map = {} + if '合约代码' in df.columns: + col_map['合约代码'] = 'symbol' + if '产品代码' in df.columns: + col_map['产品代码'] = 'product' + if '品种' in df.columns: + col_map['品种'] = 'product' + if '交易单位' in df.columns: + col_map['交易单位'] = 'multiplier' + if '最小变动价位' in df.columns: + col_map['最小变动价位'] = 'price_tick' + if '上市日' in df.columns: + col_map['上市日'] = 'list_date' + if '到期日' in df.columns: + col_map['到期日'] = 'expire_date' + + df = df.rename(columns=col_map) + + for _, row in df.iterrows(): + symbol = row.get("symbol") + if not symbol: + continue + + # 提取品种代码 (通常 symbol 最后两位是年月,前面是品种,如 rb2401 -> rb) + product = row.get("product", "") + if not product and len(symbol) > 2: + # 简单提取字母部分 + import re + match = re.match(r"([a-zA-Z]+)", symbol) + if match: + product = match.group(1).lower() + + multiplier = row.get("multiplier") + if multiplier: + try: + multiplier = int(float(str(multiplier).replace(',', ''))) + except: + multiplier = 10 + else: + multiplier = 10 # 默认值 + + price_tick = row.get("price_tick") + if price_tick: + try: + price_tick = float(str(price_tick).replace(',', '')) + except: + price_tick = None + + results.append({ + "symbol": symbol, + "exchange": ex, + "name": symbol, # AKShare 通常不返回中文名称,用代码代替 + "product": product, + "multiplier": multiplier, + "price_tick": price_tick, + "expire_date": self._parse_date(row.get("expire_date")), + "is_active": True, + }) + except Exception as e: + logging.warning(f"获取 {ex} 合约列表失败: {e}") + continue + + except Exception as e: + logging.error(f"获取合约列表异常: {e}") + + return results + + def _parse_date(self, date_str) -> Optional[datetime]: + """解析日期""" + if not date_str: + return None + try: + if isinstance(date_str, str): + return datetime.strptime(date_str, "%Y-%m-%d") + elif isinstance(date_str, pd.Timestamp): + return date_str.to_pydatetime() + return None + except: + return None + + def get_kline_daily(self, symbol: str, start_date: str, end_date: str) -> pd.DataFrame: + """获取日 K 线数据""" + logger.info(f"[AKShare-日K线] 开始获取 symbol={symbol}, start_date={start_date}, end_date={end_date}") + + if not self._initialized: + logger.info(f"[AKShare-日K线] 初始化 AKShare") + self.initialize() + + try: + # AKShare 期货日 K 线接口:futures_zh_daily_sina + logger.info(f"[AKShare-日K线] 调用 ak.futures_zh_daily_sina(symbol='{symbol}')") + df = ak.futures_zh_daily_sina(symbol=symbol) + + if df is None or df.empty: + logger.warning(f"[AKShare-日K线] AKShare 返回空数据,symbol={symbol}") + return pd.DataFrame() + + logger.info(f"[AKShare-日K线] AKShare 返回 {len(df)} 条原始数据") + logger.debug(f"[AKShare-日K线] 原始数据列: {df.columns.tolist()}") + logger.debug(f"[AKShare-日K线] 原始数据样例:\n{df.head()}") + + # 过滤日期范围 + df['date'] = pd.to_datetime(df['date']) + logger.debug(f"[AKShare-日K线] 日期范围: {df['date'].min()} ~ {df['date'].max()}") + + mask = (df['date'] >= start_date) & (df['date'] <= end_date) + df_filtered = df.loc[mask].copy() + logger.info(f"[AKShare-日K线] 日期过滤后剩余 {len(df_filtered)} 条记录") + + # 统一列名 + df_filtered = df_filtered.rename(columns={ + "date": "trade_date", + "open": "open", + "high": "high", + "low": "low", + "close": "close", + "volume": "volume", + "hold": "open_interest", + "settle": "settle", + }) + + # AKShare 日线通常没有 turnover 和 pre_settle,置空 + df_filtered["turnover"] = None + df_filtered["pre_settle"] = None + + df_filtered["trade_date"] = pd.to_datetime(df_filtered["trade_date"]) + logger.info(f"[AKShare-日K线] 最终返回 {len(df_filtered)} 条记录") + + return df_filtered[["trade_date", "open", "high", "low", "close", "volume", "turnover", "open_interest", "settle", "pre_settle"]] + + except Exception as e: + logger.error(f"[AKShare-日K线] 获取 {symbol} 日 K 线失败: {e}", exc_info=True) + return pd.DataFrame() + + def get_kline_weekly(self, symbol: str, start_date: str, end_date: str) -> pd.DataFrame: + """获取周 K 线数据 (通过日 K 聚合)""" + daily_df = self.get_kline_daily(symbol, start_date, end_date) + if daily_df.empty: + return pd.DataFrame() + + daily_df = daily_df.set_index("trade_date") + weekly = daily_df.resample("W-FRI").agg({ + "open": "first", + "high": "max", + "low": "min", + "close": "last", + "volume": "sum", + "turnover": "sum", + "open_interest": "last", + }).dropna() + + weekly = weekly.reset_index() + weekly = weekly.rename(columns={"trade_date": "trade_date"}) + return weekly + + def get_kline_intraday(self, symbol: str, period: str, start_date: str, end_date: str) -> pd.DataFrame: + """获取分钟级 K 线数据""" + if not self._initialized: + self.initialize() + + try: + # AKShare 期货分钟 K 线接口:futures_zh_minute_sina + period_map = {"5m": "5", "15m": "15", "30m": "30", "60m": "60"} + freq = period_map.get(period, "5") + + df = ak.futures_zh_minute_sina(symbol=symbol, period=freq) + + if df is None or df.empty: + return pd.DataFrame() + + # 过滤日期 + df['datetime'] = pd.to_datetime(df['datetime']) + mask = (df['datetime'] >= start_date) & (df['datetime'] <= end_date) + df = df.loc[mask].copy() + + # 统一列名 + df = df.rename(columns={ + "datetime": "trade_time", + "open": "open", + "high": "high", + "low": "low", + "close": "close", + "volume": "volume", + "hold": "open_interest", + }) + + df["turnover"] = None + + return df[["trade_time", "open", "high", "low", "close", "volume", "turnover", "open_interest"]] + + except Exception as e: + logging.error(f"获取 {symbol} 分钟 K 线失败: {e}") + return pd.DataFrame() diff --git a/backend/app/services/datasource/base.py b/backend/app/services/datasource/base.py new file mode 100644 index 0000000..b90ef26 --- /dev/null +++ b/backend/app/services/datasource/base.py @@ -0,0 +1,76 @@ +from abc import ABC, abstractmethod +from typing import List, Optional +from datetime import datetime +import pandas as pd + + +class DataSourceBase(ABC): + """数据源基类,所有数据源适配器必须实现这些接口""" + + def __init__(self, config: dict): + self.config = config + self.name = self.__class__.__name__ + self._initialized = False + + @abstractmethod + def initialize(self) -> bool: + """初始化数据源连接,返回是否成功""" + pass + + @abstractmethod + def get_contract_list(self, exchange: Optional[str] = None) -> List[dict]: + """ + 获取合约列表 + 返回: [{"symbol": "rb2401", "exchange": "SHFE", "name": "螺纹钢2401", ...}] + """ + pass + + @abstractmethod + def get_kline_daily( + self, + symbol: str, + start_date: str, + end_date: str + ) -> pd.DataFrame: + """ + 获取日K线数据 + 返回 DataFrame 包含: trade_date, open, high, low, close, volume, turnover, open_interest, settle, pre_settle + """ + pass + + @abstractmethod + def get_kline_weekly( + self, + symbol: str, + start_date: str, + end_date: str + ) -> pd.DataFrame: + """ + 获取周K线数据 + 返回 DataFrame 包含: trade_date, open, high, low, close, volume, turnover, open_interest + """ + pass + + @abstractmethod + def get_kline_intraday( + self, + symbol: str, + period: str, # 5m/15m/30m/60m + start_date: str, + end_date: str + ) -> pd.DataFrame: + """ + 获取分钟级K线数据 + 返回 DataFrame 包含: trade_time, open, high, low, close, volume, turnover, open_interest + """ + pass + + def health_check(self) -> tuple[bool, str]: + """健康检查,返回 (是否健康, 错误信息)""" + try: + ok = self.initialize() + if ok: + return True, "" + return False, "初始化失败" + except Exception as e: + return False, str(e) diff --git a/backend/app/services/datasource/manager.py b/backend/app/services/datasource/manager.py new file mode 100644 index 0000000..893f4fd --- /dev/null +++ b/backend/app/services/datasource/manager.py @@ -0,0 +1,97 @@ +from typing import Dict, Optional, List +import json +from app.services.datasource.base import DataSourceBase +from app.services.datasource.tushare import TushareSource +from app.services.datasource.akshare import AkshareSource +from app.database import SessionLocal +from app.models import DataSourceConfig + + +class DataSourceManager: + """数据源管理器:管理多个数据源的注册、切换和调用""" + + _sources: Dict[str, DataSourceBase] = {} + _source_map = { + "tushare": TushareSource, + "akshare": AkshareSource, + # "ctp": CtpSource, # 后续扩展 + } + + @classmethod + def register(cls, name: str, source_class): + """注册新的数据源类型""" + cls._source_map[name] = source_class + + @classmethod + def get_source(cls, name: str) -> Optional[DataSourceBase]: + """获取已初始化的数据源实例""" + return cls._sources.get(name) + + @classmethod + def load_enabled_sources(cls): + """从数据库加载启用的数据源""" + db = SessionLocal() + try: + configs = db.query(DataSourceConfig).filter( + DataSourceConfig.is_enabled == True + ).order_by(DataSourceConfig.priority).all() + + for cfg in configs: + if cfg.source_name in cls._source_map: + source_class = cls._source_map[cfg.source_name] + try: + config = json.loads(cfg.config_json) if cfg.config_json else {} + except json.JSONDecodeError: + config = {} + source = source_class(config) + cls._sources[cfg.source_name] = source + finally: + db.close() + + @classmethod + def get_primary_source(cls) -> Optional[DataSourceBase]: + """获取优先级最高的已启用数据源""" + if not cls._sources: + cls.load_enabled_sources() + + # 按优先级排序 + db = SessionLocal() + try: + primary_cfg = db.query(DataSourceConfig).filter( + DataSourceConfig.is_enabled == True + ).order_by(DataSourceConfig.priority).first() + + if primary_cfg and primary_cfg.source_name in cls._sources: + return cls._sources[primary_cfg.source_name] + return None + finally: + db.close() + + @classmethod + def get_all_sources_status(cls) -> List[dict]: + """获取所有数据源状态""" + db = SessionLocal() + try: + configs = db.query(DataSourceConfig).all() + result = [] + for cfg in configs: + # 解析 config_json + try: + config_json = json.loads(cfg.config_json) if cfg.config_json else {} + except json.JSONDecodeError: + config_json = {} + + status = { + "source_name": cfg.source_name, + "display_name": cfg.display_name, + "is_enabled": cfg.is_enabled, + "priority": cfg.priority, + "status": cfg.status, + "error_msg": cfg.error_msg, + "last_sync_time": cfg.last_sync_time, + "config_json": config_json, + } + result.append(status) + return result + finally: + db.close() diff --git a/backend/app/services/datasource/tushare.py b/backend/app/services/datasource/tushare.py new file mode 100644 index 0000000..364cd2c --- /dev/null +++ b/backend/app/services/datasource/tushare.py @@ -0,0 +1,201 @@ +import tushare as ts +import pandas as pd +from typing import List, Optional +from datetime import datetime +from app.services.datasource.base import DataSourceBase +from app.config import settings + + +class TushareSource(DataSourceBase): + """Tushare 数据源适配器""" + + def __init__(self, config: dict): + super().__init__(config) + self.name = "tushare" + self.pro = None + self._token = config.get("token", settings.TUSHARE_TOKEN) + + def initialize(self) -> bool: + """初始化 Tushare 连接""" + try: + ts.set_token(self._token) + self.pro = ts.pro_api() + # 简单测试连接 + self.pro.trade_cal(exchange="DCE", start_date="20240101", end_date="20240105") + self._initialized = True + return True + except Exception as e: + self._initialized = False + raise e + + def _format_date(self, date_str: str) -> str: + """将 YYYY-MM-DD 转换为 YYYYMMDD""" + return date_str.replace("-", "") + + def get_contract_list(self, exchange: Optional[str] = None) -> List[dict]: + """获取期货合约列表""" + if not self._initialized: + self.initialize() + + # Tushare 获取合约信息 + df = self.pro.fut_basic( + exchange=exchange or "CFFEX", # 需要分别查询每个交易所 + fut_type="1", # 1=标准合约 + fut_series="" + ) + + results = [] + if df is not None and not df.empty: + for _, row in df.iterrows(): + results.append({ + "symbol": row.get("symbol", ""), + "exchange": self._map_exchange(row.get("exchange", "")), + "name": row.get("name", ""), + "product": row.get("underlying_symbol", ""), + "multiplier": int(row.get("contract_multiplier", 10)) if row.get("contract_multiplier") else 10, + "price_tick": float(row.get("price_tick", 0)) if row.get("price_tick") else None, + "expire_date": self._parse_date(row.get("delist_date", "")), + "is_active": row.get("list_status", "") == "L", + }) + + return results + + def _map_exchange(self, exchange: str) -> str: + """交易所代码映射""" + mapping = { + "CFFEX": "CFFEX", + "SHFE": "SHFE", + "DCE": "DCE", + "CZCE": "ZCE", # Tushare 用 CZCE,我们统一用 ZCE + "INE": "INE", + "GFEX": "GFEX", + } + return mapping.get(exchange, exchange) + + def _parse_date(self, date_str: str) -> Optional[datetime]: + """解析日期字符串""" + if not date_str: + return None + try: + return datetime.strptime(str(date_str), "%Y%m%d") + except Exception: + return None + + def get_kline_daily( + self, + symbol: str, + start_date: str, + end_date: str + ) -> pd.DataFrame: + """获取日K线数据""" + if not self._initialized: + self.initialize() + + start = self._format_date(start_date) + end = self._format_date(end_date) + + df = self.pro.fut_daily( + ts_code=symbol, + start_date=start, + end_date=end + ) + + if df is None or df.empty: + return pd.DataFrame() + + # 统一列名 + df = df.rename(columns={ + "trade_date": "trade_date", + "open": "open", + "high": "high", + "low": "low", + "close": "close", + "vol": "volume", + "amount": "turnover", + "oi": "open_interest", + "settle": "settle", + "pre_settle": "pre_settle", + }) + + df["trade_date"] = pd.to_datetime(df["trade_date"]) + return df[["trade_date", "open", "high", "low", "close", "volume", "turnover", "open_interest", "settle", "pre_settle"]] + + def get_kline_weekly( + self, + symbol: str, + start_date: str, + end_date: str + ) -> pd.DataFrame: + """ + 获取周K线数据 + Tushare 没有直接的周K接口,通过日K聚合 + """ + daily_df = self.get_kline_daily(symbol, start_date, end_date) + if daily_df.empty: + return pd.DataFrame() + + # 按周聚合 + daily_df = daily_df.set_index("trade_date") + weekly = daily_df.resample("W-FRI").agg({ + "open": "first", + "high": "max", + "low": "min", + "close": "last", + "volume": "sum", + "turnover": "sum", + "open_interest": "last", + }).dropna() + + weekly = weekly.reset_index() + weekly = weekly.rename(columns={"trade_date": "trade_date"}) + return weekly + + def get_kline_intraday( + self, + symbol: str, + period: str, + start_date: str, + end_date: str + ) -> pd.DataFrame: + """ + 获取分钟级K线数据 + Tushare 的 fut_mins 接口 + """ + if not self._initialized: + self.initialize() + + # Tushare fut_mins 接口 + start = self._format_date(start_date) + end = self._format_date(end_date) + + # 分钟数映射 + freq_map = {"5m": "5", "15m": "15", "30m": "30", "60m": "60"} + freq = freq_map.get(period, "5") + + try: + df = self.pro.fut_mins( + ts_code=symbol, + freq=freq, + start_date=start, + end_date=end + ) + except Exception: + # 部分交易所可能不支持分钟数据 + return pd.DataFrame() + + if df is None or df.empty: + return pd.DataFrame() + + df = df.rename(columns={ + "trade_time": "trade_time", + "open": "open", + "high": "high", + "low": "low", + "close": "close", + "vol": "volume", + "amount": "turnover", + "oi": "open_interest", + }) + + df["trade_time"] = pd.to_datetime(df["trade_time"]) + return df[["trade_time", "open", "high", "low", "close", "volume", "turnover", "open_interest"]] diff --git a/backend/app/services/kline_service.py b/backend/app/services/kline_service.py new file mode 100644 index 0000000..8b1c585 --- /dev/null +++ b/backend/app/services/kline_service.py @@ -0,0 +1,346 @@ +from typing import List, Optional +from datetime import datetime, timedelta +from sqlalchemy.orm import Session +from sqlalchemy import and_ +import pandas as pd +import logging + +from app.models import KlineDaily, KlineWeekly, KlineIntraday, ContractInfo +from app.services.datasource.manager import DataSourceManager +from app.database import SessionLocal + +logger = logging.getLogger(__name__) + + +class KlineService: + """K线数据服务:负责从数据源拉取数据、存储到数据库、查询缓存""" + + def __init__(self): + self.manager = DataSourceManager() + + def _ensure_source(self): + """确保数据源已加载""" + source = self.manager.get_primary_source() + if not source: + raise Exception("没有可用的数据源,请先在管理后台配置并启用数据源") + return source + + # ========== 同步数据 ========== + + def sync_daily( + self, + symbol: str, + start_date: str, + end_date: str + ) -> int: + """同步日K线数据到数据库""" + logger.info(f"[同步日K线] 开始同步 symbol={symbol}, start_date={start_date}, end_date={end_date}") + source = self._ensure_source() + logger.info(f"[同步日K线] 使用数据源: {source.name}") + + df = source.get_kline_daily(symbol, start_date, end_date) + logger.info(f"[同步日K线] 从数据源获取到 {len(df)} 条记录") + + if df.empty: + logger.warning(f"[同步日K线] 数据源返回空数据,symbol={symbol}") + return 0 + + db = SessionLocal() + count = 0 + try: + for _, row in df.iterrows(): + kline = db.query(KlineDaily).filter( + and_( + KlineDaily.symbol == symbol, + KlineDaily.trade_date == row["trade_date"] + ) + ).first() + + if kline: + kline.open = row.get("open") + kline.high = row.get("high") + kline.low = row.get("low") + kline.close = row.get("close") + kline.volume = row.get("volume") + kline.turnover = row.get("turnover") + kline.open_interest = row.get("open_interest") + kline.settle = row.get("settle") + kline.pre_settle = row.get("pre_settle") + kline.updated_at = datetime.utcnow() + else: + kline = KlineDaily( + symbol=symbol, + trade_date=row["trade_date"], + open=row.get("open"), + high=row.get("high"), + low=row.get("low"), + close=row.get("close"), + volume=row.get("volume"), + turnover=row.get("turnover"), + open_interest=row.get("open_interest"), + settle=row.get("settle"), + pre_settle=row.get("pre_settle"), + ) + db.add(kline) + count += 1 + + db.commit() + logger.info(f"[同步日K线] 成功同步 {count} 条记录到数据库") + except Exception as e: + db.rollback() + logger.error(f"[同步日K线] 同步失败: {e}", exc_info=True) + raise + finally: + db.close() + + return count + + def sync_weekly( + self, + symbol: str, + start_date: str, + end_date: str + ) -> int: + """同步周K线数据""" + source = self._ensure_source() + df = source.get_kline_weekly(symbol, start_date, end_date) + if df.empty: + return 0 + + db = SessionLocal() + count = 0 + try: + for _, row in df.iterrows(): + kline = db.query(KlineWeekly).filter( + and_( + KlineWeekly.symbol == symbol, + KlineWeekly.trade_date == row["trade_date"] + ) + ).first() + + if kline: + kline.open = row.get("open") + kline.high = row.get("high") + kline.low = row.get("low") + kline.close = row.get("close") + kline.volume = row.get("volume") + kline.turnover = row.get("turnover") + kline.open_interest = row.get("open_interest") + kline.updated_at = datetime.utcnow() + else: + kline = KlineWeekly( + symbol=symbol, + trade_date=row["trade_date"], + open=row.get("open"), + high=row.get("high"), + low=row.get("low"), + close=row.get("close"), + volume=row.get("volume"), + turnover=row.get("turnover"), + open_interest=row.get("open_interest"), + ) + db.add(kline) + count += 1 + + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() + + return count + + def sync_intraday( + self, + symbol: str, + period: str, + start_date: str, + end_date: str + ) -> int: + """同步分钟级K线数据""" + source = self._ensure_source() + df = source.get_kline_intraday(symbol, period, start_date, end_date) + if df.empty: + return 0 + + db = SessionLocal() + count = 0 + try: + for _, row in df.iterrows(): + kline = db.query(KlineIntraday).filter( + and_( + KlineIntraday.symbol == symbol, + KlineIntraday.period == period, + KlineIntraday.trade_time == row["trade_time"] + ) + ).first() + + if kline: + kline.open = row.get("open") + kline.high = row.get("high") + kline.low = row.get("low") + kline.close = row.get("close") + kline.volume = row.get("volume") + kline.turnover = row.get("turnover") + kline.open_interest = row.get("open_interest") + kline.updated_at = datetime.utcnow() + else: + kline = KlineIntraday( + symbol=symbol, + period=period, + trade_time=row["trade_time"], + open=row.get("open"), + high=row.get("high"), + low=row.get("low"), + close=row.get("close"), + volume=row.get("volume"), + turnover=row.get("turnover"), + open_interest=row.get("open_interest"), + ) + db.add(kline) + count += 1 + + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() + + return count + + # ========== 查询数据 ========== + + def get_kline( + self, + symbol: str, + period: str, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + limit: int = 500 + ) -> List[dict]: + """查询K线数据(优先查库,如果数据库没有数据则自动同步)""" + logger.info(f"[查询K线] 开始查询 symbol={symbol}, period={period}, start_date={start_date}, end_date={end_date}, limit={limit}") + db = SessionLocal() + try: + if period == "daily": + items = self._query_daily(db, symbol, start_date, end_date, limit) + elif period == "weekly": + items = self._query_weekly(db, symbol, start_date, end_date, limit) + else: + items = self._query_intraday(db, symbol, period, start_date, end_date, limit) + + # 如果数据库中没有数据,自动同步 + if len(items) == 0: + logger.info(f"[查询K线] 数据库中没有 {symbol} 的 {period} K线数据,开始自动同步") + try: + sync_start = start_date or "2020-01-01" + sync_end = end_date or datetime.now().strftime("%Y-%m-%d") + logger.info(f"[查询K线] 自动同步日期范围: {sync_start} ~ {sync_end}") + + if period == "daily": + count = self.sync_daily(symbol, sync_start, sync_end) + elif period == "weekly": + count = self.sync_weekly(symbol, sync_start, sync_end) + else: + count = self.sync_intraday(symbol, period, sync_start, sync_end) + + if count > 0: + logger.info(f"[查询K线] 自动同步成功,共同步 {count} 条记录,重新查询数据库") + # 重新查询数据库获取同步后的数据 + if period == "daily": + items = self._query_daily(db, symbol, start_date, end_date, limit) + elif period == "weekly": + items = self._query_weekly(db, symbol, start_date, end_date, limit) + else: + items = self._query_intraday(db, symbol, period, start_date, end_date, limit) + else: + logger.warning(f"[查询K线] 自动同步完成,但数据源返回空数据") + except Exception as e: + logger.error(f"[查询K线] 自动同步失败: {e}", exc_info=True) + # 同步失败不影响查询,继续返回空结果 + + return items + finally: + db.close() + + def _query_daily(self, db: Session, symbol: str, start_date: str, end_date: str, limit: int) -> List[dict]: + logger.info(f"[查询日K线] symbol={symbol}, start_date={start_date}, end_date={end_date}, limit={limit}") + query = db.query(KlineDaily).filter(KlineDaily.symbol == symbol) + if start_date: + query = query.filter(KlineDaily.trade_date >= start_date) + if end_date: + query = query.filter(KlineDaily.trade_date <= end_date) + query = query.order_by(KlineDaily.trade_date.desc()).limit(limit) + + items = query.all() + logger.info(f"[查询日K线] 从数据库查询到 {len(items)} 条记录") + + return [ + { + "trade_time": item.trade_date, + "open": item.open, + "high": item.high, + "low": item.low, + "close": item.close, + "volume": item.volume, + "turnover": item.turnover, + "open_interest": item.open_interest, + } + for item in items + ] + + def _query_weekly(self, db: Session, symbol: str, start_date: str, end_date: str, limit: int) -> List[dict]: + query = db.query(KlineWeekly).filter(KlineWeekly.symbol == symbol) + if start_date: + query = query.filter(KlineWeekly.trade_date >= start_date) + if end_date: + query = query.filter(KlineWeekly.trade_date <= end_date) + query = query.order_by(KlineWeekly.trade_date.desc()).limit(limit) + + items = query.all() + return [ + { + "trade_time": item.trade_date, + "open": item.open, + "high": item.high, + "low": item.low, + "close": item.close, + "volume": item.volume, + "turnover": item.turnover, + "open_interest": item.open_interest, + } + for item in items + ] + + def _query_intraday(self, db: Session, symbol: str, period: str, start_date: str, end_date: str, limit: int) -> List[dict]: + query = db.query(KlineIntraday).filter( + and_( + KlineIntraday.symbol == symbol, + KlineIntraday.period == period + ) + ) + if start_date: + query = query.filter(KlineIntraday.trade_time >= start_date) + if end_date: + query = query.filter(KlineIntraday.trade_time <= end_date) + query = query.order_by(KlineIntraday.trade_time.desc()).limit(limit) + + items = query.all() + return [ + { + "trade_time": item.trade_time, + "open": item.open, + "high": item.high, + "low": item.low, + "close": item.close, + "volume": item.volume, + "turnover": item.turnover, + "open_interest": item.open_interest, + } + for item in items + ] + + +kline_service = KlineService() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..5260482 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy==2.0.36 +psycopg2-binary==2.9.10 +alembic==1.14.0 +pydantic==2.10.3 +pydantic-settings==2.7.0 +tushare==1.4.21 +akshare +redis==5.2.1 +httpx==0.28.1 +python-dotenv==1.0.1 +pandas==2.2.3 +apscheduler==3.10.4 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..259987d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +version: '3.8' + +services: + postgres: + image: timescale/timescaledb:latest-pg16 + environment: + POSTGRES_DB: futures_data + POSTGRES_USER: futures + POSTGRES_PASSWORD: futures123 + ports: + - "5432:5432" + volumes: + - pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U futures -d futures_data"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: ../Dockerfile.backend + ports: + - "8000:8000" + environment: + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: futures_data + DB_USER: futures + DB_PASSWORD: futures123 + REDIS_HOST: redis + REDIS_PORT: 6379 + TUSHARE_TOKEN: ${TUSHARE_TOKEN:-} + volumes: + - ./backend/app:/app/app + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + frontend: + build: + context: ./frontend + dockerfile: ../Dockerfile.frontend + ports: + - "3000:3000" + volumes: + - ./frontend:/app + - /app/node_modules + depends_on: + - backend + +volumes: + pg_data: diff --git a/docs/PROJECT_GUIDE.md b/docs/PROJECT_GUIDE.md new file mode 100644 index 0000000..7b2b8c9 --- /dev/null +++ b/docs/PROJECT_GUIDE.md @@ -0,0 +1,202 @@ +# 期货统一数据平台 - 项目文档 + +> **版本**: 0.1.0 +> **状态**: 开发中 (Dev) +> **维护者**: 小马哥 + +--- + +## 1. 项目概述 + +本项目是一个**期货统一数据平台**,旨在解决多数据源接入、数据格式不统一、历史行情查询困难等问题。平台提供标准化的 RESTful API 供外部系统或 AI 调用,并配备管理后台进行数据源配置、合约管理及行情可视化。 + +### 核心目标 +- **统一出口**:无论底层是 Tushare、CTP 还是其他数据源,对外提供统一的数据格式。 +- **灵活配置**:支持多数据源切换、优先级管理。 +- **高效缓存**:针对历史 K 线数据(日 K/周 K/分钟级)进行分级存储。 +- **易于扩展**:基于适配器模式,新增数据源只需实现标准接口。 + +--- + +## 2. 技术架构 + +### 2.1 技术栈 + +| 层次 | 技术选型 | 说明 | +|------|----------|------| +| **前端** | Vue3 + Vite + Element Plus | 现代化 SPA,响应式布局 | +| **图表** | Apache ECharts 5 | 专业金融 K 线图表渲染 | +| **后端** | Python 3.11 + FastAPI | 高性能异步 Web 框架 | +| **数据库** | PostgreSQL + TimescaleDB | 专为时序数据优化,支持高效压缩与查询 | +| **本地开发** | SQLite | 无需外部依赖,快速验证 | +| **缓存/消息** | Redis | 实时行情缓存与发布订阅 | +| **数据源** | Tushare / AKShare / CTP (规划) | 外部数据接入 | +| **部署** | Docker Compose | 一键环境搭建 | + +### 2.2 数据源架构 + +```text +[外部系统/AI] <--> [统一 API 网关] <--> [业务逻辑层] <--> [数据源适配器] + | + [缓存层 (Redis)] + | + [持久层 (PostgreSQL)] +``` + +- **适配器模式**:`DataSourceBase` 定义了 `get_kline_daily`、`get_contract_list` 等标准方法。 +- **管理器模式**:`DataSourceManager` 负责加载配置、优先级排序和实例管理。 + +--- + +## 3. 部署与启动指南 + +### 3.1 环境准备 +- Docker & Docker Compose(生产/测试环境) +- Python 3.11+(本地开发环境) +- Node.js 20+(前端开发) + +### 3.2 Docker Compose 部署(推荐) + +适用于拥有完整基础设施(PostgreSQL, Redis)的环境。 + +```bash +cd share_data/project/futures-data-platform + +# 1. 配置环境变量 +cp .env.example .env +# 编辑 .env,填入你的 Tushare Token: TUSHARE_TOKEN=xxxxxx + +# 2. 启动服务 +docker compose up -d --build + +# 3. 查看日志 +docker compose logs -f backend +``` + +**服务地址:** +- 管理后台:http://localhost:3000 +- 后端 API:http://localhost:8000 +- Swagger 文档:http://localhost:8000/docs + +### 3.3 本地开发模式(SQLite) + +适用于快速调试,无需启动数据库容器。 + +```bash +cd backend + +# 1. 安装依赖 +pip install -r requirements.txt + +# 2. 使用 SQLite 启动后端 +export DATABASE_URL="sqlite:///./futures.db" +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +# 3. 启动前端 (新终端) +cd frontend +npm install +npm run dev +``` + +--- + +## 4. 操作指南 + +### 4.1 配置数据源 +1. 进入**"数据源监控"**页面。 +2. 点击 `tushare` 或 `akshare` 行的**"配置"**按钮。 +3. 在弹出的配置对话框中: + - **启用状态**:使用开关快速启用/禁用该数据源 + - **显示名称**:自定义数据源在界面上的显示名称 + - **Token** (Tushare):填入你的 Tushare API Token + - **最大重试次数** (AKShare):设置反爬重试策略 + - **优先级**:数字越小优先级越高,系统将优先使用高优先级数据源 +4. 点击**"测试连接"**,确认状态为 `ok`。 +5. 点击**"保存"**完成配置。 + +> **提示**:也可在表格的"启用"列直接点击开关快速切换启用/禁用状态,无需打开配置对话框。 + +### 4.2 同步合约信息 +1. 进入**"合约管理"**页面。 +2. 点击右上角**"同步合约"**按钮。 +3. 系统将遍历所有交易所(CFFEX, SHFE, DCE 等)拉取合约信息并入库。 +4. 同步完成后,列表将显示所有活跃合约。 + +### 4.3 查询与展示 K 线 +1. 进入**"K 线查询"**页面。 +2. 选择合约(如 `rb2401`)和周期(如 `日 K`)。 +3. 点击**"同步数据"**(首次查询建议同步,后续直接查询数据库)。 +4. 点击**"查询"**,下方将渲染 ECharts K 线图和详细数据表格。 + +--- + +## 5. 注意事项 + +### 5.1 Tushare 积分限制 +- 不同积分等级对数据调用的频率和数据范围有限制。 +- 高频调用分钟级数据或全量历史数据可能需要较高级别的积分(如 2000 分以上)。 +- **建议**:在"接口测试"中先测试单个合约的同步,确认返回数据正常后再批量同步。 + +### 5.2 AKShare 反爬策略 +AKShare 数据源内置了 `SmartRequester` 反爬管理器,包含以下策略: +- **随机 User-Agent**:每次请求轮换浏览器指纹。 +- **拟人化延时**:首次请求随机延迟 0.5~1.5s,重试时指数级增加。 +- **智能重试**:遇到 403 或网络错误自动重试(默认 3 次)。 +- **Referer 伪装**:自动匹配目标网站的 Referer。 +> 注:IP 代理功能预留接口,当前版本主要依赖频率控制和头部伪装。 + +### 5.3 数据一致性 +- 平台默认**"优先读库"**策略。如果数据库中已有缓存数据,直接返回;若无,则调用数据源。 +- `sync_kline` 接口采用 `upsert` 逻辑(插入或更新),支持增量同步,不会破坏已有数据。 + +### 5.4 生产环境配置 +- **数据库**:务必使用 Docker Compose 中的 PostgreSQL + TimescaleDB,SQLite 仅用于本地开发。 +- **安全性**:生产环境应移除 `cors.allow_origins=["*"]` 的宽泛策略,改为白名单;数据库密码应通过 Secret 管理。 + +--- + +## 6. 后续规划与改进 + +### Phase 1: 基础完善 (当前阶段) +- [x] 历史 K 线数据同步与查询(日 K/周 K/分钟) +- [x] 合约信息管理 +- [x] Tushare 数据源接入 +- [x] AKShare 数据源接入 (含反爬策略) +- [x] 管理后台基础功能 + +### Phase 2: 实时行情与推送 +- [ ] **实时行情引擎**:开发 CTP 适配器,接入实时 Tick 和 K 线。 +- [ ] **WebSocket 订阅**:实现实时数据推送服务,支持客户端按合约订阅。 +- [ ] **行情快照缓存**:利用 Redis 实现最新行情快照的快速读取。 + +### Phase 3: 性能与高可用 +- [ ] **TimescaleDB 优化**:配置超表(Hypertables)和自动压缩策略。 +- [ ] **定时任务调度**:使用 APScheduler 实现每日收盘后的自动数据同步。 +- [ ] **多数据源回退**:主数据源异常时,自动切换到备用数据源。 + +### Phase 4: 高级功能 +- [ ] **数据清洗与校验**:增加数据质量监控,自动识别异常跳空或缺失数据。 +- [ ] **AI 辅助接口**:提供专为大模型优化的自然语言转 SQL 接口。 + +--- + +## 7. 开发规范 + +- **代码风格**:后端遵循 PEP 8,使用 `black` 格式化;前端遵循 ESLint + Prettier。 +- **提交规范**: + - `feat`: 新功能 + - `fix`: 修复 + - `docs`: 文档更新 + - `refactor`: 重构 +- **API 响应格式**: + ```json + { + "code": 0, + "message": "ok", + "data": { ... } + } + ``` + +--- + +> 文档最后更新:2026-05-07 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..70c0b5b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + 期货统一数据平台 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..8a5303e --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1848 @@ +{ + "name": "futures-data-platform-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "futures-data-platform-frontend", + "version": "0.1.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.7.9", + "dayjs": "^1.11.13", + "echarts": "^5.5.1", + "element-plus": "^2.9.1", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.4", + "vite": "^6.4.2" + } + }, + "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.20", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "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/@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/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/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": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "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/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/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/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/element-plus": { + "version": "2.13.7", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.7.tgz", + "integrity": "sha512-XdHATFZOyzVFL1DaHQ90IOJQSg9UnSAV+bhDW+YB5UoZ0Hxs50mwqjqfwXkuwpSag+VXXizVcErBR6Movo5daw==", + "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.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "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/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/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/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/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/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "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/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-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/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..289d731 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "futures-data-platform-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.7.9", + "dayjs": "^1.11.13", + "echarts": "^5.5.1", + "element-plus": "^2.9.1", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.4", + "vite": "^6.4.2" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..8911ea7 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..ec71904 --- /dev/null +++ b/frontend/src/api/index.js @@ -0,0 +1,26 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: '/api/v1', + timeout: 30000, +}) + +// 健康检查 +export const getHealth = () => axios.get('/api/health') + +// 合约 +export const getContracts = (params) => api.get('/contracts', { params }) +export const getContract = (symbol) => api.get(`/contracts/${symbol}`) +export const syncContracts = () => api.post('/contracts/sync') + +// K线 +export const getKline = (params) => api.get('/kline', { params }) +export const syncKline = (data) => api.post('/kline/sync', data) + +// 数据源 +export const getDatasources = () => api.get('/datasources') +export const createDatasource = (data) => api.post('/datasources', data) +export const updateDatasource = (name, data) => api.put(`/datasources/${name}`, data) +export const testDatasource = (name) => api.post(`/datasources/${name}/test`) + +export default api diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..18548be --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,29 @@ +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' +import App from './App.vue' + +import DataSourceView from './views/DataSourceView.vue' +import ContractView from './views/ContractView.vue' +import KlineView from './views/KlineView.vue' +import ApiTestView from './views/ApiTestView.vue' + +const routes = [ + { path: '/', redirect: '/datasource' }, + { path: '/datasource', name: 'datasource', component: DataSourceView }, + { path: '/contracts', name: 'contracts', component: ContractView }, + { path: '/kline', name: 'kline', component: KlineView }, + { path: '/api-test', name: 'api-test', component: ApiTestView }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +const app = createApp(App) +app.use(ElementPlus, { locale: zhCn }) +app.use(router) +app.mount('#app') diff --git a/frontend/src/views/ApiTestView.vue b/frontend/src/views/ApiTestView.vue new file mode 100644 index 0000000..5b4a348 --- /dev/null +++ b/frontend/src/views/ApiTestView.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/frontend/src/views/ContractView.vue b/frontend/src/views/ContractView.vue new file mode 100644 index 0000000..8e47a71 --- /dev/null +++ b/frontend/src/views/ContractView.vue @@ -0,0 +1,103 @@ + + + diff --git a/frontend/src/views/DataSourceView.vue b/frontend/src/views/DataSourceView.vue new file mode 100644 index 0000000..f69b9f0 --- /dev/null +++ b/frontend/src/views/DataSourceView.vue @@ -0,0 +1,173 @@ + + + diff --git a/frontend/src/views/KlineView.vue b/frontend/src/views/KlineView.vue new file mode 100644 index 0000000..683a552 --- /dev/null +++ b/frontend/src/views/KlineView.vue @@ -0,0 +1,238 @@ + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..1660d5b --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + host: '0.0.0.0', + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, +}) diff --git a/screenshots/01-datasource.png b/screenshots/01-datasource.png new file mode 100644 index 0000000..a0d5ea6 Binary files /dev/null and b/screenshots/01-datasource.png differ diff --git a/screenshots/02-contracts.png b/screenshots/02-contracts.png new file mode 100644 index 0000000..05ddddf Binary files /dev/null and b/screenshots/02-contracts.png differ diff --git a/screenshots/03-kline.png b/screenshots/03-kline.png new file mode 100644 index 0000000..61a87f1 Binary files /dev/null and b/screenshots/03-kline.png differ diff --git a/screenshots/04-api-test.png b/screenshots/04-api-test.png new file mode 100644 index 0000000..5625635 Binary files /dev/null and b/screenshots/04-api-test.png differ diff --git a/screenshots/05-datasource-config.png b/screenshots/05-datasource-config.png new file mode 100644 index 0000000..59a02c6 Binary files /dev/null and b/screenshots/05-datasource-config.png differ diff --git a/screenshots/06-akshare-config.png b/screenshots/06-akshare-config.png new file mode 100644 index 0000000..59a02c6 Binary files /dev/null and b/screenshots/06-akshare-config.png differ diff --git a/screenshots/07-akshare-config-refreshed.png b/screenshots/07-akshare-config-refreshed.png new file mode 100644 index 0000000..59a02c6 Binary files /dev/null and b/screenshots/07-akshare-config-refreshed.png differ diff --git a/screenshots/08-akshare-config-final.png b/screenshots/08-akshare-config-final.png new file mode 100644 index 0000000..59a02c6 Binary files /dev/null and b/screenshots/08-akshare-config-final.png differ diff --git a/screenshots/09-config-refresh.png b/screenshots/09-config-refresh.png new file mode 100644 index 0000000..59a02c6 Binary files /dev/null and b/screenshots/09-config-refresh.png differ diff --git a/screenshots/10-config-port3001.png b/screenshots/10-config-port3001.png new file mode 100644 index 0000000..24632ec Binary files /dev/null and b/screenshots/10-config-port3001.png differ diff --git a/screenshots/11-config-dialog-open.png b/screenshots/11-config-dialog-open.png new file mode 100644 index 0000000..c6649a5 Binary files /dev/null and b/screenshots/11-config-dialog-open.png differ diff --git a/screenshots/12-switch-toggled.png b/screenshots/12-switch-toggled.png new file mode 100644 index 0000000..c6649a5 Binary files /dev/null and b/screenshots/12-switch-toggled.png differ diff --git a/screenshots/13-config-dialog.png b/screenshots/13-config-dialog.png new file mode 100644 index 0000000..a484ed7 Binary files /dev/null and b/screenshots/13-config-dialog.png differ