From 65dd83bb372d4d6d9dbf64490e177b7cd3bb756e Mon Sep 17 00:00:00 2001 From: Lxy Date: Fri, 10 Apr 2026 23:06:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E5=A7=8B=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 37 + .gitignore | 42 + Dockerfile.backend | 17 + Dockerfile.frontend | 20 + README.md | Bin 0 -> 7042 bytes backend/api/__init__.py | 11 + backend/api/auth.py | 102 ++ backend/api/batch.py | 163 ++ backend/api/cache.py | 170 +++ backend/api/historical.py | 113 ++ backend/api/realtime.py | 227 +++ backend/api/settings.py | 131 ++ backend/auth/dependencies.py | 62 + backend/auth/jwt_handler.py | 44 + backend/config.py | 65 + backend/main.py | 95 ++ backend/models/__init__.py | 23 + backend/models/database.py | 38 + backend/models/schemas.py | 210 +++ backend/models/tables.py | 106 ++ backend/requirements.txt | 15 + backend/services/__init__.py | 4 + backend/services/config_service.py | 65 + backend/services/data_service.py | 323 ++++ database/init.sql | 144 ++ docker-compose.yml | 67 + frontend/index.html | 13 + frontend/package-lock.json | 2207 ++++++++++++++++++++++++++++ frontend/package.json | 26 + frontend/src/App.vue | 26 + frontend/src/api/index.js | 50 + frontend/src/main.js | 22 + frontend/src/router/index.js | 81 + frontend/src/stores/auth.js | 43 + frontend/src/views/ApiTest.vue | 807 ++++++++++ frontend/src/views/Batch.vue | 164 +++ frontend/src/views/Cache.vue | 229 +++ frontend/src/views/Dashboard.vue | 187 +++ frontend/src/views/Historical.vue | 181 +++ frontend/src/views/Layout.vue | 128 ++ frontend/src/views/Login.vue | 108 ++ frontend/src/views/Realtime.vue | 157 ++ frontend/src/views/Settings.vue | 121 ++ frontend/vite.config.js | 25 + nginx.conf | 28 + 45 files changed, 6897 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile.backend create mode 100644 Dockerfile.frontend create mode 100644 README.md create mode 100644 backend/api/__init__.py create mode 100644 backend/api/auth.py create mode 100644 backend/api/batch.py create mode 100644 backend/api/cache.py create mode 100644 backend/api/historical.py create mode 100644 backend/api/realtime.py create mode 100644 backend/api/settings.py create mode 100644 backend/auth/dependencies.py create mode 100644 backend/auth/jwt_handler.py create mode 100644 backend/config.py create mode 100644 backend/main.py create mode 100644 backend/models/__init__.py create mode 100644 backend/models/database.py create mode 100644 backend/models/schemas.py create mode 100644 backend/models/tables.py create mode 100644 backend/requirements.txt create mode 100644 backend/services/__init__.py create mode 100644 backend/services/config_service.py create mode 100644 backend/services/data_service.py create mode 100644 database/init.sql create mode 100644 docker-compose.yml create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/index.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/stores/auth.js create mode 100644 frontend/src/views/ApiTest.vue create mode 100644 frontend/src/views/Batch.vue create mode 100644 frontend/src/views/Cache.vue create mode 100644 frontend/src/views/Dashboard.vue create mode 100644 frontend/src/views/Historical.vue create mode 100644 frontend/src/views/Layout.vue create mode 100644 frontend/src/views/Login.vue create mode 100644 frontend/src/views/Realtime.vue create mode 100644 frontend/src/views/Settings.vue create mode 100644 frontend/vite.config.js create mode 100644 nginx.conf diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9007cd1 --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# AmazingData 数据服务平台 - 环境变量配置 +# 复制此文件为 .env 并修改相应配置 + +# ==================== 应用配置 ==================== +APP_NAME=AmazingData Platform +APP_ENV=development +DEBUG=true +SECRET_KEY=your-secret-key-change-in-production + +# ==================== 数据库配置 (MySQL) ==================== +DB_HOST=localhost +DB_PORT=3306 +DB_USER=root +DB_PASSWORD=root123 +DB_NAME=amazingdata_platform + +# ==================== AmazingData SDK 配置 ==================== +AMAZING_DATA_USERNAME=11200008169 +AMAZING_DATA_PASSWORD=11200008169@2026 +AMAZING_DATA_HOST=140.206.44.234 +AMAZING_DATA_PORT=8600 + +# ==================== 服务配置 ==================== +BACKEND_HOST=0.0.0.0 +BACKEND_PORT=8000 +FRONTEND_PORT=3000 + +# ==================== 数据配置 ==================== +DATA_SAVE_PATH=./data +REALTIME_SAVE_DAYS=7 +CACHE_AUTO_SAVE_INTERVAL=60 +MAX_CONCURRENT_TASKS=5 + +# ==================== JWT 配置 ==================== +JWT_SECRET_KEY=your-jwt-secret-key +JWT_ALGORITHM=HS256 +JWT_EXPIRE_MINUTES=1440 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..650b7a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg +*.egg-info/ +dist/ +build/ +*.whl + +# Virtual Environment +venv/ +env/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env +.env.local + +# Node +node_modules/ +frontend/dist/ + +# Data +data/*.json +data/**/*.json +*.db +*.sqlite + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log \ No newline at end of file diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..1dba214 --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +WORKDIR /app + +# 安装依赖 +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple + +# 复制后端代码 +COPY backend/ ./backend/ + +# 创建数据目录 +RUN mkdir -p /app/data + +EXPOSE 8000 + +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..62cdc6f --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,20 @@ +FROM node:18-alpine AS builder + +WORKDIR /app + +# 复制前端代码 +COPY frontend/package*.json ./ +RUN npm install --registry=https://registry.npmmirror.com + +COPY frontend/ ./ +RUN npm run build + +# 使用 Nginx 提供静态文件 +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e9b216230986ef4573c3bf3f7736618296b652e5 GIT binary patch literal 7042 zcmcgxU2L1x89qjWwI1y*m_W*g`k@M1mBx1ZJEhz(N*SYdf^C|Vn1tGK?6ip+Y{yOd zle8j&RwgtNupy=~1r@;^cl=zTN^pfxHz77|wo0oaO+#ucgj%T;3(s?oUmyGPXFJih z%5waj^PTg&|L=Lvo2CD(SB`pAjjDs{m>O1Dl~%j)T)@{+W2tO%O?n}c@4puHE5Ewg z^J=*9>L_+TgBBQ<+I$XkB{4tfN$H!5Biu`bDGPmJ*nc-ZA3gGWE(SJuT#J-$dO#idwSM8X2?Of{V zwk6J84*MonEs2VhU+8@Jo}tB*FO-R`OZ!59@9OQlIy*BUJ8xp=Z&g%vsW6Zp z$^2}9{Bu<(@{{CSeOHRlV6EYbJZ#ZCOeQYQlL}=JB=W#H@^D`yb4DneXD_;o;fsei zg$;k5;g?g_!)szQMMqhF$V)Z#trhB&&P|XJUDS*{tEq8@+Wucq+fmUhHAwCarC`dJygO!mwC%vAFyGoY zZ3if$SuLJ>DiKVt%1{#e<|cgB#;zXYZzslk5Mg=5*`umkJ&Ezd_(s*!YA z$_FOG?XG{#<)&rNZgm7)9|nzFnTOh$$MAksjp1(|Ys9{e`gUO5oErGzfXpzqKFGh= zd;09JM?`nskiGzo90C87F@Oxv0gV|>!k8ZfWTIzPJb5v2=uvqovhs;M{%^Yab17t-|C3HRyf>IK6hw+B=xyI3% zwMV}n#O|ZOpij!)ID9P$#9{DIz`J-IhH!kg@AET%?Akv|Zn@ggnDpy|kZ}a#cI^)d zjX>|nLNh_krPK@HdRRY+XXas{f||fr@b}GUr>Tu^q8I0>RZ<9Ol%oEf04}|X{%zmT zc8;$DTO}|~slmg8on4_IJ zL(k5EbNX3Gf5a<{EaErj++gf--rH}6fJ9%XwWu%AhShc!^ym-d%xYWKQ961oCCu#i zV~tszgS3>9(Ne5XxBNLc!KWH8qpAa`|D!<;7_bc)xs5 z;p6s`Ab4Efhq`0dFy40ukoXZ)`q(hyWBFKeeq5dH822l|lV6KF4sXgJW|&pH(aG8Q z(uLBy$;-%ff*`+wlmUPAIC?wdhH>sjCDKdL@1i_anxiof5P|uLS-_1%PKhS9MsqaA zZUHzfdX!lI__AMO0tKg0H^RmD2)GKH?Sz|NP)mwsS zw@zG|V%Dog6YU>FG|`UB^AO_Eq7NME>9H0StWnsInP3Fmw8kSA?fJl=k_Q&0%at=} zswEz=+QJKwU32s2>iJ?W&LZB2p(igb)p{?ns4t5n8fQ#1KeomYFWVJzSsy@DCupt&O`4^1VZam%rL#d5L$<(AmLQDUtmDDo>(@GPO%&_azQAX@VbA~E00 zxX5#msP4O-u5(&Z>EX!K+AUs6sKKhKcoYY#( z2=vXylS6EA?=JlX)6 zc)MXqTVorq7u@Q1j2%`)0arES=8M0PPvCs%#Y|5-i4Nrh; z$pH0e^)ti|SN2LSFm;twHa1dTP9^n?OzOY2u}@1^-f&QdWnkF?a&jkpB?#?N(>$4Y z>!9y?qbUyty64>a_Mp-hU)ANzZmO$m_1~>9Z!c`_&1I`RwSYPf}lx zn5T=$Yp7gLz@Ja(+LHApKg~x5FAnY{e-v>S;z-U6Q%_OL7(QQAo#z5w>y~_H=L87BR74tWYlt&v?>gC+5GWH>7pQ zTNd-5w}e8G-H28c-E|K&IF4w|!|PaAB|yiU3h#(g4K>qYJq6y$^2nN&|0mEH5ZhB% z>|fl%xy(-0%CODpFa~cq4(W~kTRk=Ub?G1H-YsVm>AFpKZ5;2M zEOkllUys}-xkqw=z57*nY#809W6@ia8CP;L)Mnn+xsn;y4{E&O{sw>JY7NHhmC5a& zI*Fk!7=uPS^;^=Z;9qvNqUu@9 zZLZl-fNt39Fj;;;d&*>G{}$}rqITf#Hhg#1?I!QcYP@dX}jCs2(U!Q&J^LxGRZwqu_?3NPG) z+2>q#skUJC?osU#9oVs1dy*M%_dr;#W!;){d&nTp0s5l&y^&0 aMlaxhQ+~A%J$|0Fdv(;ZtH=F}^&bJ{h%QI~ literal 0 HcmV?d00001 diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..d453fd4 --- /dev/null +++ b/backend/api/__init__.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter +from backend.api import auth, historical, realtime, batch, cache, settings + +api_router = APIRouter() + +api_router.include_router(auth.router, prefix="/auth", tags=["认证"]) +api_router.include_router(historical.router, prefix="/historical", tags=["历史数据"]) +api_router.include_router(realtime.router, prefix="/realtime", tags=["实时订阅"]) +api_router.include_router(batch.router, prefix="/batch", tags=["批量操作"]) +api_router.include_router(cache.router, prefix="/cache", tags=["缓存管理"]) +api_router.include_router(settings.router, prefix="/settings", tags=["系统配置"]) \ No newline at end of file diff --git a/backend/api/auth.py b/backend/api/auth.py new file mode 100644 index 0000000..945b900 --- /dev/null +++ b/backend/api/auth.py @@ -0,0 +1,102 @@ +""" +AmazingData 数据服务平台 - 认证 API +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from datetime import datetime +from backend.models.database import get_db +from backend.models.tables import User +from backend.models.schemas import LoginRequest, LoginResponse, UserInfo, BaseResponse +from backend.auth.jwt_handler import verify_password, create_access_token +from backend.auth.dependencies import get_current_active_user + +router = APIRouter() + + +@router.post("/login", response_model=LoginResponse) +async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): + """用户登录""" + user = db.query(User).filter(User.username == form_data.username).first() + + if not user or not verify_password(form_data.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user") + + # 更新最后登录时间 + user.last_login = datetime.utcnow() + db.commit() + + # 创建 Token + access_token = create_access_token(data={"sub": user.username, "role": user.role}) + + return LoginResponse( + access_token=access_token, + token_type="bearer", + user_info={ + "id": user.id, + "username": user.username, + "role": user.role, + "is_active": user.is_active, + "last_login": user.last_login + } + ) + + +@router.post("/login-json", response_model=LoginResponse) +async def login_json(request: LoginRequest, db: Session = Depends(get_db)): + """用户登录(JSON 格式)""" + user = db.query(User).filter(User.username == request.username).first() + + if not user or not verify_password(request.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password" + ) + + if not user.is_active: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user") + + user.last_login = datetime.utcnow() + db.commit() + + access_token = create_access_token(data={"sub": user.username, "role": user.role}) + + return LoginResponse( + access_token=access_token, + token_type="bearer", + user_info={ + "id": user.id, + "username": user.username, + "role": user.role, + "is_active": user.is_active, + "last_login": user.last_login + } + ) + + +@router.get("/me", response_model=BaseResponse) +async def get_current_user_info(current_user: User = Depends(get_current_active_user)): + """获取当前用户信息""" + return BaseResponse( + data={ + "id": current_user.id, + "username": current_user.username, + "role": current_user.role, + "is_active": current_user.is_active, + "last_login": current_user.last_login + } + ) + + +@router.post("/logout", response_model=BaseResponse) +async def logout(current_user: User = Depends(get_current_active_user)): + """用户登出""" + return BaseResponse(message="Logout successful") \ No newline at end of file diff --git a/backend/api/batch.py b/backend/api/batch.py new file mode 100644 index 0000000..d760bda --- /dev/null +++ b/backend/api/batch.py @@ -0,0 +1,163 @@ +""" +AmazingData 数据服务平台 - 批量操作 API +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import Optional, List +from datetime import datetime +from backend.models.database import get_db +from backend.models.schemas import ( + BaseResponse, BatchTaskRequest, BatchTaskStatus +) +from backend.models.tables import BatchTask, User +from backend.auth.dependencies import get_current_user +from backend.services.data_service import data_service + +router = APIRouter() + + +@router.post("/execute", response_model=BaseResponse) +async def execute_batch_task( + request: BatchTaskRequest, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user) +): + """执行批量任务""" + import threading + + # 创建任务记录 + task = BatchTask( + task_type=request.task_type, + task_params={ + "codes": request.codes, + "use_main_contract": request.use_main_contract, + "trading_days": request.trading_days, + "batch_size": request.batch_size + }, + status="pending", + output_path=request.save_path, + created_by=current_user.username if current_user else "anonymous" + ) + db.add(task) + db.commit() + db.refresh(task) + + def batch_worker(): + """批量工作线程""" + try: + task.status = "running" + task.started_at = datetime.utcnow() + db.commit() + + if request.task_type == "stock": + result = data_service.batch_get_stock_kline( + codes=request.codes, + trading_days=request.trading_days, + save_path=request.save_path, + batch_size=request.batch_size + ) + elif request.task_type == "future": + result = data_service.batch_get_future_kline( + underlying_codes=request.codes, + use_main_contract=request.use_main_contract, + trading_days=request.trading_days, + save_path=request.save_path + ) + else: + task.status = "error" + task.error_message = f"Unknown task type: {request.task_type}" + db.commit() + return + + if "error" in result: + task.status = "error" + task.error_message = result["error"] + else: + task.status = "completed" + task.success_count = len(result) + task.completed_at = datetime.utcnow() + + db.commit() + + except Exception as e: + task.status = "error" + task.error_message = str(e) + db.commit() + + thread = threading.Thread(target=batch_worker, daemon=True) + thread.start() + + return BaseResponse( + data={ + "task_id": task.id, + "status": "pending", + "message": "Batch task queued" + } + ) + + +@router.get("/tasks", response_model=BaseResponse) +async def list_batch_tasks( + status: Optional[str] = None, + task_type: Optional[str] = None, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user) +): + """列出批量任务""" + query = db.query(BatchTask) + if status: + query = query.filter(BatchTask.status == status) + if task_type: + query = query.filter(BatchTask.task_type == task_type) + + tasks = query.order_by(BatchTask.created_at.desc()).all() + + return BaseResponse(data={ + "tasks": [ + { + "id": t.id, + "task_type": t.task_type, + "total_count": t.total_count, + "processed_count": t.processed_count, + "success_count": t.success_count, + "failed_count": t.failed_count, + "status": t.status, + "output_path": t.output_path, + "error_message": t.error_message, + "started_at": t.started_at.isoformat() if t.started_at else None, + "completed_at": t.completed_at.isoformat() if t.completed_at else None, + "created_at": t.created_at.isoformat() if t.created_at else None + } + for t in tasks + ], + "total": len(tasks) + }) + + +@router.get("/tasks/{task_id}", response_model=BaseResponse) +async def get_batch_task( + task_id: int, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user) +): + """获取批量任务详情""" + task = db.query(BatchTask).filter(BatchTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + return BaseResponse(data={ + "id": task.id, + "task_type": task.task_type, + "task_params": task.task_params, + "total_count": task.total_count, + "processed_count": task.processed_count, + "success_count": task.success_count, + "failed_count": task.failed_count, + "status": task.status, + "output_path": task.output_path, + "error_message": task.error_message, + "started_at": task.started_at.isoformat() if task.started_at else None, + "completed_at": task.completed_at.isoformat() if task.completed_at else None, + "created_at": task.created_at.isoformat() if task.created_at else None + }) \ No newline at end of file diff --git a/backend/api/cache.py b/backend/api/cache.py new file mode 100644 index 0000000..1d9702b --- /dev/null +++ b/backend/api/cache.py @@ -0,0 +1,170 @@ +""" +AmazingData 数据服务平台 - 缓存管理 API +""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func +from typing import Optional +import os +import json +from datetime import datetime +from backend.models.database import get_db +from backend.models.schemas import BaseResponse, CacheFileItem, CacheStats +from backend.models.tables import CacheRecord, User +from backend.auth.dependencies import get_current_user +from backend.config import settings + +router = APIRouter() + + +@router.get("/list", response_model=BaseResponse) +async def list_cache_files( + file_type: Optional[str] = Query(None, description="文件类型: stock/future/realtime"), + trading_day: Optional[str] = Query(None, description="交易日"), + code: Optional[str] = Query(None, description="代码"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user) +): + """列出缓存文件""" + query = db.query(CacheRecord) + + if file_type: + query = query.filter(CacheRecord.file_type == file_type) + if trading_day: + query = query.filter(CacheRecord.trading_day == trading_day) + if code: + query = query.filter(CacheRecord.code == code) + + total = query.count() + records = query.order_by(CacheRecord.created_at.desc())\ + .offset((page - 1) * page_size)\ + .limit(page_size)\ + .all() + + return BaseResponse(data={ + "files": [ + { + "id": r.id, + "filename": r.filename, + "file_type": r.file_type, + "trading_day": r.trading_day, + "code": r.code, + "period": r.period, + "record_count": r.record_count, + "file_size": r.file_size, + "file_path": r.file_path, + "created_at": r.created_at.isoformat() if r.created_at else None + } + for r in records + ], + "total": total, + "page": page, + "page_size": page_size + }) + + +@router.get("/stats", response_model=BaseResponse) +async def get_cache_stats( + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user) +): + """获取缓存统计""" + # 数据库统计 + total_files = db.query(CacheRecord).count() + total_size = db.query(CacheRecord).with_entities( + func.coalesce(func.sum(CacheRecord.file_size), 0) + ).scalar() + + # 按类型统计 + by_type = {} + type_stats = db.query(CacheRecord.file_type, func.count(CacheRecord.id))\ + .group_by(CacheRecord.file_type).all() + for t, count in type_stats: + by_type[t] = count + + # 按日期统计 + by_day = {} + day_stats = db.query(CacheRecord.trading_day, func.count(CacheRecord.id))\ + .filter(CacheRecord.trading_day.isnot(None))\ + .group_by(CacheRecord.trading_day).all() + for d, count in day_stats: + by_day[d] = count + + # 文件系统统计 + data_path = settings.DATA_SAVE_PATH + disk_stats = {} + if os.path.exists(data_path): + for root, dirs, files in os.walk(data_path): + for f in files: + if f.endswith('.json'): + filepath = os.path.join(root, f) + disk_stats[f] = os.path.getsize(filepath) + + return BaseResponse(data={ + "total_files": total_files, + "total_size": total_size, + "by_type": by_type, + "by_day": by_day, + "disk_files": len(disk_stats), + "disk_size": sum(disk_stats.values()) + }) + + +@router.get("/data/{file_type}/{trading_day}", response_model=BaseResponse) +async def get_cache_data( + file_type: str, + trading_day: str, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user) +): + """获取缓存数据""" + # 查找文件 + if file_type == "stock": + filename = f"kline_{trading_day}.json" + filepath = os.path.join(settings.DATA_SAVE_PATH, "stock", filename) + elif file_type == "future": + filename = f"futures_{trading_day}.json" + filepath = os.path.join(settings.DATA_SAVE_PATH, "future", filename) + else: + raise HTTPException(status_code=400, detail="Invalid file type") + + if not os.path.exists(filepath): + raise HTTPException(status_code=404, detail="File not found") + + with open(filepath, 'r', encoding='utf-8') as f: + data = json.load(f) + + return BaseResponse(data=data) + + +@router.delete("/cleanup", response_model=BaseResponse) +async def cleanup_old_cache( + days: int = Query(30, ge=1), + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user) +): + """清理旧缓存""" + from datetime import timedelta + + cutoff_date = datetime.utcnow() - timedelta(days=days) + + # 删除数据库记录 + old_records = db.query(CacheRecord).filter(CacheRecord.created_at < cutoff_date).all() + + deleted_count = 0 + for record in old_records: + # 删除文件 + if record.file_path and os.path.exists(record.file_path): + try: + os.remove(record.file_path) + except Exception: + pass + db.delete(record) + deleted_count += 1 + + db.commit() + + return BaseResponse(data={"deleted_count": deleted_count}) \ No newline at end of file diff --git a/backend/api/historical.py b/backend/api/historical.py new file mode 100644 index 0000000..7ce2f34 --- /dev/null +++ b/backend/api/historical.py @@ -0,0 +1,113 @@ +""" +AmazingData 数据服务平台 - 历史数据 API +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import Optional, List +from backend.models.database import get_db +from backend.models.schemas import ( + BaseResponse, SingleKlineRequest, BatchStockRequest, + BatchFutureRequest, KlineDataResponse +) +from backend.services.data_service import data_service +from backend.auth.dependencies import get_current_user +from backend.models.tables import User + +router = APIRouter() + + +@router.post("/single", response_model=BaseResponse) +async def get_single_kline( + request: SingleKlineRequest, + current_user: Optional[User] = Depends(get_current_user) +): + """获取单只股票/期货K线数据""" + result = data_service.get_single_kline( + code=request.code, + trading_day=request.trading_day, + period=request.period.value, + save_path=request.save_path + ) + + if "error" in result: + return BaseResponse(code=500, message=result["error"], data=result) + + return BaseResponse(data=result) + + +@router.post("/batch-stocks", response_model=BaseResponse) +async def batch_get_stock_kline( + request: BatchStockRequest, + current_user: Optional[User] = Depends(get_current_user) +): + """批量获取股票K线数据""" + result = data_service.batch_get_stock_kline( + codes=request.codes, + trading_days=request.trading_days, + save_path=request.save_path, + batch_size=request.batch_size + ) + + if "error" in result: + return BaseResponse(code=500, message=result["error"], data=result) + + return BaseResponse(data={"files": result, "count": len(result)}) + + +@router.post("/batch-futures", response_model=BaseResponse) +async def batch_get_future_kline( + request: BatchFutureRequest, + current_user: Optional[User] = Depends(get_current_user) +): + """批量获取期货K线数据""" + result = data_service.batch_get_future_kline( + underlying_codes=request.underlying_codes, + use_main_contract=request.use_main_contract, + trading_days=request.trading_days, + save_path=request.save_path + ) + + if "error" in result: + return BaseResponse(code=500, message=result["error"], data=result) + + return BaseResponse(data={"files": result, "count": len(result)}) + + +@router.get("/stock-codes", response_model=BaseResponse) +async def get_stock_codes(current_user: Optional[User] = Depends(get_current_user)): + """获取股票代码列表""" + codes = data_service.get_stock_codes() + return BaseResponse(data={"codes": codes, "count": len(codes)}) + + +@router.get("/future-codes", response_model=BaseResponse) +async def get_future_codes(current_user: Optional[User] = Depends(get_current_user)): + """获取期货代码列表""" + codes = data_service.get_future_codes() + return BaseResponse(data={"codes": codes, "count": len(codes)}) + + +@router.get("/trading-days", response_model=BaseResponse) +async def get_trading_days( + year: Optional[int] = None, + month: Optional[int] = None, + current_user: Optional[User] = Depends(get_current_user) +): + """获取交易日列表(简化实现)""" + from datetime import date, timedelta + + today = date.today() + if year: + target_date = date(year, month or 1, 1) + else: + target_date = today + + # 简单返回最近30天(实际应查询交易日历) + trading_days = [] + for i in range(30): + d = target_date - timedelta(days=i) + if d.weekday() < 5: # 排除周末 + trading_days.append(d.strftime('%Y%m%d')) + + return BaseResponse(data={"trading_days": trading_days}) \ No newline at end of file diff --git a/backend/api/realtime.py b/backend/api/realtime.py new file mode 100644 index 0000000..08ca2d9 --- /dev/null +++ b/backend/api/realtime.py @@ -0,0 +1,227 @@ +""" +AmazingData 数据服务平台 - 实时订阅 API +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import Optional, List +from datetime import datetime +from backend.models.database import get_db +from backend.models.schemas import ( + BaseResponse, SubscribeRequest, SubscribeResponse, TaskStatus +) +from backend.models.tables import SubscriptionTask, User +from backend.auth.dependencies import get_current_user +from backend.services.data_service import data_service + +router = APIRouter() + +# 存储运行中的订阅任务 +active_subscriptions = {} + + +@router.post("/subscribe", response_model=BaseResponse) +async def subscribe_kline( + request: SubscribeRequest, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user) +): + """启动实时K线订阅任务""" + try: + import AmazingData as ad + import threading + import os + import json + + # 创建任务记录 + task_name = request.task_name or f"subscribe_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + + task = SubscriptionTask( + task_name=task_name, + codes=[c.value if hasattr(c, 'value') else c for c in request.codes], + periods=[p.value if hasattr(p, 'value') else p for p in request.periods], + save_path=request.save_path, + duration=request.duration, + save_interval=request.save_interval, + status="running", + started_at=datetime.utcnow(), + created_by=current_user.username if current_user else "anonymous" + ) + db.add(task) + db.commit() + db.refresh(task) + + # 启动订阅线程 + def subscribe_worker(): + """订阅工作线程""" + try: + # 登录 + ad.login( + username=data_service.AMAZING_DATA_USERNAME if hasattr(data_service, 'AMAZING_DATA_USERNAME') else "11200008169", + password=data_service.AMAZING_DATA_PASSWORD if hasattr(data_service, 'AMAZING_DATA_PASSWORD') else "11200008169@2026", + host=data_service.AMAZING_DATA_HOST if hasattr(data_service, 'AMAZING_DATA_HOST') else "140.206.44.234", + port=data_service.AMAZING_DATA_PORT if hasattr(data_service, 'AMAZING_DATA_PORT') else 8600 + ) + + save_path = request.save_path or "./data/realtime" + os.makedirs(save_path, exist_ok=True) + + # 周期映射 + period_map = { + "min1": ad.constant.Period.min1.value if hasattr(ad.constant, 'Period') else 1, + "min5": ad.constant.Period.min5.value if hasattr(ad.constant, 'Period') else 5, + "min15": ad.constant.Period.min15.value if hasattr(ad.constant, 'Period') else 15, + "min30": ad.constant.Period.min30.value if hasattr(ad.constant, 'Period') else 30, + "min60": ad.constant.Period.min60.value if hasattr(ad.constant, 'Period') else 60, + } + + # 为每个品种和周期创建订阅 + for code in request.codes: + code_val = code.value if hasattr(code, 'value') else code + for period in request.periods: + period_val = period.value if hasattr(period, 'value') else period + period_value = period_map.get(period_val, 5) + + def on_data(data): + """数据回调""" + try: + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"{code_val.replace('.', '_')}_{period_val}_{timestamp}.json" + filepath = os.path.join(save_path, filename) + + result = { + "code": code_val, + "period": period_val, + "timestamp": datetime.now().isoformat(), + "data": data + } + + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(result, f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"Save data error: {e}") + + # 订阅K线 + ad.subscribe_kline( + code=code_val, + period=period_value, + callback=on_data + ) + + # 保持运行 + import time + if request.duration > 0: + time.sleep(request.duration) + else: + # 无限运行,直到被停止 + while task.status == "running": + time.sleep(1) + + # 取消订阅 + for code in request.codes: + code_val = code.value if hasattr(code, 'value') else code + ad.unsubscribe_kline(code=code_val) + + ad.logout(data_service.AMAZING_DATA_USERNAME if hasattr(data_service, 'AMAZING_DATA_USERNAME') else "11200008169") + + # 更新任务状态 + task.status = "stopped" + task.stopped_at = datetime.utcnow() + db.commit() + + except Exception as e: + task.status = "error" + task.stopped_at = datetime.utcnow() + db.commit() + print(f"Subscribe error: {e}") + + thread = threading.Thread(target=subscribe_worker, daemon=True) + thread.start() + active_subscriptions[task.id] = thread + + return BaseResponse( + data={ + "task_id": task.id, + "task_name": task_name, + "status": "running", + "message": "Subscription started" + } + ) + + except Exception as e: + return BaseResponse(code=500, message=str(e)) + + +@router.post("/stop/{task_id}", response_model=BaseResponse) +async def stop_subscription( + task_id: int, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user) +): + """停止订阅任务""" + task = db.query(SubscriptionTask).filter(SubscriptionTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + task.status = "stopped" + task.stopped_at = datetime.utcnow() + db.commit() + + return BaseResponse(message="Subscription stopped") + + +@router.get("/tasks", response_model=BaseResponse) +async def list_subscription_tasks( + status: Optional[str] = None, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user) +): + """列出订阅任务""" + query = db.query(SubscriptionTask) + if status: + query = query.filter(SubscriptionTask.status == status) + + tasks = query.order_by(SubscriptionTask.created_at.desc()).all() + + return BaseResponse(data={ + "tasks": [ + { + "id": t.id, + "task_name": t.task_name, + "codes": t.codes, + "periods": t.periods, + "status": t.status, + "started_at": t.started_at.isoformat() if t.started_at else None, + "stopped_at": t.stopped_at.isoformat() if t.stopped_at else None, + "created_at": t.created_at.isoformat() if t.created_at else None + } + for t in tasks + ], + "total": len(tasks) + }) + + +@router.get("/tasks/{task_id}", response_model=BaseResponse) +async def get_subscription_task( + task_id: int, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user) +): + """获取订阅任务详情""" + task = db.query(SubscriptionTask).filter(SubscriptionTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + return BaseResponse(data={ + "id": task.id, + "task_name": task.task_name, + "codes": task.codes, + "periods": task.periods, + "save_path": task.save_path, + "duration": task.duration, + "save_interval": task.save_interval, + "status": task.status, + "started_at": task.started_at.isoformat() if task.started_at else None, + "stopped_at": task.stopped_at.isoformat() if task.stopped_at else None, + "created_at": task.created_at.isoformat() if task.created_at else None + }) \ No newline at end of file diff --git a/backend/api/settings.py b/backend/api/settings.py new file mode 100644 index 0000000..241911b --- /dev/null +++ b/backend/api/settings.py @@ -0,0 +1,131 @@ +""" +AmazingData 数据服务平台 - 系统配置 API +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import Optional, Dict, Any +from backend.models.database import get_db +from backend.models.schemas import BaseResponse, ConfigItem, ConfigUpdateRequest, TestConnectionResponse +from backend.models.tables import SystemConfig, User +from backend.auth.dependencies import get_current_user, require_admin +from backend.services.config_service import config_service +from backend.services.data_service import data_service + +router = APIRouter() + + +@router.get("/list", response_model=BaseResponse) +async def list_configs( + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user) +): + """获取所有配置""" + configs = config_service.get_all_configs(db) + + return BaseResponse(data={ + "configs": [ + { + "id": c.id, + "config_key": c.config_key, + "config_value": c.config_value, + "config_type": c.config_type, + "description": c.description + } + for c in configs + ], + "total": len(configs) + }) + + +@router.get("/{key}", response_model=BaseResponse) +async def get_config( + key: str, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user) +): + """获取单个配置""" + config = config_service.get_config(db, key) + if not config: + raise HTTPException(status_code=404, detail="Config not found") + + return BaseResponse(data={ + "id": config.id, + "config_key": config.config_key, + "config_value": config.config_value, + "config_type": config.config_type, + "description": config.description + }) + + +@router.put("/{key}", response_model=BaseResponse) +async def update_config( + key: str, + request: ConfigUpdateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + """更新配置(需要管理员权限)""" + config = config_service.update_config(db, key, request.config_value) + if not config: + raise HTTPException(status_code=404, detail="Config not found") + + return BaseResponse(data={ + "id": config.id, + "config_key": config.config_key, + "config_value": config.config_value, + "config_type": config.config_type, + "description": config.description + }) + + +@router.put("/batch", response_model=BaseResponse) +async def batch_update_configs( + configs: Dict[str, str], + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + """批量更新配置(需要管理员权限)""" + success = config_service.batch_update_configs(db, configs) + if not success: + return BaseResponse(code=500, message="Batch update failed") + + return BaseResponse(message="Batch update successful") + + +@router.get("/amazing-data/config", response_model=BaseResponse) +async def get_amazing_data_config( + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user) +): + """获取 AmazingData 连接配置""" + config = config_service.get_amazing_data_config(db) + # 隐藏密码 + config["password"] = "***" + return BaseResponse(data=config) + + +@router.post("/test-connection", response_model=TestConnectionResponse) +async def test_connection( + current_user: Optional[User] = Depends(get_current_user) +): + """测试 AmazingData 连接""" + result = data_service.test_connection() + return TestConnectionResponse(**result) + + +@router.get("/system/info", response_model=BaseResponse) +async def get_system_info( + current_user: Optional[User] = Depends(get_current_user) +): + """获取系统信息""" + import platform + import sys + + return BaseResponse(data={ + "platform": platform.system(), + "platform_version": platform.version(), + "python_version": platform.python_version(), + "app_env": "development", + "app_name": "AmazingData Platform" + }) \ No newline at end of file diff --git a/backend/auth/dependencies.py b/backend/auth/dependencies.py new file mode 100644 index 0000000..a75456c --- /dev/null +++ b/backend/auth/dependencies.py @@ -0,0 +1,62 @@ +""" +AmazingData 数据服务平台 - 认证依赖 +""" + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session +from backend.models.database import get_db +from backend.models.tables import User +from backend.auth.jwt_handler import decode_access_token +from typing import Optional + +# OAuth2 密码流 +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False) + + +async def get_current_user( + token: Optional[str] = Depends(oauth2_scheme), + db: Session = Depends(get_db) +) -> Optional[User]: + """获取当前用户(可选认证)""" + if not token: + return None + + payload = decode_access_token(token) + if payload is None: + return None + + username: str = payload.get("sub") + if username is None: + return None + + user = db.query(User).filter(User.username == username).first() + if user is None or not user.is_active: + return None + + return user + + +async def get_current_active_user( + current_user: Optional[User] = Depends(get_current_user) +) -> User: + """获取当前活跃用户(需要认证)""" + if current_user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + return current_user + + +async def require_admin( + current_user: User = Depends(get_current_active_user) +) -> User: + """要求管理员权限""" + if current_user.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin privileges required" + ) + return current_user \ No newline at end of file diff --git a/backend/auth/jwt_handler.py b/backend/auth/jwt_handler.py new file mode 100644 index 0000000..ba78bb5 --- /dev/null +++ b/backend/auth/jwt_handler.py @@ -0,0 +1,44 @@ +""" +AmazingData 数据服务平台 - JWT 认证处理 +""" + +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from backend.config import settings + +# 密码加密上下文 +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """验证密码""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """获取密码哈希""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """创建 JWT Token""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.JWT_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + return encoded_jwt + + +def decode_access_token(token: str) -> Optional[dict]: + """解码 JWT Token""" + try: + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + return payload + except JWTError: + return None \ No newline at end of file diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..0ee6511 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,65 @@ +""" +AmazingData 数据服务平台 - 配置管理 +""" + +from pydantic_settings import BaseSettings +from typing import Optional +import os + + +class Settings(BaseSettings): + """应用配置""" + + # 应用配置 + APP_NAME: str = "AmazingData Platform" + APP_ENV: str = "development" + DEBUG: bool = True + SECRET_KEY: str = "your-secret-key-change-in-production" + + # 数据库配置 + DB_HOST: str = "localhost" + DB_PORT: int = 3306 + DB_USER: str = "root" + DB_PASSWORD: str = "root123" + DB_NAME: str = "amazingdata_platform" + + # AmazingData SDK 配置 + AMAZING_DATA_USERNAME: str = "11200008169" + AMAZING_DATA_PASSWORD: str = "11200008169@2026" + AMAZING_DATA_HOST: str = "140.206.44.234" + AMAZING_DATA_PORT: int = 8600 + + # 服务配置 + BACKEND_HOST: str = "0.0.0.0" + BACKEND_PORT: int = 8000 + FRONTEND_PORT: int = 3000 + + # 数据配置 + DATA_SAVE_PATH: str = "./data" + REALTIME_SAVE_DAYS: int = 7 + CACHE_AUTO_SAVE_INTERVAL: int = 60 + MAX_CONCURRENT_TASKS: int = 5 + + # JWT 配置 + JWT_SECRET_KEY: str = "your-jwt-secret-key" + JWT_ALGORITHM: str = "HS256" + JWT_EXPIRE_MINUTES: int = 1440 + + @property + def DATABASE_URL(self) -> str: + """获取数据库连接 URL""" + return f"mysql+pymysql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4" + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + case_sensitive = True + + +# 创建全局配置实例 +settings = Settings() + + +def get_settings() -> Settings: + """获取配置(用于依赖注入)""" + return settings \ No newline at end of file diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..d7e3087 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,95 @@ +""" +AmazingData 数据服务平台 - FastAPI 主应用 +""" + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from contextlib import asynccontextmanager +import os + +from backend.config import settings +from backend.models.database import init_db +from backend.api import api_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期管理""" + # 启动时执行 + print("Starting AmazingData Platform...") + + # 初始化数据库 + try: + init_db() + print("Database initialized successfully") + except Exception as e: + print(f"Database initialization warning: {e}") + + # 创建数据目录 + os.makedirs(os.path.join(settings.DATA_SAVE_PATH, "single"), exist_ok=True) + os.makedirs(os.path.join(settings.DATA_SAVE_PATH, "stock"), exist_ok=True) + os.makedirs(os.path.join(settings.DATA_SAVE_PATH, "future"), exist_ok=True) + os.makedirs(os.path.join(settings.DATA_SAVE_PATH, "realtime"), exist_ok=True) + os.makedirs(os.path.join(settings.DATA_SAVE_PATH, "batch"), exist_ok=True) + + print("AmazingData Platform started successfully!") + + yield + + # 关闭时执行 + print("Shutting down AmazingData Platform...") + + +# 创建 FastAPI 应用 +app = FastAPI( + title="AmazingData Platform", + description="AmazingData 数据服务平台 - 提供股票、期货K线数据获取和实时订阅服务", + version="1.0.0", + lifespan=lifespan +) + +# CORS 中间件 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 注册路由 +app.include_router(api_router, prefix="/api/v1") + + +@app.get("/") +async def root(): + """根路径""" + return { + "name": "AmazingData Platform", + "version": "1.0.0", + "status": "running", + "docs": "/docs" + } + + +@app.get("/health") +async def health_check(): + """健康检查""" + return {"status": "healthy"} + + +# 挂载静态文件(前端构建产物) +frontend_dist = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend", "dist") +if os.path.exists(frontend_dist): + app.mount("/", StaticFiles(directory=frontend_dist, html=True), name="frontend") + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "backend.main:app", + host=settings.BACKEND_HOST, + port=settings.BACKEND_PORT, + reload=settings.DEBUG + ) \ No newline at end of file diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..862109b --- /dev/null +++ b/backend/models/__init__.py @@ -0,0 +1,23 @@ +from backend.models.database import Base, engine, SessionLocal, get_db, init_db +from backend.models.tables import ( + SystemConfig, + User, + SubscriptionTask, + BatchTask, + CacheRecord, + OperationLog +) + +__all__ = [ + "Base", + "engine", + "SessionLocal", + "get_db", + "init_db", + "SystemConfig", + "User", + "SubscriptionTask", + "BatchTask", + "CacheRecord", + "OperationLog" +] \ No newline at end of file diff --git a/backend/models/database.py b/backend/models/database.py new file mode 100644 index 0000000..d1514d2 --- /dev/null +++ b/backend/models/database.py @@ -0,0 +1,38 @@ +""" +AmazingData 数据服务平台 - 数据库配置 +""" + +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from backend.config import settings + +# 创建数据库引擎 +engine = create_engine( + settings.DATABASE_URL, + pool_pre_ping=True, + pool_recycle=3600, + echo=settings.DEBUG +) + +# 创建会话工厂 +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# 创建基类 +Base = declarative_base() + + +def get_db(): + """获取数据库会话(用于依赖注入)""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """初始化数据库(创建所有表)""" + from backend.models import tables # 导入所有表模型 + Base.metadata.create_all(bind=engine) + print("Database tables created successfully!") \ No newline at end of file diff --git a/backend/models/schemas.py b/backend/models/schemas.py new file mode 100644 index 0000000..cf9db9f --- /dev/null +++ b/backend/models/schemas.py @@ -0,0 +1,210 @@ +""" +AmazingData 数据服务平台 - Pydantic 数据模型 +""" + +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any +from datetime import datetime +from enum import Enum + + +# ==================== 通用响应模型 ==================== + +class ResponseCode(int, Enum): + SUCCESS = 200 + ERROR = 500 + UNAUTHORIZED = 401 + FORBIDDEN = 403 + NOT_FOUND = 404 + + +class BaseResponse(BaseModel): + """基础响应模型""" + code: int = ResponseCode.SUCCESS + message: str = "success" + data: Optional[Any] = None + + +# ==================== 认证模型 ==================== + +class LoginRequest(BaseModel): + """登录请求""" + username: str = Field(..., min_length=3, max_length=50) + password: str = Field(..., min_length=6) + + +class LoginResponse(BaseModel): + """登录响应""" + access_token: str + token_type: str = "bearer" + user_info: Dict[str, Any] + + +class UserInfo(BaseModel): + """用户信息""" + id: int + username: str + role: str + is_active: bool + last_login: Optional[datetime] + + +# ==================== 历史数据模型 ==================== + +class PeriodEnum(str, Enum): + """K线周期枚举""" + DAILY = "day" + MIN1 = "min1" + MIN5 = "min5" + MIN15 = "min15" + MIN30 = "min30" + MIN60 = "min60" + + +class SingleKlineRequest(BaseModel): + """单只K线请求""" + code: str = Field(..., description="代码,如 000001.SZ 或 ag2605.SHF") + trading_day: Optional[str] = Field(None, description="交易日,格式 YYYYMMDD,默认最近交易日") + period: PeriodEnum = Field(PeriodEnum.DAILY, description="K线周期") + save_path: Optional[str] = Field("./data/single", description="保存路径") + + +class BatchStockRequest(BaseModel): + """批量股票请求""" + codes: Optional[List[str]] = Field(None, description="股票代码列表,None表示全部A股") + trading_days: Optional[List[str]] = Field(None, description="交易日列表") + save_path: Optional[str] = Field("./data/stock", description="保存路径") + batch_size: int = Field(100, description="批次大小") + + +class BatchFutureRequest(BaseModel): + """批量期货请求""" + underlying_codes: Optional[List[str]] = Field(None, description="品种代码列表") + use_main_contract: bool = Field(True, description="是否使用主力合约") + trading_days: Optional[List[str]] = Field(None, description="交易日列表") + save_path: Optional[str] = Field("./data/future", description="保存路径") + + +class KlineDataResponse(BaseModel): + """K线数据响应""" + code: str + trading_day: str + period: str + count: int + data: List[Dict[str, Any]] + file_path: str + + +# ==================== 实时订阅模型 ==================== + +class SubscribePeriodEnum(str, Enum): + """订阅周期枚举""" + MIN1 = "min1" + MIN5 = "min5" + MIN15 = "min15" + MIN30 = "min30" + MIN60 = "min60" + + +class SubscribeRequest(BaseModel): + """订阅请求""" + codes: List[str] = Field(..., description="品种代码列表") + periods: List[SubscribePeriodEnum] = Field([SubscribePeriodEnum.MIN5], description="订阅周期") + save_path: Optional[str] = Field("./data/realtime", description="保存路径") + duration: int = Field(0, description="运行时长(秒),0=无限") + save_interval: int = Field(60, description="保存间隔(秒)") + task_name: Optional[str] = Field(None, description="任务名称") + + +class SubscribeResponse(BaseModel): + """订阅响应""" + task_id: int + task_name: str + status: str + message: str + + +class TaskStatus(BaseModel): + """任务状态""" + id: int + task_name: str + codes: List[str] + periods: List[str] + status: str + started_at: Optional[datetime] + stopped_at: Optional[datetime] + created_at: datetime + + +# ==================== 批量任务模型 ==================== + +class BatchTaskRequest(BaseModel): + """批量任务请求""" + task_type: str = Field(..., description="任务类型: stock/future") + codes: Optional[List[str]] = Field(None, description="代码列表") + use_main_contract: bool = Field(True, description="是否主力合约") + trading_days: Optional[List[str]] = Field(None, description="交易日列表") + save_path: Optional[str] = Field("./data/batch", description="保存路径") + batch_size: int = Field(100, description="批次大小") + + +class BatchTaskStatus(BaseModel): + """批量任务状态""" + id: int + task_type: str + total_count: int + processed_count: int + success_count: int + failed_count: int + status: str + output_path: Optional[str] + error_message: Optional[str] + started_at: Optional[datetime] + completed_at: Optional[datetime] + + +# ==================== 缓存管理模型 ==================== + +class CacheFileItem(BaseModel): + """缓存文件项""" + id: int + filename: str + file_type: str + trading_day: Optional[str] + code: Optional[str] + period: Optional[str] + record_count: int + file_size: int + file_path: str + created_at: datetime + + +class CacheStats(BaseModel): + """缓存统计""" + total_files: int + total_size: int + by_type: Dict[str, int] + by_day: Dict[str, int] + + +# ==================== 系统配置模型 ==================== + +class ConfigItem(BaseModel): + """配置项""" + id: int + config_key: str + config_value: str + config_type: str + description: Optional[str] + + +class ConfigUpdateRequest(BaseModel): + """配置更新请求""" + config_value: str + + +class TestConnectionResponse(BaseModel): + """连接测试响应""" + success: bool + message: str + details: Optional[Dict[str, Any]] = None \ No newline at end of file diff --git a/backend/models/tables.py b/backend/models/tables.py new file mode 100644 index 0000000..37a10f3 --- /dev/null +++ b/backend/models/tables.py @@ -0,0 +1,106 @@ +""" +AmazingData 数据服务平台 - 数据库表模型 +""" + +from sqlalchemy import Column, Integer, String, Text, DateTime, BigInteger, JSON, Boolean, ForeignKey, Index +from sqlalchemy.sql import func +from backend.models.database import Base + + +class SystemConfig(Base): + """系统配置表""" + __tablename__ = "system_config" + + id = Column(Integer, primary_key=True, autoincrement=True) + config_key = Column(String(100), unique=True, nullable=False, index=True) + config_value = Column(Text) + config_type = Column(String(20), default="string") + description = Column(String(255)) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class User(Base): + """用户表""" + __tablename__ = "users" + + id = Column(Integer, primary_key=True, autoincrement=True) + username = Column(String(50), unique=True, nullable=False, index=True) + password_hash = Column(String(255), nullable=False) + role = Column(String(20), default="user") + is_active = Column(Boolean, default=True) + last_login = Column(DateTime) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class SubscriptionTask(Base): + """订阅任务表""" + __tablename__ = "subscription_tasks" + + id = Column(Integer, primary_key=True, autoincrement=True) + task_name = Column(String(100), nullable=False) + codes = Column(JSON, nullable=False) + periods = Column(JSON, nullable=False) + save_path = Column(String(255)) + duration = Column(Integer, default=0) + save_interval = Column(Integer, default=60) + status = Column(String(20), default="pending", index=True) + subscription_id = Column(String(100)) + started_at = Column(DateTime) + stopped_at = Column(DateTime) + created_by = Column(String(50)) + created_at = Column(DateTime, server_default=func.now(), index=True) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class BatchTask(Base): + """批量任务表""" + __tablename__ = "batch_tasks" + + id = Column(Integer, primary_key=True, autoincrement=True) + task_type = Column(String(20), nullable=False, index=True) + task_params = Column(JSON) + total_count = Column(Integer, default=0) + processed_count = Column(Integer, default=0) + success_count = Column(Integer, default=0) + failed_count = Column(Integer, default=0) + status = Column(String(20), default="pending", index=True) + output_path = Column(String(255)) + error_message = Column(Text) + started_at = Column(DateTime) + completed_at = Column(DateTime) + created_by = Column(String(50)) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class CacheRecord(Base): + """数据缓存记录表""" + __tablename__ = "cache_records" + + id = Column(Integer, primary_key=True, autoincrement=True) + filename = Column(String(255), nullable=False, index=True) + file_type = Column(String(20), nullable=False, index=True) + trading_day = Column(String(8), index=True) + code = Column(String(20)) + period = Column(String(10)) + record_count = Column(Integer, default=0) + file_size = Column(BigInteger, default=0) + file_path = Column(String(255)) + created_at = Column(DateTime, server_default=func.now()) + + +class OperationLog(Base): + """操作日志表""" + __tablename__ = "operation_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, index=True) + operation = Column(String(50), nullable=False, index=True) + resource = Column(String(100)) + detail = Column(Text) + ip_address = Column(String(45)) + status = Column(String(20), default="success") + error_message = Column(Text) + created_at = Column(DateTime, server_default=func.now(), index=True) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..1a493bc --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,15 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +pymysql==1.1.0 +cryptography==41.0.7 +pydantic==2.5.2 +pydantic-settings==2.1.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +websockets==12.0 +pandas==2.1.4 +numpy==1.26.2 +python-dotenv==1.0.0 +aiofiles==23.2.1 \ No newline at end of file diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..c5969fc --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1,4 @@ +from backend.services.data_service import AmazingDataPlatformService +from backend.services.config_service import ConfigService + +__all__ = ["AmazingDataPlatformService", "ConfigService"] \ No newline at end of file diff --git a/backend/services/config_service.py b/backend/services/config_service.py new file mode 100644 index 0000000..a828135 --- /dev/null +++ b/backend/services/config_service.py @@ -0,0 +1,65 @@ +""" +AmazingData 数据服务平台 - 配置服务 +""" + +from typing import List, Optional, Dict, Any +from sqlalchemy.orm import Session +from backend.models.tables import SystemConfig +from backend.config import settings + + +class ConfigService: + """配置服务""" + + @staticmethod + def get_all_configs(db: Session) -> List[SystemConfig]: + """获取所有配置""" + return db.query(SystemConfig).all() + + @staticmethod + def get_config(db: Session, key: str) -> Optional[SystemConfig]: + """获取单个配置""" + return db.query(SystemConfig).filter(SystemConfig.config_key == key).first() + + @staticmethod + def get_config_value(db: Session, key: str, default: str = None) -> str: + """获取配置值""" + config = db.query(SystemConfig).filter(SystemConfig.config_key == key).first() + return config.config_value if config else default + + @staticmethod + def update_config(db: Session, key: str, value: str) -> Optional[SystemConfig]: + """更新配置""" + config = db.query(SystemConfig).filter(SystemConfig.config_key == key).first() + if config: + config.config_value = value + db.commit() + db.refresh(config) + return config + + @staticmethod + def batch_update_configs(db: Session, configs: Dict[str, str]) -> bool: + """批量更新配置""" + try: + for key, value in configs.items(): + config = db.query(SystemConfig).filter(SystemConfig.config_key == key).first() + if config: + config.config_value = value + db.commit() + return True + except Exception: + db.rollback() + return False + + @staticmethod + def get_amazing_data_config(db: Session) -> Dict[str, Any]: + """获取 AmazingData 配置""" + return { + "username": ConfigService.get_config_value(db, "amazing_data_username", settings.AMAZING_DATA_USERNAME), + "password": ConfigService.get_config_value(db, "amazing_data_password", settings.AMAZING_DATA_PASSWORD), + "host": ConfigService.get_config_value(db, "amazing_data_host", settings.AMAZING_DATA_HOST), + "port": int(ConfigService.get_config_value(db, "amazing_data_port", str(settings.AMAZING_DATA_PORT))), + } + + +config_service = ConfigService() \ No newline at end of file diff --git a/backend/services/data_service.py b/backend/services/data_service.py new file mode 100644 index 0000000..17668f4 --- /dev/null +++ b/backend/services/data_service.py @@ -0,0 +1,323 @@ +""" +AmazingData 数据服务平台 - 数据服务 +封装现有的 data_service.py 功能 +""" + +import sys +import os +import json +import threading +from datetime import datetime, date +from typing import Dict, List, Optional, Any, Callable +from pathlib import Path + +# 添加父目录到路径以导入现有模块 +parent_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, parent_dir) + +from backend.config import settings + + +class AmazingDataPlatformService: + """AmazingData 数据平台服务""" + + def __init__(self): + self.adapter = None + self.connected = False + self._lock = threading.Lock() + self.data_save_path = settings.DATA_SAVE_PATH + + def connect(self) -> bool: + """连接 AmazingData""" + try: + import AmazingData as ad + + ret = ad.login( + username=settings.AMAZING_DATA_USERNAME, + password=settings.AMAZING_DATA_PASSWORD, + host=settings.AMAZING_DATA_HOST, + port=settings.AMAZING_DATA_PORT + ) + + if ret: + self.connected = True + self.adapter = ad + return True + return False + except Exception as e: + print(f"Connect error: {e}") + return False + + def disconnect(self) -> bool: + """断开连接""" + try: + if self.connected and self.adapter: + import AmazingData as ad + ad.logout(settings.AMAZING_DATA_USERNAME) + self.connected = False + return True + except Exception as e: + print(f"Disconnect error: {e}") + return False + + def get_single_kline( + self, + code: str, + trading_day: Optional[str] = None, + period: str = "day", + save_path: Optional[str] = None + ) -> Dict[str, Any]: + """获取单只股票/期货K线数据""" + try: + import AmazingData as ad + + if not self.connected: + self.connect() + + # 获取历史数据 + history = ad.HistoryData() + + # 转换周期 + period_map = { + "day": ad.constant.Period.day.value, + "min1": ad.constant.Period.min1.value, + "min5": ad.constant.Period.min5.value, + "min15": ad.constant.Period.min15.value, + "min30": ad.constant.Period.min30.value, + "min60": ad.constant.Period.min60.value, + } + period_value = period_map.get(period, ad.constant.Period.day.value) + + # 如果没有指定交易日,使用最近交易日 + if not trading_day: + trading_day = self._get_latest_trading_day() + + # 获取数据 + start_date = f"{trading_day[:4]}-{trading_day[4:6]}-{trading_day[6:8]}" + end_date = start_date + + df = history.get_kline_data( + code=code, + period=period_value, + start_date=start_date, + end_date=end_date + ) + + # 转换数据 + data_list = [] + if df is not None and len(df) > 0: + data_list = df.to_dict('records') + + # 保存文件 + if save_path is None: + save_path = os.path.join(self.data_save_path, "single") + + os.makedirs(save_path, exist_ok=True) + safe_code = code.replace('.', '_') + filename = f"{safe_code}_{trading_day}_{period}.json" + filepath = os.path.join(save_path, filename) + + result = { + "code": code, + "trading_day": trading_day, + "period": period, + "data": data_list, + "count": len(data_list) + } + + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(result, f, ensure_ascii=False, indent=2) + + result["file_path"] = filepath + return result + + except Exception as e: + return {"error": str(e), "code": code, "trading_day": trading_day or "", "data": [], "count": 0} + + def batch_get_stock_kline( + self, + codes: Optional[List[str]] = None, + trading_days: Optional[List[str]] = None, + save_path: Optional[str] = None, + batch_size: int = 100 + ) -> Dict[str, str]: + """批量获取股票K线数据""" + try: + import AmazingData as ad + + if not self.connected: + self.connect() + + if save_path is None: + save_path = os.path.join(self.data_save_path, "stock") + os.makedirs(save_path, exist_ok=True) + + # 获取股票代码 + if codes is None: + base = ad.BaseData() + codes = base.get_code_list("EXTRA_STOCK_A") + + # 获取交易日 + if trading_days is None: + trading_days = [self._get_latest_trading_day()] + + result_files = {} + history = ad.HistoryData() + + for trading_day in trading_days: + start_date = f"{trading_day[:4]}-{trading_day[4:6]}-{trading_day[6:8]}" + day_data = {} + + for i, code in enumerate(codes): + try: + period_value = ad.constant.Period.day.value + df = history.get_kline_data( + code=code, + period=period_value, + start_date=start_date, + end_date=start_date + ) + if df is not None and len(df) > 0: + day_data[code] = df.to_dict('records') + except Exception: + continue + + # 保存文件 + filename = f"kline_{trading_day}.json" + filepath = os.path.join(save_path, filename) + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(day_data, f, ensure_ascii=False, indent=2) + + result_files[trading_day] = filepath + + return result_files + + except Exception as e: + return {"error": str(e)} + + def batch_get_future_kline( + self, + underlying_codes: Optional[List[str]] = None, + use_main_contract: bool = True, + trading_days: Optional[List[str]] = None, + save_path: Optional[str] = None + ) -> Dict[str, str]: + """批量获取期货K线数据""" + try: + import AmazingData as ad + + if not self.connected: + self.connect() + + if save_path is None: + save_path = os.path.join(self.data_save_path, "future") + os.makedirs(save_path, exist_ok=True) + + # 获取交易日 + if trading_days is None: + trading_days = [self._get_latest_trading_day()] + + result_files = {} + history = ad.HistoryData() + + for trading_day in trading_days: + start_date = f"{trading_day[:4]}-{trading_day[4:6]}-{trading_day[6:8]}" + all_data = [] + + # 获取期货代码 + if underlying_codes: + codes = [] + for uc in underlying_codes: + if '.' in uc: + codes.append(uc) + else: + # 简单主力合约识别 + codes.append(f"{uc}2605.SHF") + else: + base = ad.BaseData() + codes = base.get_code_list("EXTRA_FUTURE")[:50] # 限制数量 + + for code in codes: + try: + period_value = ad.constant.Period.day.value + df = history.get_kline_data( + code=code, + period=period_value, + start_date=start_date, + end_date=start_date + ) + if df is not None and len(df) > 0: + all_data.extend(df.to_dict('records')) + except Exception: + continue + + # 保存文件 + filename = f"futures_{trading_day}.json" + filepath = os.path.join(save_path, filename) + result = { + "metadata": { + "source": "AmazingData", + "trading_day": trading_day, + "fetch_time": datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + "total_codes": len(codes), + "total_records": len(all_data) + }, + "data": all_data + } + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(result, f, ensure_ascii=False, indent=2) + + result_files[trading_day] = filepath + + return result_files + + except Exception as e: + return {"error": str(e)} + + def get_stock_codes(self) -> List[str]: + """获取股票代码列表""" + try: + import AmazingData as ad + if not self.connected: + self.connect() + base = ad.BaseData() + return base.get_code_list("EXTRA_STOCK_A") + except Exception: + return [] + + def get_future_codes(self) -> List[str]: + """获取期货代码列表""" + try: + import AmazingData as ad + if not self.connected: + self.connect() + base = ad.BaseData() + return base.get_code_list("EXTRA_FUTURE") + except Exception: + return [] + + def _get_latest_trading_day(self) -> str: + """获取最近交易日""" + today = date.today() + return today.strftime('%Y%m%d') + + def test_connection(self) -> Dict[str, Any]: + """测试连接""" + try: + import AmazingData as ad + ret = ad.login( + username=settings.AMAZING_DATA_USERNAME, + password=settings.AMAZING_DATA_PASSWORD, + host=settings.AMAZING_DATA_HOST, + port=settings.AMAZING_DATA_PORT + ) + if ret: + ad.logout(settings.AMAZING_DATA_USERNAME) + return {"success": True, "message": "Connection successful"} + return {"success": False, "message": "Login failed"} + except Exception as e: + return {"success": False, "message": str(e)} + + +# 全局服务实例 +data_service = AmazingDataPlatformService() \ No newline at end of file diff --git a/database/init.sql b/database/init.sql new file mode 100644 index 0000000..629ee47 --- /dev/null +++ b/database/init.sql @@ -0,0 +1,144 @@ +-- ===================================================== +-- AmazingData 数据服务平台 - MySQL 数据库初始化脚本 +-- 版本: 1.0 +-- 日期: 2026-04-09 +-- ===================================================== + +CREATE DATABASE IF NOT EXISTS amazingdata_platform DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE amazingdata_platform; + +-- ===================================================== +-- 1. 系统配置表 +-- ===================================================== +CREATE TABLE IF NOT EXISTS `system_config` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `config_key` VARCHAR(100) NOT NULL UNIQUE COMMENT '配置键', + `config_value` TEXT COMMENT '配置值', + `config_type` VARCHAR(20) DEFAULT 'string' COMMENT '类型: string/number/boolean/json', + `description` VARCHAR(255) COMMENT '描述', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_config_key` (`config_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置表'; + +-- ===================================================== +-- 2. 用户表 +-- ===================================================== +CREATE TABLE IF NOT EXISTS `users` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', + `password_hash` VARCHAR(255) NOT NULL COMMENT '密码哈希', + `role` VARCHAR(20) DEFAULT 'user' COMMENT '角色: admin/user', + `is_active` TINYINT(1) DEFAULT 1 COMMENT '是否激活', + `last_login` DATETIME COMMENT '最后登录时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_username` (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; + +-- ===================================================== +-- 3. 订阅任务表 +-- ===================================================== +CREATE TABLE IF NOT EXISTS `subscription_tasks` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `task_name` VARCHAR(100) NOT NULL COMMENT '任务名称', + `codes` JSON NOT NULL COMMENT '品种代码列表', + `periods` JSON NOT NULL COMMENT '订阅周期列表', + `save_path` VARCHAR(255) COMMENT '保存路径', + `duration` INT DEFAULT 0 COMMENT '运行时长(秒), 0=无限', + `save_interval` INT DEFAULT 60 COMMENT '保存间隔(秒)', + `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/running/stopped/error', + `subscription_id` VARCHAR(100) COMMENT '订阅实例ID', + `started_at` DATETIME COMMENT '开始时间', + `stopped_at` DATETIME COMMENT '停止时间', + `created_by` VARCHAR(50) COMMENT '创建人', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_status` (`status`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅任务表'; + +-- ===================================================== +-- 4. 批量任务表 +-- ===================================================== +CREATE TABLE IF NOT EXISTS `batch_tasks` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `task_type` VARCHAR(20) NOT NULL COMMENT '类型: stock/future', + `task_params` JSON COMMENT '任务参数', + `total_count` INT DEFAULT 0 COMMENT '总数量', + `processed_count` INT DEFAULT 0 COMMENT '已处理数量', + `success_count` INT DEFAULT 0 COMMENT '成功数量', + `failed_count` INT DEFAULT 0 COMMENT '失败数量', + `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态', + `output_path` VARCHAR(255) COMMENT '输出路径', + `error_message` TEXT COMMENT '错误信息', + `started_at` DATETIME, + `completed_at` DATETIME, + `created_by` VARCHAR(50), + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_status` (`status`), + INDEX `idx_task_type` (`task_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='批量任务表'; + +-- ===================================================== +-- 5. 数据缓存记录表 +-- ===================================================== +CREATE TABLE IF NOT EXISTS `cache_records` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `filename` VARCHAR(255) NOT NULL COMMENT '文件名', + `file_type` VARCHAR(20) NOT NULL COMMENT '类型: stock/future/realtime', + `trading_day` VARCHAR(8) COMMENT '交易日', + `code` VARCHAR(20) COMMENT '代码', + `period` VARCHAR(10) COMMENT '周期', + `record_count` INT DEFAULT 0 COMMENT '记录数', + `file_size` BIGINT DEFAULT 0 COMMENT '文件大小(字节)', + `file_path` VARCHAR(255) COMMENT '完整路径', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_file_type` (`file_type`), + INDEX `idx_trading_day` (`trading_day`), + INDEX `idx_filename` (`filename`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据缓存记录表'; + +-- ===================================================== +-- 6. 操作日志表 +-- ===================================================== +CREATE TABLE IF NOT EXISTS `operation_logs` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT COMMENT '用户ID', + `operation` VARCHAR(50) NOT NULL COMMENT '操作类型', + `resource` VARCHAR(100) COMMENT '操作资源', + `detail` TEXT COMMENT '详细信息', + `ip_address` VARCHAR(45) COMMENT 'IP地址', + `status` VARCHAR(20) DEFAULT 'success' COMMENT '状态', + `error_message` TEXT COMMENT '错误信息', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_user_id` (`user_id`), + INDEX `idx_operation` (`operation`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表'; + +-- ===================================================== +-- 初始数据 +-- ===================================================== + +-- 系统配置初始数据 +INSERT INTO `system_config` (`config_key`, `config_value`, `config_type`, `description`) VALUES +('amazing_data_username', '11200008169', 'string', 'AmazingData 用户名'), +('amazing_data_password', '11200008169@2026', 'string', 'AmazingData 密码'), +('amazing_data_host', '140.206.44.234', 'string', 'AmazingData 服务器地址'), +('amazing_data_port', '8600', 'number', 'AmazingData 端口'), +('realtime_save_days', '7', 'number', '实时数据保存天数'), +('cache_auto_save_interval', '60', 'number', '缓存自动保存间隔(秒)'), +('max_concurrent_tasks', '5', 'number', '最大并发任务数'), +('data_save_path', './data', 'string', '数据保存路径'); + +-- 默认管理员账号 (密码: admin123) +-- 密码哈希使用 bcrypt 生成 +INSERT INTO `users` (`username`, `password_hash`, `role`) VALUES +('admin', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYILp92S.0i', 'admin'); + +-- ===================================================== +-- 完成 +-- ===================================================== +SELECT 'Database initialization completed successfully!' AS message; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8da016c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,67 @@ +version: '3.8' + +services: + # MySQL 数据库 + mysql: + image: mysql:8.0 + container_name: amazingdata-mysql + restart: always + environment: + MYSQL_ROOT_PASSWORD: root123 + MYSQL_DATABASE: amazingdata_platform + MYSQL_USER: amazingdata + MYSQL_PASSWORD: amazingdata123 + ports: + - "3307:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./database/init.sql:/docker-entrypoint-initdb.d/init.sql + command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + timeout: 20s + retries: 10 + + # 后端服务 + backend: + build: + context: . + dockerfile: Dockerfile.backend + container_name: amazingdata-backend + restart: always + ports: + - "8000:8000" + environment: + - DB_HOST=mysql + - DB_PORT=3306 + - DB_USER=amazingdata + - DB_PASSWORD=amazingdata123 + - DB_NAME=amazingdata_platform + - AMAZING_DATA_USERNAME=${AMAZING_DATA_USERNAME:-11200008169} + - AMAZING_DATA_PASSWORD=${AMAZING_DATA_PASSWORD:-11200008169@2026} + - AMAZING_DATA_HOST=${AMAZING_DATA_HOST:-140.206.44.234} + - AMAZING_DATA_PORT=${AMAZING_DATA_PORT:-8600} + - SECRET_KEY=${SECRET_KEY:-your-secret-key} + - JWT_SECRET_KEY=${JWT_SECRET_KEY:-your-jwt-secret-key} + volumes: + - ./data:/app/data + depends_on: + mysql: + condition: service_healthy + command: uvicorn backend.main:app --host 0.0.0.0 --port 8000 + + # 前端服务 + frontend: + build: + context: . + dockerfile: Dockerfile.frontend + container_name: amazingdata-frontend + restart: always + ports: + - "3000:80" + depends_on: + - backend + +volumes: + mysql_data: + driver: local \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3754738 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + AmazingData 数据服务平台 + + +
+ + + \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..f0d6a65 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2207 @@ +{ + "name": "amazingdata-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "amazingdata-frontend", + "version": "1.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.6.2", + "dayjs": "^1.11.10", + "echarts": "^5.4.3", + "element-plus": "^2.4.4", + "pinia": "^2.1.7", + "vue": "^3.4.0", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.5.2", + "sass": "^1.69.5", + "vite": "^5.0.8" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz", + "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.0.0 || ^5.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/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.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/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.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/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.6", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.6.tgz", + "integrity": "sha512-XHgwXr8Fjz6i+6BaqFhAbae/dJbG7bBAAlHrY3pWL7dpj+JcqcOyKYt4Oy5KP86FQwS1k4uIZDjCx2FyUR5lDg==", + "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.npmjs.org/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.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/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.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "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.npmjs.org/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.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/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.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "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.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "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.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/sass": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", + "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.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", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz", + "integrity": "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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..cecfa77 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "amazingdata-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.2.5", + "pinia": "^2.1.7", + "element-plus": "^2.4.4", + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.6.2", + "echarts": "^5.4.3", + "dayjs": "^1.11.10" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.5.2", + "vite": "^5.0.8", + "sass": "^1.69.5" + } +} \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..7716628 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..96e1fcf --- /dev/null +++ b/frontend/src/api/index.js @@ -0,0 +1,50 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' + +const api = axios.create({ + baseURL: '', + timeout: 30000 +}) + +// 请求拦截器 +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// 响应拦截器 +api.interceptors.response.use( + (response) => { + const data = response.data + if (data.code && data.code !== 200) { + ElMessage.error(data.message || '请求失败') + return Promise.reject(new Error(data.message)) + } + return response + }, + (error) => { + if (error.response) { + const { status, data } = error.response + if (status === 401) { + localStorage.removeItem('token') + localStorage.removeItem('userInfo') + window.location.href = '/login' + } else { + ElMessage.error(data?.detail || data?.message || '请求失败') + } + } else { + ElMessage.error('网络错误') + } + return Promise.reject(error) + } +) + +export default api \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..8995cfa --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,22 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' +import App from './App.vue' +import router from './router' + +const app = createApp(App) +const pinia = createPinia() + +// 注册所有 Element Plus 图标 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(pinia) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) + +app.mount('#app') \ No newline at end of file diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..e1260fb --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,81 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/Login.vue'), + meta: { requiresAuth: false } + }, + { + path: '/', + component: () => import('@/views/Layout.vue'), + redirect: '/dashboard', + meta: { requiresAuth: true }, + children: [ + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('@/views/Dashboard.vue'), + meta: { title: '仪表盘' } + }, + { + path: 'historical', + name: 'Historical', + component: () => import('@/views/Historical.vue'), + meta: { title: '历史数据' } + }, + { + path: 'realtime', + name: 'Realtime', + component: () => import('@/views/Realtime.vue'), + meta: { title: '实时订阅' } + }, + { + path: 'batch', + name: 'Batch', + component: () => import('@/views/Batch.vue'), + meta: { title: '批量操作' } + }, + { + path: 'cache', + name: 'Cache', + component: () => import('@/views/Cache.vue'), + meta: { title: '缓存管理' } + }, + { + path: 'settings', + name: 'Settings', + component: () => import('@/views/Settings.vue'), + meta: { title: '系统配置' } + }, + { + path: 'api-test', + name: 'ApiTest', + component: () => import('@/views/ApiTest.vue'), + meta: { title: 'API测试' } + } + ] + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// 路由守卫 +router.beforeEach((to, from, next) => { + const authStore = useAuthStore() + + if (to.meta.requiresAuth !== false && !authStore.isLoggedIn) { + next('/login') + } else if (to.path === '/login' && authStore.isLoggedIn) { + next('/') + } else { + next() + } +}) + +export default router \ No newline at end of file diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js new file mode 100644 index 0000000..5156573 --- /dev/null +++ b/frontend/src/stores/auth.js @@ -0,0 +1,43 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import api from '@/api' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem('token') || '') + const userInfo = ref(JSON.parse(localStorage.getItem('userInfo') || 'null')) + + const isLoggedIn = computed(() => !!token.value) + + async function login(username, password) { + const formData = new URLSearchParams() + formData.append('username', username) + formData.append('password', password) + + const res = await api.post('/api/v1/auth/login', formData, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }) + + token.value = res.data.access_token + userInfo.value = res.data.user_info + + localStorage.setItem('token', res.data.access_token) + localStorage.setItem('userInfo', JSON.stringify(res.data.user_info)) + + return res.data + } + + function logout() { + token.value = '' + userInfo.value = null + localStorage.removeItem('token') + localStorage.removeItem('userInfo') + } + + return { + token, + userInfo, + isLoggedIn, + login, + logout + } +}) \ No newline at end of file diff --git a/frontend/src/views/ApiTest.vue b/frontend/src/views/ApiTest.vue new file mode 100644 index 0000000..4221781 --- /dev/null +++ b/frontend/src/views/ApiTest.vue @@ -0,0 +1,807 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Batch.vue b/frontend/src/views/Batch.vue new file mode 100644 index 0000000..54d57d1 --- /dev/null +++ b/frontend/src/views/Batch.vue @@ -0,0 +1,164 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Cache.vue b/frontend/src/views/Cache.vue new file mode 100644 index 0000000..16317ae --- /dev/null +++ b/frontend/src/views/Cache.vue @@ -0,0 +1,229 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..e24389b --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,187 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Historical.vue b/frontend/src/views/Historical.vue new file mode 100644 index 0000000..4cf9f3a --- /dev/null +++ b/frontend/src/views/Historical.vue @@ -0,0 +1,181 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue new file mode 100644 index 0000000..c27331a --- /dev/null +++ b/frontend/src/views/Layout.vue @@ -0,0 +1,128 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..1bfbfb3 --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,108 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Realtime.vue b/frontend/src/views/Realtime.vue new file mode 100644 index 0000000..8c4ceb5 --- /dev/null +++ b/frontend/src/views/Realtime.vue @@ -0,0 +1,157 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue new file mode 100644 index 0000000..6143bca --- /dev/null +++ b/frontend/src/views/Settings.vue @@ -0,0 +1,121 @@ + + + \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..11d21c0 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true + } + } + }, + build: { + outDir: 'dist', + assetsDir: 'assets' + } +}) \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..74c9f4f --- /dev/null +++ b/nginx.conf @@ -0,0 +1,28 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # 前端静态文件 + location / { + try_files $uri $uri/ /index.html; + } + + # API 代理到后端 + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 健康检查 + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } +} \ No newline at end of file