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 000000000..c4ae89e29 Binary files /dev/null and b/sources/fastapi_server.png differ 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,