From 9847157980397311f0155b1a5c09f6d06a1ce4e7 Mon Sep 17 00:00:00 2001 From: Krane <56824280+freesme@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:56:38 +0800 Subject: [PATCH] =?UTF-8?q?Feature/React=20web=20support=20=E6=96=B0?= =?UTF-8?q?=E7=9A=84WebUI=20(#256)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): add FastAPI & React web connect * feat: add FastAPI package * feat: system base struct * feat: frontend base struct * feat: 基础分析接口,历史记录接口 * fix: 使用正确的 FastAPI 方法定义,避免线程阻塞 * fix: 对接历史报告页、详情页 * fix: 修复接口问题 * fix: 网络配置 允许公网访问 跨域配置 * fix: 页面打包 & 提供 Server 静态访问 * fix: 优化dock栏样式 * fix: 优化图标样式 * fix: 修改部分配色 * fix: 删除垃圾文档 * fix: 删除垃圾代码 * fix: 驼峰转换工具 * fix: 历史记录列表滚动加载(分页) * feat: 显示分析中任务 * fix: 调整布局 * fix: 优化 Market Sentiment 组件动效 * fix: 历史列表组件bug * fix: FastAPI 日志配置 * feat: 新闻历史接口 * feat: 资讯列表 * feat: 优化布局 * fix: 修复页面元素变宽问题 * fix: 中文标题 * fix: 任务列表状态显示问题 * fix: 抽取日志配置 * fix: 优化报告价格显示 * fix: 修复编译错误 * fix: FastAPI 启动提取到 main.py * fix: 补充新web-ui启动文档 * fix: 默认发送通知 * fix: FastAPI 模式适配 docker * fix: FastAPI 模式文档 * Update api/v1/endpoints/stocks.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: package-lock.json * fix: 修改错别字 * fix: 更新文档说明 * fix: 更新文档说明 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 25 +- api/__init__.py | 12 + api/app.py | 164 + api/deps.py | 60 + api/middlewares/__init__.py | 13 + api/middlewares/error_handler.py | 128 + api/v1/__init__.py | 13 + api/v1/endpoints/__init__.py | 13 + api/v1/endpoints/analysis.py | 539 ++ api/v1/endpoints/health.py | 34 + api/v1/endpoints/history.py | 282 ++ api/v1/endpoints/stocks.py | 176 + api/v1/router.py | 35 + api/v1/schemas/__init__.py | 65 + api/v1/schemas/analysis.py | 220 + api/v1/schemas/common.py | 78 + api/v1/schemas/history.py | 175 + api/v1/schemas/stocks.py | 95 + apps/dsa-web/.gitignore | 24 + apps/dsa-web/eslint.config.js | 23 + apps/dsa-web/index.html | 13 + apps/dsa-web/package-lock.json | 4397 +++++++++++++++++ apps/dsa-web/package.json | 39 + apps/dsa-web/postcss.config.js | 6 + apps/dsa-web/public/vite.svg | 1 + apps/dsa-web/src/App.css | 42 + apps/dsa-web/src/App.tsx | 104 + apps/dsa-web/src/api/analysis.ts | 146 + apps/dsa-web/src/api/history.ts | 70 + apps/dsa-web/src/api/index.ts | 12 + apps/dsa-web/src/api/utils.ts | 13 + apps/dsa-web/src/assets/react.svg | 1 + apps/dsa-web/src/components/common/Badge.tsx | 58 + apps/dsa-web/src/components/common/Button.tsx | 121 + apps/dsa-web/src/components/common/Card.tsx | 92 + .../src/components/common/Collapsible.tsx | 69 + apps/dsa-web/src/components/common/Drawer.tsx | 91 + .../src/components/common/JsonViewer.tsx | 92 + .../dsa-web/src/components/common/Loading.tsx | 9 + .../src/components/common/Pagination.tsx | 111 + .../src/components/common/ScoreGauge.tsx | 186 + apps/dsa-web/src/components/common/Select.tsx | 80 + apps/dsa-web/src/components/common/index.ts | 10 + .../src/components/history/HistoryList.tsx | 160 + apps/dsa-web/src/components/history/index.ts | 1 + .../src/components/report/ReportDetails.tsx | 127 + .../src/components/report/ReportNews.tsx | 137 + .../src/components/report/ReportOverview.tsx | 133 + .../src/components/report/ReportStrategy.tsx | 82 + .../src/components/report/ReportSummary.tsx | 46 + apps/dsa-web/src/components/report/index.ts | 5 + .../src/components/tasks/TaskPanel.tsx | 160 + apps/dsa-web/src/components/tasks/index.ts | 2 + apps/dsa-web/src/hooks/index.ts | 7 + apps/dsa-web/src/hooks/useTaskStream.ts | 249 + apps/dsa-web/src/index.css | 739 +++ apps/dsa-web/src/main.tsx | 10 + apps/dsa-web/src/pages/HomePage.tsx | 322 ++ apps/dsa-web/src/pages/NotFoundPage.tsx | 38 + apps/dsa-web/src/stores/analysisStore.ts | 67 + apps/dsa-web/src/stores/index.ts | 1 + apps/dsa-web/src/types/analysis.ts | 194 + apps/dsa-web/src/utils/constants.ts | 2 + apps/dsa-web/src/utils/format.ts | 45 + apps/dsa-web/src/utils/validation.ts | 29 + apps/dsa-web/tailwind.config.js | 95 + apps/dsa-web/tsconfig.app.json | 28 + apps/dsa-web/tsconfig.json | 7 + apps/dsa-web/tsconfig.node.json | 26 + apps/dsa-web/vite.config.ts | 23 + docker/Dockerfile | 19 +- docker/docker-compose.yml | 11 + docs/architecture/api_spec.json | 961 ++++ docs/docker/zeabur-deployment.md | 26 +- docs/full-guide.md | 33 +- main.py | 210 +- requirements.txt | 4 + server.py | 54 + sources/fastapi_server.png | Bin 0 -> 160297 bytes src/analyzer.py | 6 + src/core/pipeline.py | 7 + src/logging_config.py | 120 + src/repositories/__init__.py | 17 + src/repositories/analysis_repo.py | 130 + src/repositories/stock_repo.py | 138 + src/services/__init__.py | 19 + src/services/analysis_service.py | 178 + src/services/history_service.py | 222 + src/services/stock_service.py | 186 + src/services/task_queue.py | 537 ++ src/storage.py | 94 +- 91 files changed, 13527 insertions(+), 117 deletions(-) create mode 100644 api/__init__.py create mode 100644 api/app.py create mode 100644 api/deps.py create mode 100644 api/middlewares/__init__.py create mode 100644 api/middlewares/error_handler.py create mode 100644 api/v1/__init__.py create mode 100644 api/v1/endpoints/__init__.py create mode 100644 api/v1/endpoints/analysis.py create mode 100644 api/v1/endpoints/health.py create mode 100644 api/v1/endpoints/history.py create mode 100644 api/v1/endpoints/stocks.py create mode 100644 api/v1/router.py create mode 100644 api/v1/schemas/__init__.py create mode 100644 api/v1/schemas/analysis.py create mode 100644 api/v1/schemas/common.py create mode 100644 api/v1/schemas/history.py create mode 100644 api/v1/schemas/stocks.py create mode 100644 apps/dsa-web/.gitignore create mode 100644 apps/dsa-web/eslint.config.js create mode 100644 apps/dsa-web/index.html create mode 100644 apps/dsa-web/package-lock.json create mode 100644 apps/dsa-web/package.json create mode 100644 apps/dsa-web/postcss.config.js create mode 100644 apps/dsa-web/public/vite.svg create mode 100644 apps/dsa-web/src/App.css create mode 100644 apps/dsa-web/src/App.tsx create mode 100644 apps/dsa-web/src/api/analysis.ts create mode 100644 apps/dsa-web/src/api/history.ts create mode 100644 apps/dsa-web/src/api/index.ts create mode 100644 apps/dsa-web/src/api/utils.ts create mode 100644 apps/dsa-web/src/assets/react.svg create mode 100644 apps/dsa-web/src/components/common/Badge.tsx create mode 100644 apps/dsa-web/src/components/common/Button.tsx create mode 100644 apps/dsa-web/src/components/common/Card.tsx create mode 100644 apps/dsa-web/src/components/common/Collapsible.tsx create mode 100644 apps/dsa-web/src/components/common/Drawer.tsx create mode 100644 apps/dsa-web/src/components/common/JsonViewer.tsx create mode 100644 apps/dsa-web/src/components/common/Loading.tsx create mode 100644 apps/dsa-web/src/components/common/Pagination.tsx create mode 100644 apps/dsa-web/src/components/common/ScoreGauge.tsx create mode 100644 apps/dsa-web/src/components/common/Select.tsx create mode 100644 apps/dsa-web/src/components/common/index.ts create mode 100644 apps/dsa-web/src/components/history/HistoryList.tsx create mode 100644 apps/dsa-web/src/components/history/index.ts create mode 100644 apps/dsa-web/src/components/report/ReportDetails.tsx create mode 100644 apps/dsa-web/src/components/report/ReportNews.tsx create mode 100644 apps/dsa-web/src/components/report/ReportOverview.tsx create mode 100644 apps/dsa-web/src/components/report/ReportStrategy.tsx create mode 100644 apps/dsa-web/src/components/report/ReportSummary.tsx create mode 100644 apps/dsa-web/src/components/report/index.ts create mode 100644 apps/dsa-web/src/components/tasks/TaskPanel.tsx create mode 100644 apps/dsa-web/src/components/tasks/index.ts create mode 100644 apps/dsa-web/src/hooks/index.ts create mode 100644 apps/dsa-web/src/hooks/useTaskStream.ts create mode 100644 apps/dsa-web/src/index.css create mode 100644 apps/dsa-web/src/main.tsx create mode 100644 apps/dsa-web/src/pages/HomePage.tsx create mode 100644 apps/dsa-web/src/pages/NotFoundPage.tsx create mode 100644 apps/dsa-web/src/stores/analysisStore.ts create mode 100644 apps/dsa-web/src/stores/index.ts create mode 100644 apps/dsa-web/src/types/analysis.ts create mode 100644 apps/dsa-web/src/utils/constants.ts create mode 100644 apps/dsa-web/src/utils/format.ts create mode 100644 apps/dsa-web/src/utils/validation.ts create mode 100644 apps/dsa-web/tailwind.config.js create mode 100644 apps/dsa-web/tsconfig.app.json create mode 100644 apps/dsa-web/tsconfig.json create mode 100644 apps/dsa-web/tsconfig.node.json create mode 100644 apps/dsa-web/vite.config.ts create mode 100644 docs/architecture/api_spec.json create mode 100644 server.py create mode 100644 sources/fastapi_server.png create mode 100644 src/logging_config.py create mode 100644 src/repositories/__init__.py create mode 100644 src/repositories/analysis_repo.py create mode 100644 src/repositories/stock_repo.py create mode 100644 src/services/__init__.py create mode 100644 src/services/analysis_service.py create mode 100644 src/services/history_service.py create mode 100644 src/services/stock_service.py create mode 100644 src/services/task_queue.py diff --git a/README.md b/README.md index a5b5b48c8..82a96d0cf 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ python main.py > 📖 完整环境变量、定时任务配置请参考 [完整配置指南](docs/full-guide.md) -## 🖥️ 本地 WebUI(可选) +## 🖥️ 本地 WebUI(可选 - 将在后续的版本弃用) ```bash python main.py --webui # 启动 WebUI + 执行分析 @@ -205,6 +205,29 @@ python main.py --webui-only # 仅启动 WebUI > 详细说明请参考 [完整指南 - WebUI](docs/full-guide.md#本地-webui-管理界面) +## 🧩 FastAPI Web 服务(可选) + +![img.png](sources/fastapi_server.png) + +```bash +cd ./apps/dsa-web # 进入 React Web 目录 +npm install +npm run build # 编译 React Web 页面 会在根目录生成 /static 文件夹 + +cd ../.. # 返回项目根目录 +python main.py --serve # 启动 FastAPI + 执行分析 +python main.py --serve-only # 仅启动 FastAPI +python main.py --serve-only --host 0.0.0.0 --port 8000 # 指定启动端口 +``` + +访问 `http://127.0.0.1:8000` 即可使用该页面(注意一定要执行 `npm install` 步骤,否则没有页面) + +也可以使用下面命令单独启动: + +```bash +uvicorn server:app --reload --host 0.0.0.0 --port 8000 +``` + ## 🗺️ Roadmap 查看已支持的功能和未来规划:[更新日志](docs/CHANGELOG.md) diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 000000000..5cbed5b11 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +""" +=================================== +API 模块初始化 +=================================== + +职责: +1. 导出 API 模块的公共接口 +2. 统一版本管理 +""" + +__version__ = "1.0.0" diff --git a/api/app.py b/api/app.py new file mode 100644 index 000000000..f6f09adaa --- /dev/null +++ b/api/app.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +""" +=================================== +FastAPI 应用工厂模块 +=================================== + +职责: +1. 创建和配置 FastAPI 应用实例 +2. 配置 CORS 中间件 +3. 注册路由和异常处理器 +4. 托管前端静态文件(生产模式) + +使用方式: + from api.app import create_app + app = create_app() +""" + +import os +from datetime import datetime +from pathlib import Path +from typing import Optional + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + +from api.v1 import api_v1_router +from api.middlewares.error_handler import add_error_handlers +from api.v1.schemas.common import RootResponse, HealthResponse + + +def create_app(static_dir: Optional[Path] = None) -> FastAPI: + """ + 创建并配置 FastAPI 应用实例 + + Args: + static_dir: 静态文件目录路径(可选,默认为项目根目录下的 static) + + Returns: + 配置完成的 FastAPI 应用实例 + """ + # 默认静态文件目录 + if static_dir is None: + static_dir = Path(__file__).parent.parent / "static" + + # 创建 FastAPI 实例 + app = FastAPI( + title="Daily Stock Analysis API", + description=( + "A股/港股/美股自选股智能分析系统 API\n\n" + "## 功能模块\n" + "- 股票分析:触发 AI 智能分析\n" + "- 历史记录:查询历史分析报告\n" + "- 股票数据:获取行情数据\n\n" + "## 认证方式\n" + "当前版本暂无认证要求" + ), + version="1.0.0", + ) + + # ============================================================ + # CORS 配置 + # ============================================================ + + allowed_origins = [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:3000", + "http://127.0.0.1:3000", + ] + + # 从环境变量添加额外的允许来源 + extra_origins = os.environ.get("CORS_ORIGINS", "") + if extra_origins: + allowed_origins.extend([o.strip() for o in extra_origins.split(",") if o.strip()]) + + # 允许所有来源(开发/演示用) + if os.environ.get("CORS_ALLOW_ALL", "").lower() == "true": + allowed_origins = ["*"] + + app.add_middleware( + CORSMiddleware, + allow_origins=allowed_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # ============================================================ + # 注册路由 + # ============================================================ + + app.include_router(api_v1_router) + add_error_handlers(app) + + # ============================================================ + # 根路由和健康检查 + # ============================================================ + + has_frontend = static_dir.exists() and (static_dir / "index.html").exists() + + if has_frontend: + @app.get("/", include_in_schema=False) + async def root(): + """根路由 - 返回前端页面""" + return FileResponse(static_dir / "index.html") + else: + @app.get( + "/", + response_model=RootResponse, + tags=["Health"], + summary="API 根路由", + description="返回 API 运行状态信息" + ) + async def root() -> RootResponse: + """根路由 - API 状态信息""" + return RootResponse( + message="Daily Stock Analysis API is running", + version="1.0.0" + ) + + @app.get( + "/api/health", + response_model=HealthResponse, + tags=["Health"], + summary="健康检查", + description="用于负载均衡器或监控系统检查服务状态" + ) + async def health_check() -> HealthResponse: + """健康检查接口""" + return HealthResponse( + status="ok", + timestamp=datetime.now().isoformat() + ) + + # ============================================================ + # 静态文件托管(前端 SPA) + # ============================================================ + + if has_frontend: + # 挂载静态资源目录 + assets_dir = static_dir / "assets" + if assets_dir.exists(): + app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") + + # SPA 路由回退 + @app.get("/{full_path:path}", include_in_schema=False) + async def serve_spa(request: Request, full_path: str): + """SPA 路由回退 - 非 API 路由返回 index.html""" + if full_path.startswith("api/"): + return None + + file_path = static_dir / full_path + if file_path.exists() and file_path.is_file(): + return FileResponse(file_path) + + return FileResponse(static_dir / "index.html") + + return app + + +# 默认应用实例(供 uvicorn 直接使用) +app = create_app() diff --git a/api/deps.py b/api/deps.py new file mode 100644 index 000000000..a6c39ae95 --- /dev/null +++ b/api/deps.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +""" +=================================== +API 依赖注入模块 +=================================== + +职责: +1. 提供数据库 Session 依赖 +2. 提供配置依赖 +3. 提供服务层依赖 +""" + +from typing import Generator + +from sqlalchemy.orm import Session + +from src.storage import DatabaseManager +from src.config import get_config, Config + + +def get_db() -> Generator[Session, None, None]: + """ + 获取数据库 Session 依赖 + + 使用 FastAPI 依赖注入机制,确保请求结束后自动关闭 Session + + Yields: + Session: SQLAlchemy Session 对象 + + Example: + @router.get("/items") + async def get_items(db: Session = Depends(get_db)): + ... + """ + db_manager = DatabaseManager.get_instance() + session = db_manager.get_session() + try: + yield session + finally: + session.close() + + +def get_config_dep() -> Config: + """ + 获取配置依赖 + + Returns: + Config: 配置单例对象 + """ + return get_config() + + +def get_database_manager() -> DatabaseManager: + """ + 获取数据库管理器依赖 + + Returns: + DatabaseManager: 数据库管理器单例对象 + """ + return DatabaseManager.get_instance() diff --git a/api/middlewares/__init__.py b/api/middlewares/__init__.py new file mode 100644 index 000000000..0f9f4b602 --- /dev/null +++ b/api/middlewares/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +""" +=================================== +API 中间件模块初始化 +=================================== + +职责: +1. 导出所有中间件 +""" + +from api.middlewares.error_handler import ErrorHandlerMiddleware + +__all__ = ["ErrorHandlerMiddleware"] diff --git a/api/middlewares/error_handler.py b/api/middlewares/error_handler.py new file mode 100644 index 000000000..a88b76941 --- /dev/null +++ b/api/middlewares/error_handler.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +""" +=================================== +全局异常处理中间件 +=================================== + +职责: +1. 捕获未处理的异常 +2. 统一错误响应格式 +3. 记录错误日志 +""" + +import logging +import traceback +from typing import Callable + +from fastapi import Request, Response +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware + +logger = logging.getLogger(__name__) + + +class ErrorHandlerMiddleware(BaseHTTPMiddleware): + """ + 全局异常处理中间件 + + 捕获所有未处理的异常,返回统一格式的错误响应 + """ + + async def dispatch( + self, + request: Request, + call_next: Callable + ) -> Response: + """ + 处理请求,捕获异常 + + Args: + request: 请求对象 + call_next: 下一个处理器 + + Returns: + Response: 响应对象 + """ + try: + response = await call_next(request) + return response + + except Exception as e: + # 记录错误日志 + logger.error( + f"未处理的异常: {e}\n" + f"请求路径: {request.url.path}\n" + f"请求方法: {request.method}\n" + f"堆栈: {traceback.format_exc()}" + ) + + # 返回统一格式的错误响应 + return JSONResponse( + status_code=500, + content={ + "error": "internal_error", + "message": "服务器内部错误,请稍后重试", + "detail": str(e) if logger.isEnabledFor(logging.DEBUG) else None + } + ) + + +def add_error_handlers(app) -> None: + """ + 添加全局异常处理器 + + 为 FastAPI 应用添加各类异常的处理器 + + Args: + app: FastAPI 应用实例 + """ + from fastapi import HTTPException + from fastapi.exceptions import RequestValidationError + + @app.exception_handler(HTTPException) + async def http_exception_handler(request: Request, exc: HTTPException): + """处理 HTTP 异常""" + # 如果 detail 已经是 ErrorResponse 格式的 dict,直接使用 + if isinstance(exc.detail, dict) and "error" in exc.detail and "message" in exc.detail: + return JSONResponse( + status_code=exc.status_code, + content=exc.detail + ) + # 否则将 detail 包装成 ErrorResponse 格式 + return JSONResponse( + status_code=exc.status_code, + content={ + "error": "http_error", + "message": str(exc.detail) if exc.detail else "HTTP Error", + "detail": None + } + ) + + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(request: Request, exc: RequestValidationError): + """处理请求验证异常""" + return JSONResponse( + status_code=422, + content={ + "error": "validation_error", + "message": "请求参数验证失败", + "detail": exc.errors() + } + ) + + @app.exception_handler(Exception) + async def general_exception_handler(request: Request, exc: Exception): + """处理通用异常""" + logger.error( + f"未处理的异常: {exc}\n" + f"请求路径: {request.url.path}\n" + f"堆栈: {traceback.format_exc()}" + ) + return JSONResponse( + status_code=500, + content={ + "error": "internal_error", + "message": "服务器内部错误", + "detail": None + } + ) diff --git a/api/v1/__init__.py b/api/v1/__init__.py new file mode 100644 index 000000000..2c308a587 --- /dev/null +++ b/api/v1/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +""" +=================================== +API v1 模块初始化 +=================================== + +职责: +1. 导出 v1 版本 API 的路由 +""" + +from api.v1.router import router as api_v1_router + +__all__ = ["api_v1_router"] diff --git a/api/v1/endpoints/__init__.py b/api/v1/endpoints/__init__.py new file mode 100644 index 000000000..a4df60d20 --- /dev/null +++ b/api/v1/endpoints/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +""" +=================================== +API v1 Endpoints 模块初始化 +=================================== + +职责: +1. 导出所有 endpoint 路由模块 +""" + +from api.v1.endpoints import health, analysis, history, stocks + +__all__ = ["health", "analysis", "history", "stocks"] diff --git a/api/v1/endpoints/analysis.py b/api/v1/endpoints/analysis.py new file mode 100644 index 000000000..1cf2691dc --- /dev/null +++ b/api/v1/endpoints/analysis.py @@ -0,0 +1,539 @@ +# -*- coding: utf-8 -*- +""" +=================================== +股票分析接口 +=================================== + +职责: +1. 提供 POST /api/v1/analysis/analyze 触发分析接口 +2. 提供 GET /api/v1/analysis/status/{task_id} 查询任务状态接口 +3. 提供 GET /api/v1/analysis/tasks 获取任务列表接口 +4. 提供 GET /api/v1/analysis/tasks/stream SSE 实时推送接口 + +特性: +- 异步任务队列:分析任务异步执行,不阻塞请求 +- 防重复提交:相同股票代码正在分析时返回 409 +- SSE 实时推送:任务状态变化实时通知前端 +""" + +import asyncio +import json +import logging +from datetime import datetime +from typing import Optional, Union, Dict, Any + +from fastapi import APIRouter, HTTPException, Depends, Query +from fastapi.responses import JSONResponse, StreamingResponse + +from api.deps import get_config_dep +from api.v1.schemas.analysis import ( + AnalyzeRequest, + AnalysisResultResponse, + TaskAccepted, + TaskStatus, + TaskInfo, + TaskListResponse, + DuplicateTaskErrorResponse, +) +from api.v1.schemas.common import ErrorResponse +from api.v1.schemas.history import ( + AnalysisReport, + ReportMeta, + ReportSummary, + ReportStrategy, + ReportDetails, +) +from src.config import Config +from src.services.task_queue import ( + get_task_queue, + DuplicateTaskError, + TaskStatus as TaskStatusEnum, +) + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ============================================================ +# POST /analyze - 触发股票分析 +# ============================================================ + +@router.post( + "/analyze", + response_model=AnalysisResultResponse, + responses={ + 200: {"description": "分析完成(同步模式)", "model": AnalysisResultResponse}, + 202: {"description": "分析任务已接受(异步模式)", "model": TaskAccepted}, + 400: {"description": "请求参数错误", "model": ErrorResponse}, + 409: {"description": "股票正在分析中,拒绝重复提交", "model": DuplicateTaskErrorResponse}, + 500: {"description": "分析失败", "model": ErrorResponse}, + }, + summary="触发股票分析", + description="启动 AI 智能分析任务,支持同步和异步模式。异步模式下相同股票代码不允许重复提交。" +) +def trigger_analysis( + request: AnalyzeRequest, + config: Config = Depends(get_config_dep) +) -> Union[AnalysisResultResponse, JSONResponse]: + """ + 触发股票分析 + + 启动 AI 智能分析任务,支持单只或多只股票批量分析 + + 流程: + 1. 校验请求参数 + 2. 异步模式:检查重复 -> 提交任务队列 -> 返回 202 + 3. 同步模式:直接执行分析 -> 返回 200 + + Args: + request: 分析请求参数 + config: 配置依赖 + + Returns: + AnalysisResultResponse: 分析结果(同步模式) + TaskAccepted: 任务已接受(异步模式,返回 202) + + Raises: + HTTPException: 400 - 请求参数错误 + HTTPException: 409 - 股票正在分析中 + HTTPException: 500 - 分析失败 + """ + # 校验请求参数 + stock_codes = [] + if request.stock_code: + stock_codes.append(request.stock_code) + if request.stock_codes: + stock_codes.extend(request.stock_codes) + + if not stock_codes: + raise HTTPException( + status_code=400, + detail={ + "error": "validation_error", + "message": "必须提供 stock_code 或 stock_codes 参数" + } + ) + + # 去重 + stock_codes = list(dict.fromkeys(stock_codes)) + stock_code = stock_codes[0] # 当前只处理第一个 + + # 异步模式:使用任务队列 + if request.async_mode: + return _handle_async_analysis(stock_code, request) + + # 同步模式:直接执行分析 + return _handle_sync_analysis(stock_code, request) + + +def _handle_async_analysis( + stock_code: str, + request: AnalyzeRequest +) -> JSONResponse: + """ + 处理异步分析请求 + + 提交任务到队列,立即返回 202 + 如果股票正在分析中,返回 409 + """ + task_queue = get_task_queue() + + try: + # 提交任务(如果重复会抛出 DuplicateTaskError) + task_info = task_queue.submit_task( + stock_code=stock_code, + stock_name=None, # 名称在分析过程中获取 + report_type=request.report_type, + force_refresh=request.force_refresh, + ) + + # 返回 202 Accepted + task_accepted = TaskAccepted( + task_id=task_info.task_id, + status="pending", + message=f"分析任务已加入队列: {stock_code}" + ) + return JSONResponse( + status_code=202, + content=task_accepted.model_dump() + ) + + except DuplicateTaskError as e: + # 股票正在分析中,返回 409 Conflict + error_response = DuplicateTaskErrorResponse( + error="duplicate_task", + message=str(e), + stock_code=e.stock_code, + existing_task_id=e.existing_task_id, + ) + return JSONResponse( + status_code=409, + content=error_response.model_dump() + ) + + +def _handle_sync_analysis( + stock_code: str, + request: AnalyzeRequest +) -> AnalysisResultResponse: + """ + 处理同步分析请求 + + 直接执行分析,等待完成后返回结果 + """ + import uuid + from src.services.analysis_service import AnalysisService + + query_id = uuid.uuid4().hex + + try: + service = AnalysisService() + result = service.analyze_stock( + stock_code=stock_code, + report_type=request.report_type, + force_refresh=request.force_refresh, + query_id=query_id + ) + + if result is None: + raise HTTPException( + status_code=500, + detail={ + "error": "analysis_failed", + "message": f"分析股票 {stock_code} 失败" + } + ) + + # 构建报告结构 + report_data = result.get("report", {}) + report = _build_analysis_report( + report_data, query_id, stock_code, result.get("stock_name") + ) + + return AnalysisResultResponse( + query_id=query_id, + stock_code=result.get("stock_code", stock_code), + stock_name=result.get("stock_name"), + report=report.model_dump() if report else None, + created_at=datetime.now().isoformat() + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"分析失败: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail={ + "error": "internal_error", + "message": f"分析过程发生错误: {str(e)}" + } + ) + + +# ============================================================ +# GET /tasks - 获取任务列表 +# ============================================================ + +@router.get( + "/tasks", + response_model=TaskListResponse, + responses={ + 200: {"description": "任务列表"}, + }, + summary="获取分析任务列表", + description="获取当前所有分析任务,可按状态筛选" +) +def get_task_list( + status: Optional[str] = Query( + None, + description="筛选状态:pending, processing, completed, failed(支持逗号分隔多个)" + ), + limit: int = Query(20, description="返回数量限制", ge=1, le=100), +) -> TaskListResponse: + """ + 获取分析任务列表 + + Args: + status: 状态筛选(可选) + limit: 返回数量限制 + + Returns: + TaskListResponse: 任务列表响应 + """ + task_queue = get_task_queue() + + # 获取所有任务 + all_tasks = task_queue.list_all_tasks(limit=limit) + + # 状态筛选 + if status: + status_list = [s.strip().lower() for s in status.split(",")] + all_tasks = [t for t in all_tasks if t.status.value in status_list] + + # 统计信息 + stats = task_queue.get_task_stats() + + # 转换为 Schema + task_infos = [ + TaskInfo( + task_id=t.task_id, + stock_code=t.stock_code, + stock_name=t.stock_name, + status=t.status.value, + progress=t.progress, + message=t.message, + report_type=t.report_type, + created_at=t.created_at.isoformat(), + started_at=t.started_at.isoformat() if t.started_at else None, + completed_at=t.completed_at.isoformat() if t.completed_at else None, + error=t.error, + ) + for t in all_tasks + ] + + return TaskListResponse( + total=stats["total"], + pending=stats["pending"], + processing=stats["processing"], + tasks=task_infos, + ) + + +# ============================================================ +# GET /tasks/stream - SSE 实时推送 +# ============================================================ + +@router.get( + "/tasks/stream", + responses={ + 200: {"description": "SSE 事件流", "content": {"text/event-stream": {}}}, + }, + summary="任务状态 SSE 流", + description="通过 Server-Sent Events 实时推送任务状态变化" +) +async def task_stream(): + """ + SSE 任务状态流 + + 事件类型: + - connected: 连接成功 + - task_created: 新任务创建 + - task_started: 任务开始执行 + - task_completed: 任务完成 + - task_failed: 任务失败 + - heartbeat: 心跳(每 30 秒) + + Returns: + StreamingResponse: SSE 事件流 + """ + async def event_generator(): + task_queue = get_task_queue() + event_queue: asyncio.Queue = asyncio.Queue() + + # 发送连接成功事件 + yield _format_sse_event("connected", {"message": "Connected to task stream"}) + + # 发送当前进行中的任务 + pending_tasks = task_queue.list_pending_tasks() + for task in pending_tasks: + yield _format_sse_event("task_created", task.to_dict()) + + # 订阅任务事件 + task_queue.subscribe(event_queue) + + try: + while True: + try: + # 等待事件,超时发送心跳 + event = await asyncio.wait_for(event_queue.get(), timeout=30) + yield _format_sse_event(event["type"], event["data"]) + except asyncio.TimeoutError: + # 心跳 + yield _format_sse_event("heartbeat", { + "timestamp": datetime.now().isoformat() + }) + except asyncio.CancelledError: + # 客户端断开连接 + pass + finally: + task_queue.unsubscribe(event_queue) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # 禁用 Nginx 缓冲 + } + ) + + +def _format_sse_event(event_type: str, data: Dict[str, Any]) -> str: + """ + 格式化 SSE 事件 + + Args: + event_type: 事件类型 + data: 事件数据 + + Returns: + SSE 格式字符串 + """ + return f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + +# ============================================================ +# GET /status/{task_id} - 查询单个任务状态 +# ============================================================ + +@router.get( + "/status/{task_id}", + response_model=TaskStatus, + responses={ + 200: {"description": "任务状态"}, + 404: {"description": "任务不存在", "model": ErrorResponse}, + }, + summary="查询分析任务状态", + description="根据 task_id 查询单个任务的状态" +) +def get_analysis_status(task_id: str) -> TaskStatus: + """ + 查询分析任务状态 + + 优先从任务队列查询,如果不存在则从数据库查询历史记录 + + Args: + task_id: 任务 ID + + Returns: + TaskStatus: 任务状态信息 + + Raises: + HTTPException: 404 - 任务不存在 + """ + # 1. 先从任务队列查询 + task_queue = get_task_queue() + task = task_queue.get_task(task_id) + + if task: + return TaskStatus( + task_id=task.task_id, + status=task.status.value, + progress=task.progress, + result=None, # 进行中的任务没有结果 + error=task.error, + ) + + # 2. 从数据库查询已完成的记录 + try: + from src.storage import DatabaseManager + db = DatabaseManager.get_instance() + records = db.get_analysis_history(query_id=task_id, limit=1) + + if records: + record = records[0] + return TaskStatus( + task_id=task_id, + status="completed", + progress=100, + result=AnalysisResultResponse( + query_id=task_id, + stock_code=record.code, + stock_name=record.name, + report=None, + created_at=record.created_at.isoformat() if record.created_at else datetime.now().isoformat() + ), + error=None + ) + + except Exception as e: + logger.error(f"查询任务状态失败: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail={ + "error": "internal_error", + "message": f"查询任务状态失败: {str(e)}" + } + ) + + # 3. 任务不存在 + raise HTTPException( + status_code=404, + detail={ + "error": "not_found", + "message": f"任务 {task_id} 不存在或已过期" + } + ) + + +# ============================================================ +# 辅助函数 +# ============================================================ + +def _build_analysis_report( + report_data: Dict[str, Any], + query_id: str, + stock_code: str, + stock_name: Optional[str] = None +) -> AnalysisReport: + """ + 构建符合 API 规范的分析报告 + + Args: + report_data: 原始报告数据 + query_id: 查询 ID + stock_code: 股票代码 + stock_name: 股票名称 + + Returns: + AnalysisReport: 结构化的分析报告 + """ + meta_data = report_data.get("meta", {}) + summary_data = report_data.get("summary", {}) + strategy_data = report_data.get("strategy", {}) + details_data = report_data.get("details", {}) + + meta = ReportMeta( + query_id=meta_data.get("query_id", query_id), + stock_code=meta_data.get("stock_code", stock_code), + stock_name=meta_data.get("stock_name", stock_name), + report_type=meta_data.get("report_type", "detailed"), + created_at=meta_data.get("created_at", datetime.now().isoformat()), + current_price=meta_data.get("current_price"), + change_pct=meta_data.get("change_pct"), + ) + + summary = ReportSummary( + analysis_summary=summary_data.get("analysis_summary"), + operation_advice=summary_data.get("operation_advice"), + trend_prediction=summary_data.get("trend_prediction"), + sentiment_score=summary_data.get("sentiment_score"), + sentiment_label=summary_data.get("sentiment_label") + ) + + strategy = None + if strategy_data: + strategy = ReportStrategy( + ideal_buy=strategy_data.get("ideal_buy"), + secondary_buy=strategy_data.get("secondary_buy"), + stop_loss=strategy_data.get("stop_loss"), + take_profit=strategy_data.get("take_profit") + ) + + details = None + if details_data: + details = ReportDetails( + news_content=details_data.get("news_summary") or details_data.get("news_content"), + raw_result=details_data, + context_snapshot=None + ) + + return AnalysisReport( + meta=meta, + summary=summary, + strategy=strategy, + details=details + ) diff --git a/api/v1/endpoints/health.py b/api/v1/endpoints/health.py new file mode 100644 index 000000000..78b4ccb7f --- /dev/null +++ b/api/v1/endpoints/health.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +""" +=================================== +健康检查接口 +=================================== + +职责: +1. 提供 /api/v1/health 健康检查接口 +2. 用于负载均衡器和监控系统 +""" + +from datetime import datetime + +from fastapi import APIRouter + +from api.v1.schemas.common import HealthResponse + +router = APIRouter() + + +@router.get("/health", response_model=HealthResponse) +async def health_check() -> HealthResponse: + """ + 健康检查接口 + + 用于负载均衡器或监控系统检查服务状态 + + Returns: + HealthResponse: 包含服务状态和时间戳 + """ + return HealthResponse( + status="ok", + timestamp=datetime.now().isoformat() + ) diff --git a/api/v1/endpoints/history.py b/api/v1/endpoints/history.py new file mode 100644 index 000000000..f7d7a2066 --- /dev/null +++ b/api/v1/endpoints/history.py @@ -0,0 +1,282 @@ +# -*- coding: utf-8 -*- +""" +=================================== +历史记录接口 +=================================== + +职责: +1. 提供 GET /api/v1/history 历史列表查询接口 +2. 提供 GET /api/v1/history/{query_id} 历史详情查询接口 +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query, Depends + +from api.deps import get_database_manager +from api.v1.schemas.history import ( + HistoryListResponse, + HistoryItem, + NewsIntelItem, + NewsIntelResponse, + AnalysisReport, + ReportMeta, + ReportSummary, + ReportStrategy, + ReportDetails, +) +from api.v1.schemas.common import ErrorResponse +from src.storage import DatabaseManager +from src.services.history_service import HistoryService + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get( + "", + response_model=HistoryListResponse, + responses={ + 200: {"description": "历史记录列表"}, + 500: {"description": "服务器错误", "model": ErrorResponse}, + }, + summary="获取历史分析列表", + description="分页获取历史分析记录摘要,支持按股票代码和日期范围筛选" +) +def get_history_list( + stock_code: Optional[str] = Query(None, description="股票代码筛选"), + start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD)"), + end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD)"), + page: int = Query(1, ge=1, description="页码(从 1 开始)"), + limit: int = Query(20, ge=1, le=100, description="每页数量"), + db_manager: DatabaseManager = Depends(get_database_manager) +) -> HistoryListResponse: + """ + 获取历史分析列表 + + 分页获取历史分析记录摘要,支持按股票代码和日期范围筛选 + + Args: + stock_code: 股票代码筛选 + start_date: 开始日期 + end_date: 结束日期 + page: 页码 + limit: 每页数量 + db_manager: 数据库管理器依赖 + + Returns: + HistoryListResponse: 历史记录列表 + """ + try: + service = HistoryService(db_manager) + + # 使用 def 而非 async def,FastAPI 自动在线程池中执行 + result = service.get_history_list( + stock_code=stock_code, + start_date=start_date, + end_date=end_date, + page=page, + limit=limit + ) + + # 转换为响应模型 + items = [ + HistoryItem( + query_id=item.get("query_id", ""), + stock_code=item.get("stock_code", ""), + stock_name=item.get("stock_name"), + report_type=item.get("report_type"), + sentiment_score=item.get("sentiment_score"), + operation_advice=item.get("operation_advice"), + created_at=item.get("created_at") + ) + for item in result.get("items", []) + ] + + return HistoryListResponse( + total=result.get("total", 0), + page=page, + limit=limit, + items=items + ) + + except Exception as e: + logger.error(f"查询历史列表失败: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail={ + "error": "internal_error", + "message": f"查询历史列表失败: {str(e)}" + } + ) + + +@router.get( + "/{query_id}", + response_model=AnalysisReport, + responses={ + 200: {"description": "报告详情"}, + 404: {"description": "报告不存在", "model": ErrorResponse}, + 500: {"description": "服务器错误", "model": ErrorResponse}, + }, + summary="获取历史报告详情", + description="根据 query_id 获取完整的历史分析报告" +) +def get_history_detail( + query_id: str, + db_manager: DatabaseManager = Depends(get_database_manager) +) -> AnalysisReport: + """ + 获取历史报告详情 + + 根据 query_id 获取完整的历史分析报告 + + Args: + query_id: 分析记录唯一标识 + db_manager: 数据库管理器依赖 + + Returns: + AnalysisReport: 完整分析报告 + + Raises: + HTTPException: 404 - 报告不存在 + """ + try: + service = HistoryService(db_manager) + + # 使用 def 而非 async def,FastAPI 自动在线程池中执行 + result = service.get_history_detail(query_id) + + if result is None: + raise HTTPException( + status_code=404, + detail={ + "error": "not_found", + "message": f"未找到 query_id={query_id} 的分析记录" + } + ) + + # 从 context_snapshot 中提取价格信息 + current_price = None + change_pct = None + context_snapshot = result.get("context_snapshot") + if context_snapshot and isinstance(context_snapshot, dict): + # 尝试从 enhanced_context.realtime 获取 + enhanced_context = context_snapshot.get("enhanced_context") or {} + realtime = enhanced_context.get("realtime") or {} + current_price = realtime.get("price") + change_pct = realtime.get("change_pct") or realtime.get("change_60d") + + # 也尝试从 realtime_quote_raw 获取 + if current_price is None: + realtime_quote_raw = context_snapshot.get("realtime_quote_raw") or {} + current_price = realtime_quote_raw.get("price") + change_pct = change_pct or realtime_quote_raw.get("change_pct") or realtime_quote_raw.get("pct_chg") + + # 构建响应模型 + meta = ReportMeta( + query_id=result.get("query_id", query_id), + stock_code=result.get("stock_code", ""), + stock_name=result.get("stock_name"), + report_type=result.get("report_type"), + created_at=result.get("created_at"), + current_price=current_price, + change_pct=change_pct + ) + + summary = ReportSummary( + analysis_summary=result.get("analysis_summary"), + operation_advice=result.get("operation_advice"), + trend_prediction=result.get("trend_prediction"), + sentiment_score=result.get("sentiment_score"), + sentiment_label=result.get("sentiment_label") + ) + + strategy = ReportStrategy( + ideal_buy=result.get("ideal_buy"), + secondary_buy=result.get("secondary_buy"), + stop_loss=result.get("stop_loss"), + take_profit=result.get("take_profit") + ) + + details = ReportDetails( + news_content=result.get("news_content"), + raw_result=result.get("raw_result"), + context_snapshot=result.get("context_snapshot") + ) + + return AnalysisReport( + meta=meta, + summary=summary, + strategy=strategy, + details=details + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"查询历史详情失败: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail={ + "error": "internal_error", + "message": f"查询历史详情失败: {str(e)}" + } + ) + + +@router.get( + "/{query_id}/news", + response_model=NewsIntelResponse, + responses={ + 200: {"description": "新闻情报列表"}, + 500: {"description": "服务器错误", "model": ErrorResponse}, + }, + summary="获取历史报告关联新闻", + description="根据 query_id 获取关联的新闻情报列表(为空也返回 200)" +) +def get_history_news( + query_id: str, + limit: int = Query(20, ge=1, le=100, description="返回数量限制"), + db_manager: DatabaseManager = Depends(get_database_manager) +) -> NewsIntelResponse: + """ + 获取历史报告关联新闻 + + Args: + query_id: 分析记录唯一标识 + limit: 返回数量限制 + db_manager: 数据库管理器依赖 + + Returns: + NewsIntelResponse: 新闻情报列表 + """ + try: + service = HistoryService(db_manager) + items = service.get_news_intel(query_id=query_id, limit=limit) + + response_items = [ + NewsIntelItem( + title=item.get("title", ""), + snippet=item.get("snippet"), + url=item.get("url", "") + ) + for item in items + ] + + return NewsIntelResponse( + total=len(response_items), + items=response_items + ) + + except Exception as e: + logger.error(f"查询新闻情报失败: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail={ + "error": "internal_error", + "message": f"查询新闻情报失败: {str(e)}" + } + ) diff --git a/api/v1/endpoints/stocks.py b/api/v1/endpoints/stocks.py new file mode 100644 index 000000000..5b7895395 --- /dev/null +++ b/api/v1/endpoints/stocks.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +""" +=================================== +股票数据接口 +=================================== + +职责: +1. 提供 GET /api/v1/stocks/{code}/quote 实时行情接口 +2. 提供 GET /api/v1/stocks/{code}/history 历史行情接口 +""" + +import logging + +from fastapi import APIRouter, HTTPException, Query + +from api.v1.schemas.stocks import ( + StockQuote, + StockHistoryResponse, + KLineData, +) +from api.v1.schemas.common import ErrorResponse +from src.services.stock_service import StockService + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get( + "/{stock_code}/quote", + response_model=StockQuote, + responses={ + 200: {"description": "行情数据"}, + 404: {"description": "股票不存在", "model": ErrorResponse}, + 500: {"description": "服务器错误", "model": ErrorResponse}, + }, + summary="获取股票实时行情", + description="获取指定股票的最新行情数据" +) +def get_stock_quote(stock_code: str) -> StockQuote: + """ + 获取股票实时行情 + + 获取指定股票的最新行情数据 + + Args: + stock_code: 股票代码(如 600519、00700、AAPL) + + Returns: + StockQuote: 实时行情数据 + + Raises: + HTTPException: 404 - 股票不存在 + """ + try: + service = StockService() + + # 使用 def 而非 async def,FastAPI 自动在线程池中执行 + result = service.get_realtime_quote(stock_code) + + if result is None: + raise HTTPException( + status_code=404, + detail={ + "error": "not_found", + "message": f"未找到股票 {stock_code} 的行情数据" + } + ) + + return StockQuote( + stock_code=result.get("stock_code", stock_code), + stock_name=result.get("stock_name"), + current_price=result.get("current_price", 0.0), + change=result.get("change"), + change_percent=result.get("change_percent"), + open=result.get("open"), + high=result.get("high"), + low=result.get("low"), + prev_close=result.get("prev_close"), + volume=result.get("volume"), + amount=result.get("amount"), + update_time=result.get("update_time") + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"获取实时行情失败: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail={ + "error": "internal_error", + "message": f"获取实时行情失败: {str(e)}" + } + ) + + +@router.get( + "/{stock_code}/history", + response_model=StockHistoryResponse, + responses={ + 200: {"description": "历史行情数据"}, + 422: {"description": "不支持的周期参数", "model": ErrorResponse}, + 500: {"description": "服务器错误", "model": ErrorResponse}, + }, + summary="获取股票历史行情", + description="获取指定股票的历史 K 线数据" +) +def get_stock_history( + stock_code: str, + period: str = Query("daily", description="K 线周期", pattern="^(daily|weekly|monthly)$"), + days: int = Query(30, ge=1, le=365, description="获取天数") +) -> StockHistoryResponse: + """ + 获取股票历史行情 + + 获取指定股票的历史 K 线数据 + + Args: + stock_code: 股票代码 + period: K 线周期 (daily/weekly/monthly) + days: 获取天数 + + Returns: + StockHistoryResponse: 历史行情数据 + """ + try: + service = StockService() + + # 使用 def 而非 async def,FastAPI 自动在线程池中执行 + result = service.get_history_data( + stock_code=stock_code, + period=period, + days=days + ) + + # 转换为响应模型 + data = [ + KLineData( + date=item.get("date"), + open=item.get("open"), + high=item.get("high"), + low=item.get("low"), + close=item.get("close"), + volume=item.get("volume"), + amount=item.get("amount"), + change_percent=item.get("change_percent") + ) + for item in result.get("data", []) + ] + + return StockHistoryResponse( + stock_code=stock_code, + stock_name=result.get("stock_name"), + period=period, + data=data + ) + + except ValueError as e: + # period 参数不支持的错误(如 weekly/monthly) + raise HTTPException( + status_code=422, + detail={ + "error": "unsupported_period", + "message": str(e) + } + ) + except Exception as e: + logger.error(f"获取历史行情失败: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail={ + "error": "internal_error", + "message": f"获取历史行情失败: {str(e)}" + } + ) diff --git a/api/v1/router.py b/api/v1/router.py new file mode 100644 index 000000000..a40da15fb --- /dev/null +++ b/api/v1/router.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +""" +=================================== +API v1 路由聚合 +=================================== + +职责: +1. 聚合 v1 版本的所有 endpoint 路由 +2. 统一添加 /api/v1 前缀 +""" + +from fastapi import APIRouter + +from api.v1.endpoints import health, analysis, history, stocks + +# 创建 v1 版本主路由 +router = APIRouter(prefix="/api/v1") + +router.include_router( + analysis.router, + prefix="/analysis", + tags=["Analysis"] +) + +router.include_router( + history.router, + prefix="/history", + tags=["History"] +) + +router.include_router( + stocks.router, + prefix="/stocks", + tags=["Stocks"] +) diff --git a/api/v1/schemas/__init__.py b/api/v1/schemas/__init__.py new file mode 100644 index 000000000..d01973cf0 --- /dev/null +++ b/api/v1/schemas/__init__.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +""" +=================================== +API v1 Schemas 模块初始化 +=================================== + +职责: +1. 导出所有 Pydantic 模型 +""" + +from api.v1.schemas.common import ( + RootResponse, + HealthResponse, + ErrorResponse, + SuccessResponse, +) +from api.v1.schemas.analysis import ( + AnalyzeRequest, + AnalysisResultResponse, + TaskAccepted, + TaskStatus, +) +from api.v1.schemas.history import ( + HistoryItem, + HistoryListResponse, + NewsIntelItem, + NewsIntelResponse, + AnalysisReport, + ReportMeta, + ReportSummary, + ReportStrategy, + ReportDetails, +) +from api.v1.schemas.stocks import ( + StockQuote, + StockHistoryResponse, + KLineData, +) + +__all__ = [ + # common + "RootResponse", + "HealthResponse", + "ErrorResponse", + "SuccessResponse", + # analysis + "AnalyzeRequest", + "AnalysisResultResponse", + "TaskAccepted", + "TaskStatus", + # history + "HistoryItem", + "HistoryListResponse", + "NewsIntelItem", + "NewsIntelResponse", + "AnalysisReport", + "ReportMeta", + "ReportSummary", + "ReportStrategy", + "ReportDetails", + # stocks + "StockQuote", + "StockHistoryResponse", + "KLineData", +] diff --git a/api/v1/schemas/analysis.py b/api/v1/schemas/analysis.py new file mode 100644 index 000000000..e4e906bde --- /dev/null +++ b/api/v1/schemas/analysis.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8 -*- +""" +=================================== +分析相关模型 +=================================== + +职责: +1. 定义分析请求和响应模型 +2. 定义任务状态模型 +3. 定义异步任务队列相关模型 +""" + +from typing import Optional, List, Any +from enum import Enum + +from pydantic import BaseModel, Field + + +class TaskStatusEnum(str, Enum): + """任务状态枚举""" + PENDING = "pending" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + + +class AnalyzeRequest(BaseModel): + """分析请求模型""" + + stock_code: Optional[str] = Field( + None, + description="单只股票代码", + example="600519" + ) + stock_codes: Optional[List[str]] = Field( + None, + description="多只股票代码(与 stock_code 二选一)", + example=["600519", "000858"] + ) + report_type: str = Field( + "detailed", + description="报告类型", + pattern="^(simple|detailed)$" + ) + force_refresh: bool = Field( + True, + description="是否强制刷新(忽略缓存)" + ) + async_mode: bool = Field( + False, + description="是否使用异步模式" + ) + + class Config: + json_schema_extra = { + "example": { + "stock_code": "600519", + "report_type": "detailed", + "force_refresh": False, + "async_mode": False + } + } + + +class AnalysisResultResponse(BaseModel): + """分析结果响应模型""" + + query_id: str = Field(..., description="分析记录唯一标识") + stock_code: str = Field(..., description="股票代码") + stock_name: Optional[str] = Field(None, description="股票名称") + report: Optional[Any] = Field(None, description="分析报告") + created_at: str = Field(..., description="创建时间") + + class Config: + json_schema_extra = { + "example": { + "query_id": "abc123def456", + "stock_code": "600519", + "stock_name": "贵州茅台", + "report": { + "summary": { + "sentiment_score": 75, + "operation_advice": "持有" + } + }, + "created_at": "2024-01-01T12:00:00" + } + } + + +class TaskAccepted(BaseModel): + """异步任务接受响应""" + + task_id: str = Field(..., description="任务 ID,用于查询状态") + status: str = Field( + ..., + description="任务状态", + pattern="^(pending|processing)$" + ) + message: Optional[str] = Field(None, description="提示信息") + + class Config: + json_schema_extra = { + "example": { + "task_id": "task_abc123", + "status": "pending", + "message": "Analysis task accepted" + } + } + + +class TaskStatus(BaseModel): + """任务状态模型""" + + task_id: str = Field(..., description="任务 ID") + status: str = Field( + ..., + description="任务状态", + pattern="^(pending|processing|completed|failed)$" + ) + progress: Optional[int] = Field( + None, + description="进度百分比 (0-100)", + ge=0, + le=100 + ) + result: Optional[AnalysisResultResponse] = Field( + None, + description="分析结果(仅在 completed 时存在)" + ) + error: Optional[str] = Field( + None, + description="错误信息(仅在 failed 时存在)" + ) + + class Config: + json_schema_extra = { + "example": { + "task_id": "task_abc123", + "status": "completed", + "progress": 100, + "result": None, + "error": None + } + } + + +class TaskInfo(BaseModel): + """ + 任务详情模型 + + 用于任务列表和 SSE 事件推送 + """ + + task_id: str = Field(..., description="任务 ID") + stock_code: str = Field(..., description="股票代码") + stock_name: Optional[str] = Field(None, description="股票名称") + status: TaskStatusEnum = Field(..., description="任务状态") + progress: int = Field(0, description="进度百分比 (0-100)", ge=0, le=100) + message: Optional[str] = Field(None, description="状态消息") + report_type: str = Field("detailed", description="报告类型") + created_at: str = Field(..., description="创建时间") + started_at: Optional[str] = Field(None, description="开始执行时间") + completed_at: Optional[str] = Field(None, description="完成时间") + error: Optional[str] = Field(None, description="错误信息(仅在 failed 时存在)") + + class Config: + json_schema_extra = { + "example": { + "task_id": "abc123def456", + "stock_code": "600519", + "stock_name": "贵州茅台", + "status": "processing", + "progress": 50, + "message": "正在分析中...", + "report_type": "detailed", + "created_at": "2026-02-05T10:30:00", + "started_at": "2026-02-05T10:30:01", + "completed_at": None, + "error": None + } + } + + +class TaskListResponse(BaseModel): + """任务列表响应模型""" + + total: int = Field(..., description="任务总数") + pending: int = Field(..., description="等待中的任务数") + processing: int = Field(..., description="处理中的任务数") + tasks: List[TaskInfo] = Field(..., description="任务列表") + + class Config: + json_schema_extra = { + "example": { + "total": 3, + "pending": 1, + "processing": 2, + "tasks": [] + } + } + + +class DuplicateTaskErrorResponse(BaseModel): + """重复任务错误响应模型""" + + error: str = Field("duplicate_task", description="错误类型") + message: str = Field(..., description="错误信息") + stock_code: str = Field(..., description="股票代码") + existing_task_id: str = Field(..., description="已存在的任务 ID") + + class Config: + json_schema_extra = { + "example": { + "error": "duplicate_task", + "message": "股票 600519 正在分析中", + "stock_code": "600519", + "existing_task_id": "abc123def456" + } + } diff --git a/api/v1/schemas/common.py b/api/v1/schemas/common.py new file mode 100644 index 000000000..8b3fc908d --- /dev/null +++ b/api/v1/schemas/common.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +""" +=================================== +通用响应模型 +=================================== + +职责: +1. 定义通用的响应模型(HealthResponse, ErrorResponse 等) +2. 提供统一的响应格式 +""" + +from typing import Optional, Any + +from pydantic import BaseModel, Field + + +class RootResponse(BaseModel): + """API 根路由响应""" + + message: str = Field(..., description="API 运行状态消息", example="Daily Stock Analysis API is running") + version: Optional[str] = Field(None, description="API 版本", example="1.0.0") + + class Config: + json_schema_extra = { + "example": { + "message": "Daily Stock Analysis API is running", + "version": "1.0.0" + } + } + + +class HealthResponse(BaseModel): + """健康检查响应""" + + status: str = Field(..., description="服务状态", example="ok") + timestamp: Optional[str] = Field(None, description="时间戳") + + class Config: + json_schema_extra = { + "example": { + "status": "ok", + "timestamp": "2024-01-01T12:00:00" + } + } + + +class ErrorResponse(BaseModel): + """错误响应""" + + error: str = Field(..., description="错误类型", example="validation_error") + message: str = Field(..., description="错误详情", example="请求参数错误") + detail: Optional[Any] = Field(None, description="附加错误信息") + + class Config: + json_schema_extra = { + "example": { + "error": "not_found", + "message": "资源不存在", + "detail": None + } + } + + +class SuccessResponse(BaseModel): + """通用成功响应""" + + success: bool = Field(True, description="是否成功") + message: Optional[str] = Field(None, description="成功消息") + data: Optional[Any] = Field(None, description="响应数据") + + class Config: + json_schema_extra = { + "example": { + "success": True, + "message": "操作成功", + "data": None + } + } diff --git a/api/v1/schemas/history.py b/api/v1/schemas/history.py new file mode 100644 index 000000000..6bdfbd1a1 --- /dev/null +++ b/api/v1/schemas/history.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +""" +=================================== +历史记录相关模型 +=================================== + +职责: +1. 定义历史记录列表和详情模型 +2. 定义分析报告完整模型 +""" + +from typing import Optional, List, Any + +from pydantic import BaseModel, Field + + +class HistoryItem(BaseModel): + """历史记录摘要(列表展示用)""" + + query_id: str = Field(..., description="分析记录唯一标识") + stock_code: str = Field(..., description="股票代码") + stock_name: Optional[str] = Field(None, description="股票名称") + report_type: Optional[str] = Field(None, description="报告类型") + sentiment_score: Optional[int] = Field( + None, + description="情绪评分 (0-100)", + ge=0, + le=100 + ) + operation_advice: Optional[str] = Field(None, description="操作建议") + created_at: Optional[str] = Field(None, description="创建时间") + + class Config: + json_schema_extra = { + "example": { + "query_id": "abc123", + "stock_code": "600519", + "stock_name": "贵州茅台", + "report_type": "detailed", + "sentiment_score": 75, + "operation_advice": "持有", + "created_at": "2024-01-01T12:00:00" + } + } + + +class HistoryListResponse(BaseModel): + """历史记录列表响应""" + + total: int = Field(..., description="总记录数") + page: int = Field(..., description="当前页码") + limit: int = Field(..., description="每页数量") + items: List[HistoryItem] = Field(default_factory=list, description="记录列表") + + class Config: + json_schema_extra = { + "example": { + "total": 100, + "page": 1, + "limit": 20, + "items": [] + } + } + + +class NewsIntelItem(BaseModel): + """新闻情报条目""" + + title: str = Field(..., description="新闻标题") + snippet: str = Field("", description="新闻摘要(最多50字)") + url: str = Field(..., description="新闻链接") + + class Config: + json_schema_extra = { + "example": { + "title": "公司发布业绩快报,营收同比增长 20%", + "snippet": "公司公告显示,季度营收同比增长 20%...", + "url": "https://example.com/news/123" + } + } + + +class NewsIntelResponse(BaseModel): + """新闻情报响应""" + + total: int = Field(..., description="新闻条数") + items: List[NewsIntelItem] = Field(default_factory=list, description="新闻列表") + + class Config: + json_schema_extra = { + "example": { + "total": 2, + "items": [] + } + } + + +class ReportMeta(BaseModel): + """报告元信息""" + + query_id: str = Field(..., description="分析记录唯一标识") + stock_code: str = Field(..., description="股票代码") + stock_name: Optional[str] = Field(None, description="股票名称") + report_type: Optional[str] = Field(None, description="报告类型") + created_at: Optional[str] = Field(None, description="创建时间") + current_price: Optional[float] = Field(None, description="分析时股价") + change_pct: Optional[float] = Field(None, description="分析时涨跌幅(%)") + + +class ReportSummary(BaseModel): + """报告概览区""" + + analysis_summary: Optional[str] = Field(None, description="关键结论") + operation_advice: Optional[str] = Field(None, description="操作建议") + trend_prediction: Optional[str] = Field(None, description="趋势预测") + sentiment_score: Optional[int] = Field( + None, + description="情绪评分 (0-100)", + ge=0, + le=100 + ) + sentiment_label: Optional[str] = Field(None, description="情绪标签") + + +class ReportStrategy(BaseModel): + """策略点位区""" + + ideal_buy: Optional[str] = Field(None, description="理想买入价") + secondary_buy: Optional[str] = Field(None, description="第二买入价") + stop_loss: Optional[str] = Field(None, description="止损价") + take_profit: Optional[str] = Field(None, description="止盈价") + + +class ReportDetails(BaseModel): + """报告详情区""" + + news_content: Optional[str] = Field(None, description="新闻摘要") + raw_result: Optional[Any] = Field(None, description="原始分析结果(JSON)") + context_snapshot: Optional[Any] = Field(None, description="分析时上下文快照(JSON)") + + +class AnalysisReport(BaseModel): + """完整分析报告""" + + meta: ReportMeta = Field(..., description="元信息") + summary: ReportSummary = Field(..., description="概览区") + strategy: Optional[ReportStrategy] = Field(None, description="策略点位区") + details: Optional[ReportDetails] = Field(None, description="详情区") + + class Config: + json_schema_extra = { + "example": { + "meta": { + "query_id": "abc123", + "stock_code": "600519", + "stock_name": "贵州茅台", + "report_type": "detailed", + "created_at": "2024-01-01T12:00:00" + }, + "summary": { + "analysis_summary": "技术面向好,建议持有", + "operation_advice": "持有", + "trend_prediction": "看多", + "sentiment_score": 75, + "sentiment_label": "乐观" + }, + "strategy": { + "ideal_buy": "1800.00", + "secondary_buy": "1750.00", + "stop_loss": "1700.00", + "take_profit": "2000.00" + }, + "details": None + } + } diff --git a/api/v1/schemas/stocks.py b/api/v1/schemas/stocks.py new file mode 100644 index 000000000..c37574623 --- /dev/null +++ b/api/v1/schemas/stocks.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +""" +=================================== +股票数据相关模型 +=================================== + +职责: +1. 定义股票实时行情模型 +2. 定义历史 K 线数据模型 +""" + +from typing import Optional, List + +from pydantic import BaseModel, Field + + +class StockQuote(BaseModel): + """股票实时行情""" + + stock_code: str = Field(..., description="股票代码") + stock_name: Optional[str] = Field(None, description="股票名称") + current_price: float = Field(..., description="当前价格") + change: Optional[float] = Field(None, description="涨跌额") + change_percent: Optional[float] = Field(None, description="涨跌幅 (%)") + open: Optional[float] = Field(None, description="开盘价") + high: Optional[float] = Field(None, description="最高价") + low: Optional[float] = Field(None, description="最低价") + prev_close: Optional[float] = Field(None, description="昨收价") + volume: Optional[float] = Field(None, description="成交量(股)") + amount: Optional[float] = Field(None, description="成交额(元)") + update_time: Optional[str] = Field(None, description="更新时间") + + class Config: + json_schema_extra = { + "example": { + "stock_code": "600519", + "stock_name": "贵州茅台", + "current_price": 1800.00, + "change": 15.00, + "change_percent": 0.84, + "open": 1785.00, + "high": 1810.00, + "low": 1780.00, + "prev_close": 1785.00, + "volume": 10000000, + "amount": 18000000000, + "update_time": "2024-01-01T15:00:00" + } + } + + +class KLineData(BaseModel): + """K 线数据点""" + + date: str = Field(..., description="日期") + open: float = Field(..., description="开盘价") + high: float = Field(..., description="最高价") + low: float = Field(..., description="最低价") + close: float = Field(..., description="收盘价") + volume: Optional[float] = Field(None, description="成交量") + amount: Optional[float] = Field(None, description="成交额") + change_percent: Optional[float] = Field(None, description="涨跌幅 (%)") + + class Config: + json_schema_extra = { + "example": { + "date": "2024-01-01", + "open": 1785.00, + "high": 1810.00, + "low": 1780.00, + "close": 1800.00, + "volume": 10000000, + "amount": 18000000000, + "change_percent": 0.84 + } + } + + +class StockHistoryResponse(BaseModel): + """股票历史行情响应""" + + stock_code: str = Field(..., description="股票代码") + stock_name: Optional[str] = Field(None, description="股票名称") + period: str = Field(..., description="K 线周期") + data: List[KLineData] = Field(default_factory=list, description="K 线数据列表") + + class Config: + json_schema_extra = { + "example": { + "stock_code": "600519", + "stock_name": "贵州茅台", + "period": "daily", + "data": [] + } + } diff --git a/apps/dsa-web/.gitignore b/apps/dsa-web/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/apps/dsa-web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/dsa-web/eslint.config.js b/apps/dsa-web/eslint.config.js new file mode 100644 index 000000000..5e6b472f5 --- /dev/null +++ b/apps/dsa-web/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/apps/dsa-web/index.html b/apps/dsa-web/index.html new file mode 100644 index 000000000..2eed617b7 --- /dev/null +++ b/apps/dsa-web/index.html @@ -0,0 +1,13 @@ + + + + + + + dsa-web + + +
+ + + diff --git a/apps/dsa-web/package-lock.json b/apps/dsa-web/package-lock.json new file mode 100644 index 000000000..dd788b440 --- /dev/null +++ b/apps/dsa-web/package-lock.json @@ -0,0 +1,4397 @@ +{ + "name": "dsa-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dsa-web", + "version": "0.0.0", + "dependencies": { + "axios": "^1.13.4", + "camelcase-keys": "^10.0.2", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.0", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/postcss": "^4.1.18", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.24", + "babel-plugin-react-compiler": "^1.0.0", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.18", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", + "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "postcss": "^8.4.41", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.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/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.10.tgz", + "integrity": "sha512-+0/4J266CBGPUq/ELg7QUHhN25WYjE0wYTPSQJn1xeu8DOlIOPxXxrNGiLmfAWl7HMMgWFWXpt9IDjMWrF5Iow==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", + "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", + "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/type-utils": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.54.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", + "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", + "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.54.0", + "@typescript-eslint/types": "^8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", + "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", + "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", + "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.54.0", + "@typescript-eslint/tsconfig-utils": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", + "integrity": "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.2", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", + "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-9.0.0.tgz", + "integrity": "sha512-TO9xmyXTZ9HUHI8M1OnvExxYB0eYVS/1e5s7IDMTAoIcwUd+aNcFODs6Xk83mobk0velyHFQgA1yIrvYc6wclw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-10.0.2.tgz", + "integrity": "sha512-PVHCLVbJ7nWGal0lPAmBN5eSLjIynlMUk2EPmL9aPl6QyJ6+FoszTKwldPzkuVqg5teZbPTbb8Oenzyw9GSJRw==", + "license": "MIT", + "dependencies": { + "camelcase": "^9.0.0", + "map-obj": "6.0.0", + "quick-lru": "^7.3.0", + "type-fest": "^5.4.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001767", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", + "integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "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", + "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/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "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/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "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", + "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", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/map-obj": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-6.0.0.tgz", + "integrity": "sha512-PwDvwt/tK70+luLw5k9ySLtzLAzwf7tZTY9GBj63Y010nHRPjwHcQTpTd5JwQqITC2ty7prtxBo71iwyYY0TAg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-lru": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-7.3.0.tgz", + "integrity": "sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "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.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.3.tgz", + "integrity": "sha512-AXSAQJu79WGc79/3e9/CR77I/KQgeY1AhNvcShIH4PTcGYyC4xv6H4R4AUOwkPS5799KlVDAu8zExeCrkGquiA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", + "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.54.0", + "@typescript-eslint/parser": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/apps/dsa-web/package.json b/apps/dsa-web/package.json new file mode 100644 index 000000000..465ffe307 --- /dev/null +++ b/apps/dsa-web/package.json @@ -0,0 +1,39 @@ +{ + "name": "dsa-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.13.4", + "camelcase-keys": "^10.0.2", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.0", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/postcss": "^4.1.18", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.24", + "babel-plugin-react-compiler": "^1.0.0", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.18", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } +} diff --git a/apps/dsa-web/postcss.config.js b/apps/dsa-web/postcss.config.js new file mode 100644 index 000000000..14502dc1c --- /dev/null +++ b/apps/dsa-web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + autoprefixer: {}, + }, +} diff --git a/apps/dsa-web/public/vite.svg b/apps/dsa-web/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/apps/dsa-web/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/dsa-web/src/App.css b/apps/dsa-web/src/App.css new file mode 100644 index 000000000..b9d355df2 --- /dev/null +++ b/apps/dsa-web/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/apps/dsa-web/src/App.tsx b/apps/dsa-web/src/App.tsx new file mode 100644 index 000000000..8216f93a4 --- /dev/null +++ b/apps/dsa-web/src/App.tsx @@ -0,0 +1,104 @@ +import type React from 'react'; +import {BrowserRouter as Router, Routes, Route, NavLink} from 'react-router-dom'; +import HomePage from './pages/HomePage'; +import NotFoundPage from './pages/NotFoundPage'; +import './App.css'; + +// 侧边导航图标 +const HomeIcon: React.FC<{ active?: boolean }> = ({active}) => ( + + + +); + +const SettingsIcon: React.FC = () => ( + + + + +); + +type DockItem = { + key: string; + label: string; + to: string; + icon: React.FC<{ active?: boolean }>; +}; + +const NAV_ITEMS: DockItem[] = [ + { + key: 'home', + label: '首页', + to: '/', + icon: HomeIcon, + }, +]; + +// Dock 导航栏 +const DockNav: React.FC = () => { + return ( + + ); +}; + +const App: React.FC = () => { + return ( + +
+ {/* Dock 导航 */} + + + {/* 主内容区 */} +
+ + }/> + }/> + +
+
+
+ ); +}; + +export default App; diff --git a/apps/dsa-web/src/api/analysis.ts b/apps/dsa-web/src/api/analysis.ts new file mode 100644 index 000000000..038d4d8f9 --- /dev/null +++ b/apps/dsa-web/src/api/analysis.ts @@ -0,0 +1,146 @@ +import apiClient from './index'; +import { toCamelCase } from './utils'; +import type { + AnalysisRequest, + AnalysisResult, + AnalysisReport, + TaskStatus, + TaskListResponse, +} from '../types/analysis'; + +// ============ API 接口 ============ + +export const analysisApi = { + /** + * 触发股票分析 + * @param data 分析请求参数 + * @returns 同步模式返回 AnalysisResult,异步模式返回 TaskAccepted(需检查 status code) + */ + analyze: async (data: AnalysisRequest): Promise => { + const requestData = { + stock_code: data.stockCode, + report_type: data.reportType || 'detailed', + force_refresh: data.forceRefresh || false, + async_mode: data.asyncMode || false, + }; + + const response = await apiClient.post>( + '/api/v1/analysis/analyze', + requestData + ); + + const result = toCamelCase(response.data); + + // 确保 report 字段正确转换 + if (result.report) { + result.report = toCamelCase(result.report); + } + + return result; + }, + + /** + * 异步模式触发分析 + * 返回 task_id,通过 SSE 或轮询获取结果 + * @param data 分析请求参数 + * @returns 任务接受响应或抛出 409 错误 + */ + analyzeAsync: async (data: AnalysisRequest): Promise<{ taskId: string; status: string; message?: string }> => { + const requestData = { + stock_code: data.stockCode, + report_type: data.reportType || 'detailed', + force_refresh: data.forceRefresh || false, + async_mode: true, + }; + + const response = await apiClient.post>( + '/api/v1/analysis/analyze', + requestData, + { + // 允许 202 状态码 + validateStatus: (status) => status === 200 || status === 202 || status === 409, + } + ); + + // 处理 409 重复提交错误 + if (response.status === 409) { + const errorData = toCamelCase<{ + error: string; + message: string; + stockCode: string; + existingTaskId: string; + }>(response.data); + throw new DuplicateTaskError(errorData.stockCode, errorData.existingTaskId, errorData.message); + } + + return toCamelCase<{ taskId: string; status: string; message?: string }>(response.data); + }, + + /** + * 获取异步任务状态 + * @param taskId 任务 ID + */ + getStatus: async (taskId: string): Promise => { + const response = await apiClient.get>( + `/api/v1/analysis/status/${taskId}` + ); + + const data = toCamelCase(response.data); + + // 确保嵌套的 result 也被正确转换 + if (data.result) { + data.result = toCamelCase(data.result); + if (data.result.report) { + data.result.report = toCamelCase(data.result.report); + } + } + + return data; + }, + + /** + * 获取任务列表 + * @param params 筛选参数 + */ + getTasks: async (params?: { + status?: string; + limit?: number; + }): Promise => { + const response = await apiClient.get>( + '/api/v1/analysis/tasks', + { params } + ); + + const data = toCamelCase(response.data); + + return data; + }, + + /** + * 获取 SSE 流 URL + * 用于 EventSource 连接 + */ + getTaskStreamUrl: (): string => { + // 获取 API base URL + const baseUrl = apiClient.defaults.baseURL || ''; + return `${baseUrl}/api/v1/analysis/tasks/stream`; + }, +}; + +// ============ 自定义错误类 ============ + +/** + * 重复任务错误 + * 当股票正在分析中时抛出 + */ +export class DuplicateTaskError extends Error { + stockCode: string; + existingTaskId: string; + + constructor(stockCode: string, existingTaskId: string, message?: string) { + super(message || `股票 ${stockCode} 正在分析中`); + this.name = 'DuplicateTaskError'; + this.stockCode = stockCode; + this.existingTaskId = existingTaskId; + } +} diff --git a/apps/dsa-web/src/api/history.ts b/apps/dsa-web/src/api/history.ts new file mode 100644 index 000000000..18f08616e --- /dev/null +++ b/apps/dsa-web/src/api/history.ts @@ -0,0 +1,70 @@ +import apiClient from './index'; +import { toCamelCase } from './utils'; +import type { + HistoryListResponse, + HistoryItem, + HistoryFilters, + AnalysisReport, + NewsIntelResponse, + NewsIntelItem, +} from '../types/analysis'; + +// ============ API 接口 ============ + +export interface GetHistoryListParams extends HistoryFilters { + page?: number; + limit?: number; +} + +export const historyApi = { + /** + * 获取历史分析列表 + * @param params 筛选和分页参数 + */ + getList: async (params: GetHistoryListParams = {}): Promise => { + const { stockCode, startDate, endDate, page = 1, limit = 20 } = params; + + const queryParams: Record = { page, limit }; + if (stockCode) queryParams.stock_code = stockCode; + if (startDate) queryParams.start_date = startDate; + if (endDate) queryParams.end_date = endDate; + + const response = await apiClient.get>('/api/v1/history', { + params: queryParams, + }); + + const data = toCamelCase<{ total: number; page: number; limit: number; items: HistoryItem[] }>(response.data); + return { + total: data.total, + page: data.page, + limit: data.limit, + items: data.items.map(item => toCamelCase(item)), + }; + }, + + /** + * 获取历史报告详情 + * @param queryId 分析记录唯一标识 + */ + getDetail: async (queryId: string): Promise => { + const response = await apiClient.get>(`/api/v1/history/${queryId}`); + return toCamelCase(response.data); + }, + + /** + * 获取历史报告关联新闻 + * @param queryId 分析记录唯一标识 + * @param limit 返回数量限制 + */ + getNews: async (queryId: string, limit = 20): Promise => { + const response = await apiClient.get>(`/api/v1/history/${queryId}/news`, { + params: { limit }, + }); + + const data = toCamelCase(response.data); + return { + total: data.total, + items: (data.items || []).map(item => toCamelCase(item)), + }; + }, +}; diff --git a/apps/dsa-web/src/api/index.ts b/apps/dsa-web/src/api/index.ts new file mode 100644 index 000000000..fb2c85de9 --- /dev/null +++ b/apps/dsa-web/src/api/index.ts @@ -0,0 +1,12 @@ +import axios from 'axios'; +import { API_BASE_URL } from '../utils/constants'; + +const apiClient = axios.create({ + baseURL: API_BASE_URL, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +export default apiClient; diff --git a/apps/dsa-web/src/api/utils.ts b/apps/dsa-web/src/api/utils.ts new file mode 100644 index 000000000..ac9ca6eb2 --- /dev/null +++ b/apps/dsa-web/src/api/utils.ts @@ -0,0 +1,13 @@ +import camelcaseKeys from 'camelcase-keys'; + +/** + * 将 snake_case 对象键转换为 camelCase + * @param data API 响应数据 (snake_case) + * @returns 转换后的 camelCase 对象 + */ +export function toCamelCase(data: unknown): T { + if (data === null || data === undefined) { + return data as T; + } + return camelcaseKeys(data as Record, { deep: true }) as T; +} diff --git a/apps/dsa-web/src/assets/react.svg b/apps/dsa-web/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/apps/dsa-web/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/dsa-web/src/components/common/Badge.tsx b/apps/dsa-web/src/components/common/Badge.tsx new file mode 100644 index 000000000..2f1b6c8a3 --- /dev/null +++ b/apps/dsa-web/src/components/common/Badge.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +type BadgeVariant = 'default' | 'success' | 'warning' | 'danger' | 'info' | 'history'; + +interface BadgeProps { + children: React.ReactNode; + variant?: BadgeVariant; + size?: 'sm' | 'md'; + glow?: boolean; + className?: string; +} + +const variantStyles: Record = { + default: 'bg-slate-700/50 text-gray-300 border-slate-600/50', + success: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30', + warning: 'bg-amber-500/20 text-amber-400 border-amber-500/30', + danger: 'bg-red-500/20 text-red-400 border-red-500/30', + info: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30', + history: 'bg-purple-500/20 text-purple-400 border-purple-500/30', +}; + +const glowStyles: Record = { + default: '', + success: 'shadow-emerald-500/20', + warning: 'shadow-amber-500/20', + danger: 'shadow-red-500/20', + info: 'shadow-cyan-500/20', + history: 'shadow-purple-500/20', +}; + +/** + * 标签徽章组件 + * 支持多种变体和发光效果 + */ +export const Badge: React.FC = ({ + children, + variant = 'default', + size = 'sm', + glow = false, + className = '', +}) => { + const sizeStyles = size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-3 py-1 text-sm'; + + return ( + + {children} + + ); +}; diff --git a/apps/dsa-web/src/components/common/Button.tsx b/apps/dsa-web/src/components/common/Button.tsx new file mode 100644 index 000000000..cd118a424 --- /dev/null +++ b/apps/dsa-web/src/components/common/Button.tsx @@ -0,0 +1,121 @@ +import React from 'react'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'gradient' | 'danger'; + size?: 'sm' | 'md' | 'lg'; + isLoading?: boolean; + glow?: boolean; +} + +/** + * 按钮组件 + * 支持多种变体和科技感样式 + */ +export const Button: React.FC = ({ + children, + variant = 'primary', + size = 'md', + isLoading = false, + glow = false, + className = '', + disabled, + ...props +}) => { + const baseStyle = ` + inline-flex items-center justify-center + font-medium rounded-lg + transition-all duration-200 + focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-900 + disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none + `; + + const sizeStyles = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2.5 text-sm', + lg: 'px-6 py-3 text-base', + }; + + const variantStyles = { + primary: ` + bg-cyan-600 text-white + hover:bg-cyan-500 + focus:ring-cyan-500 + shadow-lg shadow-cyan-500/25 + `, + secondary: ` + bg-slate-700 text-gray-200 + hover:bg-slate-600 + focus:ring-slate-500 + border border-slate-600 + `, + outline: ` + bg-transparent text-cyan-400 + border border-cyan-500/30 + hover:bg-cyan-500/10 hover:border-cyan-500/50 + focus:ring-cyan-500 + `, + ghost: ` + bg-transparent text-gray-300 + hover:bg-white/5 hover:text-white + focus:ring-gray-500 + `, + gradient: ` + bg-gradient-to-r from-cyan-500 to-blue-500 text-white + hover:from-cyan-400 hover:to-blue-400 + focus:ring-cyan-500 + shadow-lg shadow-cyan-500/25 + `, + danger: ` + bg-red-600 text-white + hover:bg-red-500 + focus:ring-red-500 + shadow-lg shadow-red-500/25 + `, + }; + + const glowStyles = glow + ? 'shadow-glow-cyan hover:shadow-[0_0_30px_rgba(6,182,212,0.4)]' + : ''; + + return ( + + ); +}; diff --git a/apps/dsa-web/src/components/common/Card.tsx b/apps/dsa-web/src/components/common/Card.tsx new file mode 100644 index 000000000..709cbcf30 --- /dev/null +++ b/apps/dsa-web/src/components/common/Card.tsx @@ -0,0 +1,92 @@ +import type React from 'react'; + +interface CardProps { + title?: string; + subtitle?: string; + children: React.ReactNode; + className?: string; + variant?: 'default' | 'bordered' | 'gradient'; + hoverable?: boolean; + padding?: 'none' | 'sm' | 'md' | 'lg'; +} + +/** + * 终端风格卡片组件 + * 支持渐变边框、悬浮效果 + */ +export const Card: React.FC = ({ + title, + subtitle, + children, + className = '', + variant = 'default', + hoverable = false, + padding = 'md', +}) => { + const paddingStyles = { + none: '', + sm: 'p-3', + md: 'p-4', + lg: 'p-5', + }; + + const baseStyles = 'rounded-2xl'; + + const variantStyles = { + default: 'terminal-card', + bordered: 'terminal-card terminal-card-hover', + gradient: 'gradient-border-card', + }; + + const hoverStyles = hoverable + ? 'terminal-card-hover cursor-pointer' + : ''; + + if (variant === 'gradient') { + return ( +
+
+ {(title || subtitle) && ( +
+ {subtitle && ( + {subtitle} + )} + {title && ( +

+ {title} +

+ )} +
+ )} + {children} +
+
+ ); + } + + return ( +
+ {(title || subtitle) && ( +
+ {subtitle && ( + {subtitle} + )} + {title && ( +

+ {title} +

+ )} +
+ )} + {children} +
+ ); +}; diff --git a/apps/dsa-web/src/components/common/Collapsible.tsx b/apps/dsa-web/src/components/common/Collapsible.tsx new file mode 100644 index 000000000..11e074d98 --- /dev/null +++ b/apps/dsa-web/src/components/common/Collapsible.tsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react'; + +interface CollapsibleProps { + title: string; + children: React.ReactNode; + defaultOpen?: boolean; + icon?: React.ReactNode; + className?: string; +} + +/** + * 可折叠面板组件 + * 支持动画展开/收起 + */ +export const Collapsible: React.FC = ({ + title, + children, + defaultOpen = false, + icon, + className = '', +}) => { + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( +
+ {/* 标题栏 */} + + + {/* 内容区 */} +
+
+ {children} +
+
+
+ ); +}; diff --git a/apps/dsa-web/src/components/common/Drawer.tsx b/apps/dsa-web/src/components/common/Drawer.tsx new file mode 100644 index 000000000..61792517e --- /dev/null +++ b/apps/dsa-web/src/components/common/Drawer.tsx @@ -0,0 +1,91 @@ +import type React from 'react'; +import { useEffect, useCallback } from 'react'; + +interface DrawerProps { + isOpen: boolean; + onClose: () => void; + title?: string; + children: React.ReactNode; + width?: string; +} + +/** + * 侧滑抽屉组件 - 终端风格 + */ +export const Drawer: React.FC = ({ + isOpen, + onClose, + title, + children, + width = 'max-w-2xl', +}) => { + // ESC 键关闭 + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }, + [onClose] + ); + + useEffect(() => { + if (isOpen) { + document.addEventListener('keydown', handleKeyDown); + document.body.style.overflow = 'hidden'; + } + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = ''; + }; + }, [isOpen, handleKeyDown]); + + if (!isOpen) return null; + + return ( +
+ {/* 遮罩层 */} +
+ + {/* 抽屉内容 */} +
+
+ {/* 头部 */} +
+ {title && ( +
+ DETAIL VIEW +

+ {title} +

+
+ )} + +
+ + {/* 内容区 */} +
+ {children} +
+
+
+
+ ); +}; diff --git a/apps/dsa-web/src/components/common/JsonViewer.tsx b/apps/dsa-web/src/components/common/JsonViewer.tsx new file mode 100644 index 000000000..b00a5ccef --- /dev/null +++ b/apps/dsa-web/src/components/common/JsonViewer.tsx @@ -0,0 +1,92 @@ +import React, { useState } from 'react'; + +interface JsonViewerProps { + data: Record | unknown[] | null | undefined; + maxHeight?: string; + className?: string; +} + +/** + * JSON 结构化展示组件 + * 支持语法高亮和折叠 + */ +export const JsonViewer: React.FC = ({ + data, + maxHeight = '400px', + className = '', +}) => { + const [copied, setCopied] = useState(false); + + if (!data) { + return ( +
暂无数据
+ ); + } + + const jsonString = JSON.stringify(data, null, 2); + + const handleCopy = async () => { + await navigator.clipboard.writeText(jsonString); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + // 简单的语法高亮 + const highlightJson = (json: string): React.ReactNode => { + return json.split('\n').map((line, index) => { + // 高亮 key + let highlighted = line.replace( + /"([^"]+)":/g, + '"$1":' + ); + // 高亮字符串值 + highlighted = highlighted.replace( + /: "([^"]*)"/g, + ': "$1"' + ); + // 高亮数字 + highlighted = highlighted.replace( + /: (-?\d+\.?\d*)/g, + ': $1' + ); + // 高亮布尔值和 null + highlighted = highlighted.replace( + /: (true|false|null)/g, + ': $1' + ); + + return ( +
+ ); + }); + }; + + return ( +
+ {/* 复制按钮 */} + + + {/* JSON 内容 */} +
+
+          {highlightJson(jsonString)}
+        
+
+
+ ); +}; diff --git a/apps/dsa-web/src/components/common/Loading.tsx b/apps/dsa-web/src/components/common/Loading.tsx new file mode 100644 index 000000000..2e09e7a9b --- /dev/null +++ b/apps/dsa-web/src/components/common/Loading.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export const Loading: React.FC = () => { + return ( +
+
+
+ ); +}; diff --git a/apps/dsa-web/src/components/common/Pagination.tsx b/apps/dsa-web/src/components/common/Pagination.tsx new file mode 100644 index 000000000..3ec7955e3 --- /dev/null +++ b/apps/dsa-web/src/components/common/Pagination.tsx @@ -0,0 +1,111 @@ +import type React from 'react'; + +interface PaginationProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + className?: string; +} + +/** + * 分页组件 - 终端风格 + */ +export const Pagination: React.FC = ({ + currentPage, + totalPages, + onPageChange, + className = '', +}) => { + if (totalPages <= 1) return null; + + // 生成页码数组 + const getPageNumbers = (): (number | string)[] => { + const pages: (number | string)[] = []; + const delta = 2; + + for (let i = 1; i <= totalPages; i++) { + if ( + i === 1 || + i === totalPages || + (i >= currentPage - delta && i <= currentPage + delta) + ) { + pages.push(i); + } else if (pages[pages.length - 1] !== '...') { + pages.push('...'); + } + } + + return pages; + }; + + const PageButton: React.FC<{ + page: number | string; + isActive?: boolean; + disabled?: boolean; + onClick?: () => void; + children?: React.ReactNode; + }> = ({ page, isActive, disabled, onClick, children }) => { + const isEllipsis = page === '...'; + + if (isEllipsis) { + return ( + ... + ); + } + + return ( + + ); + }; + + return ( +
+ {/* 上一页 */} + onPageChange(currentPage - 1)} + > + + + + + + {/* 页码 */} + {getPageNumbers().map((page, index) => ( + typeof page === 'number' && onPageChange(page)} + /> + ))} + + {/* 下一页 */} + onPageChange(currentPage + 1)} + > + + + + +
+ ); +}; diff --git a/apps/dsa-web/src/components/common/ScoreGauge.tsx b/apps/dsa-web/src/components/common/ScoreGauge.tsx new file mode 100644 index 000000000..f90e97ecc --- /dev/null +++ b/apps/dsa-web/src/components/common/ScoreGauge.tsx @@ -0,0 +1,186 @@ +import type React from 'react'; +import { useState, useEffect, useRef } from 'react'; +import { getSentimentLabel } from '../../types/analysis'; + +interface ScoreGaugeProps { + score: number; + size?: 'sm' | 'md' | 'lg'; + showLabel?: boolean; + className?: string; +} + +/** + * 情绪评分仪表盘 - 发光环形进度条 + * 参考金融终端风格设计,带过渡动画 + */ +export const ScoreGauge: React.FC = ({ + score, + size = 'md', + showLabel = true, + className = '', +}) => { + // 动画状态 + const [animatedScore, setAnimatedScore] = useState(0); + const [displayScore, setDisplayScore] = useState(0); + const animationRef = useRef(null); + const prevScoreRef = useRef(0); + + // 动画效果 + useEffect(() => { + const startScore = prevScoreRef.current; + const endScore = score; + const duration = 1000; // 动画时长 ms + const startTime = performance.now(); + + const animate = (currentTime: number) => { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + + // 使用 easeOutCubic 缓动函数 + const easeOut = 1 - Math.pow(1 - progress, 3); + + const currentScore = startScore + (endScore - startScore) * easeOut; + setAnimatedScore(currentScore); + setDisplayScore(Math.round(currentScore)); + + if (progress < 1) { + animationRef.current = requestAnimationFrame(animate); + } else { + prevScoreRef.current = endScore; + } + }; + + animationRef.current = requestAnimationFrame(animate); + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + }; + }, [score]); + + const label = getSentimentLabel(score); + + // 尺寸配置 + const sizeConfig = { + sm: { width: 100, stroke: 8, fontSize: 'text-2xl', labelSize: 'text-xs', gap: 6 }, + md: { width: 140, stroke: 10, fontSize: 'text-4xl', labelSize: 'text-sm', gap: 8 }, + lg: { width: 180, stroke: 12, fontSize: 'text-5xl', labelSize: 'text-base', gap: 10 }, + }; + + const { width, stroke, fontSize, labelSize, gap } = sizeConfig[size]; + const radius = (width - stroke) / 2; + const circumference = 2 * Math.PI * radius; + + // 从顶部开始,显示 270 度(3/4 圆弧) + const arcLength = circumference * 0.75; + const progress = (animatedScore / 100) * arcLength; + + // 颜色映射 - 使用动画分数计算颜色过渡 + const getStrokeColor = (s: number) => { + if (s >= 60) return '#00d4ff'; // 青色 - 贪婪 + if (s >= 40) return '#a855f7'; // 紫色 - 中性 + return '#ff4466'; // 红色 - 恐惧 + }; + + const strokeColor = getStrokeColor(animatedScore); + const glowColor = `${strokeColor}66`; + + return ( +
+ {/* 标题 */} + {showLabel && ( + + 恐惧贪婪指数 + + )} + +
+ + + {/* 渐变定义 */} + + + + + + {/* 发光滤镜 */} + + + + + + + + + + {/* 背景轨道 - 3/4 圆弧 */} + + + {/* 发光层 */} + + + {/* 进度圆弧 */} + + + + {/* 中心数值 */} +
+ + {displayScore} + + {showLabel && ( + + {label.toUpperCase()} + + )} +
+
+
+ ); +}; diff --git a/apps/dsa-web/src/components/common/Select.tsx b/apps/dsa-web/src/components/common/Select.tsx new file mode 100644 index 000000000..090a1258c --- /dev/null +++ b/apps/dsa-web/src/components/common/Select.tsx @@ -0,0 +1,80 @@ +import React from 'react'; + +interface SelectOption { + value: string; + label: string; +} + +interface SelectProps { + value: string; + onChange: (value: string) => void; + options: SelectOption[]; + label?: string; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +/** + * 下拉选择器组件 + * 科技感样式 + */ +export const Select: React.FC = ({ + value, + onChange, + options, + label, + placeholder = '请选择', + disabled = false, + className = '', +}) => { + return ( +
+ {label && ( + + )} +
+ + + {/* 下拉箭头 */} +
+ + + +
+
+
+ ); +}; diff --git a/apps/dsa-web/src/components/common/index.ts b/apps/dsa-web/src/components/common/index.ts new file mode 100644 index 000000000..8872efbde --- /dev/null +++ b/apps/dsa-web/src/components/common/index.ts @@ -0,0 +1,10 @@ +export * from './Button'; +export * from './Card'; +export * from './Loading'; +export * from './Drawer'; +export * from './Collapsible'; +export * from './ScoreGauge'; +export * from './JsonViewer'; +export * from './Select'; +export * from './Badge'; +export * from './Pagination'; diff --git a/apps/dsa-web/src/components/history/HistoryList.tsx b/apps/dsa-web/src/components/history/HistoryList.tsx new file mode 100644 index 000000000..022114ca9 --- /dev/null +++ b/apps/dsa-web/src/components/history/HistoryList.tsx @@ -0,0 +1,160 @@ +import type React from 'react'; +import { useRef, useCallback, useEffect } from 'react'; +import type { HistoryItem } from '../../types/analysis'; +import { getSentimentColor } from '../../types/analysis'; +import { formatDateTime } from '../../utils/format'; + +interface HistoryListProps { + items: HistoryItem[]; + isLoading: boolean; + isLoadingMore: boolean; + hasMore: boolean; + selectedQueryId?: string; + onItemClick: (queryId: string) => void; + onLoadMore: () => void; + className?: string; +} + +/** + * 历史记录列表组件 + * 显示最近的股票分析历史,支持点击查看详情和滚动加载更多 + */ +export const HistoryList: React.FC = ({ + items, + isLoading, + isLoadingMore, + hasMore, + selectedQueryId, + onItemClick, + onLoadMore, + className = '', +}) => { + const scrollContainerRef = useRef(null); + const loadMoreTriggerRef = useRef(null); + + // 使用 IntersectionObserver 检测滚动到底部 + const handleObserver = useCallback( + (entries: IntersectionObserverEntry[]) => { + const target = entries[0]; + // 只有当触发器真正可见且有更多数据时才加载 + if (target.isIntersecting && hasMore && !isLoading && !isLoadingMore) { + // 确保容器有滚动能力(内容超过容器高度) + const container = scrollContainerRef.current; + if (container && container.scrollHeight > container.clientHeight) { + onLoadMore(); + } + } + }, + [hasMore, isLoading, isLoadingMore, onLoadMore] + ); + + useEffect(() => { + const trigger = loadMoreTriggerRef.current; + const container = scrollContainerRef.current; + if (!trigger || !container) return; + + const observer = new IntersectionObserver(handleObserver, { + root: container, + rootMargin: '20px', // 减小预加载距离 + threshold: 0.1, // 触发器至少 10% 可见时才触发 + }); + + observer.observe(trigger); + + return () => { + observer.disconnect(); + }; + }, [handleObserver]); + + return ( + + ); +}; diff --git a/apps/dsa-web/src/components/history/index.ts b/apps/dsa-web/src/components/history/index.ts new file mode 100644 index 000000000..37033f7d7 --- /dev/null +++ b/apps/dsa-web/src/components/history/index.ts @@ -0,0 +1 @@ +export { HistoryList } from './HistoryList'; diff --git a/apps/dsa-web/src/components/report/ReportDetails.tsx b/apps/dsa-web/src/components/report/ReportDetails.tsx new file mode 100644 index 000000000..95c786568 --- /dev/null +++ b/apps/dsa-web/src/components/report/ReportDetails.tsx @@ -0,0 +1,127 @@ +import type React from 'react'; +import { useState } from 'react'; +import type { ReportDetails as ReportDetailsType } from '../../types/analysis'; +import { Card } from '../common'; + +interface ReportDetailsProps { + details?: ReportDetailsType; + queryId?: string; +} + +/** + * 透明度与追溯区组件 - 终端风格 + */ +export const ReportDetails: React.FC = ({ + details, + queryId, +}) => { + const [showRaw, setShowRaw] = useState(false); + const [showSnapshot, setShowSnapshot] = useState(false); + const [copied, setCopied] = useState(false); + + if (!details?.rawResult && !details?.contextSnapshot && !queryId) { + return null; + } + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Copy failed:', err); + } + }; + + const renderJson = (data: unknown) => { + const jsonStr = JSON.stringify(data, null, 2); + return ( +
+ +
+          {jsonStr}
+        
+
+ ); + }; + + return ( + +
+ TRANSPARENCY +

数据追溯

+
+ + {/* Query ID */} + {queryId && ( +
+ Query ID: + + {queryId} + +
+ )} + + {/* 折叠区域 */} +
+ {/* 原始分析结果 */} + {details?.rawResult && ( +
+ + {showRaw && ( +
+ {renderJson(details.rawResult)} +
+ )} +
+ )} + + {/* 分析快照 */} + {details?.contextSnapshot && ( +
+ + {showSnapshot && ( +
+ {renderJson(details.contextSnapshot)} +
+ )} +
+ )} +
+
+ ); +}; diff --git a/apps/dsa-web/src/components/report/ReportNews.tsx b/apps/dsa-web/src/components/report/ReportNews.tsx new file mode 100644 index 000000000..dbc04ab11 --- /dev/null +++ b/apps/dsa-web/src/components/report/ReportNews.tsx @@ -0,0 +1,137 @@ +import type React from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import { Card } from '../common'; +import { historyApi } from '../../api/history'; +import type { NewsIntelItem } from '../../types/analysis'; + +interface ReportNewsProps { + queryId?: string; + limit?: number; +} + +/** + * 资讯区组件 - 终端风格 + */ +export const ReportNews: React.FC = ({ queryId, limit = 20 }) => { + const [isLoading, setIsLoading] = useState(false); + const [items, setItems] = useState([]); + const [error, setError] = useState(null); + + const fetchNews = useCallback(async () => { + if (!queryId) return; + setIsLoading(true); + setError(null); + + try { + const response = await historyApi.getNews(queryId, limit); + setItems(response.items || []); + } catch (err) { + setError(err instanceof Error ? err.message : '加载资讯失败'); + } finally { + setIsLoading(false); + } + }, [queryId, limit]); + + useEffect(() => { + setItems([]); + setError(null); + + if (queryId) { + fetchNews(); + } + }, [queryId, fetchNews]); + + if (!queryId) { + return null; + } + + return ( + +
+
+ NEWS FEED +

相关资讯

+
+
+ {isLoading && ( +
+ )} + +
+
+ + {error && !isLoading && ( +
+ {error} + +
+ )} + + {isLoading && !error && ( +
+
+ 加载资讯中... +
+ )} + + {!isLoading && !error && items.length === 0 && ( +
暂无相关资讯
+ )} + + {!isLoading && !error && items.length > 0 && ( +
+ {items.map((item, index) => ( +
+
+
+

+ {item.title} +

+ {item.snippet && ( +

+ {item.snippet} +

+ )} +
+ {item.url && ( + + 跳转 + + + + + )} +
+
+ ))} + +
+ )} + + ); +}; diff --git a/apps/dsa-web/src/components/report/ReportOverview.tsx b/apps/dsa-web/src/components/report/ReportOverview.tsx new file mode 100644 index 000000000..86f2fda69 --- /dev/null +++ b/apps/dsa-web/src/components/report/ReportOverview.tsx @@ -0,0 +1,133 @@ +import type React from 'react'; +import type { ReportMeta, ReportSummary as ReportSummaryType } from '../../types/analysis'; +import { ScoreGauge, Card } from '../common'; +import { formatDateTime } from '../../utils/format'; + +interface ReportOverviewProps { + meta: ReportMeta; + summary: ReportSummaryType; + isHistory?: boolean; +} + +/** + * 报告概览区组件 - 终端风格 + */ +export const ReportOverview: React.FC = ({ + meta, + summary +}) => { + // 根据涨跌幅获取颜色 + const getPriceChangeColor = (changePct: number | undefined): string => { + if (changePct === undefined || changePct === null) return 'text-muted'; + if (changePct > 0) return 'text-[#ff4d4d]'; // 红涨 + if (changePct < 0) return 'text-[#00d46a]'; // 绿跌 + return 'text-muted'; + }; + + // 格式化涨跌幅 + const formatChangePct = (changePct: number | undefined): string => { + if (changePct === undefined || changePct === null) return '--'; + const sign = changePct > 0 ? '+' : ''; + return `${sign}${changePct.toFixed(2)}%`; + }; + + return ( +
+ {/* 主信息区 - 两列布局 */} +
+ {/* 左侧:股票信息与结论 */} +
+ {/* 股票头部 */} + +
+
+
+

+ {meta.stockName || meta.stockCode} +

+ {/* 价格和涨跌幅 */} + {meta.currentPrice != null && ( +
+ + {meta.currentPrice.toFixed(2)} + + + {formatChangePct(meta.changePct)} + +
+ )} +
+
+ + {meta.stockCode} + + + + + + {formatDateTime(meta.createdAt)} + +
+
+
+ + {/* 关键结论 */} +
+ KEY INSIGHTS +

+ {summary.analysisSummary || '暂无分析结论'} +

+
+
+ + {/* 操作建议和趋势预测 */} +
+ {/* 操作建议 */} + +
+
+ + + +
+
+

操作建议

+

+ {summary.operationAdvice || '暂无建议'} +

+
+
+
+ + {/* 趋势预测 */} + +
+
+ + + +
+
+

趋势预测

+

+ {summary.trendPrediction || '暂无预测'} +

+
+
+
+
+
+ + {/* 右侧:情绪指标 */} +
+ +
+

Market Sentiment

+ +
+
+
+
+
+ ); +}; diff --git a/apps/dsa-web/src/components/report/ReportStrategy.tsx b/apps/dsa-web/src/components/report/ReportStrategy.tsx new file mode 100644 index 000000000..1561b3ca1 --- /dev/null +++ b/apps/dsa-web/src/components/report/ReportStrategy.tsx @@ -0,0 +1,82 @@ +import type React from 'react'; +import type { ReportStrategy as ReportStrategyType } from '../../types/analysis'; +import { Card } from '../common'; + +interface ReportStrategyProps { + strategy?: ReportStrategyType; +} + +interface StrategyItemProps { + label: string; + value?: string; + color: string; +} + +const StrategyItem: React.FC = ({ + label, + value, + color, +}) => ( +
+
+ {label} + + {value || '—'} + +
+ {/* 底部指示条 */} +
+
+); + +/** + * 策略点位区组件 - 终端风格 + */ +export const ReportStrategy: React.FC = ({ strategy }) => { + if (!strategy) { + return null; + } + + const strategyItems = [ + { + label: '理想买入', + value: strategy.idealBuy, + color: '#00ff88', // success + }, + { + label: '二次买入', + value: strategy.secondaryBuy, + color: '#00d4ff', // cyan + }, + { + label: '止损价位', + value: strategy.stopLoss, + color: '#ff4466', // danger + }, + { + label: '止盈目标', + value: strategy.takeProfit, + color: '#ffaa00', // warning + }, + ]; + + return ( + +
+ STRATEGY POINTS +

狙击点位

+
+
+ {strategyItems.map((item) => ( + + ))} +
+
+ ); +}; diff --git a/apps/dsa-web/src/components/report/ReportSummary.tsx b/apps/dsa-web/src/components/report/ReportSummary.tsx new file mode 100644 index 000000000..6b981b250 --- /dev/null +++ b/apps/dsa-web/src/components/report/ReportSummary.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import type { AnalysisResult, AnalysisReport } from '../../types/analysis'; +import { ReportOverview } from './ReportOverview'; +import { ReportStrategy } from './ReportStrategy'; +import { ReportNews } from './ReportNews'; +import { ReportDetails } from './ReportDetails'; + +interface ReportSummaryProps { + data: AnalysisResult | AnalysisReport; + isHistory?: boolean; +} + +/** + * 完整报告展示组件 + * 整合概览、策略、资讯、详情四个区域 + */ +export const ReportSummary: React.FC = ({ + data, + isHistory = false, +}) => { + // 兼容 AnalysisResult 和 AnalysisReport 两种数据格式 + const report: AnalysisReport = 'report' in data ? data.report : data; + const queryId = 'queryId' in data ? data.queryId : report.meta.queryId; + + const { meta, summary, strategy, details } = report; + + return ( +
+ {/* 概览区(首屏) */} + + + {/* 策略点位区 */} + + + {/* 资讯区 */} + + + {/* 透明度与追溯区 */} + +
+ ); +}; diff --git a/apps/dsa-web/src/components/report/index.ts b/apps/dsa-web/src/components/report/index.ts new file mode 100644 index 000000000..8b3722891 --- /dev/null +++ b/apps/dsa-web/src/components/report/index.ts @@ -0,0 +1,5 @@ +export * from './ReportSummary'; +export * from './ReportOverview'; +export * from './ReportStrategy'; +export * from './ReportNews'; +export * from './ReportDetails'; diff --git a/apps/dsa-web/src/components/tasks/TaskPanel.tsx b/apps/dsa-web/src/components/tasks/TaskPanel.tsx new file mode 100644 index 000000000..eff70cb95 --- /dev/null +++ b/apps/dsa-web/src/components/tasks/TaskPanel.tsx @@ -0,0 +1,160 @@ +import type React from 'react'; +import type { TaskInfo } from '../../types/analysis'; + +/** + * 任务项组件属性 + */ +interface TaskItemProps { + task: TaskInfo; +} + +/** + * 单个任务项 + */ +const TaskItem: React.FC = ({ task }) => { + const isPending = task.status === 'pending'; + const isProcessing = task.status === 'processing'; + + return ( +
+ {/* 状态图标 */} +
+ {isProcessing ? ( + // 加载动画 + + + + + ) : isPending ? ( + // 等待图标 + + + + ) : null} +
+ + {/* 任务信息 */} +
+
+ + {task.stockName || task.stockCode} + + + {task.stockCode} + +
+ {task.message && ( +

+ {task.message} +

+ )} +
+ + {/* 状态标签 */} +
+ + {isProcessing ? '分析中' : '等待中'} + +
+
+ ); +}; + +/** + * 任务面板属性 + */ +interface TaskPanelProps { + /** 任务列表 */ + tasks: TaskInfo[]; + /** 是否显示 */ + visible?: boolean; + /** 标题 */ + title?: string; + /** 自定义类名 */ + className?: string; +} + +/** + * 任务面板组件 + * 显示进行中的分析任务列表 + */ +export const TaskPanel: React.FC = ({ + tasks, + visible = true, + title = '分析任务', + className = '', +}) => { + // 筛选活跃任务(pending 和 processing) + const activeTasks = tasks.filter( + (t) => t.status === 'pending' || t.status === 'processing' + ); + + // 无任务或不可见时不渲染 + if (!visible || activeTasks.length === 0) { + return null; + } + + const pendingCount = activeTasks.filter((t) => t.status === 'pending').length; + const processingCount = activeTasks.filter((t) => t.status === 'processing').length; + + return ( +
+ {/* 标题栏 */} +
+
+ + + + {title} +
+
+ {processingCount > 0 && ( + + + {processingCount} 进行中 + + )} + {pendingCount > 0 && ( + {pendingCount} 等待中 + )} +
+
+ + {/* 任务列表 */} +
+ {activeTasks.map((task) => ( + + ))} +
+
+ ); +}; + +export default TaskPanel; diff --git a/apps/dsa-web/src/components/tasks/index.ts b/apps/dsa-web/src/components/tasks/index.ts new file mode 100644 index 000000000..b6b96d0ae --- /dev/null +++ b/apps/dsa-web/src/components/tasks/index.ts @@ -0,0 +1,2 @@ +export { TaskPanel } from './TaskPanel'; +export { default as TaskPanelDefault } from './TaskPanel'; diff --git a/apps/dsa-web/src/hooks/index.ts b/apps/dsa-web/src/hooks/index.ts new file mode 100644 index 000000000..c80dc8d57 --- /dev/null +++ b/apps/dsa-web/src/hooks/index.ts @@ -0,0 +1,7 @@ +export { useTaskStream } from './useTaskStream'; +export type { + SSEEventType, + SSEEvent, + UseTaskStreamOptions, + UseTaskStreamResult, +} from './useTaskStream'; diff --git a/apps/dsa-web/src/hooks/useTaskStream.ts b/apps/dsa-web/src/hooks/useTaskStream.ts new file mode 100644 index 000000000..145c5b47f --- /dev/null +++ b/apps/dsa-web/src/hooks/useTaskStream.ts @@ -0,0 +1,249 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { analysisApi } from '../api/analysis'; +import type { TaskInfo } from '../types/analysis'; + +/** + * SSE 事件类型 + */ +export type SSEEventType = + | 'connected' + | 'task_created' + | 'task_started' + | 'task_completed' + | 'task_failed' + | 'heartbeat'; + +/** + * SSE 事件数据 + */ +export interface SSEEvent { + type: SSEEventType; + task?: TaskInfo; + timestamp?: string; +} + +/** + * SSE Hook 配置 + */ +export interface UseTaskStreamOptions { + /** 任务创建回调 */ + onTaskCreated?: (task: TaskInfo) => void; + /** 任务开始回调 */ + onTaskStarted?: (task: TaskInfo) => void; + /** 任务完成回调 */ + onTaskCompleted?: (task: TaskInfo) => void; + /** 任务失败回调 */ + onTaskFailed?: (task: TaskInfo) => void; + /** 连接成功回调 */ + onConnected?: () => void; + /** 连接错误回调 */ + onError?: (error: Event) => void; + /** 是否自动重连 */ + autoReconnect?: boolean; + /** 重连延迟(ms) */ + reconnectDelay?: number; + /** 是否启用 */ + enabled?: boolean; +} + +/** + * SSE Hook 返回值 + */ +export interface UseTaskStreamResult { + /** 是否已连接 */ + isConnected: boolean; + /** 手动重连 */ + reconnect: () => void; + /** 手动断开 */ + disconnect: () => void; +} + +/** + * 任务流 SSE Hook + * 用于接收实时任务状态更新 + * + * @example + * ```tsx + * const { isConnected } = useTaskStream({ + * onTaskCompleted: (task) => { + * console.log('Task completed:', task); + * refreshHistory(); + * }, + * onTaskFailed: (task) => { + * showError(task.error); + * }, + * }); + * ``` + */ +export function useTaskStream(options: UseTaskStreamOptions = {}): UseTaskStreamResult { + const { + onTaskCreated, + onTaskStarted, + onTaskCompleted, + onTaskFailed, + onConnected, + onError, + autoReconnect = true, + reconnectDelay = 3000, + enabled = true, + } = options; + + const eventSourceRef = useRef(null); + const isConnectedRef = useRef(false); + const reconnectTimeoutRef = useRef | null>(null); + + // 使用 ref 存储回调,避免 SSE 连接因回调变化而频繁重连 + const callbacksRef = useRef({ + onTaskCreated, + onTaskStarted, + onTaskCompleted, + onTaskFailed, + onConnected, + onError, + }); + + // 每次渲染时更新回调 ref(确保事件处理使用最新回调) + useEffect(() => { + callbacksRef.current = { + onTaskCreated, + onTaskStarted, + onTaskCompleted, + onTaskFailed, + onConnected, + onError, + }; + }); + + // 将 snake_case 转换为 camelCase + const toCamelCase = (data: Record): TaskInfo => { + return { + taskId: data.task_id as string, + stockCode: data.stock_code as string, + stockName: data.stock_name as string | undefined, + status: data.status as TaskInfo['status'], + progress: data.progress as number, + message: data.message as string | undefined, + reportType: data.report_type as string, + createdAt: data.created_at as string, + startedAt: data.started_at as string | undefined, + completedAt: data.completed_at as string | undefined, + error: data.error as string | undefined, + }; + }; + + // 解析 SSE 数据 + const parseEventData = useCallback((eventData: string): TaskInfo | null => { + try { + const data = JSON.parse(eventData); + return toCamelCase(data); + } catch (e) { + console.error('Failed to parse SSE event data:', e); + return null; + } + }, []); + + // 创建 EventSource 连接 + const connect = useCallback(() => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + } + + const url = analysisApi.getTaskStreamUrl(); + const eventSource = new EventSource(url); + eventSourceRef.current = eventSource; + + // 连接成功 + eventSource.addEventListener('connected', () => { + isConnectedRef.current = true; + callbacksRef.current.onConnected?.(); + }); + + // 任务创建 + eventSource.addEventListener('task_created', (e) => { + const task = parseEventData(e.data); + if (task) callbacksRef.current.onTaskCreated?.(task); + }); + + // 任务开始 + eventSource.addEventListener('task_started', (e) => { + const task = parseEventData(e.data); + if (task) callbacksRef.current.onTaskStarted?.(task); + }); + + // 任务完成 + eventSource.addEventListener('task_completed', (e) => { + const task = parseEventData(e.data); + if (task) callbacksRef.current.onTaskCompleted?.(task); + }); + + // 任务失败 + eventSource.addEventListener('task_failed', (e) => { + const task = parseEventData(e.data); + if (task) callbacksRef.current.onTaskFailed?.(task); + }); + + // 心跳 - 仅用于保持连接 + eventSource.addEventListener('heartbeat', () => { + // 可选:更新最后心跳时间 + }); + + // 错误处理 + eventSource.onerror = (error) => { + isConnectedRef.current = false; + callbacksRef.current.onError?.(error); + + // 自动重连 + if (autoReconnect && enabled) { + eventSource.close(); + reconnectTimeoutRef.current = setTimeout(() => { + connect(); + }, reconnectDelay); + } + }; + }, [ + autoReconnect, + reconnectDelay, + enabled, + parseEventData, + ]); + + // 断开连接 + const disconnect = useCallback(() => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + isConnectedRef.current = false; + }, []); + + // 重连 + const reconnect = useCallback(() => { + disconnect(); + connect(); + }, [disconnect, connect]); + + // 启用/禁用时连接/断开 + useEffect(() => { + if (enabled) { + connect(); + } else { + disconnect(); + } + + return () => { + disconnect(); + }; + }, [enabled, connect, disconnect]); + + return { + isConnected: isConnectedRef.current, + reconnect, + disconnect, + }; +} + +export default useTaskStream; diff --git a/apps/dsa-web/src/index.css b/apps/dsa-web/src/index.css new file mode 100644 index 000000000..c0eb91145 --- /dev/null +++ b/apps/dsa-web/src/index.css @@ -0,0 +1,739 @@ +@import "tailwindcss"; + +/* ============ CSS 变量 - 金融终端风格 ============ */ +:root { + /* 主色调 - 青色系 */ + --color-cyan: #00d4ff; + --color-cyan-dim: #00a8cc; + --color-cyan-glow: rgba(0, 212, 255, 0.4); + + /* 辅助色 - 紫色系 */ + --color-purple: #00d4ff; + --color-purple-dim: #00a8cc; + --color-purple-glow: rgba(168, 85, 247, 0.3); + + /* 状态色 */ + --color-success: #00ff88; + --color-warning: #ffaa00; + --color-danger: #ff4466; + + /* 背景色 - 深黑系 */ + --bg-base: #08080c; + --bg-card: #0d0d14; + --bg-elevated: #12121a; + --bg-hover: #1a1a24; + + /* 边框色 */ + --border-dim: rgba(255, 255, 255, 0.06); + --border-default: rgba(255, 255, 255, 0.1); + --border-accent: rgba(0, 212, 255, 0.3); + --border-purple: rgba(47, 165, 245, 0.3); + + /* 文字色 */ + --text-primary: #ffffff; + --text-secondary: #a0a0b0; + --text-muted: #606070; + + /* 字体 */ + font-family: 'Inter', 'SF Pro Display', system-ui, -apple-system, sans-serif; + line-height: 1.5; + font-weight: 400; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ============ 基础样式 ============ */ +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: var(--bg-base); + color: var(--text-primary); +} + +/* ============ 终端卡片样式 ============ */ +.terminal-card { + background: var(--bg-card); + border: 1px solid var(--border-default); + border-radius: 12px; + position: relative; + overflow: hidden; +} + +.terminal-card::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 12px; + padding: 1px; + background: linear-gradient( + 135deg, + rgba(85, 198, 247, 0.2) 0%, + rgba(0, 212, 255, 0.1) 50%, + rgba(85, 198, 247, 0.2) 100% + ); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; +} + +.terminal-card-hover { + transition: all 0.3s ease; +} + +.terminal-card-hover:hover { + border-color: var(--border-accent); + box-shadow: 0 0 30px rgba(0, 212, 255, 0.1); +} + +/* ============ 渐变边框卡片 ============ */ +.gradient-border-card { + background: var(--bg-card); + border-radius: 12px; + position: relative; + padding: 1px; +} + +.gradient-border-card::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 12px; + padding: 1px; + background: linear-gradient( + 180deg, + rgba(67, 178, 246, 0.4) 0%, + rgba(168, 85, 247, 0.1) 50%, + rgba(0, 212, 255, 0.2) 100% + ); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; +} + +.gradient-border-card-inner { + background: var(--bg-card); + border-radius: 11px; + height: 100%; +} + +/* ============ 毛玻璃卡片 ============ */ +.glass-card { + background: rgba(13, 13, 20, 0.7); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + position: relative; + overflow: hidden; +} + +.glass-card::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 12px; + padding: 1px; + background: linear-gradient( + 135deg, + rgba(85, 209, 247, 0.25) 0%, + rgba(0, 212, 255, 0.15) 50%, + rgba(85, 209, 247, 0.1) 100% + ); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; +} + +/* 毛玻璃卡片 - 顶部高光 */ +.glass-card::after { + content: ''; + position: absolute; + top: 0; + left: 10%; + right: 10%; + height: 1px; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.15) 50%, + transparent 100% + ); + pointer-events: none; +} + +/* ============ 历史记录列表项 ============ */ +.history-item { + display: flex; + align-items: center; + padding: 10px 12px; + border-radius: 8px; + background: rgba(18, 18, 26, 0.5); + border: 1px solid rgba(255, 255, 255, 0.04); + transition: all 0.2s ease; + cursor: pointer; + position: relative; + overflow: hidden; +} + +.history-item::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 8px; + padding: 1px; + background: linear-gradient( + 135deg, + rgba(85, 185, 247, 0.15) 0%, + transparent 50%, + rgba(0, 212, 255, 0.1) 100% + ); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease; +} + +.history-item:hover { + background: rgba(26, 26, 36, 0.8); + border-color: rgba(255, 255, 255, 0.08); + transform: translateX(2px); +} + +.history-item:hover::before { + opacity: 1; +} + +.history-item.active { + background: rgba(0, 212, 255, 0.08); + border-color: rgba(0, 212, 255, 0.25); +} + +.history-item.active::before { + opacity: 1; + background: linear-gradient( + 135deg, + rgba(0, 212, 255, 0.3) 0%, + rgba(168, 85, 247, 0.15) 100% + ); +} + +/* ============ 浮动 Dock 导航栏 ============ */ +.dock-nav { + position: fixed; + left: 24px; + top: 50%; + transform: translateY(-50%); + z-index: 60; + pointer-events: none; +} + +.dock-surface { + pointer-events: auto; + width: 72px; + padding: 14px 10px; + border-radius: 26px; + /* 调整背景色:使用更深的半透明背景,减少突兀感 */ + background: rgba(18, 18, 26, 0.6); + /* 边框调淡 */ + border: 1px solid rgba(255, 255, 255, 0.06); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + /* 阴影优化:更柔和的深色阴影 */ + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.1); + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + position: relative; +} + +.dock-surface::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; + /* 渐变边框优化:更低调的颜色 */ + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.1) 0%, + rgba(255, 255, 255, 0.05) 100% + ); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; + opacity: 0.5; +} + +/* 顶部高光保留,但减弱 */ +.dock-surface::after { + content: ''; + position: absolute; + top: 10px; + left: 12px; + right: 12px; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent); + pointer-events: none; + opacity: 0.4; +} + +.dock-logo { + width: 48px; + height: 48px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + /* Logo 渐变微调 */ + background: linear-gradient(135deg, rgba(51, 110, 168, 0.45) 0%, #367db5 100%); + color: #04141d; + text-decoration: none; + box-shadow: 0 8px 16px rgba(0, 212, 255, 0.25); + transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.2s ease; +} + +.dock-logo:hover { + transform: scale(1.05); + box-shadow: 0 10px 20px rgba(0, 212, 255, 0.35); +} + +.dock-items { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + flex: 1; + width: 100%; +} + +.dock-footer { + margin-top: auto; +} + +.dock-item { + width: 48px; + height: 48px; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + background: transparent; + border: 1px solid transparent; + text-decoration: none; + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); + position: relative; + cursor: pointer; + overflow: hidden; +} + +/* 动态交互:渐变背景层 */ +.dock-item::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02)); + opacity: 0; + transition: opacity 0.3s ease; +} + +/* 动态交互:底部光晕 */ +.dock-item::after { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%) translateY(100%); + width: 60%; + height: 40%; + background: radial-gradient(circle, rgba(0, 212, 255, 0.4), transparent 70%); + filter: blur(8px); + opacity: 0; + transition: all 0.4s ease; +} + +/* Hover 状态 - 柔和的渐变和光晕 */ +.dock-item:hover { + color: var(--text-primary); + transform: translateY(-2px); + border-color: rgba(255, 255, 255, 0.08); +} + +.dock-item:hover::before { + opacity: 1; +} + +.dock-item:hover::after { + opacity: 0.6; + transform: translateX(-50%) translateY(40%); +} + +/* 激活状态 - 使用 Logo 同款渐变风格及交互 */ +.dock-item.is-active { + /* 复用 Logo 的渐变背景 */ + background: linear-gradient(135deg, rgba(51, 110, 168, 0.45) 0%, #367db5 100%); + color: #04141d; /* 深色图标 */ + border-color: transparent; + box-shadow: 0 8px 16px rgba(0, 212, 255, 0.25); + /* Logo 的弹跳动画曲线 */ + transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.2s ease; +} + +.dock-item.is-active:hover { + /* Logo 的悬浮交互:放大而非上浮 */ + transform: scale(1.05); + box-shadow: 0 10px 20px rgba(0, 212, 255, 0.35); + /* 保持颜色 */ + color: #04141d; + border-color: transparent; +} + +/* 激活状态下隐藏通用的 hover 效果元素,避免冲突 */ +.dock-item.is-active::after, +.dock-item.is-active::before { + opacity: 0 !important; +} + +.dock-item.is-placeholder, +.dock-item[disabled] { + color: var(--text-muted); + opacity: 0.5; + cursor: not-allowed; +} + +.dock-item.is-placeholder:hover, +.dock-item[disabled]:hover { + transform: none; + background: transparent; + border-color: transparent; +} + +.dock-item.is-placeholder:hover::before, +.dock-item.is-placeholder:hover::after { + opacity: 0; +} + +.dock-safe-area { + padding-left: 120px; +} + +/* ============ 标题样式 ============ */ +.label-uppercase { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-purple); +} + +.title-gradient { + background: linear-gradient(90deg, #ffffff 0%, #a0a0b0 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; +} + +/* ============ 环形仪表盘 ============ */ +.gauge-ring { + filter: drop-shadow(0 0 8px var(--color-cyan-glow)); +} + +.gauge-track { + stroke: rgba(255, 255, 255, 0.05); + fill: none; +} + +.gauge-progress { + fill: none; + stroke-linecap: round; + transition: stroke-dashoffset 0.8s ease-out; +} + +.gauge-glow { + filter: blur(6px); + opacity: 0.6; +} + +/* ============ 输入框样式 ============ */ +.input-terminal { + width: 100%; + padding: 10px 14px; + border-radius: 8px; + background: var(--bg-elevated); + border: 1px solid var(--border-default); + color: var(--text-primary); + font-size: 14px; + outline: none; + transition: all 0.2s ease; +} + +.input-terminal::placeholder { + color: var(--text-muted); +} + +.input-terminal:focus { + border-color: var(--border-accent); + box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1); +} + +.input-terminal:hover:not(:focus) { + border-color: rgba(255, 255, 255, 0.15); +} + +/* ============ 按钮样式 ============ */ +.btn-primary { + background: linear-gradient(135deg, var(--color-cyan) 0%, var(--color-cyan-dim) 100%); + color: #000; + font-weight: 600; + padding: 10px 20px; + border-radius: 8px; + border: none; + cursor: pointer; + transition: all 0.2s ease; + font-size: 13px; +} + +.btn-primary:hover { + box-shadow: 0 0 20px var(--color-cyan-glow); + transform: translateY(-1px); +} + +.btn-primary:active { + transform: translateY(0); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.btn-secondary { + background: transparent; + color: var(--text-secondary); + font-weight: 500; + padding: 10px 20px; + border-radius: 8px; + border: 1px solid var(--border-default); + cursor: pointer; + transition: all 0.2s ease; + font-size: 13px; +} + +.btn-secondary:hover { + border-color: var(--border-accent); + color: var(--color-cyan); +} + +/* ============ 徽章样式 ============ */ +.badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; +} + +.badge-cyan { + background: rgba(0, 212, 255, 0.15); + color: var(--color-cyan); + border: 1px solid rgba(0, 212, 255, 0.3); +} + +.badge-purple { + background: rgba(168, 85, 247, 0.15); + color: var(--color-purple); + border: 1px solid rgba(168, 85, 247, 0.3); +} + +.badge-success { + background: rgba(0, 255, 136, 0.1); + color: var(--color-success); +} + +.badge-danger { + background: rgba(255, 68, 102, 0.1); + color: var(--color-danger); +} + +/* ============ 列表项样式 ============ */ +.list-item { + display: flex; + align-items: center; + padding: 12px; + border-radius: 8px; + background: transparent; + border: 1px solid transparent; + transition: all 0.2s ease; + cursor: pointer; +} + +.list-item:hover { + background: var(--bg-hover); + border-color: var(--border-dim); +} + +/* ============ Feed 项目样式 ============ */ +.feed-item { + padding: 10px 0; + border-left: 2px solid var(--border-accent); + padding-left: 10px; +} + +.feed-item + .feed-item { + margin-top: 8px; +} + +/* ============ 自定义滚动条 ============ */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); +} + +/* ============ 动画效果 ============ */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes pulse-glow { + 0%, 100% { + box-shadow: 0 0 20px var(--color-cyan-glow); + } + 50% { + box-shadow: 0 0 40px var(--color-cyan-glow); + } +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.animate-fade-in { + animation: fadeIn 0.3s ease-out; +} + +.animate-slide-up { + animation: slideUp 0.4s ease-out; +} + +.animate-slide-in-right { + animation: slideInRight 0.3s ease-out; +} + +.animate-pulse-glow { + animation: pulse-glow 2s ease-in-out infinite; +} + +.animate-spin { + animation: spin 1s linear infinite; +} + +/* ============ 工具类 ============ */ +.text-cyan { color: var(--color-cyan); } +.text-purple { color: var(--color-purple); } +.text-success { color: var(--color-success); } +.text-danger { color: var(--color-danger); } +.text-warning { color: var(--color-warning); } +.text-muted { color: var(--text-muted); } +.text-secondary { color: var(--text-secondary); } + +.bg-base { background: var(--bg-base); } +.bg-card { background: var(--bg-card); } +.bg-elevated { background: var(--bg-elevated); } + +.border-accent { border-color: var(--border-accent); } +.border-purple { border-color: var(--border-purple); } + +/* 发光效果 */ +.glow-cyan { + box-shadow: 0 0 20px var(--color-cyan-glow); +} + +.glow-purple { + box-shadow: 0 0 20px var(--color-purple-glow); +} + +/* ============ 响应式 ============ */ +@media (max-width: 768px) { + .dock-nav { + left: 12px; + } + + .dock-surface { + width: 60px; + padding: 10px 8px; + border-radius: 22px; + } + + .dock-logo { + width: 42px; + height: 42px; + border-radius: 12px; + } + + .dock-item { + width: 42px; + height: 42px; + border-radius: 12px; + } + + .dock-safe-area { + padding-left: 88px; + } +} diff --git a/apps/dsa-web/src/main.tsx b/apps/dsa-web/src/main.tsx new file mode 100644 index 000000000..bef5202a3 --- /dev/null +++ b/apps/dsa-web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/apps/dsa-web/src/pages/HomePage.tsx b/apps/dsa-web/src/pages/HomePage.tsx new file mode 100644 index 000000000..514b3d098 --- /dev/null +++ b/apps/dsa-web/src/pages/HomePage.tsx @@ -0,0 +1,322 @@ +import type React from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { HistoryItem, AnalysisReport, TaskInfo } from '../types/analysis'; +import { historyApi } from '../api/history'; +import { analysisApi, DuplicateTaskError } from '../api/analysis'; +import { validateStockCode } from '../utils/validation'; +import { getRecentStartDate, toDateInputValue } from '../utils/format'; +import { useAnalysisStore } from '../stores/analysisStore'; +import { ReportSummary } from '../components/report'; +import { HistoryList } from '../components/history'; +import { TaskPanel } from '../components/tasks'; +import { useTaskStream } from '../hooks'; + +/** + * 首页 - 单页设计 + * 顶部输入 + 左侧历史 + 右侧报告 + */ +const HomePage: React.FC = () => { + const { setLoading, setError: setStoreError } = useAnalysisStore(); + + // 输入状态 + const [stockCode, setStockCode] = useState(''); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [inputError, setInputError] = useState(); + +// 历史列表状态 + const [historyItems, setHistoryItems] = useState([]); + const [isLoadingHistory, setIsLoadingHistory] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 20; + + // 报告详情状态 + const [selectedReport, setSelectedReport] = useState(null); + const [isLoadingReport, setIsLoadingReport] = useState(false); + + // 任务队列状态 + const [activeTasks, setActiveTasks] = useState([]); + const [duplicateError, setDuplicateError] = useState(null); + + // 用于跟踪当前分析请求,避免竞态条件 + const analysisRequestIdRef = useRef(0); + + // 更新任务列表中的任务 + const updateTask = useCallback((updatedTask: TaskInfo) => { + setActiveTasks((prev) => { + const index = prev.findIndex((t) => t.taskId === updatedTask.taskId); + if (index >= 0) { + const newTasks = [...prev]; + newTasks[index] = updatedTask; + return newTasks; + } + return prev; + }); + }, []); + + // 移除已完成/失败的任务 + const removeTask = useCallback((taskId: string) => { + setActiveTasks((prev) => prev.filter((t) => t.taskId !== taskId)); + }, []); + + // SSE 任务流 + useTaskStream({ + onTaskCreated: (task) => { + setActiveTasks((prev) => { + // 避免重复添加 + if (prev.some((t) => t.taskId === task.taskId)) return prev; + return [...prev, task]; + }); + }, + onTaskStarted: updateTask, + onTaskCompleted: (task) => { + // 刷新历史列表 + fetchHistory(); + // 延迟移除任务,让用户看到完成状态 + setTimeout(() => removeTask(task.taskId), 2000); + }, + onTaskFailed: (task) => { + updateTask(task); + // 显示错误提示 + setStoreError(task.error || '分析失败'); + // 延迟移除任务 + setTimeout(() => removeTask(task.taskId), 5000); + }, + onError: () => { + console.warn('SSE 连接断开,正在重连...'); + }, + enabled: true, + }); + +// 加载历史列表 + const fetchHistory = useCallback(async (autoSelectFirst = false, reset = true) => { + if (reset) { + setIsLoadingHistory(true); + setCurrentPage(1); + } else { + setIsLoadingMore(true); + } + + const page = reset ? 1 : currentPage + 1; + + try { + const response = await historyApi.getList({ + startDate: getRecentStartDate(30), + endDate: toDateInputValue(new Date()), + page, + limit: pageSize, + }); + + if (reset) { + setHistoryItems(response.items); + } else { + setHistoryItems(prev => [...prev, ...response.items]); + } + + // 判断是否还有更多数据 + const totalLoaded = reset ? response.items.length : historyItems.length + response.items.length; + setHasMore(totalLoaded < response.total); + setCurrentPage(page); + + // 如果需要自动选择第一条,且有数据,且当前没有选中报告 + if (autoSelectFirst && response.items.length > 0 && !selectedReport) { + const firstItem = response.items[0]; + setIsLoadingReport(true); + try { + const report = await historyApi.getDetail(firstItem.queryId); + setSelectedReport(report); + } catch (err) { + console.error('Failed to fetch first report:', err); + } finally { + setIsLoadingReport(false); + } + } + } catch (err) { + console.error('Failed to fetch history:', err); + } finally { + setIsLoadingHistory(false); + setIsLoadingMore(false); + } + }, [selectedReport, currentPage, historyItems.length, pageSize]); + + // 加载更多历史记录 + const handleLoadMore = useCallback(() => { + if (!isLoadingMore && hasMore) { + fetchHistory(false, false); + } + }, [fetchHistory, isLoadingMore, hasMore]); + + // 初始加载 - 自动选择第一条 + useEffect(() => { + fetchHistory(true); + }, []); + + // 点击历史项加载报告 + const handleHistoryClick = async (queryId: string) => { + // 取消当前分析请求的结果显示(通过递增 requestId) + analysisRequestIdRef.current += 1; + + setIsLoadingReport(true); + try { + const report = await historyApi.getDetail(queryId); + setSelectedReport(report); + } catch (err) { + console.error('Failed to fetch report:', err); + } finally { + setIsLoadingReport(false); + } + }; + + // 分析股票(异步模式) + const handleAnalyze = async () => { + const { valid, message, normalized } = validateStockCode(stockCode); + if (!valid) { + setInputError(message); + return; + } + + setInputError(undefined); + setDuplicateError(null); + setIsAnalyzing(true); + setLoading(true); + setStoreError(null); + + // 记录当前请求的 ID + const currentRequestId = ++analysisRequestIdRef.current; + + try { + // 使用异步模式提交分析 + const response = await analysisApi.analyzeAsync({ + stockCode: normalized, + reportType: 'detailed', + }); + + // 清空输入框 + if (currentRequestId === analysisRequestIdRef.current) { + setStockCode(''); + } + + // 任务已提交,SSE 会推送更新 + console.log('Task submitted:', response.taskId); + } catch (err) { + console.error('Analysis failed:', err); + if (currentRequestId === analysisRequestIdRef.current) { + if (err instanceof DuplicateTaskError) { + // 显示重复任务错误 + setDuplicateError(`股票 ${err.stockCode} 正在分析中,请等待完成`); + } else { + setStoreError(err instanceof Error ? err.message : '分析失败'); + } + } + } finally { + setIsAnalyzing(false); + setLoading(false); + } + }; + + // 回车提交 + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && stockCode && !isAnalyzing) { + handleAnalyze(); + } + }; + + return ( +
+ {/* 顶部输入栏 */} +
+
+
+ { + setStockCode(e.target.value.toUpperCase()); + setInputError(undefined); + }} + onKeyDown={handleKeyDown} + placeholder="输入股票代码,如 600519、00700、AAPL" + disabled={isAnalyzing} + className={`input-terminal w-full ${inputError ? 'border-danger/50' : ''}`} + /> + {inputError && ( +

{inputError}

+ )} + {duplicateError && ( +

{duplicateError}

+ )} +
+ +
+
+ + {/* 主内容区 */} +
+{/* 左侧:任务面板 + 历史列表 */} +
+ {/* 任务面板 */} + + + {/* 历史列表 */} + +
+ + {/* 右侧报告详情 */} +
+ {isLoadingReport ? ( +
+
+

加载报告中...

+
+ ) : selectedReport ? ( +
+ {/* 报告内容 */} + +
+ ) : ( +
+
+ + + +
+

开始分析

+

+ 输入股票代码进行分析,或从左侧选择历史报告查看 +

+
+ )} +
+
+
+ ); +}; + +export default HomePage; diff --git a/apps/dsa-web/src/pages/NotFoundPage.tsx b/apps/dsa-web/src/pages/NotFoundPage.tsx new file mode 100644 index 000000000..08c6e14f9 --- /dev/null +++ b/apps/dsa-web/src/pages/NotFoundPage.tsx @@ -0,0 +1,38 @@ +import type React from 'react'; +import { useNavigate } from 'react-router-dom'; + +const NotFoundPage: React.FC = () => { + const navigate = useNavigate(); + + return ( +
+ {/* 404 */} +
+ + 404 + +
+ +

页面未找到

+

抱歉,您访问的页面不存在或已被移动

+ + +
+ ); +}; + +export default NotFoundPage; diff --git a/apps/dsa-web/src/stores/analysisStore.ts b/apps/dsa-web/src/stores/analysisStore.ts new file mode 100644 index 000000000..ddb7f4886 --- /dev/null +++ b/apps/dsa-web/src/stores/analysisStore.ts @@ -0,0 +1,67 @@ +import { create } from 'zustand'; +import type { AnalysisResult, AnalysisReport } from '../types/analysis'; + +interface AnalysisState { + // 分析状态 + isLoading: boolean; + result: AnalysisResult | null; + error: string | null; + + // 历史报告视图 + isHistoryView: boolean; + historyReport: AnalysisReport | null; + + // Actions + setLoading: (loading: boolean) => void; + setResult: (result: AnalysisResult | null) => void; + setError: (error: string | null) => void; + setHistoryReport: (report: AnalysisReport | null) => void; + reset: () => void; + resetToAnalysis: () => void; +} + +export const useAnalysisStore = create((set) => ({ + // 初始状态 + isLoading: false, + result: null, + error: null, + isHistoryView: false, + historyReport: null, + + // Actions + setLoading: (loading) => set({ isLoading: loading }), + + setResult: (result) => + set({ + result, + error: null, + isHistoryView: false, + historyReport: null, + }), + + setError: (error) => set({ error, isLoading: false }), + + setHistoryReport: (report) => + set({ + historyReport: report, + isHistoryView: true, + result: null, + error: null, + isLoading: false, + }), + + reset: () => + set({ + isLoading: false, + result: null, + error: null, + isHistoryView: false, + historyReport: null, + }), + + resetToAnalysis: () => + set({ + isHistoryView: false, + historyReport: null, + }), +})); diff --git a/apps/dsa-web/src/stores/index.ts b/apps/dsa-web/src/stores/index.ts new file mode 100644 index 000000000..54fc0e07a --- /dev/null +++ b/apps/dsa-web/src/stores/index.ts @@ -0,0 +1 @@ +export * from './analysisStore'; diff --git a/apps/dsa-web/src/types/analysis.ts b/apps/dsa-web/src/types/analysis.ts new file mode 100644 index 000000000..468dbde38 --- /dev/null +++ b/apps/dsa-web/src/types/analysis.ts @@ -0,0 +1,194 @@ +/** + * 股票分析相关类型定义 + * 与 API 规范 (api_spec.json) 对齐 + */ + +// ============ 请求类型 ============ + +export interface AnalysisRequest { + stockCode: string; + reportType?: 'simple' | 'detailed'; + forceRefresh?: boolean; + asyncMode?: boolean; +} + +// ============ 报告类型 ============ + +/** 报告元信息 */ +export interface ReportMeta { + queryId: string; + stockCode: string; + stockName: string; + reportType: 'simple' | 'detailed'; + createdAt: string; + currentPrice?: number; + changePct?: number; +} + +/** 情绪标签 */ +export type SentimentLabel = '极度悲观' | '悲观' | '中性' | '乐观' | '极度乐观'; + +/** 报告概览区 */ +export interface ReportSummary { + analysisSummary: string; + operationAdvice: string; + trendPrediction: string; + sentimentScore: number; + sentimentLabel?: SentimentLabel; +} + +/** 策略点位区 */ +export interface ReportStrategy { + idealBuy?: string; + secondaryBuy?: string; + stopLoss?: string; + takeProfit?: string; +} + +/** 详情区(可折叠) */ +export interface ReportDetails { + newsContent?: string; + rawResult?: Record; + contextSnapshot?: Record; +} + +/** 完整分析报告 */ +export interface AnalysisReport { + meta: ReportMeta; + summary: ReportSummary; + strategy?: ReportStrategy; + details?: ReportDetails; +} + +// ============ 分析结果类型 ============ + +/** 同步分析返回结果 */ +export interface AnalysisResult { + queryId: string; + stockCode: string; + stockName: string; + report: AnalysisReport; + createdAt: string; +} + +/** 异步任务接受响应 */ +export interface TaskAccepted { + taskId: string; + status: 'pending' | 'processing'; + message?: string; +} + +/** 任务状态 */ +export interface TaskStatus { + taskId: string; + status: 'pending' | 'processing' | 'completed' | 'failed'; + progress?: number; + result?: AnalysisResult; + error?: string; +} + +/** 任务详情(用于任务列表和 SSE 事件) */ +export interface TaskInfo { + taskId: string; + stockCode: string; + stockName?: string; + status: 'pending' | 'processing' | 'completed' | 'failed'; + progress: number; + message?: string; + reportType: string; + createdAt: string; + startedAt?: string; + completedAt?: string; + error?: string; +} + +/** 任务列表响应 */ +export interface TaskListResponse { + total: number; + pending: number; + processing: number; + tasks: TaskInfo[]; +} + +/** 重复任务错误响应 */ +export interface DuplicateTaskError { + error: 'duplicate_task'; + message: string; + stockCode: string; + existingTaskId: string; +} + +// ============ 历史记录类型 ============ + +/** 历史记录摘要(列表展示用) */ +export interface HistoryItem { + queryId: string; + stockCode: string; + stockName?: string; + reportType?: string; + sentimentScore?: number; + operationAdvice?: string; + createdAt: string; +} + +/** 历史记录列表响应 */ +export interface HistoryListResponse { + total: number; + page: number; + limit: number; + items: HistoryItem[]; +} + +/** 新闻情报条目 */ +export interface NewsIntelItem { + title: string; + snippet: string; + url: string; +} + +/** 新闻情报响应 */ +export interface NewsIntelResponse { + total: number; + items: NewsIntelItem[]; +} + +/** 历史列表筛选参数 */ +export interface HistoryFilters { + stockCode?: string; + startDate?: string; + endDate?: string; +} + +/** 历史列表分页参数 */ +export interface HistoryPagination { + page: number; + limit: number; +} + +// ============ 错误类型 ============ + +export interface ApiError { + error: string; + message: string; + detail?: Record; +} + +// ============ 辅助函数 ============ + +/** 根据情绪评分获取情绪标签 */ +export const getSentimentLabel = (score: number): SentimentLabel => { + if (score <= 20) return '极度悲观'; + if (score <= 40) return '悲观'; + if (score <= 60) return '中性'; + if (score <= 80) return '乐观'; + return '极度乐观'; +}; + +/** 根据情绪评分获取颜色 */ +export const getSentimentColor = (score: number): string => { + if (score <= 20) return '#ef4444'; // red-500 + if (score <= 40) return '#f97316'; // orange-500 + if (score <= 60) return '#eab308'; // yellow-500 + if (score <= 80) return '#22c55e'; // green-500 + return '#10b981'; // emerald-500 +}; diff --git a/apps/dsa-web/src/utils/constants.ts b/apps/dsa-web/src/utils/constants.ts new file mode 100644 index 000000000..eb8dec8c8 --- /dev/null +++ b/apps/dsa-web/src/utils/constants.ts @@ -0,0 +1,2 @@ +// 生产环境使用相对路径(同源),开发环境使用环境变量或默认本地地址 +export const API_BASE_URL = import.meta.env.VITE_API_URL || (import.meta.env.PROD ? '' : 'http://127.0.0.1:8000'); diff --git a/apps/dsa-web/src/utils/format.ts b/apps/dsa-web/src/utils/format.ts new file mode 100644 index 000000000..425288ff2 --- /dev/null +++ b/apps/dsa-web/src/utils/format.ts @@ -0,0 +1,45 @@ +export const formatDateTime = (value?: string): string => { + if (!value) return '—'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(date); +}; + +export const formatDate = (value?: string): string => { + if (!value) return '—'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(date); +}; + +export const toDateInputValue = (date: Date): string => { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + const day = `${date.getDate()}`.padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +export const getRecentStartDate = (days: number): string => { + const date = new Date(); + date.setDate(date.getDate() - days); + return toDateInputValue(date); +}; + +export const formatReportType = (value?: string): string => { + if (!value) return '—'; + if (value === 'simple') return '普通'; + if (value === 'detailed') return '标准'; + return value; +}; diff --git a/apps/dsa-web/src/utils/validation.ts b/apps/dsa-web/src/utils/validation.ts new file mode 100644 index 000000000..c1795559a --- /dev/null +++ b/apps/dsa-web/src/utils/validation.ts @@ -0,0 +1,29 @@ +interface ValidationResult { + valid: boolean; + message?: string; + normalized: string; +} + +// 兼容 A/H/美股常见代码格式的基础校验 +export const validateStockCode = (value: string): ValidationResult => { + const normalized = value.trim().toUpperCase(); + + if (!normalized) { + return { valid: false, message: '请输入股票代码', normalized }; + } + + const patterns = [ + /^\d{6}$/, // A 股 6 位数字 + /^(SH|SZ)\d{6}$/, // A 股带交易所前缀 + /^\d{5}$/, // 港股 5 位数字 + /^[A-Z]{1,6}(\.[A-Z]{1,2})?$/, // 美股常见 Ticker + ]; + + const valid = patterns.some((regex) => regex.test(normalized)); + + return { + valid, + message: valid ? undefined : '股票代码格式不正确', + normalized, + }; +}; diff --git a/apps/dsa-web/tailwind.config.js b/apps/dsa-web/tailwind.config.js new file mode 100644 index 000000000..15b4f5ca9 --- /dev/null +++ b/apps/dsa-web/tailwind.config.js @@ -0,0 +1,95 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + // 主色调 - 青色 + 'cyan': { + DEFAULT: '#00d4ff', + dim: '#00a8cc', + glow: 'rgba(0, 212, 255, 0.4)', + }, + // 辅助色 - 紫色 + 'purple': { + DEFAULT: '#6f61f1', + dim: '#533483', + glow: 'rgba(168, 85, 247, 0.3)', + }, + // 状态色 + 'success': '#00ff88', + 'warning': '#ffaa00', + 'danger': '#ff4466', + // 背景色 + 'base': '#08080c', + 'card': '#0d0d14', + 'elevated': '#12121a', + 'hover': '#1a1a24', + // 文字色 + 'primary': '#ffffff', + 'secondary': '#a0a0b0', + 'muted': '#606070', + // 边框色 + 'border': { + dim: 'rgba(255, 255, 255, 0.06)', + DEFAULT: 'rgba(255, 255, 255, 0.1)', + accent: 'rgba(0, 212, 255, 0.3)', + purple: 'rgba(168, 85, 247, 0.3)', + }, + }, + backgroundImage: { + 'gradient-purple-cyan': 'linear-gradient(135deg, rgba(168, 85, 247, 0.2) 0%, rgba(0, 212, 255, 0.1) 100%)', + 'gradient-card-border': 'linear-gradient(180deg, rgba(168, 85, 247, 0.4) 0%, rgba(168, 85, 247, 0.1) 50%, rgba(0, 212, 255, 0.2) 100%)', + 'gradient-cyan': 'linear-gradient(135deg, #00d4ff 0%, #00a8cc 100%)', + }, + boxShadow: { + 'glow-cyan': '0 0 20px rgba(0, 212, 255, 0.4)', + 'glow-purple': '0 0 20px rgba(168, 85, 247, 0.3)', + 'glow-success': '0 0 20px rgba(0, 255, 136, 0.3)', + 'glow-danger': '0 0 20px rgba(255, 68, 102, 0.3)', + }, + borderRadius: { + 'xl': '12px', + '2xl': '16px', + '3xl': '20px', + }, + fontSize: { + 'xxs': '10px', + 'label': '11px', + }, + spacing: { + '18': '4.5rem', + '22': '5.5rem', + }, + animation: { + 'fade-in': 'fadeIn 0.3s ease-out', + 'slide-up': 'slideUp 0.4s ease-out', + 'slide-in-right': 'slideInRight 0.3s ease-out', + 'pulse-glow': 'pulseGlow 2s ease-in-out infinite', + 'spin-slow': 'spin 2s linear infinite', + }, + keyframes: { + fadeIn: { + 'from': { opacity: '0' }, + 'to': { opacity: '1' }, + }, + slideUp: { + 'from': { opacity: '0', transform: 'translateY(10px)' }, + 'to': { opacity: '1', transform: 'translateY(0)' }, + }, + slideInRight: { + 'from': { opacity: '0', transform: 'translateX(100%)' }, + 'to': { opacity: '1', transform: 'translateX(0)' }, + }, + pulseGlow: { + '0%, 100%': { boxShadow: '0 0 20px rgba(0, 212, 255, 0.4)' }, + '50%': { boxShadow: '0 0 40px rgba(0, 212, 255, 0.6)' }, + }, + }, + }, + }, + plugins: [], +} diff --git a/apps/dsa-web/tsconfig.app.json b/apps/dsa-web/tsconfig.app.json new file mode 100644 index 000000000..a9b5a59ca --- /dev/null +++ b/apps/dsa-web/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/apps/dsa-web/tsconfig.json b/apps/dsa-web/tsconfig.json new file mode 100644 index 000000000..1ffef600d --- /dev/null +++ b/apps/dsa-web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/apps/dsa-web/tsconfig.node.json b/apps/dsa-web/tsconfig.node.json new file mode 100644 index 000000000..8a67f62f4 --- /dev/null +++ b/apps/dsa-web/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/dsa-web/vite.config.ts b/apps/dsa-web/vite.config.ts new file mode 100644 index 000000000..e764270dd --- /dev/null +++ b/apps/dsa-web/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + react({ + babel: { + plugins: [['babel-plugin-react-compiler']], + }, + }), + ], + server: { + host: '0.0.0.0', // 允许公网访问 + port: 5173, // 默认端口 + }, + build: { + // 打包输出到项目根目录的 static 文件夹 + outDir: path.resolve(__dirname, '../../static'), + emptyOutDir: true, + }, +}) diff --git a/docker/Dockerfile b/docker/Dockerfile index 35f0ff84a..b9d5ee7c2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,7 +1,17 @@ # =================================== # A股自选股智能分析系统 - Docker 镜像 # =================================== -# 基于 Python 3.11 slim 镜像,体积小、启动快 +# 多阶段构建:前端打包 + 后端运行 + +FROM node:20-slim AS web-builder + +WORKDIR /app/apps/dsa-web + +COPY apps/dsa-web/package.json apps/dsa-web/package-lock.json ./ +RUN npm ci + +COPY apps/dsa-web/ ./ +RUN npm run build FROM python:3.11-slim @@ -26,10 +36,12 @@ RUN pip install --no-cache-dir -r requirements.txt # 复制应用代码 COPY *.py ./ +COPY api/ ./api/ COPY data_provider/ ./data_provider/ COPY web/ ./web/ COPY bot/ ./bot/ COPY src/ ./src/ +COPY --from=web-builder /app/static ./static/ # 创建数据目录 RUN mkdir -p /app/data /app/logs /app/reports @@ -48,9 +60,10 @@ EXPOSE 8000 # 数据卷(持久化数据) VOLUME ["/app/data", "/app/logs", "/app/reports"] -# 健康检查(支持 WebUI 模式) +# 健康检查(支持 WebUI / FastAPI 模式) HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ - CMD curl -f http://localhost:8000/health || python -c "import sys; sys.exit(0)" + CMD curl -f http://localhost:8000/api/health || curl -f http://localhost:8000/health \ + || python -c "import sys; sys.exit(0)" # 默认命令(可被覆盖) CMD ["python", "main.py", "--schedule"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1f3c54d43..15b0ad8e5 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -5,6 +5,7 @@ # 使用方式: # 定时模式: docker-compose -f ./docker/docker-compose.yml up -d # WebUI模式: docker-compose -f ./docker/docker-compose.yml up -d webui +# FastAPI模式: docker-compose -f ./docker/docker-compose.yml up -d server # 同时启动: docker-compose -f ./docker/docker-compose.yml up -d analyzer webui version: '3.8' @@ -24,6 +25,8 @@ x-common: &common - ../logs:/app/logs - ../reports:/app/reports - ../.env:/app/.env + # 如需覆盖前端静态资源,可挂载本地 static 目录 + # - ../static:/app/static:ro environment: - TZ=Asia/Shanghai @@ -62,3 +65,11 @@ services: command: ["python", "main.py", "--webui-only"] ports: - "${WEBUI_PORT:-8000}:${WEBUI_PORT:-8000}" + + # FastAPI 模式 + server: + <<: *common + container_name: stock-server + command: ["python", "main.py", "--serve-only", "--host", "0.0.0.0", "--port", "${API_PORT:-8000}"] + ports: + - "${API_PORT:-8000}:${API_PORT:-8000}" diff --git a/docs/architecture/api_spec.json b/docs/architecture/api_spec.json new file mode 100644 index 000000000..c88bfd733 --- /dev/null +++ b/docs/architecture/api_spec.json @@ -0,0 +1,961 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Daily Stock Analysis API", + "description": "A股/港股/美股自选股智能分析系统 API\n\n## 功能模块\n- 股票分析:触发 AI 智能分析\n- 历史记录:查询历史分析报告\n- 股票数据:获取行情数据\n\n## 认证方式\n当前版本暂无认证要求", + "version": "1.0.0", + "contact": { + "name": "Daily Stock Analysis Team" + } + }, + "servers": [ + { + "url": "http://localhost:8000", + "description": "本地开发服务器" + } + ], + "tags": [ + { + "name": "Health", + "description": "健康检查接口" + }, + { + "name": "Analysis", + "description": "股票分析相关接口" + }, + { + "name": "History", + "description": "历史记录相关接口" + } + ], + "paths": { + "/": { + "get": { + "tags": [ + "Health" + ], + "summary": "API 根路由", + "description": "返回 API 运行状态信息", + "operationId": "root", + "responses": { + "200": { + "description": "API 正常运行", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RootResponse" + } + } + } + } + } + } + }, + "/api/health": { + "get": { + "tags": [ + "Health" + ], + "summary": "健康检查", + "description": "用于负载均衡器或监控系统检查服务状态", + "operationId": "healthCheck", + "responses": { + "200": { + "description": "服务健康", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + } + } + }, + "/api/v1/analysis/analyze": { + "post": { + "tags": [ + "Analysis" + ], + "summary": "触发股票分析", + "description": "启动 AI 智能分析任务,支持单只或多只股票批量分析", + "operationId": "triggerAnalysis", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyzeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "分析完成(同步模式)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalysisResult" + } + } + } + }, + "202": { + "description": "分析任务已接受(异步模式)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskAccepted" + } + } + } + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "股票正在分析中,拒绝重复提交", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DuplicateTaskError" + } + } + } + }, + "500": { + "description": "分析失败", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/analysis/tasks": { + "get": { + "tags": [ + "Analysis" + ], + "summary": "获取分析任务列表", + "description": "获取当前所有分析任务,支持按状态筛选。返回进行中和最近完成的任务。", + "operationId": "getAnalysisTasks", + "parameters": [ + { + "name": "status", + "in": "query", + "description": "筛选状态:pending, processing, completed, failed(支持逗号分隔多个)", + "schema": { + "type": "string", + "example": "pending,processing" + } + }, + { + "name": "limit", + "in": "query", + "description": "返回数量限制", + "schema": { + "type": "integer", + "default": 20, + "minimum": 1, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "任务列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskListResponse" + } + } + } + } + } + } + }, + "/api/v1/analysis/tasks/stream": { + "get": { + "tags": [ + "Analysis" + ], + "summary": "任务状态 SSE 流", + "description": "通过 Server-Sent Events 实时推送任务状态变化。\n\n## 事件类型\n- `connected`: 连接成功\n- `task_created`: 新任务创建\n- `task_started`: 任务开始执行\n- `task_completed`: 任务完成\n- `task_failed`: 任务失败\n- `heartbeat`: 心跳(每 30 秒)", + "operationId": "taskStream", + "responses": { + "200": { + "description": "SSE 事件流", + "content": { + "text/event-stream": { + "schema": { + "type": "string", + "example": "event: task_created\ndata: {\"task_id\": \"abc123\", \"stock_code\": \"600519\", \"status\": \"pending\"}\n\n" + } + } + } + } + } + } + }, + "/api/v1/history": { + "get": { + "tags": [ + "History" + ], + "summary": "获取历史分析列表", + "description": "分页获取历史分析记录摘要,支持按股票代码和日期范围筛选", + "operationId": "getHistoryList", + "parameters": [ + { + "name": "stock_code", + "in": "query", + "description": "股票代码筛选", + "schema": { + "type": "string" + } + }, + { + "name": "start_date", + "in": "query", + "description": "开始日期 (YYYY-MM-DD)", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "end_date", + "in": "query", + "description": "结束日期 (YYYY-MM-DD)", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "page", + "in": "query", + "description": "页码(从 1 开始)", + "schema": { + "type": "integer", + "default": 1, + "minimum": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "每页数量", + "schema": { + "type": "integer", + "default": 20, + "minimum": 1, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "历史记录列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HistoryListResponse" + } + } + } + } + } + } + }, + "/api/v1/history/{query_id}": { + "get": { + "tags": [ + "History" + ], + "summary": "获取历史报告详情", + "description": "根据 query_id 获取完整的历史分析报告", + "operationId": "getHistoryDetail", + "parameters": [ + { + "name": "query_id", + "in": "path", + "required": true, + "description": "分析记录唯一标识", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "报告详情", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalysisReport" + } + } + } + }, + "404": { + "description": "报告不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/history/{query_id}/news": { + "get": { + "tags": [ + "History" + ], + "summary": "获取历史报告关联新闻", + "description": "根据 query_id 获取关联的新闻情报列表(为空也返回 200)", + "operationId": "getHistoryNews", + "parameters": [ + { + "name": "query_id", + "in": "path", + "required": true, + "description": "分析记录唯一标识", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "返回数量限制", + "schema": { + "type": "integer", + "default": 20, + "minimum": 1, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "新闻情报列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewsIntelResponse" + } + } + } + }, + "500": { + "description": "服务器错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "RootResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Daily Stock Analysis API is running" + }, + "version": { + "type": "string", + "example": "1.0.0" + } + }, + "required": [ + "message" + ] + }, + "HealthResponse": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "status" + ] + }, + "AnalyzeRequest": { + "type": "object", + "properties": { + "stock_code": { + "type": "string", + "description": "单只股票代码", + "example": "600519" + }, + "stock_codes": { + "type": "array", + "description": "多只股票代码(与 stock_code 二选一)", + "items": { + "type": "string" + }, + "example": [ + "600519", + "000858" + ] + }, + "report_type": { + "type": "string", + "enum": [ + "simple", + "detailed" + ], + "default": "detailed", + "description": "报告类型" + }, + "force_refresh": { + "type": "boolean", + "default": false, + "description": "是否强制刷新(忽略缓存)" + }, + "async_mode": { + "type": "boolean", + "default": false, + "description": "是否使用异步模式" + } + } + }, + "AnalysisResult": { + "type": "object", + "properties": { + "query_id": { + "type": "string", + "description": "分析记录唯一标识" + }, + "stock_code": { + "type": "string" + }, + "stock_name": { + "type": "string" + }, + "report": { + "$ref": "#/components/schemas/AnalysisReport" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "query_id", + "stock_code", + "report", + "created_at" + ] + }, + "TaskAccepted": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "任务 ID,用于查询状态" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "processing" + ], + "example": "pending" + }, + "message": { + "type": "string", + "example": "Analysis task accepted" + } + }, + "required": [ + "task_id", + "status" + ] + }, + "TaskStatus": { + "type": "object", + "properties": { + "task_id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "processing", + "completed", + "failed" + ] + }, + "progress": { + "type": "integer", + "description": "进度百分比 (0-100)" + }, + "result": { + "$ref": "#/components/schemas/AnalysisResult" + }, + "error": { + "type": "string", + "description": "错误信息(仅在 failed 时存在)" + } + }, + "required": [ + "task_id", + "status" + ] + }, + "TaskInfo": { + "type": "object", + "description": "任务详情(用于任务列表和 SSE 事件)", + "properties": { + "task_id": { + "type": "string", + "description": "任务 ID" + }, + "stock_code": { + "type": "string", + "description": "股票代码" + }, + "stock_name": { + "type": "string", + "description": "股票名称" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "processing", + "completed", + "failed" + ], + "description": "任务状态" + }, + "progress": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "进度百分比" + }, + "message": { + "type": "string", + "description": "状态消息" + }, + "report_type": { + "type": "string", + "enum": [ + "simple", + "detailed" + ], + "description": "报告类型" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "创建时间" + }, + "started_at": { + "type": "string", + "format": "date-time", + "description": "开始执行时间" + }, + "completed_at": { + "type": "string", + "format": "date-time", + "description": "完成时间" + }, + "error": { + "type": "string", + "description": "错误信息" + } + }, + "required": [ + "task_id", + "stock_code", + "status", + "created_at" + ] + }, + "TaskListResponse": { + "type": "object", + "description": "任务列表响应", + "properties": { + "total": { + "type": "integer", + "description": "任务总数" + }, + "pending": { + "type": "integer", + "description": "等待中的任务数" + }, + "processing": { + "type": "integer", + "description": "处理中的任务数" + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskInfo" + }, + "description": "任务列表" + } + }, + "required": [ + "total", + "pending", + "processing", + "tasks" + ] + }, + "DuplicateTaskError": { + "type": "object", + "description": "重复任务错误响应", + "properties": { + "error": { + "type": "string", + "example": "duplicate_task", + "description": "错误类型" + }, + "message": { + "type": "string", + "example": "股票 600519 正在分析中", + "description": "错误信息" + }, + "stock_code": { + "type": "string", + "example": "600519", + "description": "股票代码" + }, + "existing_task_id": { + "type": "string", + "example": "abc123def456", + "description": "已存在的任务 ID" + } + }, + "required": [ + "error", + "message", + "stock_code", + "existing_task_id" + ] + }, + "HistoryListResponse": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "description": "总记录数" + }, + "page": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HistoryItem" + } + } + }, + "required": [ + "total", + "page", + "limit", + "items" + ] + }, + "NewsIntelItem": { + "type": "object", + "description": "新闻情报条目", + "properties": { + "title": { + "type": "string", + "description": "新闻标题" + }, + "snippet": { + "type": "string", + "description": "新闻摘要(最多50字)" + }, + "url": { + "type": "string", + "description": "新闻链接" + } + }, + "required": [ + "title", + "url" + ] + }, + "NewsIntelResponse": { + "type": "object", + "description": "新闻情报响应", + "properties": { + "total": { + "type": "integer", + "description": "新闻条数" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NewsIntelItem" + }, + "description": "新闻列表" + } + }, + "required": [ + "total", + "items" + ] + }, + "HistoryItem": { + "type": "object", + "description": "历史记录摘要(列表展示用)", + "properties": { + "query_id": { + "type": "string" + }, + "stock_code": { + "type": "string" + }, + "stock_name": { + "type": "string" + }, + "report_type": { + "type": "string" + }, + "sentiment_score": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "operation_advice": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "query_id", + "stock_code", + "created_at" + ] + }, + "AnalysisReport": { + "type": "object", + "description": "完整分析报告", + "properties": { + "meta": { + "type": "object", + "description": "元信息", + "properties": { + "query_id": { + "type": "string" + }, + "stock_code": { + "type": "string" + }, + "stock_name": { + "type": "string" + }, + "report_type": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "summary": { + "type": "object", + "description": "概览区(首屏展示)", + "properties": { + "analysis_summary": { + "type": "string", + "description": "关键结论" + }, + "operation_advice": { + "type": "string", + "description": "操作建议" + }, + "trend_prediction": { + "type": "string", + "description": "趋势预测" + }, + "sentiment_score": { + "type": "integer", + "description": "情绪评分 (0-100)" + }, + "sentiment_label": { + "type": "string", + "description": "情绪标签", + "enum": [ + "极度悲观", + "悲观", + "中性", + "乐观", + "极度乐观" + ] + } + } + }, + "strategy": { + "type": "object", + "description": "策略点位区", + "properties": { + "ideal_buy": { + "type": "string", + "description": "理想买入价" + }, + "secondary_buy": { + "type": "string", + "description": "第二买入价" + }, + "stop_loss": { + "type": "string", + "description": "止损价" + }, + "take_profit": { + "type": "string", + "description": "止盈价" + } + } + }, + "details": { + "type": "object", + "description": "详情区(可折叠)", + "properties": { + "news_content": { + "type": "string", + "description": "新闻摘要" + }, + "raw_result": { + "type": "object", + "description": "原始分析结果(JSON)" + }, + "context_snapshot": { + "type": "object", + "description": "分析时上下文快照(JSON)" + } + } + } + }, + "required": [ + "meta", + "summary" + ] + }, + "StockQuote": { + "type": "object", + "description": "股票实时行情", + "properties": { + "stock_code": { + "type": "string" + }, + "stock_name": { + "type": "string" + }, + "current_price": { + "type": "number" + }, + "change": { + "type": "number", + "description": "涨跌额" + }, + "change_percent": { + "type": "number", + "description": "涨跌幅 (%)" + }, + "open": { + "type": "number" + }, + "high": { + "type": "number" + }, + "low": { + "type": "number" + }, + "prev_close": { + "type": "number" + }, + "volume": { + "type": "number", + "description": "成交量(股)" + }, + "amount": { + "type": "number", + "description": "成交额(元)" + }, + "update_time": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "stock_code", + "current_price" + ] + }, + "ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "错误类型" + }, + "message": { + "type": "string", + "description": "错误详情" + }, + "detail": { + "type": "object", + "description": "附加错误信息" + } + }, + "required": [ + "error", + "message" + ] + } + } + } +} diff --git a/docs/docker/zeabur-deployment.md b/docs/docker/zeabur-deployment.md index 8072a9400..29749bf7a 100644 --- a/docs/docker/zeabur-deployment.md +++ b/docs/docker/zeabur-deployment.md @@ -57,6 +57,14 @@ Zeabur 会自动检测 `.github/workflows/docker-publish.yml` 文件,并使用 2. 点击「启动服务」 3. 服务启动后,你可以在「访问」标签页获取访问地址 +### 2.4 前端构建与静态资源 + +FastAPI 会自动托管 `static/` 目录下的前端资源。前端打包输出位置由 +`apps/dsa-web/vite.config.ts` 决定,默认输出到项目根目录 `static/`。 + +Dockerfile 已采用多阶段构建,前端会在镜像构建时自动打包。 +如需覆盖默认静态资源,可在宿主机手动构建并挂载到容器内 `/app/static`。 + ## 3. 配置启动命令 ### 3.1 支持的启动模式 @@ -66,8 +74,10 @@ Zeabur 会自动检测 `.github/workflows/docker-publish.yml` 文件,并使用 | 模式 | 启动命令 | 描述 | |------|----------|------| | 定时任务模式(默认) | `python main.py --schedule` | 按计划执行股票分析 | -| WebUI 模式 | `python main.py --webui` | 启动 WebUI 和定时任务 | +| WebUI 模式 | `python main.py --webui` | 启动 WebUI(旧版)和定时任务 | | 仅 WebUI 模式 | `python main.py --webui-only` | 仅启动 WebUI,不执行定时任务 | +| FastAPI 模式 | `python main.py --serve` | 启动 FastAPI 并执行分析 | +| 仅 FastAPI 模式 | `python main.py --serve-only` | 仅启动 FastAPI,不执行分析 | | 仅大盘复盘 | `python main.py --market-review` | 仅执行大盘复盘分析 | ### 3.2 配置启动命令 @@ -76,9 +86,11 @@ Zeabur 会自动检测 `.github/workflows/docker-publish.yml` 文件,并使用 2. 点击「设置」 3. 找到「启动命令」配置项 4. 输入你需要的启动命令,例如: - - 启动 WebUI:`python main.py --webui` - - 仅启动 WebUI:`python main.py --webui-only` - - 启动定时任务:`python main.py --schedule` + - 启动 WebUI:`python main.py --webui` + - 仅启动 WebUI:`python main.py --webui-only` + - 启动 FastAPI:`python main.py --serve` + - 仅启动 FastAPI:`python main.py --serve-only --host 0.0.0.0 --port 8000` + - 启动定时任务:`python main.py --schedule` 5. 点击「保存」 6. 重启服务 @@ -183,13 +195,15 @@ Zeabur 会自动检测 `.github/workflows/docker-publish.yml` 文件,并使用 系统内置了健康检查机制,默认检查: - WebUI 模式:检查 `http://localhost:8000/health` 端点 -- 非 WebUI 模式:始终返回健康状态 +- FastAPI 模式:检查 `http://localhost:8000/api/health` 端点 +- 非服务模式:始终返回健康状态 健康检查配置如下: ```dockerfile HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ - CMD curl -f http://localhost:8000/health || python -c "import sys; sys.exit(0)" + CMD curl -f http://localhost:8000/api/health || curl -f http://localhost:8000/health \ + || python -c "import sys; sys.exit(0)" ``` ## 8. 常见问题 diff --git a/docs/full-guide.md b/docs/full-guide.md index 533f56ac6..fb5b1c05b 100644 --- a/docs/full-guide.md +++ b/docs/full-guide.md @@ -215,6 +215,9 @@ daily_stock_analysis/ ## Docker 部署 +Dockerfile 使用多阶段构建,前端会在构建镜像时自动打包并内置到 `static/`。 +如需覆盖静态资源,可挂载本地 `static/` 到容器内 `/app/static`。 + ### 快速启动 ```bash @@ -229,6 +232,7 @@ vim .env # 填入 API Key 和配置 # 3. 启动容器 docker-compose -f ./docker/docker-compose.yml up -d webui # WebUI 模式(推荐) docker-compose -f ./docker/docker-compose.yml up -d analyzer # 定时任务模式 +docker-compose -f ./docker/docker-compose.yml up -d server # FastAPI Web模式(和WebUI模式占用相同端口注意避免冲突) docker-compose -f ./docker/docker-compose.yml up -d # 同时启动两种模式 # 4. 访问 WebUI @@ -244,8 +248,11 @@ docker-compose -f ./docker/docker-compose.yml logs -f webui |------|------|------| | `docker-compose -f ./docker/docker-compose.yml up -d webui` | WebUI 模式,手动触发分析 | 8000 | | `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | 定时任务模式,每日自动执行 | - | +| `docker-compose -f ./docker/docker-compose.yml up -d server` | FastAPI 模式,提供 API 与静态资源 | 8000 | | `docker-compose -f ./docker/docker-compose.yml up -d` | 同时启动两种模式 | 8000 | +> 注意:WebUI 与 FastAPI 默认端口都是 8000,若需同时启动请设置 `WEBUI_PORT` 与 `API_PORT`。 + ### Docker Compose 配置 `docker-compose.yml` 使用 YAML 锚点复用配置: @@ -254,17 +261,19 @@ docker-compose -f ./docker/docker-compose.yml logs -f webui version: '3.8' x-common: &common - build: . + build: + context: .. + dockerfile: docker/Dockerfile restart: unless-stopped env_file: - - .env + - ../.env environment: - TZ=Asia/Shanghai volumes: - - ./data:/app/data - - ./logs:/app/logs - - ./reports:/app/reports - - ./.env:/app/.env + - ../data:/app/data + - ../logs:/app/logs + - ../reports:/app/reports + - ../.env:/app/.env services: # 定时任务模式 @@ -279,6 +288,14 @@ services: command: ["python", "main.py", "--webui-only"] ports: - "8000:8000" + + # FastAPI 模式 + server: + <<: *common + container_name: stock-server + command: ["python", "main.py", "--serve-only", "--host", "0.0.0.0", "--port", "8000"] + ports: + - "8000:8000" ``` ### 常用命令 @@ -289,6 +306,7 @@ docker-compose -f ./docker/docker-compose.yml ps # 查看日志 docker-compose -f ./docker/docker-compose.yml logs -f webui +docker-compose -f ./docker/docker-compose.yml logs -f server # 停止服务 docker-compose -f ./docker/docker-compose.yml down @@ -301,8 +319,9 @@ docker-compose -f ./docker/docker-compose.yml up -d webui ### 手动构建镜像 ```bash -docker build -t stock-analysis . +docker build -f docker/Dockerfile -t stock-analysis . docker run -d --env-file .env -p 8000:8000 -v ./data:/app/data stock-analysis python main.py --webui-only +docker run -d --env-file .env -p 8000:8000 -v ./data:/app/data stock-analysis python main.py --serve-only --host 0.0.0.0 --port 8000 ``` --- diff --git a/main.py b/main.py index f66cca08d..dc9a8d6d1 100644 --- a/main.py +++ b/main.py @@ -41,84 +41,18 @@ import sys import time import uuid from datetime import datetime, timezone, timedelta -from logging.handlers import RotatingFileHandler from pathlib import Path from typing import List, Optional -from src.feishu_doc import FeishuDocManager from src.config import get_config, Config +from src.feishu_doc import FeishuDocManager +from src.logging_config import setup_logging from src.notification import NotificationService from src.core.pipeline import StockAnalysisPipeline from src.core.market_review import run_market_review from src.search_service import SearchService from src.analyzer import GeminiAnalyzer -# 配置日志格式 -LOG_FORMAT = '%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s' -LOG_DATE_FORMAT = '%Y-%m-%d %H:%M:%S' - - -def setup_logging(debug: bool = False, log_dir: str = "./logs") -> None: - """ - 配置日志系统(同时输出到控制台和文件) - - Args: - debug: 是否启用调试模式 - log_dir: 日志文件目录 - """ - level = logging.DEBUG if debug else logging.INFO - - # 创建日志目录 - log_path = Path(log_dir) - log_path.mkdir(parents=True, exist_ok=True) - - # 日志文件路径(按日期分文件) - today_str = datetime.now().strftime('%Y%m%d') - log_file = log_path / f"stock_analysis_{today_str}.log" - debug_log_file = log_path / f"stock_analysis_debug_{today_str}.log" - - # 创建根 logger - root_logger = logging.getLogger() - root_logger.setLevel(logging.DEBUG) # 根 logger 设为 DEBUG,由 handler 控制输出级别 - - # Handler 1: 控制台输出 - console_handler = logging.StreamHandler(sys.stdout) - console_handler.setLevel(level) - console_handler.setFormatter(logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT)) - root_logger.addHandler(console_handler) - - # Handler 2: 常规日志文件(INFO 级别,10MB 轮转) - file_handler = RotatingFileHandler( - log_file, - maxBytes=10 * 1024 * 1024, # 10MB - backupCount=5, - encoding='utf-8' - ) - file_handler.setLevel(logging.INFO) - file_handler.setFormatter(logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT)) - root_logger.addHandler(file_handler) - - # Handler 3: 调试日志文件(DEBUG 级别,包含所有详细信息) - debug_handler = RotatingFileHandler( - debug_log_file, - maxBytes=50 * 1024 * 1024, # 50MB - backupCount=3, - encoding='utf-8' - ) - debug_handler.setLevel(logging.DEBUG) - debug_handler.setFormatter(logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT)) - root_logger.addHandler(debug_handler) - - # 降低第三方库的日志级别 - logging.getLogger('urllib3').setLevel(logging.WARNING) - logging.getLogger('sqlalchemy').setLevel(logging.WARNING) - logging.getLogger('google').setLevel(logging.WARNING) - logging.getLogger('httpx').setLevel(logging.WARNING) - - logging.info(f"日志系统初始化完成,日志目录: {log_path.absolute()}") - logging.info(f"常规日志: {log_file}") - logging.info(f"调试日志: {debug_log_file}") - logger = logging.getLogger(__name__) @@ -140,72 +74,98 @@ def parse_arguments() -> argparse.Namespace: python main.py --market-review # 仅运行大盘复盘 ''' ) - + parser.add_argument( '--debug', action='store_true', help='启用调试模式,输出详细日志' ) - + parser.add_argument( '--dry-run', action='store_true', help='仅获取数据,不进行 AI 分析' ) - + parser.add_argument( '--stocks', type=str, help='指定要分析的股票代码,逗号分隔(覆盖配置文件)' ) - + parser.add_argument( '--no-notify', action='store_true', help='不发送推送通知' ) - + parser.add_argument( '--single-notify', action='store_true', help='启用单股推送模式:每分析完一只股票立即推送,而不是汇总推送' ) - + parser.add_argument( '--workers', type=int, default=None, help='并发线程数(默认使用配置值)' ) - + parser.add_argument( '--schedule', action='store_true', help='启用定时任务模式,每日定时执行' ) - + parser.add_argument( '--market-review', action='store_true', help='仅运行大盘复盘分析' ) - + parser.add_argument( '--no-market-review', action='store_true', help='跳过大盘复盘分析' ) - + parser.add_argument( '--webui', action='store_true', - help='启动本地配置 WebUI' + help='启动本地配置 WebUI(旧版 Gradio)' ) - + parser.add_argument( '--webui-only', action='store_true', - help='仅启动 WebUI 服务,不自动执行分析(通过 /analysis API 手动触发)' + help='仅启动 WebUI 服务,不自动执行分析' + ) + + parser.add_argument( + '--serve', + action='store_true', + help='启动 FastAPI 后端服务(同时执行分析任务)' + ) + + parser.add_argument( + '--serve-only', + action='store_true', + help='仅启动 FastAPI 后端服务,不自动执行分析' + ) + + parser.add_argument( + '--port', + type=int, + default=8000, + help='FastAPI 服务端口(默认 8000)' + ) + + parser.add_argument( + '--host', + type=str, + default='0.0.0.0', + help='FastAPI 服务监听地址(默认 0.0.0.0)' ) parser.add_argument( @@ -213,7 +173,7 @@ def parse_arguments() -> argparse.Namespace: action='store_true', help='不保存分析上下文快照' ) - + return parser.parse_args() @@ -224,14 +184,14 @@ def run_full_analysis( ): """ 执行完整的分析流程(个股 + 大盘复盘) - + 这是定时任务调用的主函数 """ try: # 命令行参数 --single-notify 覆盖配置(#55) if getattr(args, 'single_notify', False): config.single_stock_notify = True - + # 创建调度器 save_context_snapshot = None if getattr(args, 'no_context_snapshot', False): @@ -244,7 +204,7 @@ def run_full_analysis( query_source="cli", save_context_snapshot=save_context_snapshot ) - + # 1. 运行个股分析 results = pipeline.run( stock_codes=stock_codes, @@ -271,7 +231,7 @@ def run_full_analysis( # 如果有结果,赋值给 market_report 用于后续飞书文档生成 if review_result: market_report = review_result - + # 输出摘要 if results: logger.info("\n===== 分析结果摘要 =====") @@ -281,7 +241,7 @@ def run_full_analysis( f"{emoji} {r.name}({r.code}): {r.operation_advice} | " f"评分 {r.sentiment_score} | {r.trend_prediction}" ) - + logger.info("\n任务执行完成") # === 新增:生成飞书云文档 === @@ -317,11 +277,38 @@ def run_full_analysis( except Exception as e: logger.error(f"飞书文档生成失败: {e}") - + except Exception as e: logger.exception(f"分析流程执行失败: {e}") +def start_api_server(host: str, port: int, config: Config) -> None: + """ + 在后台线程启动 FastAPI 服务 + + Args: + host: 监听地址 + port: 监听端口 + config: 配置对象 + """ + import threading + import uvicorn + + def run_server(): + level_name = (config.log_level or "INFO").lower() + uvicorn.run( + "api.app:app", + host=host, + port=port, + log_level=level_name, + log_config=None, + ) + + thread = threading.Thread(target=run_server, daemon=True) + thread.start() + logger.info(f"FastAPI 服务已启动: http://{host}:{port}") + + def start_bot_stream_clients(config: Config) -> None: """Start bot stream clients when enabled in config.""" # 启动钉钉 Stream 客户端 @@ -358,18 +345,18 @@ def start_bot_stream_clients(config: Config) -> None: def main() -> int: """ 主入口函数 - + Returns: 退出码(0 表示成功) """ # 解析命令行参数 args = parse_arguments() - + # 加载配置(在设置日志前加载,以获取日志目录) config = get_config() - + # 配置日志(输出到控制台和文件) - setup_logging(debug=args.debug, log_dir=config.log_dir) + setup_logging(log_prefix="stock_analysis", debug=args.debug, log_dir=config.log_dir) logger.info("=" * 60) logger.info("A股自选股智能分析系统 启动") @@ -391,14 +378,28 @@ def main() -> int: # 优先级: 命令行参数 > 配置文件 start_webui = (args.webui or args.webui_only or config.webui_enabled) and os.getenv("GITHUB_ACTIONS") != "true" + bot_clients_started = False if start_webui: try: from webui import run_server_in_thread run_server_in_thread(host=config.webui_host, port=config.webui_port) - start_bot_stream_clients(config) + bot_clients_started = True except Exception as e: logger.error(f"启动 WebUI 失败: {e}") + # === 启动 FastAPI 服务 (如果启用) === + start_serve = (args.serve or args.serve_only) and os.getenv("GITHUB_ACTIONS") != "true" + + if start_serve: + try: + start_api_server(host=args.host, port=args.port, config=config) + bot_clients_started = True + except Exception as e: + logger.error(f"启动 FastAPI 服务失败: {e}") + + if bot_clients_started: + start_bot_stream_clients(config) + # === 仅 WebUI 模式:不自动执行分析 === if args.webui_only: logger.info("模式: 仅 WebUI 服务") @@ -411,6 +412,20 @@ def main() -> int: except KeyboardInterrupt: logger.info("\n用户中断,程序退出") return 0 + + # === 仅 FastAPI 服务模式:不自动执行分析 === + if args.serve_only: + logger.info("模式: 仅 FastAPI 服务") + logger.info(f"API 服务运行中: http://{args.host}:{args.port}") + logger.info("通过 /api/v1/analysis/stock/{code} 接口触发分析") + logger.info(f"API 文档: http://{args.host}:{args.port}/docs") + logger.info("按 Ctrl+C 退出...") + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + logger.info("\n用户中断,程序退出") + return 0 try: # 模式1: 仅大盘复盘 @@ -468,11 +483,12 @@ def main() -> int: logger.info("\n程序执行完成") - # 如果启用了 WebUI 且是非定时任务模式,保持程序运行以便访问 WebUI - if start_webui and not (args.schedule or config.schedule_enabled): - logger.info("WebUI 运行中 (按 Ctrl+C 退出)...") + # 如果启用了服务且是非定时任务模式,保持程序运行 + keep_running = (start_webui or start_serve) and not (args.schedule or config.schedule_enabled) + if keep_running: + service_name = "API 服务" if start_serve else "WebUI" + logger.info(f"{service_name} 运行中 (按 Ctrl+C 退出)...") try: - # 简单的保持活跃循环 while True: time.sleep(1) except KeyboardInterrupt: diff --git a/requirements.txt b/requirements.txt index 457bfc198..d8ef9f399 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,3 +47,7 @@ discord.py>=2.0.0 # Discord 机器人开发库 # Web Content Extraction newspaper3k>=0.2.8 # Article extraction lxml_html_clean # Fix for lxml.html.clean ImportError in newer lxml versions + +# FastAPI Web 框架 +fastapi>=0.109.0 # 现代 Python Web 框架 +uvicorn[standard]>=0.27.0 # ASGI 服务器 diff --git a/server.py b/server.py new file mode 100644 index 000000000..27befd193 --- /dev/null +++ b/server.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +""" +=================================== +Daily Stock Analysis - FastAPI 后端服务入口 +=================================== + +职责: +1. 提供 RESTful API 服务 +2. 配置 CORS 跨域支持 +3. 健康检查接口 +4. 托管前端静态文件(生产模式) + +启动方式: + uvicorn server:app --reload --host 0.0.0.0 --port 8000 + + 或使用 main.py: + python main.py --serve-only # 仅启动 API 服务 + python main.py --serve # API 服务 + 执行分析 +""" + +import logging + +from src.config import setup_env, get_config +from src.logging_config import setup_logging + +# 初始化环境变量与日志 +setup_env() + +config = get_config() +level_name = (config.log_level or "INFO").upper() +level = getattr(logging, level_name, logging.INFO) + +setup_logging( + log_prefix="api_server", + console_level=level, + extra_quiet_loggers=['uvicorn', 'fastapi'], +) + +# 从 api.app 导入应用实例 +from api.app import app # noqa: E402 + +# 导出 app 供 uvicorn 使用 +__all__ = ['app'] + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "server:app", + host="0.0.0.0", + port=8000, + reload=True, + ) diff --git a/sources/fastapi_server.png b/sources/fastapi_server.png new file mode 100644 index 0000000000000000000000000000000000000000..c4ae89e2904a977cf7d0c5ac5bbfda1c3c8d1a95 GIT binary patch literal 160297 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 literal 0 HcmV?d00001 diff --git a/src/analyzer.py b/src/analyzer.py index 5b086bbd5..aef8192bd 100644 --- a/src/analyzer.py +++ b/src/analyzer.py @@ -195,6 +195,10 @@ class AnalysisResult: success: bool = True error_message: Optional[str] = None + # ========== 价格数据(分析时快照)========== + current_price: Optional[float] = None # 分析时的股价 + change_pct: Optional[float] = None # 分析时的涨跌幅(%) + def to_dict(self) -> Dict[str, Any]: """转换为字典""" return { @@ -227,6 +231,8 @@ class AnalysisResult: 'search_performed': self.search_performed, 'success': self.success, 'error_message': self.error_message, + 'current_price': self.current_price, + 'change_pct': self.change_pct, } def get_core_conclusion(self) -> str: diff --git a/src/core/pipeline.py b/src/core/pipeline.py index 93918e47b..a097be1dd 100644 --- a/src/core/pipeline.py +++ b/src/core/pipeline.py @@ -285,6 +285,12 @@ class StockAnalysisPipeline: # Step 7: 调用 AI 分析(传入增强的上下文和新闻) result = self.analyzer.analyze(enhanced_context, news_context=news_context) + # Step 7.5: 填充分析时的价格信息到 result + if result: + realtime_data = enhanced_context.get('realtime', {}) + result.current_price = realtime_data.get('price') + result.change_pct = realtime_data.get('change_pct') + # Step 8: 保存分析历史记录 if result: try: @@ -350,6 +356,7 @@ class StockAnalysisPipeline: enhanced['realtime'] = { 'name': getattr(realtime_quote, 'name', ''), 'price': getattr(realtime_quote, 'price', None), + 'change_pct': getattr(realtime_quote, 'change_pct', None), 'volume_ratio': volume_ratio, 'volume_ratio_desc': self._describe_volume_ratio(volume_ratio) if volume_ratio else '无数据', 'turnover_rate': getattr(realtime_quote, 'turnover_rate', None), diff --git a/src/logging_config.py b/src/logging_config.py new file mode 100644 index 000000000..96fdfb5be --- /dev/null +++ b/src/logging_config.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +""" +=================================== +日志配置模块 - 统一的日志系统初始化 +=================================== + +职责: +1. 提供统一的日志格式和配置常量 +2. 支持控制台 + 文件(常规/调试)三层日志输出 +3. 自动降低第三方库日志级别 +""" + +import logging +import sys +from datetime import datetime +from logging.handlers import RotatingFileHandler +from pathlib import Path +from typing import List, Optional + +# ============================================================ +# 日志格式常量 +# ============================================================ + +LOG_FORMAT = '%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s' +LOG_DATE_FORMAT = '%Y-%m-%d %H:%M:%S' + +# 默认需要降低日志级别的第三方库 +DEFAULT_QUIET_LOGGERS = [ + 'urllib3', + 'sqlalchemy', + 'google', + 'httpx', +] + + +def setup_logging( + log_prefix: str = "app", + log_dir: str = "./logs", + console_level: Optional[int] = None, + debug: bool = False, + extra_quiet_loggers: Optional[List[str]] = None, +) -> None: + """ + 统一的日志系统初始化 + + 配置三层日志输出: + 1. 控制台:根据 debug 参数或 console_level 设置级别 + 2. 常规日志文件:INFO 级别,10MB 轮转,保留 5 个备份 + 3. 调试日志文件:DEBUG 级别,50MB 轮转,保留 3 个备份 + + Args: + log_prefix: 日志文件名前缀(如 "api_server" -> api_server_20240101.log) + log_dir: 日志文件目录,默认 ./logs + console_level: 控制台日志级别(可选,优先于 debug 参数) + debug: 是否启用调试模式(控制台输出 DEBUG 级别) + extra_quiet_loggers: 额外需要降低日志级别的第三方库列表 + """ + # 确定控制台日志级别 + if console_level is not None: + level = console_level + else: + level = logging.DEBUG if debug else logging.INFO + + # 创建日志目录 + log_path = Path(log_dir) + log_path.mkdir(parents=True, exist_ok=True) + + # 日志文件路径(按日期分文件) + today_str = datetime.now().strftime('%Y%m%d') + log_file = log_path / f"{log_prefix}_{today_str}.log" + debug_log_file = log_path / f"{log_prefix}_debug_{today_str}.log" + + # 配置根 logger + root_logger = logging.getLogger() + root_logger.setLevel(logging.DEBUG) # 根 logger 设为 DEBUG,由 handler 控制输出级别 + + # 清除已有 handler,避免重复添加 + if root_logger.handlers: + root_logger.handlers.clear() + + # Handler 1: 控制台输出 + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(level) + console_handler.setFormatter(logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT)) + root_logger.addHandler(console_handler) + + # Handler 2: 常规日志文件(INFO 级别,10MB 轮转) + file_handler = RotatingFileHandler( + log_file, + maxBytes=10 * 1024 * 1024, # 10MB + backupCount=5, + encoding='utf-8' + ) + file_handler.setLevel(logging.INFO) + file_handler.setFormatter(logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT)) + root_logger.addHandler(file_handler) + + # Handler 3: 调试日志文件(DEBUG 级别,包含所有详细信息) + debug_handler = RotatingFileHandler( + debug_log_file, + maxBytes=50 * 1024 * 1024, # 50MB + backupCount=3, + encoding='utf-8' + ) + debug_handler.setLevel(logging.DEBUG) + debug_handler.setFormatter(logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT)) + root_logger.addHandler(debug_handler) + + # 降低第三方库的日志级别 + quiet_loggers = DEFAULT_QUIET_LOGGERS.copy() + if extra_quiet_loggers: + quiet_loggers.extend(extra_quiet_loggers) + + for logger_name in quiet_loggers: + logging.getLogger(logger_name).setLevel(logging.WARNING) + + # 输出初始化完成信息 + logging.info(f"日志系统初始化完成,日志目录: {log_path.absolute()}") + logging.info(f"常规日志: {log_file}") + logging.info(f"调试日志: {debug_log_file}") diff --git a/src/repositories/__init__.py b/src/repositories/__init__.py new file mode 100644 index 000000000..6fe31fd54 --- /dev/null +++ b/src/repositories/__init__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +""" +=================================== +数据访问层模块初始化 +=================================== + +职责: +1. 导出所有 Repository 类 +""" + +from src.repositories.analysis_repo import AnalysisRepository +from src.repositories.stock_repo import StockRepository + +__all__ = [ + "AnalysisRepository", + "StockRepository", +] diff --git a/src/repositories/analysis_repo.py b/src/repositories/analysis_repo.py new file mode 100644 index 000000000..62201b4ca --- /dev/null +++ b/src/repositories/analysis_repo.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +""" +=================================== +分析历史数据访问层 +=================================== + +职责: +1. 封装分析历史数据的数据库操作 +2. 提供 CRUD 接口 +""" + +import logging +from datetime import datetime, timedelta +from typing import Optional, List, Dict, Any + +from src.storage import DatabaseManager, AnalysisHistory + +logger = logging.getLogger(__name__) + + +class AnalysisRepository: + """ + 分析历史数据访问层 + + 封装 AnalysisHistory 表的数据库操作 + """ + + def __init__(self, db_manager: Optional[DatabaseManager] = None): + """ + 初始化数据访问层 + + Args: + db_manager: 数据库管理器(可选,默认使用单例) + """ + self.db = db_manager or DatabaseManager.get_instance() + + def get_by_query_id(self, query_id: str) -> Optional[AnalysisHistory]: + """ + 根据 query_id 获取分析记录 + + Args: + query_id: 查询 ID + + Returns: + AnalysisHistory 对象,不存在返回 None + """ + try: + records = self.db.get_analysis_history(query_id=query_id, limit=1) + return records[0] if records else None + except Exception as e: + logger.error(f"查询分析记录失败: {e}") + return None + + def get_list( + self, + code: Optional[str] = None, + days: int = 30, + limit: int = 50 + ) -> List[AnalysisHistory]: + """ + 获取分析记录列表 + + Args: + code: 股票代码筛选 + days: 时间范围(天) + limit: 返回数量限制 + + Returns: + AnalysisHistory 对象列表 + """ + try: + return self.db.get_analysis_history( + code=code, + days=days, + limit=limit + ) + except Exception as e: + logger.error(f"获取分析列表失败: {e}") + return [] + + def save( + self, + result: Any, + query_id: str, + report_type: str, + news_content: Optional[str] = None, + context_snapshot: Optional[Dict[str, Any]] = None + ) -> int: + """ + 保存分析结果 + + Args: + result: 分析结果对象 + query_id: 查询 ID + report_type: 报告类型 + news_content: 新闻内容 + context_snapshot: 上下文快照 + + Returns: + 保存的记录数 + """ + try: + return self.db.save_analysis_history( + result=result, + query_id=query_id, + report_type=report_type, + news_content=news_content, + context_snapshot=context_snapshot + ) + except Exception as e: + logger.error(f"保存分析结果失败: {e}") + return 0 + + def count_by_code(self, code: str, days: int = 30) -> int: + """ + 统计指定股票的分析记录数 + + Args: + code: 股票代码 + days: 时间范围(天) + + Returns: + 记录数量 + """ + try: + records = self.db.get_analysis_history(code=code, days=days, limit=1000) + return len(records) + except Exception as e: + logger.error(f"统计分析记录失败: {e}") + return 0 diff --git a/src/repositories/stock_repo.py b/src/repositories/stock_repo.py new file mode 100644 index 000000000..ebc573648 --- /dev/null +++ b/src/repositories/stock_repo.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +""" +=================================== +股票数据访问层 +=================================== + +职责: +1. 封装股票数据的数据库操作 +2. 提供日线数据查询接口 +""" + +import logging +from datetime import date, timedelta +from typing import Optional, List, Dict, Any + +import pandas as pd + +from src.storage import DatabaseManager, StockDaily + +logger = logging.getLogger(__name__) + + +class StockRepository: + """ + 股票数据访问层 + + 封装 StockDaily 表的数据库操作 + """ + + def __init__(self, db_manager: Optional[DatabaseManager] = None): + """ + 初始化数据访问层 + + Args: + db_manager: 数据库管理器(可选,默认使用单例) + """ + self.db = db_manager or DatabaseManager.get_instance() + + def get_latest(self, code: str, days: int = 2) -> List[StockDaily]: + """ + 获取最近 N 天的数据 + + Args: + code: 股票代码 + days: 获取天数 + + Returns: + StockDaily 对象列表(按日期降序) + """ + try: + return self.db.get_latest_data(code, days) + except Exception as e: + logger.error(f"获取最新数据失败: {e}") + return [] + + def get_range( + self, + code: str, + start_date: date, + end_date: date + ) -> List[StockDaily]: + """ + 获取指定日期范围的数据 + + Args: + code: 股票代码 + start_date: 开始日期 + end_date: 结束日期 + + Returns: + StockDaily 对象列表 + """ + try: + return self.db.get_data_range(code, start_date, end_date) + except Exception as e: + logger.error(f"获取日期范围数据失败: {e}") + return [] + + def save_dataframe( + self, + df: pd.DataFrame, + code: str, + data_source: str = "Unknown" + ) -> int: + """ + 保存 DataFrame 到数据库 + + Args: + df: 包含日线数据的 DataFrame + code: 股票代码 + data_source: 数据来源 + + Returns: + 保存的记录数 + """ + try: + return self.db.save_daily_data(df, code, data_source) + except Exception as e: + logger.error(f"保存日线数据失败: {e}") + return 0 + + def has_today_data(self, code: str, target_date: Optional[date] = None) -> bool: + """ + 检查是否有指定日期的数据 + + Args: + code: 股票代码 + target_date: 目标日期(默认今天) + + Returns: + 是否存在数据 + """ + try: + return self.db.has_today_data(code, target_date) + except Exception as e: + logger.error(f"检查数据存在失败: {e}") + return False + + def get_analysis_context( + self, + code: str, + target_date: Optional[date] = None + ) -> Optional[Dict[str, Any]]: + """ + 获取分析上下文 + + Args: + code: 股票代码 + target_date: 目标日期 + + Returns: + 分析上下文字典 + """ + try: + return self.db.get_analysis_context(code, target_date) + except Exception as e: + logger.error(f"获取分析上下文失败: {e}") + return None diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 000000000..e1b3b8d7d --- /dev/null +++ b/src/services/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +""" +=================================== +服务层模块初始化 +=================================== + +职责: +1. 导出所有服务类 +""" + +from src.services.analysis_service import AnalysisService +from src.services.history_service import HistoryService +from src.services.stock_service import StockService + +__all__ = [ + "AnalysisService", + "HistoryService", + "StockService", +] diff --git a/src/services/analysis_service.py b/src/services/analysis_service.py new file mode 100644 index 000000000..1f06a6be2 --- /dev/null +++ b/src/services/analysis_service.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +""" +=================================== +分析服务层 +=================================== + +职责: +1. 封装股票分析逻辑 +2. 调用 analyzer 和 pipeline 执行分析 +3. 保存分析结果到数据库 +""" + +import logging +import uuid +from typing import Optional, Dict, Any + +from src.repositories.analysis_repo import AnalysisRepository + +logger = logging.getLogger(__name__) + + +class AnalysisService: + """ + 分析服务 + + 封装股票分析相关的业务逻辑 + """ + + def __init__(self): + """初始化分析服务""" + self.repo = AnalysisRepository() + + def analyze_stock( + self, + stock_code: str, + report_type: str = "detailed", + force_refresh: bool = False, + query_id: Optional[str] = None, + send_notification: bool = True + ) -> Optional[Dict[str, Any]]: + """ + 执行股票分析 + + Args: + stock_code: 股票代码 + report_type: 报告类型 (simple/detailed) + force_refresh: 是否强制刷新 + query_id: 查询 ID(可选) + send_notification: 是否发送通知(API 触发默认发送) + + Returns: + 分析结果字典,包含: + - stock_code: 股票代码 + - stock_name: 股票名称 + - report: 分析报告 + """ + try: + # 导入分析相关模块 + from src.config import get_config + from src.core.pipeline import StockAnalysisPipeline + from src.enums import ReportType + + # 生成 query_id + if query_id is None: + query_id = uuid.uuid4().hex + + # 获取配置 + config = get_config() + + # 创建分析流水线 + pipeline = StockAnalysisPipeline( + config=config, + query_id=query_id, + query_source="api" + ) + + # 确定报告类型 + rt = ReportType.FULL if report_type == "detailed" else ReportType.SIMPLE + + # 执行分析 + result = pipeline.process_single_stock( + code=stock_code, + skip_analysis=False, + single_stock_notify=send_notification, + report_type=rt + ) + + if result is None: + logger.warning(f"分析股票 {stock_code} 返回空结果") + return None + + # 构建响应 + return self._build_analysis_response(result, query_id) + + except Exception as e: + logger.error(f"分析股票 {stock_code} 失败: {e}", exc_info=True) + return None + + def _build_analysis_response( + self, + result: Any, + query_id: str + ) -> Dict[str, Any]: + """ + 构建分析响应 + + Args: + result: AnalysisResult 对象 + query_id: 查询 ID + + Returns: + 格式化的响应字典 + """ + # 获取狙击点位 + sniper_points = {} + if hasattr(result, 'get_sniper_points'): + sniper_points = result.get_sniper_points() or {} + + # 计算情绪标签 + sentiment_label = self._get_sentiment_label(result.sentiment_score) + + # 构建报告结构 + report = { + "meta": { + "query_id": query_id, + "stock_code": result.code, + "stock_name": result.name, + "report_type": "detailed", + "current_price": result.current_price, + "change_pct": result.change_pct, + }, + "summary": { + "analysis_summary": result.analysis_summary, + "operation_advice": result.operation_advice, + "trend_prediction": result.trend_prediction, + "sentiment_score": result.sentiment_score, + "sentiment_label": sentiment_label, + }, + "strategy": { + "ideal_buy": sniper_points.get("ideal_buy"), + "secondary_buy": sniper_points.get("secondary_buy"), + "stop_loss": sniper_points.get("stop_loss"), + "take_profit": sniper_points.get("take_profit"), + }, + "details": { + "news_summary": result.news_summary, + "technical_analysis": result.technical_analysis, + "fundamental_analysis": result.fundamental_analysis, + "risk_warning": result.risk_warning, + } + } + + return { + "stock_code": result.code, + "stock_name": result.name, + "report": report, + } + + def _get_sentiment_label(self, score: int) -> str: + """ + 根据评分获取情绪标签 + + Args: + score: 情绪评分 (0-100) + + Returns: + 情绪标签 + """ + if score >= 80: + return "极度乐观" + elif score >= 60: + return "乐观" + elif score >= 40: + return "中性" + elif score >= 20: + return "悲观" + else: + return "极度悲观" diff --git a/src/services/history_service.py b/src/services/history_service.py new file mode 100644 index 000000000..b7f016037 --- /dev/null +++ b/src/services/history_service.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +""" +=================================== +历史查询服务层 +=================================== + +职责: +1. 封装历史记录查询逻辑 +2. 提供分页和筛选功能 +""" + +import json +import logging +from datetime import datetime, timedelta +from typing import Optional, Dict, Any, List + +from src.storage import DatabaseManager + +logger = logging.getLogger(__name__) + + +class HistoryService: + """ + 历史查询服务 + + 封装历史分析记录的查询逻辑 + """ + + def __init__(self, db_manager: Optional[DatabaseManager] = None): + """ + 初始化历史查询服务 + + Args: + db_manager: 数据库管理器(可选,默认使用单例) + """ + self.db = db_manager or DatabaseManager.get_instance() + + def get_history_list( + self, + stock_code: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + page: int = 1, + limit: int = 20 + ) -> Dict[str, Any]: + """ + 获取历史分析列表 + + Args: + stock_code: 股票代码筛选 + start_date: 开始日期 (YYYY-MM-DD) + end_date: 结束日期 (YYYY-MM-DD) + page: 页码 + limit: 每页数量 + + Returns: + 包含 total, items 的字典 + """ + try: + # 解析日期参数 + start_dt = None + end_dt = None + + if start_date: + try: + start_dt = datetime.strptime(start_date, "%Y-%m-%d").date() + except ValueError: + logger.warning(f"无效的 start_date 格式: {start_date}") + + if end_date: + try: + end_dt = datetime.strptime(end_date, "%Y-%m-%d").date() + except ValueError: + logger.warning(f"无效的 end_date 格式: {end_date}") + + # 计算 offset + offset = (page - 1) * limit + + # 使用新的分页查询方法 + records, total = self.db.get_analysis_history_paginated( + code=stock_code, + start_date=start_dt, + end_date=end_dt, + offset=offset, + limit=limit + ) + + # 转换为响应格式 + items = [] + for record in records: + items.append({ + "query_id": record.query_id, + "stock_code": record.code, + "stock_name": record.name, + "report_type": record.report_type, + "sentiment_score": record.sentiment_score, + "operation_advice": record.operation_advice, + "created_at": record.created_at.isoformat() if record.created_at else None, + }) + + return { + "total": total, + "items": items, + } + + except Exception as e: + logger.error(f"查询历史列表失败: {e}", exc_info=True) + return {"total": 0, "items": []} + + def get_history_detail(self, query_id: str) -> Optional[Dict[str, Any]]: + """ + 获取历史报告详情 + + Args: + query_id: 分析记录唯一标识 + + Returns: + 完整的分析报告字典,不存在返回 None + """ + try: + # 查询数据库 + records = self.db.get_analysis_history(query_id=query_id, limit=1) + + if not records: + return None + + record = records[0] + + # 解析 raw_result JSON + raw_result = None + if record.raw_result: + try: + raw_result = json.loads(record.raw_result) + except json.JSONDecodeError: + raw_result = record.raw_result + + # 解析 context_snapshot JSON + context_snapshot = None + if record.context_snapshot: + try: + context_snapshot = json.loads(record.context_snapshot) + except json.JSONDecodeError: + context_snapshot = record.context_snapshot + + # 计算情绪标签 + sentiment_label = self._get_sentiment_label(record.sentiment_score or 50) + + return { + "query_id": record.query_id, + "stock_code": record.code, + "stock_name": record.name, + "report_type": record.report_type, + "created_at": record.created_at.isoformat() if record.created_at else None, + "analysis_summary": record.analysis_summary, + "operation_advice": record.operation_advice, + "trend_prediction": record.trend_prediction, + "sentiment_score": record.sentiment_score, + "sentiment_label": sentiment_label, + "ideal_buy": str(record.ideal_buy) if record.ideal_buy else None, + "secondary_buy": str(record.secondary_buy) if record.secondary_buy else None, + "stop_loss": str(record.stop_loss) if record.stop_loss else None, + "take_profit": str(record.take_profit) if record.take_profit else None, + "news_content": record.news_content, + "raw_result": raw_result, + "context_snapshot": context_snapshot, + } + + except Exception as e: + logger.error(f"查询历史详情失败: {e}", exc_info=True) + return None + + def get_news_intel(self, query_id: str, limit: int = 20) -> List[Dict[str, str]]: + """ + 获取指定 query_id 关联的新闻情报 + + Args: + query_id: 分析记录唯一标识 + limit: 返回数量限制 + + Returns: + 新闻情报列表(包含 title、snippet、url) + """ + try: + records = self.db.get_news_intel_by_query_id(query_id=query_id, limit=limit) + + items: List[Dict[str, str]] = [] + for record in records: + snippet = (record.snippet or "").strip() + if len(snippet) > 50: + snippet = f"{snippet[:47]}..." + items.append({ + "title": record.title, + "snippet": snippet, + "url": record.url, + }) + + return items + + except Exception as e: + logger.error(f"查询新闻情报失败: {e}", exc_info=True) + return [] + + def _get_sentiment_label(self, score: int) -> str: + """ + 根据评分获取情绪标签 + + Args: + score: 情绪评分 (0-100) + + Returns: + 情绪标签 + """ + if score >= 80: + return "极度乐观" + elif score >= 60: + return "乐观" + elif score >= 40: + return "中性" + elif score >= 20: + return "悲观" + else: + return "极度悲观" diff --git a/src/services/stock_service.py b/src/services/stock_service.py new file mode 100644 index 000000000..a54a93e59 --- /dev/null +++ b/src/services/stock_service.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +""" +=================================== +股票数据服务层 +=================================== + +职责: +1. 封装股票数据获取逻辑 +2. 提供实时行情和历史数据接口 +""" + +import logging +from datetime import datetime, timedelta +from typing import Optional, Dict, Any, List + +from src.repositories.stock_repo import StockRepository + +logger = logging.getLogger(__name__) + + +class StockService: + """ + 股票数据服务 + + 封装股票数据获取的业务逻辑 + """ + + def __init__(self): + """初始化股票数据服务""" + self.repo = StockRepository() + + def get_realtime_quote(self, stock_code: str) -> Optional[Dict[str, Any]]: + """ + 获取股票实时行情 + + Args: + stock_code: 股票代码 + + Returns: + 实时行情数据字典 + """ + try: + # 调用数据获取器获取实时行情 + from data_provider.base import DataFetcherManager + + manager = DataFetcherManager() + quote = manager.get_realtime_quote(stock_code) + + if quote is None: + logger.warning(f"获取 {stock_code} 实时行情失败") + return None + + # UnifiedRealtimeQuote 是 dataclass,使用 getattr 安全访问字段 + # 字段映射: UnifiedRealtimeQuote -> API 响应 + # - code -> stock_code + # - name -> stock_name + # - price -> current_price + # - change_amount -> change + # - change_pct -> change_percent + # - open_price -> open + # - high -> high + # - low -> low + # - pre_close -> prev_close + # - volume -> volume + # - amount -> amount + return { + "stock_code": getattr(quote, "code", stock_code), + "stock_name": getattr(quote, "name", None), + "current_price": getattr(quote, "price", 0.0) or 0.0, + "change": getattr(quote, "change_amount", None), + "change_percent": getattr(quote, "change_pct", None), + "open": getattr(quote, "open_price", None), + "high": getattr(quote, "high", None), + "low": getattr(quote, "low", None), + "prev_close": getattr(quote, "pre_close", None), + "volume": getattr(quote, "volume", None), + "amount": getattr(quote, "amount", None), + "update_time": datetime.now().isoformat(), + } + + except ImportError: + logger.warning("DataFetcherManager 未找到,使用占位数据") + return self._get_placeholder_quote(stock_code) + except Exception as e: + logger.error(f"获取实时行情失败: {e}", exc_info=True) + return None + + def get_history_data( + self, + stock_code: str, + period: str = "daily", + days: int = 30 + ) -> Dict[str, Any]: + """ + 获取股票历史行情 + + Args: + stock_code: 股票代码 + period: K 线周期 (daily/weekly/monthly) + days: 获取天数 + + Returns: + 历史行情数据字典 + + Raises: + ValueError: 当 period 不是 daily 时抛出(weekly/monthly 暂未实现) + """ + # 验证 period 参数,只支持 daily + if period != "daily": + raise ValueError( + f"暂不支持 '{period}' 周期,目前仅支持 'daily'。" + "weekly/monthly 聚合功能将在后续版本实现。" + ) + + try: + # 调用数据获取器获取历史数据 + from data_provider.base import DataFetcherManager + + manager = DataFetcherManager() + df, source = manager.get_daily_data(stock_code, days=days) + + if df is None or df.empty: + logger.warning(f"获取 {stock_code} 历史数据失败") + return {"stock_code": stock_code, "period": period, "data": []} + + # 获取股票名称 + stock_name = manager.get_stock_name(stock_code) + + # 转换为响应格式 + data = [] + for _, row in df.iterrows(): + date_val = row.get("date") + if hasattr(date_val, "strftime"): + date_str = date_val.strftime("%Y-%m-%d") + else: + date_str = str(date_val) + + data.append({ + "date": date_str, + "open": float(row.get("open", 0)), + "high": float(row.get("high", 0)), + "low": float(row.get("low", 0)), + "close": float(row.get("close", 0)), + "volume": float(row.get("volume", 0)) if row.get("volume") else None, + "amount": float(row.get("amount", 0)) if row.get("amount") else None, + "change_percent": float(row.get("pct_chg", 0)) if row.get("pct_chg") else None, + }) + + return { + "stock_code": stock_code, + "stock_name": stock_name, + "period": period, + "data": data, + } + + except ImportError: + logger.warning("DataFetcherManager 未找到,返回空数据") + return {"stock_code": stock_code, "period": period, "data": []} + except Exception as e: + logger.error(f"获取历史数据失败: {e}", exc_info=True) + return {"stock_code": stock_code, "period": period, "data": []} + + def _get_placeholder_quote(self, stock_code: str) -> Dict[str, Any]: + """ + 获取占位行情数据(用于测试) + + Args: + stock_code: 股票代码 + + Returns: + 占位行情数据 + """ + return { + "stock_code": stock_code, + "stock_name": f"股票{stock_code}", + "current_price": 0.0, + "change": None, + "change_percent": None, + "open": None, + "high": None, + "low": None, + "prev_close": None, + "volume": None, + "amount": None, + "update_time": datetime.now().isoformat(), + } diff --git a/src/services/task_queue.py b/src/services/task_queue.py new file mode 100644 index 000000000..e8e426caa --- /dev/null +++ b/src/services/task_queue.py @@ -0,0 +1,537 @@ +# -*- coding: utf-8 -*- +""" +=================================== +A股自选股智能分析系统 - 异步任务队列 +=================================== + +职责: +1. 管理异步分析任务的生命周期 +2. 防止相同股票代码重复提交 +3. 提供 SSE 事件广播机制 +4. 任务完成后持久化到数据库 +""" + +from __future__ import annotations + +import asyncio +import logging +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor, Future +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Optional, Dict, Set, List, Callable, Any, TYPE_CHECKING + +if TYPE_CHECKING: + from asyncio import Queue as AsyncQueue + +logger = logging.getLogger(__name__) + + +class TaskStatus(str, Enum): + """任务状态枚举""" + PENDING = "pending" # 等待执行 + PROCESSING = "processing" # 执行中 + COMPLETED = "completed" # 已完成 + FAILED = "failed" # 失败 + + +@dataclass +class TaskInfo: + """ + 任务信息数据类 + + 包含任务的完整状态信息,用于 API 响应和内部管理 + """ + task_id: str + stock_code: str + stock_name: Optional[str] = None + status: TaskStatus = TaskStatus.PENDING + progress: int = 0 + message: Optional[str] = None + result: Optional[Dict[str, Any]] = None + error: Optional[str] = None + report_type: str = "detailed" + created_at: datetime = field(default_factory=datetime.now) + started_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + + def to_dict(self) -> Dict[str, Any]: + """转换为字典,用于 API 响应""" + return { + "task_id": self.task_id, + "stock_code": self.stock_code, + "stock_name": self.stock_name, + "status": self.status.value, + "progress": self.progress, + "message": self.message, + "report_type": self.report_type, + "created_at": self.created_at.isoformat(), + "started_at": self.started_at.isoformat() if self.started_at else None, + "completed_at": self.completed_at.isoformat() if self.completed_at else None, + "error": self.error, + } + + def copy(self) -> 'TaskInfo': + """创建任务信息的副本""" + return TaskInfo( + task_id=self.task_id, + stock_code=self.stock_code, + stock_name=self.stock_name, + status=self.status, + progress=self.progress, + message=self.message, + result=self.result, + error=self.error, + report_type=self.report_type, + created_at=self.created_at, + started_at=self.started_at, + completed_at=self.completed_at, + ) + + +class DuplicateTaskError(Exception): + """ + 重复提交异常 + + 当股票已在分析中时抛出此异常 + """ + def __init__(self, stock_code: str, existing_task_id: str): + self.stock_code = stock_code + self.existing_task_id = existing_task_id + super().__init__(f"股票 {stock_code} 正在分析中 (task_id: {existing_task_id})") + + +class AnalysisTaskQueue: + """ + 异步分析任务队列 + + 单例模式,全局唯一实例 + + 特性: + 1. 防止相同股票代码重复提交 + 2. 线程池执行分析任务 + 3. SSE 事件广播机制 + 4. 任务完成后自动持久化 + """ + + _instance: Optional['AnalysisTaskQueue'] = None + _instance_lock = threading.Lock() + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + with cls._instance_lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self, max_workers: int = 3): + # 防止重复初始化 + if hasattr(self, '_initialized') and self._initialized: + return + + self._max_workers = max_workers + self._executor: Optional[ThreadPoolExecutor] = None + + # 核心数据结构 + self._tasks: Dict[str, TaskInfo] = {} # task_id -> TaskInfo + self._analyzing_stocks: Dict[str, str] = {} # stock_code -> task_id + self._futures: Dict[str, Future] = {} # task_id -> Future + + # SSE 订阅者列表(asyncio.Queue 实例) + self._subscribers: List['AsyncQueue'] = [] + self._subscribers_lock = threading.Lock() + + # 主事件循环引用(用于跨线程广播) + self._main_loop: Optional[asyncio.AbstractEventLoop] = None + + # 线程安全锁 + self._data_lock = threading.RLock() + + # 任务历史保留数量(内存中) + self._max_history = 100 + + self._initialized = True + logger.info(f"[TaskQueue] 初始化完成,最大并发: {max_workers}") + + @property + def executor(self) -> ThreadPoolExecutor: + """懒加载线程池""" + if self._executor is None: + self._executor = ThreadPoolExecutor( + max_workers=self._max_workers, + thread_name_prefix="analysis_task_" + ) + return self._executor + + # ========== 任务提交与查询 ========== + + def is_analyzing(self, stock_code: str) -> bool: + """ + 检查股票是否正在分析中 + + Args: + stock_code: 股票代码 + + Returns: + True 表示正在分析中 + """ + with self._data_lock: + return stock_code in self._analyzing_stocks + + def get_analyzing_task_id(self, stock_code: str) -> Optional[str]: + """ + 获取正在分析该股票的任务 ID + + Args: + stock_code: 股票代码 + + Returns: + 任务 ID,如果没有则返回 None + """ + with self._data_lock: + return self._analyzing_stocks.get(stock_code) + + def submit_task( + self, + stock_code: str, + stock_name: Optional[str] = None, + report_type: str = "detailed", + force_refresh: bool = False, + ) -> TaskInfo: + """ + 提交分析任务 + + Args: + stock_code: 股票代码 + stock_name: 股票名称(可选) + report_type: 报告类型 + force_refresh: 是否强制刷新 + + Returns: + TaskInfo: 任务信息 + + Raises: + DuplicateTaskError: 股票正在分析中 + """ + with self._data_lock: + # 检查重复 + if stock_code in self._analyzing_stocks: + existing_task_id = self._analyzing_stocks[stock_code] + raise DuplicateTaskError(stock_code, existing_task_id) + + # 创建任务 + task_id = uuid.uuid4().hex + task_info = TaskInfo( + task_id=task_id, + stock_code=stock_code, + stock_name=stock_name, + status=TaskStatus.PENDING, + message="任务已加入队列", + report_type=report_type, + ) + + # 注册任务 + self._tasks[task_id] = task_info + self._analyzing_stocks[stock_code] = task_id + + # 提交到线程池执行 + future = self.executor.submit( + self._execute_task, + task_id, + stock_code, + report_type, + force_refresh, + ) + self._futures[task_id] = future + + logger.info(f"[TaskQueue] 任务已提交: {stock_code} -> {task_id}") + + # 广播任务创建事件(锁外执行避免死锁) + self._broadcast_event("task_created", task_info.to_dict()) + + return task_info + + def get_task(self, task_id: str) -> Optional[TaskInfo]: + """ + 获取任务信息 + + Args: + task_id: 任务 ID + + Returns: + TaskInfo 或 None + """ + with self._data_lock: + task = self._tasks.get(task_id) + return task.copy() if task else None + + def list_pending_tasks(self) -> List[TaskInfo]: + """ + 获取所有进行中的任务(pending + processing) + + Returns: + 任务列表(副本) + """ + with self._data_lock: + return [ + task.copy() for task in self._tasks.values() + if task.status in (TaskStatus.PENDING, TaskStatus.PROCESSING) + ] + + def list_all_tasks(self, limit: int = 50) -> List[TaskInfo]: + """ + 获取所有任务(按创建时间倒序) + + Args: + limit: 返回数量限制 + + Returns: + 任务列表(副本) + """ + with self._data_lock: + tasks = sorted( + self._tasks.values(), + key=lambda t: t.created_at, + reverse=True + ) + return [t.copy() for t in tasks[:limit]] + + def get_task_stats(self) -> Dict[str, int]: + """ + 获取任务统计信息 + + Returns: + 统计信息字典 + """ + with self._data_lock: + stats = { + "total": len(self._tasks), + "pending": 0, + "processing": 0, + "completed": 0, + "failed": 0, + } + for task in self._tasks.values(): + stats[task.status.value] = stats.get(task.status.value, 0) + 1 + return stats + + # ========== 任务执行 ========== + + def _execute_task( + self, + task_id: str, + stock_code: str, + report_type: str, + force_refresh: bool, + ) -> Optional[Dict[str, Any]]: + """ + 执行分析任务(在线程池中运行) + + Args: + task_id: 任务 ID + stock_code: 股票代码 + report_type: 报告类型 + force_refresh: 是否强制刷新 + + Returns: + 分析结果字典 + """ + # 更新状态为处理中 + with self._data_lock: + task = self._tasks.get(task_id) + if not task: + return None + task.status = TaskStatus.PROCESSING + task.started_at = datetime.now() + task.message = "正在分析中..." + task.progress = 10 + + self._broadcast_event("task_started", task.to_dict()) + + try: + # 导入分析服务(延迟导入避免循环依赖) + from src.services.analysis_service import AnalysisService + + # 执行分析 + service = AnalysisService() + result = service.analyze_stock( + stock_code=stock_code, + report_type=report_type, + force_refresh=force_refresh, + query_id=task_id, + ) + + if result: + # 更新任务状态为完成 + with self._data_lock: + task = self._tasks.get(task_id) + if task: + task.status = TaskStatus.COMPLETED + task.progress = 100 + task.completed_at = datetime.now() + task.result = result + task.message = "分析完成" + task.stock_name = result.get("stock_name", task.stock_name) + + # 从分析中集合移除 + if task.stock_code in self._analyzing_stocks: + del self._analyzing_stocks[task.stock_code] + + self._broadcast_event("task_completed", task.to_dict()) + logger.info(f"[TaskQueue] 任务完成: {task_id} ({stock_code})") + + # 清理过期任务 + self._cleanup_old_tasks() + + return result + else: + # 分析返回空结果 + raise Exception("分析返回空结果") + + except Exception as e: + error_msg = str(e) + logger.error(f"[TaskQueue] 任务失败: {task_id} ({stock_code}), 错误: {error_msg}") + + with self._data_lock: + task = self._tasks.get(task_id) + if task: + task.status = TaskStatus.FAILED + task.completed_at = datetime.now() + task.error = error_msg[:200] # 限制错误信息长度 + task.message = f"分析失败: {error_msg[:50]}" + + # 从分析中集合移除 + if task.stock_code in self._analyzing_stocks: + del self._analyzing_stocks[task.stock_code] + + self._broadcast_event("task_failed", task.to_dict()) + + # 清理过期任务 + self._cleanup_old_tasks() + + return None + + def _cleanup_old_tasks(self) -> int: + """ + 清理过期的已完成任务 + + 保留最近 _max_history 个任务 + + Returns: + 清理的任务数量 + """ + with self._data_lock: + if len(self._tasks) <= self._max_history: + return 0 + + # 按时间排序,删除旧的已完成任务 + completed_tasks = sorted( + [t for t in self._tasks.values() + if t.status in (TaskStatus.COMPLETED, TaskStatus.FAILED)], + key=lambda t: t.created_at + ) + + to_remove = len(self._tasks) - self._max_history + removed = 0 + + for task in completed_tasks[:to_remove]: + del self._tasks[task.task_id] + if task.task_id in self._futures: + del self._futures[task.task_id] + removed += 1 + + if removed > 0: + logger.debug(f"[TaskQueue] 清理了 {removed} 个过期任务") + + return removed + + # ========== SSE 事件广播 ========== + + def subscribe(self, queue: 'AsyncQueue') -> None: + """ + 订阅任务事件 + + Args: + queue: asyncio.Queue 实例,用于接收事件 + """ + with self._subscribers_lock: + self._subscribers.append(queue) + # 捕获当前事件循环(应在主线程的 async 上下文中调用) + try: + self._main_loop = asyncio.get_running_loop() + except RuntimeError: + # 如果不在 async 上下文中,尝试获取事件循环 + try: + self._main_loop = asyncio.get_event_loop() + except RuntimeError: + pass + logger.debug(f"[TaskQueue] 新订阅者加入,当前订阅者数: {len(self._subscribers)}") + + def unsubscribe(self, queue: 'AsyncQueue') -> None: + """ + 取消订阅任务事件 + + Args: + queue: 要取消订阅的 asyncio.Queue 实例 + """ + with self._subscribers_lock: + if queue in self._subscribers: + self._subscribers.remove(queue) + logger.debug(f"[TaskQueue] 订阅者离开,当前订阅者数: {len(self._subscribers)}") + + def _broadcast_event(self, event_type: str, data: Dict[str, Any]) -> None: + """ + 广播事件到所有订阅者 + + 使用 call_soon_threadsafe 确保跨线程安全 + + Args: + event_type: 事件类型 + data: 事件数据 + """ + event = {"type": event_type, "data": data} + + with self._subscribers_lock: + subscribers = self._subscribers.copy() + loop = self._main_loop + + if not subscribers: + return + + if loop is None: + logger.warning("[TaskQueue] 无法广播事件:主事件循环未设置") + return + + for queue in subscribers: + try: + # 使用 call_soon_threadsafe 将事件放入 asyncio 队列 + # 这是从工作线程向主事件循环发送消息的安全方式 + loop.call_soon_threadsafe(queue.put_nowait, event) + except RuntimeError as e: + # 事件循环已关闭 + logger.debug(f"[TaskQueue] 广播事件跳过(循环已关闭): {e}") + except Exception as e: + logger.warning(f"[TaskQueue] 广播事件失败: {e}") + + # ========== 清理方法 ========== + + def shutdown(self) -> None: + """关闭任务队列""" + if self._executor: + self._executor.shutdown(wait=True) + self._executor = None + logger.info("[TaskQueue] 线程池已关闭") + + +# ========== 便捷函数 ========== + +def get_task_queue() -> AnalysisTaskQueue: + """ + 获取任务队列单例 + + Returns: + AnalysisTaskQueue 实例 + """ + return AnalysisTaskQueue() diff --git a/src/storage.py b/src/storage.py index f82a804d7..8ff87b695 100644 --- a/src/storage.py +++ b/src/storage.py @@ -17,7 +17,7 @@ import json import logging import re from datetime import datetime, date, timedelta -from typing import Optional, List, Dict, Any, TYPE_CHECKING +from typing import Optional, List, Dict, Any, TYPE_CHECKING, Tuple from pathlib import Path import pandas as pd @@ -250,6 +250,7 @@ class DatabaseManager: """ _instance: Optional['DatabaseManager'] = None + _initialized: bool = False def __new__(cls, *args, **kwargs): """单例模式实现""" @@ -265,7 +266,7 @@ class DatabaseManager: Args: db_url: 数据库连接 URL(可选,默认从配置读取) """ - if self._initialized: + if getattr(self, '_initialized', False): return if db_url is None: @@ -306,7 +307,9 @@ class DatabaseManager: def reset_instance(cls) -> None: """重置单例(用于测试)""" if cls._instance is not None: - cls._instance._engine.dispose() + if hasattr(cls._instance, '_engine') and cls._instance._engine is not None: + cls._instance._engine.dispose() + cls._instance._initialized = False cls._instance = None @classmethod @@ -335,6 +338,11 @@ class DatabaseManager: # 执行查询 session.commit() # 如果需要 """ + if not getattr(self, '_initialized', False) or not hasattr(self, '_SessionLocal'): + raise RuntimeError( + "DatabaseManager 未正确初始化。" + "请确保通过 DatabaseManager.get_instance() 获取实例。" + ) session = self._SessionLocal() try: return session @@ -526,6 +534,32 @@ class DatabaseManager: return list(results) + def get_news_intel_by_query_id(self, query_id: str, limit: int = 20) -> List[NewsIntel]: + """ + 根据 query_id 获取新闻情报列表 + + Args: + query_id: 分析记录唯一标识 + limit: 返回数量限制 + + Returns: + NewsIntel 列表(按发布时间或抓取时间倒序) + """ + from sqlalchemy import func + + with self.get_session() as session: + results = session.execute( + select(NewsIntel) + .where(NewsIntel.query_id == query_id) + .order_by( + desc(func.coalesce(NewsIntel.published_date, NewsIntel.fetched_at)), + desc(NewsIntel.fetched_at) + ) + .limit(limit) + ).scalars().all() + + return list(results) + def save_analysis_history( self, result: Any, @@ -604,6 +638,60 @@ class DatabaseManager: return list(results) + def get_analysis_history_paginated( + self, + code: Optional[str] = None, + start_date: Optional[date] = None, + end_date: Optional[date] = None, + offset: int = 0, + limit: int = 20 + ) -> Tuple[List[AnalysisHistory], int]: + """ + 分页查询分析历史记录(带总数) + + Args: + code: 股票代码筛选 + start_date: 开始日期(含) + end_date: 结束日期(含) + offset: 偏移量(跳过前 N 条) + limit: 每页数量 + + Returns: + Tuple[List[AnalysisHistory], int]: (记录列表, 总数) + """ + from sqlalchemy import func + + with self.get_session() as session: + conditions = [] + + if code: + conditions.append(AnalysisHistory.code == code) + if start_date: + # created_at >= start_date 00:00:00 + conditions.append(AnalysisHistory.created_at >= datetime.combine(start_date, datetime.min.time())) + if end_date: + # created_at < end_date+1 00:00:00 (即 <= end_date 23:59:59) + conditions.append(AnalysisHistory.created_at < datetime.combine(end_date + timedelta(days=1), datetime.min.time())) + + # 构建 where 子句 + where_clause = and_(*conditions) if conditions else True + + # 查询总数 + total_query = select(func.count(AnalysisHistory.id)).where(where_clause) + total = session.execute(total_query).scalar() or 0 + + # 查询分页数据 + data_query = ( + select(AnalysisHistory) + .where(where_clause) + .order_by(desc(AnalysisHistory.created_at)) + .offset(offset) + .limit(limit) + ) + results = session.execute(data_query).scalars().all() + + return list(results), total + def get_data_range( self, code: str,