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 服务(可选)
+
+
+
+```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 (
+
+ {/* 标题栏 */}
+
+
+ {/* 内容区 */}
+
+
+ );
+};
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 && (
+
+ 恐惧贪婪指数
+
+ )}
+
+
+
+
+ {/* 中心数值 */}
+
+
+ {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 (
+
+ {/* 标题栏 */}
+
+
+
+ {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,