From 9847157980397311f0155b1a5c09f6d06a1ce4e7 Mon Sep 17 00:00:00 2001
From: Krane <56824280+freesme@users.noreply.github.com>
Date: Thu, 5 Feb 2026 20:56:38 +0800
Subject: [PATCH] =?UTF-8?q?Feature/React=20web=20support=20=E6=96=B0?=
=?UTF-8?q?=E7=9A=84WebUI=20(#256)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(web): add FastAPI & React web connect
* feat: add FastAPI package
* feat: system base struct
* feat: frontend base struct
* feat: 基础分析接口,历史记录接口
* fix: 使用正确的 FastAPI 方法定义,避免线程阻塞
* fix: 对接历史报告页、详情页
* fix: 修复接口问题
* fix: 网络配置 允许公网访问 跨域配置
* fix: 页面打包 & 提供 Server 静态访问
* fix: 优化dock栏样式
* fix: 优化图标样式
* fix: 修改部分配色
* fix: 删除垃圾文档
* fix: 删除垃圾代码
* fix: 驼峰转换工具
* fix: 历史记录列表滚动加载(分页)
* feat: 显示分析中任务
* fix: 调整布局
* fix: 优化 Market Sentiment 组件动效
* fix: 历史列表组件bug
* fix: FastAPI 日志配置
* feat: 新闻历史接口
* feat: 资讯列表
* feat: 优化布局
* fix: 修复页面元素变宽问题
* fix: 中文标题
* fix: 任务列表状态显示问题
* fix: 抽取日志配置
* fix: 优化报告价格显示
* fix: 修复编译错误
* fix: FastAPI 启动提取到 main.py
* fix: 补充新web-ui启动文档
* fix: 默认发送通知
* fix: FastAPI 模式适配 docker
* fix: FastAPI 模式文档
* Update api/v1/endpoints/stocks.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* fix: package-lock.json
* fix: 修改错别字
* fix: 更新文档说明
* fix: 更新文档说明
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
README.md | 25 +-
api/__init__.py | 12 +
api/app.py | 164 +
api/deps.py | 60 +
api/middlewares/__init__.py | 13 +
api/middlewares/error_handler.py | 128 +
api/v1/__init__.py | 13 +
api/v1/endpoints/__init__.py | 13 +
api/v1/endpoints/analysis.py | 539 ++
api/v1/endpoints/health.py | 34 +
api/v1/endpoints/history.py | 282 ++
api/v1/endpoints/stocks.py | 176 +
api/v1/router.py | 35 +
api/v1/schemas/__init__.py | 65 +
api/v1/schemas/analysis.py | 220 +
api/v1/schemas/common.py | 78 +
api/v1/schemas/history.py | 175 +
api/v1/schemas/stocks.py | 95 +
apps/dsa-web/.gitignore | 24 +
apps/dsa-web/eslint.config.js | 23 +
apps/dsa-web/index.html | 13 +
apps/dsa-web/package-lock.json | 4397 +++++++++++++++++
apps/dsa-web/package.json | 39 +
apps/dsa-web/postcss.config.js | 6 +
apps/dsa-web/public/vite.svg | 1 +
apps/dsa-web/src/App.css | 42 +
apps/dsa-web/src/App.tsx | 104 +
apps/dsa-web/src/api/analysis.ts | 146 +
apps/dsa-web/src/api/history.ts | 70 +
apps/dsa-web/src/api/index.ts | 12 +
apps/dsa-web/src/api/utils.ts | 13 +
apps/dsa-web/src/assets/react.svg | 1 +
apps/dsa-web/src/components/common/Badge.tsx | 58 +
apps/dsa-web/src/components/common/Button.tsx | 121 +
apps/dsa-web/src/components/common/Card.tsx | 92 +
.../src/components/common/Collapsible.tsx | 69 +
apps/dsa-web/src/components/common/Drawer.tsx | 91 +
.../src/components/common/JsonViewer.tsx | 92 +
.../dsa-web/src/components/common/Loading.tsx | 9 +
.../src/components/common/Pagination.tsx | 111 +
.../src/components/common/ScoreGauge.tsx | 186 +
apps/dsa-web/src/components/common/Select.tsx | 80 +
apps/dsa-web/src/components/common/index.ts | 10 +
.../src/components/history/HistoryList.tsx | 160 +
apps/dsa-web/src/components/history/index.ts | 1 +
.../src/components/report/ReportDetails.tsx | 127 +
.../src/components/report/ReportNews.tsx | 137 +
.../src/components/report/ReportOverview.tsx | 133 +
.../src/components/report/ReportStrategy.tsx | 82 +
.../src/components/report/ReportSummary.tsx | 46 +
apps/dsa-web/src/components/report/index.ts | 5 +
.../src/components/tasks/TaskPanel.tsx | 160 +
apps/dsa-web/src/components/tasks/index.ts | 2 +
apps/dsa-web/src/hooks/index.ts | 7 +
apps/dsa-web/src/hooks/useTaskStream.ts | 249 +
apps/dsa-web/src/index.css | 739 +++
apps/dsa-web/src/main.tsx | 10 +
apps/dsa-web/src/pages/HomePage.tsx | 322 ++
apps/dsa-web/src/pages/NotFoundPage.tsx | 38 +
apps/dsa-web/src/stores/analysisStore.ts | 67 +
apps/dsa-web/src/stores/index.ts | 1 +
apps/dsa-web/src/types/analysis.ts | 194 +
apps/dsa-web/src/utils/constants.ts | 2 +
apps/dsa-web/src/utils/format.ts | 45 +
apps/dsa-web/src/utils/validation.ts | 29 +
apps/dsa-web/tailwind.config.js | 95 +
apps/dsa-web/tsconfig.app.json | 28 +
apps/dsa-web/tsconfig.json | 7 +
apps/dsa-web/tsconfig.node.json | 26 +
apps/dsa-web/vite.config.ts | 23 +
docker/Dockerfile | 19 +-
docker/docker-compose.yml | 11 +
docs/architecture/api_spec.json | 961 ++++
docs/docker/zeabur-deployment.md | 26 +-
docs/full-guide.md | 33 +-
main.py | 210 +-
requirements.txt | 4 +
server.py | 54 +
sources/fastapi_server.png | Bin 0 -> 160297 bytes
src/analyzer.py | 6 +
src/core/pipeline.py | 7 +
src/logging_config.py | 120 +
src/repositories/__init__.py | 17 +
src/repositories/analysis_repo.py | 130 +
src/repositories/stock_repo.py | 138 +
src/services/__init__.py | 19 +
src/services/analysis_service.py | 178 +
src/services/history_service.py | 222 +
src/services/stock_service.py | 186 +
src/services/task_queue.py | 537 ++
src/storage.py | 94 +-
91 files changed, 13527 insertions(+), 117 deletions(-)
create mode 100644 api/__init__.py
create mode 100644 api/app.py
create mode 100644 api/deps.py
create mode 100644 api/middlewares/__init__.py
create mode 100644 api/middlewares/error_handler.py
create mode 100644 api/v1/__init__.py
create mode 100644 api/v1/endpoints/__init__.py
create mode 100644 api/v1/endpoints/analysis.py
create mode 100644 api/v1/endpoints/health.py
create mode 100644 api/v1/endpoints/history.py
create mode 100644 api/v1/endpoints/stocks.py
create mode 100644 api/v1/router.py
create mode 100644 api/v1/schemas/__init__.py
create mode 100644 api/v1/schemas/analysis.py
create mode 100644 api/v1/schemas/common.py
create mode 100644 api/v1/schemas/history.py
create mode 100644 api/v1/schemas/stocks.py
create mode 100644 apps/dsa-web/.gitignore
create mode 100644 apps/dsa-web/eslint.config.js
create mode 100644 apps/dsa-web/index.html
create mode 100644 apps/dsa-web/package-lock.json
create mode 100644 apps/dsa-web/package.json
create mode 100644 apps/dsa-web/postcss.config.js
create mode 100644 apps/dsa-web/public/vite.svg
create mode 100644 apps/dsa-web/src/App.css
create mode 100644 apps/dsa-web/src/App.tsx
create mode 100644 apps/dsa-web/src/api/analysis.ts
create mode 100644 apps/dsa-web/src/api/history.ts
create mode 100644 apps/dsa-web/src/api/index.ts
create mode 100644 apps/dsa-web/src/api/utils.ts
create mode 100644 apps/dsa-web/src/assets/react.svg
create mode 100644 apps/dsa-web/src/components/common/Badge.tsx
create mode 100644 apps/dsa-web/src/components/common/Button.tsx
create mode 100644 apps/dsa-web/src/components/common/Card.tsx
create mode 100644 apps/dsa-web/src/components/common/Collapsible.tsx
create mode 100644 apps/dsa-web/src/components/common/Drawer.tsx
create mode 100644 apps/dsa-web/src/components/common/JsonViewer.tsx
create mode 100644 apps/dsa-web/src/components/common/Loading.tsx
create mode 100644 apps/dsa-web/src/components/common/Pagination.tsx
create mode 100644 apps/dsa-web/src/components/common/ScoreGauge.tsx
create mode 100644 apps/dsa-web/src/components/common/Select.tsx
create mode 100644 apps/dsa-web/src/components/common/index.ts
create mode 100644 apps/dsa-web/src/components/history/HistoryList.tsx
create mode 100644 apps/dsa-web/src/components/history/index.ts
create mode 100644 apps/dsa-web/src/components/report/ReportDetails.tsx
create mode 100644 apps/dsa-web/src/components/report/ReportNews.tsx
create mode 100644 apps/dsa-web/src/components/report/ReportOverview.tsx
create mode 100644 apps/dsa-web/src/components/report/ReportStrategy.tsx
create mode 100644 apps/dsa-web/src/components/report/ReportSummary.tsx
create mode 100644 apps/dsa-web/src/components/report/index.ts
create mode 100644 apps/dsa-web/src/components/tasks/TaskPanel.tsx
create mode 100644 apps/dsa-web/src/components/tasks/index.ts
create mode 100644 apps/dsa-web/src/hooks/index.ts
create mode 100644 apps/dsa-web/src/hooks/useTaskStream.ts
create mode 100644 apps/dsa-web/src/index.css
create mode 100644 apps/dsa-web/src/main.tsx
create mode 100644 apps/dsa-web/src/pages/HomePage.tsx
create mode 100644 apps/dsa-web/src/pages/NotFoundPage.tsx
create mode 100644 apps/dsa-web/src/stores/analysisStore.ts
create mode 100644 apps/dsa-web/src/stores/index.ts
create mode 100644 apps/dsa-web/src/types/analysis.ts
create mode 100644 apps/dsa-web/src/utils/constants.ts
create mode 100644 apps/dsa-web/src/utils/format.ts
create mode 100644 apps/dsa-web/src/utils/validation.ts
create mode 100644 apps/dsa-web/tailwind.config.js
create mode 100644 apps/dsa-web/tsconfig.app.json
create mode 100644 apps/dsa-web/tsconfig.json
create mode 100644 apps/dsa-web/tsconfig.node.json
create mode 100644 apps/dsa-web/vite.config.ts
create mode 100644 docs/architecture/api_spec.json
create mode 100644 server.py
create mode 100644 sources/fastapi_server.png
create mode 100644 src/logging_config.py
create mode 100644 src/repositories/__init__.py
create mode 100644 src/repositories/analysis_repo.py
create mode 100644 src/repositories/stock_repo.py
create mode 100644 src/services/__init__.py
create mode 100644 src/services/analysis_service.py
create mode 100644 src/services/history_service.py
create mode 100644 src/services/stock_service.py
create mode 100644 src/services/task_queue.py
diff --git a/README.md b/README.md
index a5b5b48c8..82a96d0cf 100644
--- a/README.md
+++ b/README.md
@@ -194,7 +194,7 @@ python main.py
> 📖 完整环境变量、定时任务配置请参考 [完整配置指南](docs/full-guide.md)
-## 🖥️ 本地 WebUI(可选)
+## 🖥️ 本地 WebUI(可选 - 将在后续的版本弃用)
```bash
python main.py --webui # 启动 WebUI + 执行分析
@@ -205,6 +205,29 @@ python main.py --webui-only # 仅启动 WebUI
> 详细说明请参考 [完整指南 - WebUI](docs/full-guide.md#本地-webui-管理界面)
+## 🧩 FastAPI Web 服务(可选)
+
+
+
+```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 0000000000000000000000000000000000000000..c4ae89e2904a977cf7d0c5ac5bbfda1c3c8d1a95
GIT binary patch
literal 160297
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