mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 02:43:35 +08:00
Feature/React web support 新的WebUI (#256)
* 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>
This commit is contained in:
25
README.md
25
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)
|
||||
|
||||
12
api/__init__.py
Normal file
12
api/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
API 模块初始化
|
||||
===================================
|
||||
|
||||
职责:
|
||||
1. 导出 API 模块的公共接口
|
||||
2. 统一版本管理
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
164
api/app.py
Normal file
164
api/app.py
Normal file
@@ -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()
|
||||
60
api/deps.py
Normal file
60
api/deps.py
Normal file
@@ -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()
|
||||
13
api/middlewares/__init__.py
Normal file
13
api/middlewares/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
API 中间件模块初始化
|
||||
===================================
|
||||
|
||||
职责:
|
||||
1. 导出所有中间件
|
||||
"""
|
||||
|
||||
from api.middlewares.error_handler import ErrorHandlerMiddleware
|
||||
|
||||
__all__ = ["ErrorHandlerMiddleware"]
|
||||
128
api/middlewares/error_handler.py
Normal file
128
api/middlewares/error_handler.py
Normal file
@@ -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
|
||||
}
|
||||
)
|
||||
13
api/v1/__init__.py
Normal file
13
api/v1/__init__.py
Normal file
@@ -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"]
|
||||
13
api/v1/endpoints/__init__.py
Normal file
13
api/v1/endpoints/__init__.py
Normal file
@@ -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"]
|
||||
539
api/v1/endpoints/analysis.py
Normal file
539
api/v1/endpoints/analysis.py
Normal file
@@ -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
|
||||
)
|
||||
34
api/v1/endpoints/health.py
Normal file
34
api/v1/endpoints/health.py
Normal file
@@ -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()
|
||||
)
|
||||
282
api/v1/endpoints/history.py
Normal file
282
api/v1/endpoints/history.py
Normal file
@@ -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)}"
|
||||
}
|
||||
)
|
||||
176
api/v1/endpoints/stocks.py
Normal file
176
api/v1/endpoints/stocks.py
Normal file
@@ -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)}"
|
||||
}
|
||||
)
|
||||
35
api/v1/router.py
Normal file
35
api/v1/router.py
Normal file
@@ -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"]
|
||||
)
|
||||
65
api/v1/schemas/__init__.py
Normal file
65
api/v1/schemas/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
220
api/v1/schemas/analysis.py
Normal file
220
api/v1/schemas/analysis.py
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
78
api/v1/schemas/common.py
Normal file
78
api/v1/schemas/common.py
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
175
api/v1/schemas/history.py
Normal file
175
api/v1/schemas/history.py
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
95
api/v1/schemas/stocks.py
Normal file
95
api/v1/schemas/stocks.py
Normal file
@@ -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": []
|
||||
}
|
||||
}
|
||||
24
apps/dsa-web/.gitignore
vendored
Normal file
24
apps/dsa-web/.gitignore
vendored
Normal file
@@ -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?
|
||||
23
apps/dsa-web/eslint.config.js
Normal file
23
apps/dsa-web/eslint.config.js
Normal file
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
apps/dsa-web/index.html
Normal file
13
apps/dsa-web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>dsa-web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4397
apps/dsa-web/package-lock.json
generated
Normal file
4397
apps/dsa-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
apps/dsa-web/package.json
Normal file
39
apps/dsa-web/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
6
apps/dsa-web/postcss.config.js
Normal file
6
apps/dsa-web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
apps/dsa-web/public/vite.svg
Normal file
1
apps/dsa-web/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
42
apps/dsa-web/src/App.css
Normal file
42
apps/dsa-web/src/App.css
Normal file
@@ -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;
|
||||
}
|
||||
104
apps/dsa-web/src/App.tsx
Normal file
104
apps/dsa-web/src/App.tsx
Normal file
@@ -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}) => (
|
||||
<svg className="w-6 h-6" fill={active ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SettingsIcon: React.FC = () => (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
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 (
|
||||
<aside className="dock-nav" aria-label="主导航">
|
||||
<div className="dock-surface">
|
||||
<NavLink to="/" className="dock-logo" title="首页" aria-label="首页">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
|
||||
</svg>
|
||||
</NavLink>
|
||||
|
||||
<nav className="dock-items" aria-label="页面">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.key}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
title={item.label}
|
||||
aria-label={item.label}
|
||||
className={({isActive}) => `dock-item${isActive ? ' is-active' : ''}`}
|
||||
>
|
||||
{({isActive}) => <Icon active={isActive}/>}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="dock-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="dock-item is-placeholder"
|
||||
title="设置(即将推出)"
|
||||
aria-disabled="true"
|
||||
disabled
|
||||
>
|
||||
<SettingsIcon/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<Router>
|
||||
<div className="flex min-h-screen bg-base">
|
||||
{/* Dock 导航 */}
|
||||
<DockNav/>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<main className="flex-1 dock-safe-area">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage/>}/>
|
||||
<Route path="*" element={<NotFoundPage/>}/>
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
146
apps/dsa-web/src/api/analysis.ts
Normal file
146
apps/dsa-web/src/api/analysis.ts
Normal file
@@ -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<AnalysisResult> => {
|
||||
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<Record<string, unknown>>(
|
||||
'/api/v1/analysis/analyze',
|
||||
requestData
|
||||
);
|
||||
|
||||
const result = toCamelCase<AnalysisResult>(response.data);
|
||||
|
||||
// 确保 report 字段正确转换
|
||||
if (result.report) {
|
||||
result.report = toCamelCase<AnalysisReport>(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<Record<string, unknown>>(
|
||||
'/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<TaskStatus> => {
|
||||
const response = await apiClient.get<Record<string, unknown>>(
|
||||
`/api/v1/analysis/status/${taskId}`
|
||||
);
|
||||
|
||||
const data = toCamelCase<TaskStatus>(response.data);
|
||||
|
||||
// 确保嵌套的 result 也被正确转换
|
||||
if (data.result) {
|
||||
data.result = toCamelCase<AnalysisResult>(data.result);
|
||||
if (data.result.report) {
|
||||
data.result.report = toCamelCase<AnalysisReport>(data.result.report);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取任务列表
|
||||
* @param params 筛选参数
|
||||
*/
|
||||
getTasks: async (params?: {
|
||||
status?: string;
|
||||
limit?: number;
|
||||
}): Promise<TaskListResponse> => {
|
||||
const response = await apiClient.get<Record<string, unknown>>(
|
||||
'/api/v1/analysis/tasks',
|
||||
{ params }
|
||||
);
|
||||
|
||||
const data = toCamelCase<TaskListResponse>(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;
|
||||
}
|
||||
}
|
||||
70
apps/dsa-web/src/api/history.ts
Normal file
70
apps/dsa-web/src/api/history.ts
Normal file
@@ -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<HistoryListResponse> => {
|
||||
const { stockCode, startDate, endDate, page = 1, limit = 20 } = params;
|
||||
|
||||
const queryParams: Record<string, string | number> = { 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<Record<string, unknown>>('/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<HistoryItem>(item)),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取历史报告详情
|
||||
* @param queryId 分析记录唯一标识
|
||||
*/
|
||||
getDetail: async (queryId: string): Promise<AnalysisReport> => {
|
||||
const response = await apiClient.get<Record<string, unknown>>(`/api/v1/history/${queryId}`);
|
||||
return toCamelCase<AnalysisReport>(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取历史报告关联新闻
|
||||
* @param queryId 分析记录唯一标识
|
||||
* @param limit 返回数量限制
|
||||
*/
|
||||
getNews: async (queryId: string, limit = 20): Promise<NewsIntelResponse> => {
|
||||
const response = await apiClient.get<Record<string, unknown>>(`/api/v1/history/${queryId}/news`, {
|
||||
params: { limit },
|
||||
});
|
||||
|
||||
const data = toCamelCase<NewsIntelResponse>(response.data);
|
||||
return {
|
||||
total: data.total,
|
||||
items: (data.items || []).map(item => toCamelCase<NewsIntelItem>(item)),
|
||||
};
|
||||
},
|
||||
};
|
||||
12
apps/dsa-web/src/api/index.ts
Normal file
12
apps/dsa-web/src/api/index.ts
Normal file
@@ -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;
|
||||
13
apps/dsa-web/src/api/utils.ts
Normal file
13
apps/dsa-web/src/api/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
/**
|
||||
* 将 snake_case 对象键转换为 camelCase
|
||||
* @param data API 响应数据 (snake_case)
|
||||
* @returns 转换后的 camelCase 对象
|
||||
*/
|
||||
export function toCamelCase<T>(data: unknown): T {
|
||||
if (data === null || data === undefined) {
|
||||
return data as T;
|
||||
}
|
||||
return camelcaseKeys(data as Record<string, unknown>, { deep: true }) as T;
|
||||
}
|
||||
1
apps/dsa-web/src/assets/react.svg
Normal file
1
apps/dsa-web/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
58
apps/dsa-web/src/components/common/Badge.tsx
Normal file
58
apps/dsa-web/src/components/common/Badge.tsx
Normal file
@@ -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<BadgeVariant, string> = {
|
||||
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<BadgeVariant, string> = {
|
||||
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<BadgeProps> = ({
|
||||
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 (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center gap-1 rounded-full font-medium
|
||||
border backdrop-blur-sm
|
||||
${sizeStyles}
|
||||
${variantStyles[variant]}
|
||||
${glow ? `shadow-lg ${glowStyles[variant]}` : ''}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
121
apps/dsa-web/src/components/common/Button.tsx
Normal file
121
apps/dsa-web/src/components/common/Button.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'gradient' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
isLoading?: boolean;
|
||||
glow?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按钮组件
|
||||
* 支持多种变体和科技感样式
|
||||
*/
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
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 (
|
||||
<button
|
||||
className={`
|
||||
${baseStyle}
|
||||
${sizeStyles[size]}
|
||||
${variantStyles[variant]}
|
||||
${glowStyles}
|
||||
${className}
|
||||
`}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
处理中...
|
||||
</span>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
92
apps/dsa-web/src/components/common/Card.tsx
Normal file
92
apps/dsa-web/src/components/common/Card.tsx
Normal file
@@ -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<CardProps> = ({
|
||||
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 (
|
||||
<div className={`${variantStyles.gradient} ${className}`}>
|
||||
<div className={`gradient-border-card-inner ${paddingStyles[padding]}`}>
|
||||
{(title || subtitle) && (
|
||||
<div className="mb-3">
|
||||
{subtitle && (
|
||||
<span className="label-uppercase">{subtitle}</span>
|
||||
)}
|
||||
{title && (
|
||||
<h3 className="text-lg font-semibold text-white mt-1">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
${baseStyles}
|
||||
${variantStyles[variant]}
|
||||
${hoverStyles}
|
||||
${paddingStyles[padding]}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{(title || subtitle) && (
|
||||
<div className="mb-3">
|
||||
{subtitle && (
|
||||
<span className="label-uppercase">{subtitle}</span>
|
||||
)}
|
||||
{title && (
|
||||
<h3 className="text-lg font-semibold text-white mt-1">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
69
apps/dsa-web/src/components/common/Collapsible.tsx
Normal file
69
apps/dsa-web/src/components/common/Collapsible.tsx
Normal file
@@ -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<CollapsibleProps> = ({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
icon,
|
||||
className = '',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-xl overflow-hidden
|
||||
bg-gradient-to-br from-slate-800/50 to-slate-900/50
|
||||
border border-cyan-500/10 hover:border-cyan-500/20
|
||||
transition-all duration-300
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left
|
||||
hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{icon && <span className="text-cyan-400">{icon}</span>}
|
||||
<span className="font-medium text-gray-200">{title}</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-400 transition-transform duration-300 ${
|
||||
isOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div
|
||||
className={`
|
||||
overflow-hidden transition-all duration-300 ease-in-out
|
||||
${isOpen ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0'}
|
||||
`}
|
||||
>
|
||||
<div className="px-4 pb-4 pt-2 border-t border-cyan-500/10">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
91
apps/dsa-web/src/components/common/Drawer.tsx
Normal file
91
apps/dsa-web/src/components/common/Drawer.tsx
Normal file
@@ -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<DrawerProps> = ({
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 overflow-hidden">
|
||||
{/* 遮罩层 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm transition-opacity duration-300"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 抽屉内容 */}
|
||||
<div className={`absolute inset-y-0 right-0 w-full ${width} flex`}>
|
||||
<div
|
||||
className="relative w-full flex flex-col
|
||||
bg-card border-l border-white/10
|
||||
shadow-2xl
|
||||
transform transition-transform duration-300 ease-out
|
||||
animate-slide-in-right"
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/5">
|
||||
{title && (
|
||||
<div>
|
||||
<span className="label-uppercase">DETAIL VIEW</span>
|
||||
<h2 className="text-lg font-semibold text-white mt-1">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="dock-item !w-10 !h-10"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
92
apps/dsa-web/src/components/common/JsonViewer.tsx
Normal file
92
apps/dsa-web/src/components/common/JsonViewer.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface JsonViewerProps {
|
||||
data: Record<string, unknown> | unknown[] | null | undefined;
|
||||
maxHeight?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 结构化展示组件
|
||||
* 支持语法高亮和折叠
|
||||
*/
|
||||
export const JsonViewer: React.FC<JsonViewerProps> = ({
|
||||
data,
|
||||
maxHeight = '400px',
|
||||
className = '',
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="text-gray-500 italic py-4 text-center">暂无数据</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
'<span class="text-cyan-400">"$1"</span>:'
|
||||
);
|
||||
// 高亮字符串值
|
||||
highlighted = highlighted.replace(
|
||||
/: "([^"]*)"/g,
|
||||
': <span class="text-emerald-400">"$1"</span>'
|
||||
);
|
||||
// 高亮数字
|
||||
highlighted = highlighted.replace(
|
||||
/: (-?\d+\.?\d*)/g,
|
||||
': <span class="text-amber-400">$1</span>'
|
||||
);
|
||||
// 高亮布尔值和 null
|
||||
highlighted = highlighted.replace(
|
||||
/: (true|false|null)/g,
|
||||
': <span class="text-purple-400">$1</span>'
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: highlighted }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{/* 复制按钮 */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 px-2 py-1 text-xs rounded
|
||||
bg-slate-700 hover:bg-slate-600 text-gray-300
|
||||
transition-colors z-10"
|
||||
>
|
||||
{copied ? '已复制!' : '复制'}
|
||||
</button>
|
||||
|
||||
{/* JSON 内容 */}
|
||||
<div
|
||||
className="bg-slate-900/80 rounded-lg p-4 overflow-auto custom-scrollbar
|
||||
border border-slate-700/50 font-mono text-sm text-gray-300"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
<pre className="whitespace-pre-wrap break-words">
|
||||
{highlightJson(jsonString)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
9
apps/dsa-web/src/components/common/Loading.tsx
Normal file
9
apps/dsa-web/src/components/common/Loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Loading: React.FC = () => {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
111
apps/dsa-web/src/components/common/Pagination.tsx
Normal file
111
apps/dsa-web/src/components/common/Pagination.tsx
Normal file
@@ -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<PaginationProps> = ({
|
||||
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 (
|
||||
<span className="px-3 py-2 text-muted">...</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
min-w-[40px] h-10 px-3 rounded-lg font-medium
|
||||
transition-all duration-200
|
||||
${isActive
|
||||
? 'bg-cyan text-black'
|
||||
: 'bg-elevated text-secondary hover:bg-hover hover:text-white border border-white/5'
|
||||
}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
{children || page}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center gap-2 ${className}`}>
|
||||
{/* 上一页 */}
|
||||
<PageButton
|
||||
page="prev"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</PageButton>
|
||||
|
||||
{/* 页码 */}
|
||||
{getPageNumbers().map((page, index) => (
|
||||
<PageButton
|
||||
key={`${page}-${index}`}
|
||||
page={page}
|
||||
isActive={page === currentPage}
|
||||
onClick={() => typeof page === 'number' && onPageChange(page)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 下一页 */}
|
||||
<PageButton
|
||||
page="next"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</PageButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
186
apps/dsa-web/src/components/common/ScoreGauge.tsx
Normal file
186
apps/dsa-web/src/components/common/ScoreGauge.tsx
Normal file
@@ -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<ScoreGaugeProps> = ({
|
||||
score,
|
||||
size = 'md',
|
||||
showLabel = true,
|
||||
className = '',
|
||||
}) => {
|
||||
// 动画状态
|
||||
const [animatedScore, setAnimatedScore] = useState(0);
|
||||
const [displayScore, setDisplayScore] = useState(0);
|
||||
const animationRef = useRef<number | null>(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 (
|
||||
<div className={`flex flex-col items-center ${className}`}>
|
||||
{/* 标题 */}
|
||||
{showLabel && (
|
||||
<span className="label-uppercase mb-3 text-secondary">
|
||||
恐惧贪婪指数
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="relative" style={{ width, height: width }}>
|
||||
<svg
|
||||
className="gauge-ring overflow-visible"
|
||||
width={width}
|
||||
height={width}
|
||||
style={{ filter: `drop-shadow(0 0 12px ${glowColor})` }}
|
||||
>
|
||||
<defs>
|
||||
{/* 渐变定义 */}
|
||||
<linearGradient id={`gauge-gradient-${score}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.6" />
|
||||
<stop offset="100%" stopColor={strokeColor} stopOpacity="1" />
|
||||
</linearGradient>
|
||||
|
||||
{/* 发光滤镜 */}
|
||||
<filter id={`gauge-glow-${score}`}>
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* 背景轨道 - 3/4 圆弧 */}
|
||||
<circle
|
||||
cx={width / 2}
|
||||
cy={width / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="rgba(255, 255, 255, 0.05)"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${arcLength} ${circumference}`}
|
||||
transform={`rotate(135 ${width / 2} ${width / 2})`}
|
||||
/>
|
||||
|
||||
{/* 发光层 */}
|
||||
<circle
|
||||
cx={width / 2}
|
||||
cy={width / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={stroke + gap}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${progress} ${circumference}`}
|
||||
transform={`rotate(135 ${width / 2} ${width / 2})`}
|
||||
opacity="0.3"
|
||||
filter={`url(#gauge-glow-${score})`}
|
||||
/>
|
||||
|
||||
{/* 进度圆弧 */}
|
||||
<circle
|
||||
cx={width / 2}
|
||||
cy={width / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={`url(#gauge-gradient-${score})`}
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${progress} ${circumference}`}
|
||||
transform={`rotate(135 ${width / 2} ${width / 2})`}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* 中心数值 */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span
|
||||
className={`font-bold ${fontSize} text-white`}
|
||||
style={{
|
||||
textShadow: `0 0 30px ${glowColor}`,
|
||||
}}
|
||||
>
|
||||
{displayScore}
|
||||
</span>
|
||||
{showLabel && (
|
||||
<span
|
||||
className={`${labelSize} font-semibold mt-1`}
|
||||
style={{ color: strokeColor }}
|
||||
>
|
||||
{label.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
80
apps/dsa-web/src/components/common/Select.tsx
Normal file
80
apps/dsa-web/src/components/common/Select.tsx
Normal file
@@ -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<SelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
label,
|
||||
placeholder = '请选择',
|
||||
disabled = false,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`flex flex-col ${className}`}>
|
||||
{label && (
|
||||
<label className="mb-2 text-sm font-medium text-gray-300">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
w-full appearance-none px-4 py-2.5 pr-10 rounded-lg
|
||||
bg-slate-800/50 border border-cyan-500/20
|
||||
text-gray-200 placeholder-gray-500
|
||||
focus:outline-none focus:ring-2 focus:ring-cyan-500/40 focus:border-cyan-500/40
|
||||
hover:border-cyan-500/30
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200
|
||||
cursor-pointer
|
||||
`}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-800">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* 下拉箭头 */}
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<svg
|
||||
className="w-4 h-4 text-cyan-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
10
apps/dsa-web/src/components/common/index.ts
Normal file
10
apps/dsa-web/src/components/common/index.ts
Normal file
@@ -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';
|
||||
160
apps/dsa-web/src/components/history/HistoryList.tsx
Normal file
160
apps/dsa-web/src/components/history/HistoryList.tsx
Normal file
@@ -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<HistoryListProps> = ({
|
||||
items,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
selectedQueryId,
|
||||
onItemClick,
|
||||
onLoadMore,
|
||||
className = '',
|
||||
}) => {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreTriggerRef = useRef<HTMLDivElement>(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 (
|
||||
<aside className={`glass-card overflow-hidden flex flex-col ${className}`}>
|
||||
<div ref={scrollContainerRef} className="p-3 flex-1 overflow-y-auto">
|
||||
<h2 className="text-xs font-medium text-purple uppercase tracking-wider mb-3 flex items-center gap-1.5">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
历史记录
|
||||
</h2>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<div className="w-5 h-5 border-2 border-cyan/20 border-t-cyan rounded-full animate-spin" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted text-xs">
|
||||
暂无历史记录
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.queryId}
|
||||
type="button"
|
||||
onClick={() => onItemClick(item.queryId)}
|
||||
className={`history-item w-full text-left ${selectedQueryId === item.queryId ? 'active' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{/* 情感分数指示条 */}
|
||||
{item.sentimentScore !== undefined && (
|
||||
<span
|
||||
className="w-0.5 h-8 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: getSentimentColor(item.sentimentScore),
|
||||
boxShadow: `0 0 6px ${getSentimentColor(item.sentimentScore)}40`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<span className="font-medium text-white truncate text-xs">
|
||||
{item.stockName || item.stockCode}
|
||||
</span>
|
||||
{item.sentimentScore !== undefined && (
|
||||
<span
|
||||
className="text-xs font-mono font-semibold px-1 py-0.5 rounded"
|
||||
style={{
|
||||
color: getSentimentColor(item.sentimentScore),
|
||||
backgroundColor: `${getSentimentColor(item.sentimentScore)}15`
|
||||
}}
|
||||
>
|
||||
{item.sentimentScore}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-muted font-mono">
|
||||
{item.stockCode}
|
||||
</span>
|
||||
<span className="text-xs text-muted/50">·</span>
|
||||
<span className="text-xs text-muted">
|
||||
{formatDateTime(item.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* 加载更多触发器 */}
|
||||
<div ref={loadMoreTriggerRef} className="h-4" />
|
||||
|
||||
{/* 加载更多状态 */}
|
||||
{isLoadingMore && (
|
||||
<div className="flex justify-center py-3">
|
||||
<div className="w-4 h-4 border-2 border-cyan/20 border-t-cyan rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 没有更多数据提示 */}
|
||||
{!hasMore && items.length > 0 && (
|
||||
<div className="text-center py-2 text-muted/50 text-xs">
|
||||
已加载全部
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
1
apps/dsa-web/src/components/history/index.ts
Normal file
1
apps/dsa-web/src/components/history/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { HistoryList } from './HistoryList';
|
||||
127
apps/dsa-web/src/components/report/ReportDetails.tsx
Normal file
127
apps/dsa-web/src/components/report/ReportDetails.tsx
Normal file
@@ -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<ReportDetailsProps> = ({
|
||||
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 (
|
||||
<div className="relative overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(jsonStr)}
|
||||
className="absolute top-2 right-2 text-xs text-muted hover:text-cyan transition-colors"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
<pre className="text-xs text-secondary font-mono overflow-x-auto p-3 bg-base rounded-lg max-h-80 overflow-y-auto text-left w-0 min-w-full">
|
||||
{jsonStr}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="bordered" padding="md" className="text-left">
|
||||
<div className="mb-3 flex items-baseline gap-2">
|
||||
<span className="label-uppercase">TRANSPARENCY</span>
|
||||
<h3 className="text-base font-semibold text-white mt-0.5">数据追溯</h3>
|
||||
</div>
|
||||
|
||||
{/* Query ID */}
|
||||
{queryId && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted mb-3 pb-3 border-b border-white/5">
|
||||
<span>Query ID:</span>
|
||||
<code className="font-mono text-xs text-cyan bg-cyan/10 px-1.5 py-0.5 rounded">
|
||||
{queryId}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 折叠区域 */}
|
||||
<div className="space-y-2">
|
||||
{/* 原始分析结果 */}
|
||||
{details?.rawResult && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRaw(!showRaw)}
|
||||
className="w-full flex items-center justify-between p-2.5 rounded-lg bg-elevated hover:bg-hover transition-colors"
|
||||
>
|
||||
<span className="text-xs text-white">原始分析结果</span>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 text-muted transition-transform ${showRaw ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{showRaw && (
|
||||
<div className="mt-2 animate-fade-in min-w-0 overflow-hidden">
|
||||
{renderJson(details.rawResult)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分析快照 */}
|
||||
{details?.contextSnapshot && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSnapshot(!showSnapshot)}
|
||||
className="w-full flex items-center justify-between p-2.5 rounded-lg bg-elevated hover:bg-hover transition-colors"
|
||||
>
|
||||
<span className="text-xs text-white">分析快照</span>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 text-muted transition-transform ${showSnapshot ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{showSnapshot && (
|
||||
<div className="mt-2 animate-fade-in min-w-0 overflow-hidden">
|
||||
{renderJson(details.contextSnapshot)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
137
apps/dsa-web/src/components/report/ReportNews.tsx
Normal file
137
apps/dsa-web/src/components/report/ReportNews.tsx
Normal file
@@ -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<ReportNewsProps> = ({ queryId, limit = 20 }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [items, setItems] = useState<NewsIntelItem[]>([]);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Card variant="bordered" padding="md">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="mb-3 flex items-baseline gap-2">
|
||||
<span className="label-uppercase">NEWS FEED</span>
|
||||
<h3 className="text-base font-semibold text-white">相关资讯</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading && (
|
||||
<div className="w-3.5 h-3.5 border-2 border-cyan/20 border-t-cyan rounded-full animate-spin" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchNews}
|
||||
className="text-xs text-cyan hover:text-white transition-colors"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && !isLoading && (
|
||||
<div className="flex items-center justify-between gap-3 p-3 rounded-lg bg-danger/10 border border-danger/20 text-xs text-danger">
|
||||
<span>{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchNews}
|
||||
className="text-xs text-cyan hover:text-white transition-colors"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && !error && (
|
||||
<div className="flex items-center gap-2 text-xs text-secondary">
|
||||
<div className="w-4 h-4 border-2 border-cyan/20 border-t-cyan rounded-full animate-spin" />
|
||||
加载资讯中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && items.length === 0 && (
|
||||
<div className="text-xs text-muted">暂无相关资讯</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && items.length > 0 && (
|
||||
<div className="space-y-2 text-left">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={`${item.title}-${index}`}
|
||||
className="group p-3 rounded-lg bg-elevated/80 border border-white/5 hover:border-cyan/30 hover:bg-hover transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-sm text-white font-medium leading-snug text-left">
|
||||
{item.title}
|
||||
</p>
|
||||
{item.snippet && (
|
||||
<p className="text-xs text-secondary mt-1 text-left">
|
||||
{item.snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{item.url && (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-cyan hover:text-white transition-colors inline-flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
跳转
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M14 3h7m0 0v7m0-7L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
133
apps/dsa-web/src/components/report/ReportOverview.tsx
Normal file
133
apps/dsa-web/src/components/report/ReportOverview.tsx
Normal file
@@ -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<ReportOverviewProps> = ({
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{/* 主信息区 - 两列布局 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* 左侧:股票信息与结论 */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{/* 股票头部 */}
|
||||
<Card variant="gradient" padding="md">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
{meta.stockName || meta.stockCode}
|
||||
</h2>
|
||||
{/* 价格和涨跌幅 */}
|
||||
{meta.currentPrice != null && (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={`text-xl font-bold font-mono ${getPriceChangeColor(meta.changePct)}`}>
|
||||
{meta.currentPrice.toFixed(2)}
|
||||
</span>
|
||||
<span className={`text-sm font-semibold font-mono ${getPriceChangeColor(meta.changePct)}`}>
|
||||
{formatChangePct(meta.changePct)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className="font-mono text-xs text-cyan bg-cyan/10 px-1.5 py-0.5 rounded">
|
||||
{meta.stockCode}
|
||||
</span>
|
||||
<span className="text-xs text-muted flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{formatDateTime(meta.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 关键结论 */}
|
||||
<div className="border-t border-white/5 pt-4">
|
||||
<span className="label-uppercase">KEY INSIGHTS</span>
|
||||
<p className="text-white text-sm leading-relaxed mt-1.5 whitespace-pre-wrap text-left">
|
||||
{summary.analysisSummary || '暂无分析结论'}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 操作建议和趋势预测 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{/* 操作建议 */}
|
||||
<Card variant="bordered" padding="sm" hoverable>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-success/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-success mb-0.5">操作建议</h4>
|
||||
<p className="text-white text-sm font-medium">
|
||||
{summary.operationAdvice || '暂无建议'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 趋势预测 */}
|
||||
<Card variant="bordered" padding="sm" hoverable>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-warning/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-warning mb-0.5">趋势预测</h4>
|
||||
<p className="text-white text-sm font-medium">
|
||||
{summary.trendPrediction || '暂无预测'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:情绪指标 */}
|
||||
<div className="space-y-4">
|
||||
<Card variant="bordered" padding="md" className="!overflow-visible">
|
||||
<div className="text-center">
|
||||
<h3 className="text-sm font-medium text-white mb-4">Market Sentiment</h3>
|
||||
<ScoreGauge score={summary.sentimentScore} size="lg" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
82
apps/dsa-web/src/components/report/ReportStrategy.tsx
Normal file
82
apps/dsa-web/src/components/report/ReportStrategy.tsx
Normal file
@@ -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<StrategyItemProps> = ({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}) => (
|
||||
<div className="relative overflow-hidden rounded-lg bg-elevated border border-white/5 p-3 hover:border-white/10 transition-colors">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted mb-0.5">{label}</span>
|
||||
<span
|
||||
className="text-lg font-bold font-mono"
|
||||
style={{ color: value ? color : 'var(--text-muted)' }}
|
||||
>
|
||||
{value || '—'}
|
||||
</span>
|
||||
</div>
|
||||
{/* 底部指示条 */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5"
|
||||
style={{ background: `linear-gradient(90deg, ${color}00, ${color}, ${color}00)` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* 策略点位区组件 - 终端风格
|
||||
*/
|
||||
export const ReportStrategy: React.FC<ReportStrategyProps> = ({ 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 (
|
||||
<Card variant="bordered" padding="md">
|
||||
<div className="mb-3 flex items-baseline gap-2">
|
||||
<span className="label-uppercase">STRATEGY POINTS</span>
|
||||
<h3 className="text-base font-semibold text-white">狙击点位</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{strategyItems.map((item) => (
|
||||
<StrategyItem key={item.label} {...item} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
46
apps/dsa-web/src/components/report/ReportSummary.tsx
Normal file
46
apps/dsa-web/src/components/report/ReportSummary.tsx
Normal file
@@ -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<ReportSummaryProps> = ({
|
||||
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 (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* 概览区(首屏) */}
|
||||
<ReportOverview
|
||||
meta={meta}
|
||||
summary={summary}
|
||||
isHistory={isHistory}
|
||||
/>
|
||||
|
||||
{/* 策略点位区 */}
|
||||
<ReportStrategy strategy={strategy} />
|
||||
|
||||
{/* 资讯区 */}
|
||||
<ReportNews queryId={queryId} />
|
||||
|
||||
{/* 透明度与追溯区 */}
|
||||
<ReportDetails details={details} queryId={queryId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
5
apps/dsa-web/src/components/report/index.ts
Normal file
5
apps/dsa-web/src/components/report/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './ReportSummary';
|
||||
export * from './ReportOverview';
|
||||
export * from './ReportStrategy';
|
||||
export * from './ReportNews';
|
||||
export * from './ReportDetails';
|
||||
160
apps/dsa-web/src/components/tasks/TaskPanel.tsx
Normal file
160
apps/dsa-web/src/components/tasks/TaskPanel.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import type React from 'react';
|
||||
import type { TaskInfo } from '../../types/analysis';
|
||||
|
||||
/**
|
||||
* 任务项组件属性
|
||||
*/
|
||||
interface TaskItemProps {
|
||||
task: TaskInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个任务项
|
||||
*/
|
||||
const TaskItem: React.FC<TaskItemProps> = ({ task }) => {
|
||||
const isPending = task.status === 'pending';
|
||||
const isProcessing = task.status === 'processing';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 bg-elevated rounded-lg border border-white/5">
|
||||
{/* 状态图标 */}
|
||||
<div className="shrink-0">
|
||||
{isProcessing ? (
|
||||
// 加载动画
|
||||
<svg className="w-4 h-4 text-cyan animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : isPending ? (
|
||||
// 等待图标
|
||||
<svg className="w-4 h-4 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 任务信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white truncate">
|
||||
{task.stockName || task.stockCode}
|
||||
</span>
|
||||
<span className="text-xs text-muted">
|
||||
{task.stockCode}
|
||||
</span>
|
||||
</div>
|
||||
{task.message && (
|
||||
<p className="text-xs text-secondary truncate mt-0.5">
|
||||
{task.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 状态标签 */}
|
||||
<div className="flex-shrink-0">
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
isProcessing
|
||||
? 'bg-cyan/20 text-cyan'
|
||||
: 'bg-white/10 text-muted'
|
||||
}`}
|
||||
>
|
||||
{isProcessing ? '分析中' : '等待中'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 任务面板属性
|
||||
*/
|
||||
interface TaskPanelProps {
|
||||
/** 任务列表 */
|
||||
tasks: TaskInfo[];
|
||||
/** 是否显示 */
|
||||
visible?: boolean;
|
||||
/** 标题 */
|
||||
title?: string;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务面板组件
|
||||
* 显示进行中的分析任务列表
|
||||
*/
|
||||
export const TaskPanel: React.FC<TaskPanelProps> = ({
|
||||
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 (
|
||||
<div className={`bg-surface rounded-xl border border-white/5 overflow-hidden ${className}`}>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-cyan" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-white">{title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
{processingCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 bg-cyan rounded-full animate-pulse" />
|
||||
{processingCount} 进行中
|
||||
</span>
|
||||
)}
|
||||
{pendingCount > 0 && (
|
||||
<span>{pendingCount} 等待中</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 任务列表 */}
|
||||
<div className="p-2 space-y-2 max-h-64 overflow-y-auto">
|
||||
{activeTasks.map((task) => (
|
||||
<TaskItem key={task.taskId} task={task} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskPanel;
|
||||
2
apps/dsa-web/src/components/tasks/index.ts
Normal file
2
apps/dsa-web/src/components/tasks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TaskPanel } from './TaskPanel';
|
||||
export { default as TaskPanelDefault } from './TaskPanel';
|
||||
7
apps/dsa-web/src/hooks/index.ts
Normal file
7
apps/dsa-web/src/hooks/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { useTaskStream } from './useTaskStream';
|
||||
export type {
|
||||
SSEEventType,
|
||||
SSEEvent,
|
||||
UseTaskStreamOptions,
|
||||
UseTaskStreamResult,
|
||||
} from './useTaskStream';
|
||||
249
apps/dsa-web/src/hooks/useTaskStream.ts
Normal file
249
apps/dsa-web/src/hooks/useTaskStream.ts
Normal file
@@ -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<EventSource | null>(null);
|
||||
const isConnectedRef = useRef(false);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<string, unknown>): 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;
|
||||
739
apps/dsa-web/src/index.css
Normal file
739
apps/dsa-web/src/index.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
10
apps/dsa-web/src/main.tsx
Normal file
10
apps/dsa-web/src/main.tsx
Normal file
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
322
apps/dsa-web/src/pages/HomePage.tsx
Normal file
322
apps/dsa-web/src/pages/HomePage.tsx
Normal file
@@ -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<string>();
|
||||
|
||||
// 历史列表状态
|
||||
const [historyItems, setHistoryItems] = useState<HistoryItem[]>([]);
|
||||
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<AnalysisReport | null>(null);
|
||||
const [isLoadingReport, setIsLoadingReport] = useState(false);
|
||||
|
||||
// 任务队列状态
|
||||
const [activeTasks, setActiveTasks] = useState<TaskInfo[]>([]);
|
||||
const [duplicateError, setDuplicateError] = useState<string | null>(null);
|
||||
|
||||
// 用于跟踪当前分析请求,避免竞态条件
|
||||
const analysisRequestIdRef = useRef<number>(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 (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* 顶部输入栏 */}
|
||||
<header className="flex-shrink-0 px-4 py-3 border-b border-white/5">
|
||||
<div className="flex items-center gap-2 max-w-2xl">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
value={stockCode}
|
||||
onChange={(e) => {
|
||||
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 && (
|
||||
<p className="absolute -bottom-4 left-0 text-xs text-danger">{inputError}</p>
|
||||
)}
|
||||
{duplicateError && (
|
||||
<p className="absolute -bottom-4 left-0 text-xs text-warning">{duplicateError}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAnalyze}
|
||||
disabled={!stockCode || isAnalyzing}
|
||||
className="btn-primary flex items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
分析中
|
||||
</>
|
||||
) : (
|
||||
'分析'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<main className="flex-1 flex overflow-hidden p-3 gap-3">
|
||||
{/* 左侧:任务面板 + 历史列表 */}
|
||||
<div className="flex flex-col gap-3 w-64 flex-shrink-0 overflow-hidden">
|
||||
{/* 任务面板 */}
|
||||
<TaskPanel tasks={activeTasks} />
|
||||
|
||||
{/* 历史列表 */}
|
||||
<HistoryList
|
||||
items={historyItems}
|
||||
isLoading={isLoadingHistory}
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
selectedQueryId={selectedReport?.meta.queryId}
|
||||
onItemClick={handleHistoryClick}
|
||||
onLoadMore={handleLoadMore}
|
||||
className="max-h-[62vh] overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧报告详情 */}
|
||||
<section className="flex-1 overflow-y-auto pl-1">
|
||||
{isLoadingReport ? (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<div className="w-10 h-10 border-3 border-cyan/20 border-t-cyan rounded-full animate-spin" />
|
||||
<p className="mt-3 text-secondary text-sm">加载报告中...</p>
|
||||
</div>
|
||||
) : selectedReport ? (
|
||||
<div className="max-w-4xl">
|
||||
{/* 报告内容 */}
|
||||
<ReportSummary data={selectedReport} isHistory />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="w-12 h-12 mb-3 rounded-xl bg-elevated flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-white mb-1.5">开始分析</h3>
|
||||
<p className="text-xs text-muted max-w-xs">
|
||||
输入股票代码进行分析,或从左侧选择历史报告查看
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
38
apps/dsa-web/src/pages/NotFoundPage.tsx
Normal file
38
apps/dsa-web/src/pages/NotFoundPage.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const NotFoundPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center text-center px-4">
|
||||
{/* 404 */}
|
||||
<div className="relative mb-8">
|
||||
<span
|
||||
className="text-8xl font-bold text-transparent bg-clip-text"
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(135deg, #00d4ff 0%, #a855f7 100%)',
|
||||
}}
|
||||
>
|
||||
404
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-white mb-2">页面未找到</h1>
|
||||
<p className="text-muted mb-8">抱歉,您访问的页面不存在或已被移动</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary flex items-center gap-2"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
返回首页
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFoundPage;
|
||||
67
apps/dsa-web/src/stores/analysisStore.ts
Normal file
67
apps/dsa-web/src/stores/analysisStore.ts
Normal file
@@ -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<AnalysisState>((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,
|
||||
}),
|
||||
}));
|
||||
1
apps/dsa-web/src/stores/index.ts
Normal file
1
apps/dsa-web/src/stores/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './analysisStore';
|
||||
194
apps/dsa-web/src/types/analysis.ts
Normal file
194
apps/dsa-web/src/types/analysis.ts
Normal file
@@ -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<string, unknown>;
|
||||
contextSnapshot?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 完整分析报告 */
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
// ============ 辅助函数 ============
|
||||
|
||||
/** 根据情绪评分获取情绪标签 */
|
||||
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
|
||||
};
|
||||
2
apps/dsa-web/src/utils/constants.ts
Normal file
2
apps/dsa-web/src/utils/constants.ts
Normal file
@@ -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');
|
||||
45
apps/dsa-web/src/utils/format.ts
Normal file
45
apps/dsa-web/src/utils/format.ts
Normal file
@@ -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;
|
||||
};
|
||||
29
apps/dsa-web/src/utils/validation.ts
Normal file
29
apps/dsa-web/src/utils/validation.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
95
apps/dsa-web/tailwind.config.js
Normal file
95
apps/dsa-web/tailwind.config.js
Normal file
@@ -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: [],
|
||||
}
|
||||
28
apps/dsa-web/tsconfig.app.json
Normal file
28
apps/dsa-web/tsconfig.app.json
Normal file
@@ -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"]
|
||||
}
|
||||
7
apps/dsa-web/tsconfig.json
Normal file
7
apps/dsa-web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
apps/dsa-web/tsconfig.node.json
Normal file
26
apps/dsa-web/tsconfig.node.json
Normal file
@@ -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"]
|
||||
}
|
||||
23
apps/dsa-web/vite.config.ts
Normal file
23
apps/dsa-web/vite.config.ts
Normal file
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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"]
|
||||
|
||||
@@ -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}"
|
||||
|
||||
961
docs/architecture/api_spec.json
Normal file
961
docs/architecture/api_spec.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. 常见问题
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
168
main.py
168
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__)
|
||||
|
||||
@@ -199,13 +133,39 @@ def parse_arguments() -> argparse.Namespace:
|
||||
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(
|
||||
@@ -322,6 +282,33 @@ def run_full_analysis(
|
||||
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 客户端
|
||||
@@ -369,7 +356,7 @@ def main() -> int:
|
||||
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 服务")
|
||||
@@ -412,6 +413,20 @@ def main() -> int:
|
||||
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: 仅大盘复盘
|
||||
if args.market_review:
|
||||
@@ -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:
|
||||
|
||||
@@ -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 服务器
|
||||
|
||||
54
server.py
Normal file
54
server.py
Normal file
@@ -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,
|
||||
)
|
||||
BIN
sources/fastapi_server.png
Normal file
BIN
sources/fastapi_server.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
@@ -195,6 +195,10 @@ class AnalysisResult:
|
||||
success: bool = True
|
||||
error_message: Optional[str] = None
|
||||
|
||||
# ========== 价格数据(分析时快照)==========
|
||||
current_price: Optional[float] = None # 分析时的股价
|
||||
change_pct: Optional[float] = None # 分析时的涨跌幅(%)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
@@ -227,6 +231,8 @@ class AnalysisResult:
|
||||
'search_performed': self.search_performed,
|
||||
'success': self.success,
|
||||
'error_message': self.error_message,
|
||||
'current_price': self.current_price,
|
||||
'change_pct': self.change_pct,
|
||||
}
|
||||
|
||||
def get_core_conclusion(self) -> str:
|
||||
|
||||
@@ -285,6 +285,12 @@ class StockAnalysisPipeline:
|
||||
# Step 7: 调用 AI 分析(传入增强的上下文和新闻)
|
||||
result = self.analyzer.analyze(enhanced_context, news_context=news_context)
|
||||
|
||||
# Step 7.5: 填充分析时的价格信息到 result
|
||||
if result:
|
||||
realtime_data = enhanced_context.get('realtime', {})
|
||||
result.current_price = realtime_data.get('price')
|
||||
result.change_pct = realtime_data.get('change_pct')
|
||||
|
||||
# Step 8: 保存分析历史记录
|
||||
if result:
|
||||
try:
|
||||
@@ -350,6 +356,7 @@ class StockAnalysisPipeline:
|
||||
enhanced['realtime'] = {
|
||||
'name': getattr(realtime_quote, 'name', ''),
|
||||
'price': getattr(realtime_quote, 'price', None),
|
||||
'change_pct': getattr(realtime_quote, 'change_pct', None),
|
||||
'volume_ratio': volume_ratio,
|
||||
'volume_ratio_desc': self._describe_volume_ratio(volume_ratio) if volume_ratio else '无数据',
|
||||
'turnover_rate': getattr(realtime_quote, 'turnover_rate', None),
|
||||
|
||||
120
src/logging_config.py
Normal file
120
src/logging_config.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
日志配置模块 - 统一的日志系统初始化
|
||||
===================================
|
||||
|
||||
职责:
|
||||
1. 提供统一的日志格式和配置常量
|
||||
2. 支持控制台 + 文件(常规/调试)三层日志输出
|
||||
3. 自动降低第三方库日志级别
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
# ============================================================
|
||||
# 日志格式常量
|
||||
# ============================================================
|
||||
|
||||
LOG_FORMAT = '%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s'
|
||||
LOG_DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
|
||||
|
||||
# 默认需要降低日志级别的第三方库
|
||||
DEFAULT_QUIET_LOGGERS = [
|
||||
'urllib3',
|
||||
'sqlalchemy',
|
||||
'google',
|
||||
'httpx',
|
||||
]
|
||||
|
||||
|
||||
def setup_logging(
|
||||
log_prefix: str = "app",
|
||||
log_dir: str = "./logs",
|
||||
console_level: Optional[int] = None,
|
||||
debug: bool = False,
|
||||
extra_quiet_loggers: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
统一的日志系统初始化
|
||||
|
||||
配置三层日志输出:
|
||||
1. 控制台:根据 debug 参数或 console_level 设置级别
|
||||
2. 常规日志文件:INFO 级别,10MB 轮转,保留 5 个备份
|
||||
3. 调试日志文件:DEBUG 级别,50MB 轮转,保留 3 个备份
|
||||
|
||||
Args:
|
||||
log_prefix: 日志文件名前缀(如 "api_server" -> api_server_20240101.log)
|
||||
log_dir: 日志文件目录,默认 ./logs
|
||||
console_level: 控制台日志级别(可选,优先于 debug 参数)
|
||||
debug: 是否启用调试模式(控制台输出 DEBUG 级别)
|
||||
extra_quiet_loggers: 额外需要降低日志级别的第三方库列表
|
||||
"""
|
||||
# 确定控制台日志级别
|
||||
if console_level is not None:
|
||||
level = console_level
|
||||
else:
|
||||
level = logging.DEBUG if debug else logging.INFO
|
||||
|
||||
# 创建日志目录
|
||||
log_path = Path(log_dir)
|
||||
log_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 日志文件路径(按日期分文件)
|
||||
today_str = datetime.now().strftime('%Y%m%d')
|
||||
log_file = log_path / f"{log_prefix}_{today_str}.log"
|
||||
debug_log_file = log_path / f"{log_prefix}_debug_{today_str}.log"
|
||||
|
||||
# 配置根 logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.DEBUG) # 根 logger 设为 DEBUG,由 handler 控制输出级别
|
||||
|
||||
# 清除已有 handler,避免重复添加
|
||||
if root_logger.handlers:
|
||||
root_logger.handlers.clear()
|
||||
|
||||
# Handler 1: 控制台输出
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(level)
|
||||
console_handler.setFormatter(logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT))
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# Handler 2: 常规日志文件(INFO 级别,10MB 轮转)
|
||||
file_handler = RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=5,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT))
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
# Handler 3: 调试日志文件(DEBUG 级别,包含所有详细信息)
|
||||
debug_handler = RotatingFileHandler(
|
||||
debug_log_file,
|
||||
maxBytes=50 * 1024 * 1024, # 50MB
|
||||
backupCount=3,
|
||||
encoding='utf-8'
|
||||
)
|
||||
debug_handler.setLevel(logging.DEBUG)
|
||||
debug_handler.setFormatter(logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT))
|
||||
root_logger.addHandler(debug_handler)
|
||||
|
||||
# 降低第三方库的日志级别
|
||||
quiet_loggers = DEFAULT_QUIET_LOGGERS.copy()
|
||||
if extra_quiet_loggers:
|
||||
quiet_loggers.extend(extra_quiet_loggers)
|
||||
|
||||
for logger_name in quiet_loggers:
|
||||
logging.getLogger(logger_name).setLevel(logging.WARNING)
|
||||
|
||||
# 输出初始化完成信息
|
||||
logging.info(f"日志系统初始化完成,日志目录: {log_path.absolute()}")
|
||||
logging.info(f"常规日志: {log_file}")
|
||||
logging.info(f"调试日志: {debug_log_file}")
|
||||
17
src/repositories/__init__.py
Normal file
17
src/repositories/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
数据访问层模块初始化
|
||||
===================================
|
||||
|
||||
职责:
|
||||
1. 导出所有 Repository 类
|
||||
"""
|
||||
|
||||
from src.repositories.analysis_repo import AnalysisRepository
|
||||
from src.repositories.stock_repo import StockRepository
|
||||
|
||||
__all__ = [
|
||||
"AnalysisRepository",
|
||||
"StockRepository",
|
||||
]
|
||||
130
src/repositories/analysis_repo.py
Normal file
130
src/repositories/analysis_repo.py
Normal file
@@ -0,0 +1,130 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
分析历史数据访问层
|
||||
===================================
|
||||
|
||||
职责:
|
||||
1. 封装分析历史数据的数据库操作
|
||||
2. 提供 CRUD 接口
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from src.storage import DatabaseManager, AnalysisHistory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnalysisRepository:
|
||||
"""
|
||||
分析历史数据访问层
|
||||
|
||||
封装 AnalysisHistory 表的数据库操作
|
||||
"""
|
||||
|
||||
def __init__(self, db_manager: Optional[DatabaseManager] = None):
|
||||
"""
|
||||
初始化数据访问层
|
||||
|
||||
Args:
|
||||
db_manager: 数据库管理器(可选,默认使用单例)
|
||||
"""
|
||||
self.db = db_manager or DatabaseManager.get_instance()
|
||||
|
||||
def get_by_query_id(self, query_id: str) -> Optional[AnalysisHistory]:
|
||||
"""
|
||||
根据 query_id 获取分析记录
|
||||
|
||||
Args:
|
||||
query_id: 查询 ID
|
||||
|
||||
Returns:
|
||||
AnalysisHistory 对象,不存在返回 None
|
||||
"""
|
||||
try:
|
||||
records = self.db.get_analysis_history(query_id=query_id, limit=1)
|
||||
return records[0] if records else None
|
||||
except Exception as e:
|
||||
logger.error(f"查询分析记录失败: {e}")
|
||||
return None
|
||||
|
||||
def get_list(
|
||||
self,
|
||||
code: Optional[str] = None,
|
||||
days: int = 30,
|
||||
limit: int = 50
|
||||
) -> List[AnalysisHistory]:
|
||||
"""
|
||||
获取分析记录列表
|
||||
|
||||
Args:
|
||||
code: 股票代码筛选
|
||||
days: 时间范围(天)
|
||||
limit: 返回数量限制
|
||||
|
||||
Returns:
|
||||
AnalysisHistory 对象列表
|
||||
"""
|
||||
try:
|
||||
return self.db.get_analysis_history(
|
||||
code=code,
|
||||
days=days,
|
||||
limit=limit
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"获取分析列表失败: {e}")
|
||||
return []
|
||||
|
||||
def save(
|
||||
self,
|
||||
result: Any,
|
||||
query_id: str,
|
||||
report_type: str,
|
||||
news_content: Optional[str] = None,
|
||||
context_snapshot: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""
|
||||
保存分析结果
|
||||
|
||||
Args:
|
||||
result: 分析结果对象
|
||||
query_id: 查询 ID
|
||||
report_type: 报告类型
|
||||
news_content: 新闻内容
|
||||
context_snapshot: 上下文快照
|
||||
|
||||
Returns:
|
||||
保存的记录数
|
||||
"""
|
||||
try:
|
||||
return self.db.save_analysis_history(
|
||||
result=result,
|
||||
query_id=query_id,
|
||||
report_type=report_type,
|
||||
news_content=news_content,
|
||||
context_snapshot=context_snapshot
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"保存分析结果失败: {e}")
|
||||
return 0
|
||||
|
||||
def count_by_code(self, code: str, days: int = 30) -> int:
|
||||
"""
|
||||
统计指定股票的分析记录数
|
||||
|
||||
Args:
|
||||
code: 股票代码
|
||||
days: 时间范围(天)
|
||||
|
||||
Returns:
|
||||
记录数量
|
||||
"""
|
||||
try:
|
||||
records = self.db.get_analysis_history(code=code, days=days, limit=1000)
|
||||
return len(records)
|
||||
except Exception as e:
|
||||
logger.error(f"统计分析记录失败: {e}")
|
||||
return 0
|
||||
138
src/repositories/stock_repo.py
Normal file
138
src/repositories/stock_repo.py
Normal file
@@ -0,0 +1,138 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
股票数据访问层
|
||||
===================================
|
||||
|
||||
职责:
|
||||
1. 封装股票数据的数据库操作
|
||||
2. 提供日线数据查询接口
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from src.storage import DatabaseManager, StockDaily
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StockRepository:
|
||||
"""
|
||||
股票数据访问层
|
||||
|
||||
封装 StockDaily 表的数据库操作
|
||||
"""
|
||||
|
||||
def __init__(self, db_manager: Optional[DatabaseManager] = None):
|
||||
"""
|
||||
初始化数据访问层
|
||||
|
||||
Args:
|
||||
db_manager: 数据库管理器(可选,默认使用单例)
|
||||
"""
|
||||
self.db = db_manager or DatabaseManager.get_instance()
|
||||
|
||||
def get_latest(self, code: str, days: int = 2) -> List[StockDaily]:
|
||||
"""
|
||||
获取最近 N 天的数据
|
||||
|
||||
Args:
|
||||
code: 股票代码
|
||||
days: 获取天数
|
||||
|
||||
Returns:
|
||||
StockDaily 对象列表(按日期降序)
|
||||
"""
|
||||
try:
|
||||
return self.db.get_latest_data(code, days)
|
||||
except Exception as e:
|
||||
logger.error(f"获取最新数据失败: {e}")
|
||||
return []
|
||||
|
||||
def get_range(
|
||||
self,
|
||||
code: str,
|
||||
start_date: date,
|
||||
end_date: date
|
||||
) -> List[StockDaily]:
|
||||
"""
|
||||
获取指定日期范围的数据
|
||||
|
||||
Args:
|
||||
code: 股票代码
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
|
||||
Returns:
|
||||
StockDaily 对象列表
|
||||
"""
|
||||
try:
|
||||
return self.db.get_data_range(code, start_date, end_date)
|
||||
except Exception as e:
|
||||
logger.error(f"获取日期范围数据失败: {e}")
|
||||
return []
|
||||
|
||||
def save_dataframe(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
code: str,
|
||||
data_source: str = "Unknown"
|
||||
) -> int:
|
||||
"""
|
||||
保存 DataFrame 到数据库
|
||||
|
||||
Args:
|
||||
df: 包含日线数据的 DataFrame
|
||||
code: 股票代码
|
||||
data_source: 数据来源
|
||||
|
||||
Returns:
|
||||
保存的记录数
|
||||
"""
|
||||
try:
|
||||
return self.db.save_daily_data(df, code, data_source)
|
||||
except Exception as e:
|
||||
logger.error(f"保存日线数据失败: {e}")
|
||||
return 0
|
||||
|
||||
def has_today_data(self, code: str, target_date: Optional[date] = None) -> bool:
|
||||
"""
|
||||
检查是否有指定日期的数据
|
||||
|
||||
Args:
|
||||
code: 股票代码
|
||||
target_date: 目标日期(默认今天)
|
||||
|
||||
Returns:
|
||||
是否存在数据
|
||||
"""
|
||||
try:
|
||||
return self.db.has_today_data(code, target_date)
|
||||
except Exception as e:
|
||||
logger.error(f"检查数据存在失败: {e}")
|
||||
return False
|
||||
|
||||
def get_analysis_context(
|
||||
self,
|
||||
code: str,
|
||||
target_date: Optional[date] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取分析上下文
|
||||
|
||||
Args:
|
||||
code: 股票代码
|
||||
target_date: 目标日期
|
||||
|
||||
Returns:
|
||||
分析上下文字典
|
||||
"""
|
||||
try:
|
||||
return self.db.get_analysis_context(code, target_date)
|
||||
except Exception as e:
|
||||
logger.error(f"获取分析上下文失败: {e}")
|
||||
return None
|
||||
19
src/services/__init__.py
Normal file
19
src/services/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
服务层模块初始化
|
||||
===================================
|
||||
|
||||
职责:
|
||||
1. 导出所有服务类
|
||||
"""
|
||||
|
||||
from src.services.analysis_service import AnalysisService
|
||||
from src.services.history_service import HistoryService
|
||||
from src.services.stock_service import StockService
|
||||
|
||||
__all__ = [
|
||||
"AnalysisService",
|
||||
"HistoryService",
|
||||
"StockService",
|
||||
]
|
||||
178
src/services/analysis_service.py
Normal file
178
src/services/analysis_service.py
Normal file
@@ -0,0 +1,178 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
分析服务层
|
||||
===================================
|
||||
|
||||
职责:
|
||||
1. 封装股票分析逻辑
|
||||
2. 调用 analyzer 和 pipeline 执行分析
|
||||
3. 保存分析结果到数据库
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from src.repositories.analysis_repo import AnalysisRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnalysisService:
|
||||
"""
|
||||
分析服务
|
||||
|
||||
封装股票分析相关的业务逻辑
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化分析服务"""
|
||||
self.repo = AnalysisRepository()
|
||||
|
||||
def analyze_stock(
|
||||
self,
|
||||
stock_code: str,
|
||||
report_type: str = "detailed",
|
||||
force_refresh: bool = False,
|
||||
query_id: Optional[str] = None,
|
||||
send_notification: bool = True
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
执行股票分析
|
||||
|
||||
Args:
|
||||
stock_code: 股票代码
|
||||
report_type: 报告类型 (simple/detailed)
|
||||
force_refresh: 是否强制刷新
|
||||
query_id: 查询 ID(可选)
|
||||
send_notification: 是否发送通知(API 触发默认发送)
|
||||
|
||||
Returns:
|
||||
分析结果字典,包含:
|
||||
- stock_code: 股票代码
|
||||
- stock_name: 股票名称
|
||||
- report: 分析报告
|
||||
"""
|
||||
try:
|
||||
# 导入分析相关模块
|
||||
from src.config import get_config
|
||||
from src.core.pipeline import StockAnalysisPipeline
|
||||
from src.enums import ReportType
|
||||
|
||||
# 生成 query_id
|
||||
if query_id is None:
|
||||
query_id = uuid.uuid4().hex
|
||||
|
||||
# 获取配置
|
||||
config = get_config()
|
||||
|
||||
# 创建分析流水线
|
||||
pipeline = StockAnalysisPipeline(
|
||||
config=config,
|
||||
query_id=query_id,
|
||||
query_source="api"
|
||||
)
|
||||
|
||||
# 确定报告类型
|
||||
rt = ReportType.FULL if report_type == "detailed" else ReportType.SIMPLE
|
||||
|
||||
# 执行分析
|
||||
result = pipeline.process_single_stock(
|
||||
code=stock_code,
|
||||
skip_analysis=False,
|
||||
single_stock_notify=send_notification,
|
||||
report_type=rt
|
||||
)
|
||||
|
||||
if result is None:
|
||||
logger.warning(f"分析股票 {stock_code} 返回空结果")
|
||||
return None
|
||||
|
||||
# 构建响应
|
||||
return self._build_analysis_response(result, query_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"分析股票 {stock_code} 失败: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def _build_analysis_response(
|
||||
self,
|
||||
result: Any,
|
||||
query_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
构建分析响应
|
||||
|
||||
Args:
|
||||
result: AnalysisResult 对象
|
||||
query_id: 查询 ID
|
||||
|
||||
Returns:
|
||||
格式化的响应字典
|
||||
"""
|
||||
# 获取狙击点位
|
||||
sniper_points = {}
|
||||
if hasattr(result, 'get_sniper_points'):
|
||||
sniper_points = result.get_sniper_points() or {}
|
||||
|
||||
# 计算情绪标签
|
||||
sentiment_label = self._get_sentiment_label(result.sentiment_score)
|
||||
|
||||
# 构建报告结构
|
||||
report = {
|
||||
"meta": {
|
||||
"query_id": query_id,
|
||||
"stock_code": result.code,
|
||||
"stock_name": result.name,
|
||||
"report_type": "detailed",
|
||||
"current_price": result.current_price,
|
||||
"change_pct": result.change_pct,
|
||||
},
|
||||
"summary": {
|
||||
"analysis_summary": result.analysis_summary,
|
||||
"operation_advice": result.operation_advice,
|
||||
"trend_prediction": result.trend_prediction,
|
||||
"sentiment_score": result.sentiment_score,
|
||||
"sentiment_label": sentiment_label,
|
||||
},
|
||||
"strategy": {
|
||||
"ideal_buy": sniper_points.get("ideal_buy"),
|
||||
"secondary_buy": sniper_points.get("secondary_buy"),
|
||||
"stop_loss": sniper_points.get("stop_loss"),
|
||||
"take_profit": sniper_points.get("take_profit"),
|
||||
},
|
||||
"details": {
|
||||
"news_summary": result.news_summary,
|
||||
"technical_analysis": result.technical_analysis,
|
||||
"fundamental_analysis": result.fundamental_analysis,
|
||||
"risk_warning": result.risk_warning,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"stock_code": result.code,
|
||||
"stock_name": result.name,
|
||||
"report": report,
|
||||
}
|
||||
|
||||
def _get_sentiment_label(self, score: int) -> str:
|
||||
"""
|
||||
根据评分获取情绪标签
|
||||
|
||||
Args:
|
||||
score: 情绪评分 (0-100)
|
||||
|
||||
Returns:
|
||||
情绪标签
|
||||
"""
|
||||
if score >= 80:
|
||||
return "极度乐观"
|
||||
elif score >= 60:
|
||||
return "乐观"
|
||||
elif score >= 40:
|
||||
return "中性"
|
||||
elif score >= 20:
|
||||
return "悲观"
|
||||
else:
|
||||
return "极度悲观"
|
||||
222
src/services/history_service.py
Normal file
222
src/services/history_service.py
Normal file
@@ -0,0 +1,222 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
历史查询服务层
|
||||
===================================
|
||||
|
||||
职责:
|
||||
1. 封装历史记录查询逻辑
|
||||
2. 提供分页和筛选功能
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from src.storage import DatabaseManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HistoryService:
|
||||
"""
|
||||
历史查询服务
|
||||
|
||||
封装历史分析记录的查询逻辑
|
||||
"""
|
||||
|
||||
def __init__(self, db_manager: Optional[DatabaseManager] = None):
|
||||
"""
|
||||
初始化历史查询服务
|
||||
|
||||
Args:
|
||||
db_manager: 数据库管理器(可选,默认使用单例)
|
||||
"""
|
||||
self.db = db_manager or DatabaseManager.get_instance()
|
||||
|
||||
def get_history_list(
|
||||
self,
|
||||
stock_code: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
page: int = 1,
|
||||
limit: int = 20
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取历史分析列表
|
||||
|
||||
Args:
|
||||
stock_code: 股票代码筛选
|
||||
start_date: 开始日期 (YYYY-MM-DD)
|
||||
end_date: 结束日期 (YYYY-MM-DD)
|
||||
page: 页码
|
||||
limit: 每页数量
|
||||
|
||||
Returns:
|
||||
包含 total, items 的字典
|
||||
"""
|
||||
try:
|
||||
# 解析日期参数
|
||||
start_dt = None
|
||||
end_dt = None
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
logger.warning(f"无效的 start_date 格式: {start_date}")
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
logger.warning(f"无效的 end_date 格式: {end_date}")
|
||||
|
||||
# 计算 offset
|
||||
offset = (page - 1) * limit
|
||||
|
||||
# 使用新的分页查询方法
|
||||
records, total = self.db.get_analysis_history_paginated(
|
||||
code=stock_code,
|
||||
start_date=start_dt,
|
||||
end_date=end_dt,
|
||||
offset=offset,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
# 转换为响应格式
|
||||
items = []
|
||||
for record in records:
|
||||
items.append({
|
||||
"query_id": record.query_id,
|
||||
"stock_code": record.code,
|
||||
"stock_name": record.name,
|
||||
"report_type": record.report_type,
|
||||
"sentiment_score": record.sentiment_score,
|
||||
"operation_advice": record.operation_advice,
|
||||
"created_at": record.created_at.isoformat() if record.created_at else None,
|
||||
})
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"查询历史列表失败: {e}", exc_info=True)
|
||||
return {"total": 0, "items": []}
|
||||
|
||||
def get_history_detail(self, query_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取历史报告详情
|
||||
|
||||
Args:
|
||||
query_id: 分析记录唯一标识
|
||||
|
||||
Returns:
|
||||
完整的分析报告字典,不存在返回 None
|
||||
"""
|
||||
try:
|
||||
# 查询数据库
|
||||
records = self.db.get_analysis_history(query_id=query_id, limit=1)
|
||||
|
||||
if not records:
|
||||
return None
|
||||
|
||||
record = records[0]
|
||||
|
||||
# 解析 raw_result JSON
|
||||
raw_result = None
|
||||
if record.raw_result:
|
||||
try:
|
||||
raw_result = json.loads(record.raw_result)
|
||||
except json.JSONDecodeError:
|
||||
raw_result = record.raw_result
|
||||
|
||||
# 解析 context_snapshot JSON
|
||||
context_snapshot = None
|
||||
if record.context_snapshot:
|
||||
try:
|
||||
context_snapshot = json.loads(record.context_snapshot)
|
||||
except json.JSONDecodeError:
|
||||
context_snapshot = record.context_snapshot
|
||||
|
||||
# 计算情绪标签
|
||||
sentiment_label = self._get_sentiment_label(record.sentiment_score or 50)
|
||||
|
||||
return {
|
||||
"query_id": record.query_id,
|
||||
"stock_code": record.code,
|
||||
"stock_name": record.name,
|
||||
"report_type": record.report_type,
|
||||
"created_at": record.created_at.isoformat() if record.created_at else None,
|
||||
"analysis_summary": record.analysis_summary,
|
||||
"operation_advice": record.operation_advice,
|
||||
"trend_prediction": record.trend_prediction,
|
||||
"sentiment_score": record.sentiment_score,
|
||||
"sentiment_label": sentiment_label,
|
||||
"ideal_buy": str(record.ideal_buy) if record.ideal_buy else None,
|
||||
"secondary_buy": str(record.secondary_buy) if record.secondary_buy else None,
|
||||
"stop_loss": str(record.stop_loss) if record.stop_loss else None,
|
||||
"take_profit": str(record.take_profit) if record.take_profit else None,
|
||||
"news_content": record.news_content,
|
||||
"raw_result": raw_result,
|
||||
"context_snapshot": context_snapshot,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"查询历史详情失败: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def get_news_intel(self, query_id: str, limit: int = 20) -> List[Dict[str, str]]:
|
||||
"""
|
||||
获取指定 query_id 关联的新闻情报
|
||||
|
||||
Args:
|
||||
query_id: 分析记录唯一标识
|
||||
limit: 返回数量限制
|
||||
|
||||
Returns:
|
||||
新闻情报列表(包含 title、snippet、url)
|
||||
"""
|
||||
try:
|
||||
records = self.db.get_news_intel_by_query_id(query_id=query_id, limit=limit)
|
||||
|
||||
items: List[Dict[str, str]] = []
|
||||
for record in records:
|
||||
snippet = (record.snippet or "").strip()
|
||||
if len(snippet) > 50:
|
||||
snippet = f"{snippet[:47]}..."
|
||||
items.append({
|
||||
"title": record.title,
|
||||
"snippet": snippet,
|
||||
"url": record.url,
|
||||
})
|
||||
|
||||
return items
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"查询新闻情报失败: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
def _get_sentiment_label(self, score: int) -> str:
|
||||
"""
|
||||
根据评分获取情绪标签
|
||||
|
||||
Args:
|
||||
score: 情绪评分 (0-100)
|
||||
|
||||
Returns:
|
||||
情绪标签
|
||||
"""
|
||||
if score >= 80:
|
||||
return "极度乐观"
|
||||
elif score >= 60:
|
||||
return "乐观"
|
||||
elif score >= 40:
|
||||
return "中性"
|
||||
elif score >= 20:
|
||||
return "悲观"
|
||||
else:
|
||||
return "极度悲观"
|
||||
186
src/services/stock_service.py
Normal file
186
src/services/stock_service.py
Normal file
@@ -0,0 +1,186 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
股票数据服务层
|
||||
===================================
|
||||
|
||||
职责:
|
||||
1. 封装股票数据获取逻辑
|
||||
2. 提供实时行情和历史数据接口
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from src.repositories.stock_repo import StockRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StockService:
|
||||
"""
|
||||
股票数据服务
|
||||
|
||||
封装股票数据获取的业务逻辑
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化股票数据服务"""
|
||||
self.repo = StockRepository()
|
||||
|
||||
def get_realtime_quote(self, stock_code: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取股票实时行情
|
||||
|
||||
Args:
|
||||
stock_code: 股票代码
|
||||
|
||||
Returns:
|
||||
实时行情数据字典
|
||||
"""
|
||||
try:
|
||||
# 调用数据获取器获取实时行情
|
||||
from data_provider.base import DataFetcherManager
|
||||
|
||||
manager = DataFetcherManager()
|
||||
quote = manager.get_realtime_quote(stock_code)
|
||||
|
||||
if quote is None:
|
||||
logger.warning(f"获取 {stock_code} 实时行情失败")
|
||||
return None
|
||||
|
||||
# UnifiedRealtimeQuote 是 dataclass,使用 getattr 安全访问字段
|
||||
# 字段映射: UnifiedRealtimeQuote -> API 响应
|
||||
# - code -> stock_code
|
||||
# - name -> stock_name
|
||||
# - price -> current_price
|
||||
# - change_amount -> change
|
||||
# - change_pct -> change_percent
|
||||
# - open_price -> open
|
||||
# - high -> high
|
||||
# - low -> low
|
||||
# - pre_close -> prev_close
|
||||
# - volume -> volume
|
||||
# - amount -> amount
|
||||
return {
|
||||
"stock_code": getattr(quote, "code", stock_code),
|
||||
"stock_name": getattr(quote, "name", None),
|
||||
"current_price": getattr(quote, "price", 0.0) or 0.0,
|
||||
"change": getattr(quote, "change_amount", None),
|
||||
"change_percent": getattr(quote, "change_pct", None),
|
||||
"open": getattr(quote, "open_price", None),
|
||||
"high": getattr(quote, "high", None),
|
||||
"low": getattr(quote, "low", None),
|
||||
"prev_close": getattr(quote, "pre_close", None),
|
||||
"volume": getattr(quote, "volume", None),
|
||||
"amount": getattr(quote, "amount", None),
|
||||
"update_time": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
except ImportError:
|
||||
logger.warning("DataFetcherManager 未找到,使用占位数据")
|
||||
return self._get_placeholder_quote(stock_code)
|
||||
except Exception as e:
|
||||
logger.error(f"获取实时行情失败: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def get_history_data(
|
||||
self,
|
||||
stock_code: str,
|
||||
period: str = "daily",
|
||||
days: int = 30
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取股票历史行情
|
||||
|
||||
Args:
|
||||
stock_code: 股票代码
|
||||
period: K 线周期 (daily/weekly/monthly)
|
||||
days: 获取天数
|
||||
|
||||
Returns:
|
||||
历史行情数据字典
|
||||
|
||||
Raises:
|
||||
ValueError: 当 period 不是 daily 时抛出(weekly/monthly 暂未实现)
|
||||
"""
|
||||
# 验证 period 参数,只支持 daily
|
||||
if period != "daily":
|
||||
raise ValueError(
|
||||
f"暂不支持 '{period}' 周期,目前仅支持 'daily'。"
|
||||
"weekly/monthly 聚合功能将在后续版本实现。"
|
||||
)
|
||||
|
||||
try:
|
||||
# 调用数据获取器获取历史数据
|
||||
from data_provider.base import DataFetcherManager
|
||||
|
||||
manager = DataFetcherManager()
|
||||
df, source = manager.get_daily_data(stock_code, days=days)
|
||||
|
||||
if df is None or df.empty:
|
||||
logger.warning(f"获取 {stock_code} 历史数据失败")
|
||||
return {"stock_code": stock_code, "period": period, "data": []}
|
||||
|
||||
# 获取股票名称
|
||||
stock_name = manager.get_stock_name(stock_code)
|
||||
|
||||
# 转换为响应格式
|
||||
data = []
|
||||
for _, row in df.iterrows():
|
||||
date_val = row.get("date")
|
||||
if hasattr(date_val, "strftime"):
|
||||
date_str = date_val.strftime("%Y-%m-%d")
|
||||
else:
|
||||
date_str = str(date_val)
|
||||
|
||||
data.append({
|
||||
"date": date_str,
|
||||
"open": float(row.get("open", 0)),
|
||||
"high": float(row.get("high", 0)),
|
||||
"low": float(row.get("low", 0)),
|
||||
"close": float(row.get("close", 0)),
|
||||
"volume": float(row.get("volume", 0)) if row.get("volume") else None,
|
||||
"amount": float(row.get("amount", 0)) if row.get("amount") else None,
|
||||
"change_percent": float(row.get("pct_chg", 0)) if row.get("pct_chg") else None,
|
||||
})
|
||||
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
"period": period,
|
||||
"data": data,
|
||||
}
|
||||
|
||||
except ImportError:
|
||||
logger.warning("DataFetcherManager 未找到,返回空数据")
|
||||
return {"stock_code": stock_code, "period": period, "data": []}
|
||||
except Exception as e:
|
||||
logger.error(f"获取历史数据失败: {e}", exc_info=True)
|
||||
return {"stock_code": stock_code, "period": period, "data": []}
|
||||
|
||||
def _get_placeholder_quote(self, stock_code: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取占位行情数据(用于测试)
|
||||
|
||||
Args:
|
||||
stock_code: 股票代码
|
||||
|
||||
Returns:
|
||||
占位行情数据
|
||||
"""
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": f"股票{stock_code}",
|
||||
"current_price": 0.0,
|
||||
"change": None,
|
||||
"change_percent": None,
|
||||
"open": None,
|
||||
"high": None,
|
||||
"low": None,
|
||||
"prev_close": None,
|
||||
"volume": None,
|
||||
"amount": None,
|
||||
"update_time": datetime.now().isoformat(),
|
||||
}
|
||||
537
src/services/task_queue.py
Normal file
537
src/services/task_queue.py
Normal file
@@ -0,0 +1,537 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
A股自选股智能分析系统 - 异步任务队列
|
||||
===================================
|
||||
|
||||
职责:
|
||||
1. 管理异步分析任务的生命周期
|
||||
2. 防止相同股票代码重复提交
|
||||
3. 提供 SSE 事件广播机制
|
||||
4. 任务完成后持久化到数据库
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor, Future
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Set, List, Callable, Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from asyncio import Queue as AsyncQueue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
"""任务状态枚举"""
|
||||
PENDING = "pending" # 等待执行
|
||||
PROCESSING = "processing" # 执行中
|
||||
COMPLETED = "completed" # 已完成
|
||||
FAILED = "failed" # 失败
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskInfo:
|
||||
"""
|
||||
任务信息数据类
|
||||
|
||||
包含任务的完整状态信息,用于 API 响应和内部管理
|
||||
"""
|
||||
task_id: str
|
||||
stock_code: str
|
||||
stock_name: Optional[str] = None
|
||||
status: TaskStatus = TaskStatus.PENDING
|
||||
progress: int = 0
|
||||
message: Optional[str] = None
|
||||
result: Optional[Dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
report_type: str = "detailed"
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典,用于 API 响应"""
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"stock_code": self.stock_code,
|
||||
"stock_name": self.stock_name,
|
||||
"status": self.status.value,
|
||||
"progress": self.progress,
|
||||
"message": self.message,
|
||||
"report_type": self.report_type,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
def copy(self) -> 'TaskInfo':
|
||||
"""创建任务信息的副本"""
|
||||
return TaskInfo(
|
||||
task_id=self.task_id,
|
||||
stock_code=self.stock_code,
|
||||
stock_name=self.stock_name,
|
||||
status=self.status,
|
||||
progress=self.progress,
|
||||
message=self.message,
|
||||
result=self.result,
|
||||
error=self.error,
|
||||
report_type=self.report_type,
|
||||
created_at=self.created_at,
|
||||
started_at=self.started_at,
|
||||
completed_at=self.completed_at,
|
||||
)
|
||||
|
||||
|
||||
class DuplicateTaskError(Exception):
|
||||
"""
|
||||
重复提交异常
|
||||
|
||||
当股票已在分析中时抛出此异常
|
||||
"""
|
||||
def __init__(self, stock_code: str, existing_task_id: str):
|
||||
self.stock_code = stock_code
|
||||
self.existing_task_id = existing_task_id
|
||||
super().__init__(f"股票 {stock_code} 正在分析中 (task_id: {existing_task_id})")
|
||||
|
||||
|
||||
class AnalysisTaskQueue:
|
||||
"""
|
||||
异步分析任务队列
|
||||
|
||||
单例模式,全局唯一实例
|
||||
|
||||
特性:
|
||||
1. 防止相同股票代码重复提交
|
||||
2. 线程池执行分析任务
|
||||
3. SSE 事件广播机制
|
||||
4. 任务完成后自动持久化
|
||||
"""
|
||||
|
||||
_instance: Optional['AnalysisTaskQueue'] = None
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
with cls._instance_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, max_workers: int = 3):
|
||||
# 防止重复初始化
|
||||
if hasattr(self, '_initialized') and self._initialized:
|
||||
return
|
||||
|
||||
self._max_workers = max_workers
|
||||
self._executor: Optional[ThreadPoolExecutor] = None
|
||||
|
||||
# 核心数据结构
|
||||
self._tasks: Dict[str, TaskInfo] = {} # task_id -> TaskInfo
|
||||
self._analyzing_stocks: Dict[str, str] = {} # stock_code -> task_id
|
||||
self._futures: Dict[str, Future] = {} # task_id -> Future
|
||||
|
||||
# SSE 订阅者列表(asyncio.Queue 实例)
|
||||
self._subscribers: List['AsyncQueue'] = []
|
||||
self._subscribers_lock = threading.Lock()
|
||||
|
||||
# 主事件循环引用(用于跨线程广播)
|
||||
self._main_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
|
||||
# 线程安全锁
|
||||
self._data_lock = threading.RLock()
|
||||
|
||||
# 任务历史保留数量(内存中)
|
||||
self._max_history = 100
|
||||
|
||||
self._initialized = True
|
||||
logger.info(f"[TaskQueue] 初始化完成,最大并发: {max_workers}")
|
||||
|
||||
@property
|
||||
def executor(self) -> ThreadPoolExecutor:
|
||||
"""懒加载线程池"""
|
||||
if self._executor is None:
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=self._max_workers,
|
||||
thread_name_prefix="analysis_task_"
|
||||
)
|
||||
return self._executor
|
||||
|
||||
# ========== 任务提交与查询 ==========
|
||||
|
||||
def is_analyzing(self, stock_code: str) -> bool:
|
||||
"""
|
||||
检查股票是否正在分析中
|
||||
|
||||
Args:
|
||||
stock_code: 股票代码
|
||||
|
||||
Returns:
|
||||
True 表示正在分析中
|
||||
"""
|
||||
with self._data_lock:
|
||||
return stock_code in self._analyzing_stocks
|
||||
|
||||
def get_analyzing_task_id(self, stock_code: str) -> Optional[str]:
|
||||
"""
|
||||
获取正在分析该股票的任务 ID
|
||||
|
||||
Args:
|
||||
stock_code: 股票代码
|
||||
|
||||
Returns:
|
||||
任务 ID,如果没有则返回 None
|
||||
"""
|
||||
with self._data_lock:
|
||||
return self._analyzing_stocks.get(stock_code)
|
||||
|
||||
def submit_task(
|
||||
self,
|
||||
stock_code: str,
|
||||
stock_name: Optional[str] = None,
|
||||
report_type: str = "detailed",
|
||||
force_refresh: bool = False,
|
||||
) -> TaskInfo:
|
||||
"""
|
||||
提交分析任务
|
||||
|
||||
Args:
|
||||
stock_code: 股票代码
|
||||
stock_name: 股票名称(可选)
|
||||
report_type: 报告类型
|
||||
force_refresh: 是否强制刷新
|
||||
|
||||
Returns:
|
||||
TaskInfo: 任务信息
|
||||
|
||||
Raises:
|
||||
DuplicateTaskError: 股票正在分析中
|
||||
"""
|
||||
with self._data_lock:
|
||||
# 检查重复
|
||||
if stock_code in self._analyzing_stocks:
|
||||
existing_task_id = self._analyzing_stocks[stock_code]
|
||||
raise DuplicateTaskError(stock_code, existing_task_id)
|
||||
|
||||
# 创建任务
|
||||
task_id = uuid.uuid4().hex
|
||||
task_info = TaskInfo(
|
||||
task_id=task_id,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
status=TaskStatus.PENDING,
|
||||
message="任务已加入队列",
|
||||
report_type=report_type,
|
||||
)
|
||||
|
||||
# 注册任务
|
||||
self._tasks[task_id] = task_info
|
||||
self._analyzing_stocks[stock_code] = task_id
|
||||
|
||||
# 提交到线程池执行
|
||||
future = self.executor.submit(
|
||||
self._execute_task,
|
||||
task_id,
|
||||
stock_code,
|
||||
report_type,
|
||||
force_refresh,
|
||||
)
|
||||
self._futures[task_id] = future
|
||||
|
||||
logger.info(f"[TaskQueue] 任务已提交: {stock_code} -> {task_id}")
|
||||
|
||||
# 广播任务创建事件(锁外执行避免死锁)
|
||||
self._broadcast_event("task_created", task_info.to_dict())
|
||||
|
||||
return task_info
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[TaskInfo]:
|
||||
"""
|
||||
获取任务信息
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
|
||||
Returns:
|
||||
TaskInfo 或 None
|
||||
"""
|
||||
with self._data_lock:
|
||||
task = self._tasks.get(task_id)
|
||||
return task.copy() if task else None
|
||||
|
||||
def list_pending_tasks(self) -> List[TaskInfo]:
|
||||
"""
|
||||
获取所有进行中的任务(pending + processing)
|
||||
|
||||
Returns:
|
||||
任务列表(副本)
|
||||
"""
|
||||
with self._data_lock:
|
||||
return [
|
||||
task.copy() for task in self._tasks.values()
|
||||
if task.status in (TaskStatus.PENDING, TaskStatus.PROCESSING)
|
||||
]
|
||||
|
||||
def list_all_tasks(self, limit: int = 50) -> List[TaskInfo]:
|
||||
"""
|
||||
获取所有任务(按创建时间倒序)
|
||||
|
||||
Args:
|
||||
limit: 返回数量限制
|
||||
|
||||
Returns:
|
||||
任务列表(副本)
|
||||
"""
|
||||
with self._data_lock:
|
||||
tasks = sorted(
|
||||
self._tasks.values(),
|
||||
key=lambda t: t.created_at,
|
||||
reverse=True
|
||||
)
|
||||
return [t.copy() for t in tasks[:limit]]
|
||||
|
||||
def get_task_stats(self) -> Dict[str, int]:
|
||||
"""
|
||||
获取任务统计信息
|
||||
|
||||
Returns:
|
||||
统计信息字典
|
||||
"""
|
||||
with self._data_lock:
|
||||
stats = {
|
||||
"total": len(self._tasks),
|
||||
"pending": 0,
|
||||
"processing": 0,
|
||||
"completed": 0,
|
||||
"failed": 0,
|
||||
}
|
||||
for task in self._tasks.values():
|
||||
stats[task.status.value] = stats.get(task.status.value, 0) + 1
|
||||
return stats
|
||||
|
||||
# ========== 任务执行 ==========
|
||||
|
||||
def _execute_task(
|
||||
self,
|
||||
task_id: str,
|
||||
stock_code: str,
|
||||
report_type: str,
|
||||
force_refresh: bool,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
执行分析任务(在线程池中运行)
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
stock_code: 股票代码
|
||||
report_type: 报告类型
|
||||
force_refresh: 是否强制刷新
|
||||
|
||||
Returns:
|
||||
分析结果字典
|
||||
"""
|
||||
# 更新状态为处理中
|
||||
with self._data_lock:
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return None
|
||||
task.status = TaskStatus.PROCESSING
|
||||
task.started_at = datetime.now()
|
||||
task.message = "正在分析中..."
|
||||
task.progress = 10
|
||||
|
||||
self._broadcast_event("task_started", task.to_dict())
|
||||
|
||||
try:
|
||||
# 导入分析服务(延迟导入避免循环依赖)
|
||||
from src.services.analysis_service import AnalysisService
|
||||
|
||||
# 执行分析
|
||||
service = AnalysisService()
|
||||
result = service.analyze_stock(
|
||||
stock_code=stock_code,
|
||||
report_type=report_type,
|
||||
force_refresh=force_refresh,
|
||||
query_id=task_id,
|
||||
)
|
||||
|
||||
if result:
|
||||
# 更新任务状态为完成
|
||||
with self._data_lock:
|
||||
task = self._tasks.get(task_id)
|
||||
if task:
|
||||
task.status = TaskStatus.COMPLETED
|
||||
task.progress = 100
|
||||
task.completed_at = datetime.now()
|
||||
task.result = result
|
||||
task.message = "分析完成"
|
||||
task.stock_name = result.get("stock_name", task.stock_name)
|
||||
|
||||
# 从分析中集合移除
|
||||
if task.stock_code in self._analyzing_stocks:
|
||||
del self._analyzing_stocks[task.stock_code]
|
||||
|
||||
self._broadcast_event("task_completed", task.to_dict())
|
||||
logger.info(f"[TaskQueue] 任务完成: {task_id} ({stock_code})")
|
||||
|
||||
# 清理过期任务
|
||||
self._cleanup_old_tasks()
|
||||
|
||||
return result
|
||||
else:
|
||||
# 分析返回空结果
|
||||
raise Exception("分析返回空结果")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error(f"[TaskQueue] 任务失败: {task_id} ({stock_code}), 错误: {error_msg}")
|
||||
|
||||
with self._data_lock:
|
||||
task = self._tasks.get(task_id)
|
||||
if task:
|
||||
task.status = TaskStatus.FAILED
|
||||
task.completed_at = datetime.now()
|
||||
task.error = error_msg[:200] # 限制错误信息长度
|
||||
task.message = f"分析失败: {error_msg[:50]}"
|
||||
|
||||
# 从分析中集合移除
|
||||
if task.stock_code in self._analyzing_stocks:
|
||||
del self._analyzing_stocks[task.stock_code]
|
||||
|
||||
self._broadcast_event("task_failed", task.to_dict())
|
||||
|
||||
# 清理过期任务
|
||||
self._cleanup_old_tasks()
|
||||
|
||||
return None
|
||||
|
||||
def _cleanup_old_tasks(self) -> int:
|
||||
"""
|
||||
清理过期的已完成任务
|
||||
|
||||
保留最近 _max_history 个任务
|
||||
|
||||
Returns:
|
||||
清理的任务数量
|
||||
"""
|
||||
with self._data_lock:
|
||||
if len(self._tasks) <= self._max_history:
|
||||
return 0
|
||||
|
||||
# 按时间排序,删除旧的已完成任务
|
||||
completed_tasks = sorted(
|
||||
[t for t in self._tasks.values()
|
||||
if t.status in (TaskStatus.COMPLETED, TaskStatus.FAILED)],
|
||||
key=lambda t: t.created_at
|
||||
)
|
||||
|
||||
to_remove = len(self._tasks) - self._max_history
|
||||
removed = 0
|
||||
|
||||
for task in completed_tasks[:to_remove]:
|
||||
del self._tasks[task.task_id]
|
||||
if task.task_id in self._futures:
|
||||
del self._futures[task.task_id]
|
||||
removed += 1
|
||||
|
||||
if removed > 0:
|
||||
logger.debug(f"[TaskQueue] 清理了 {removed} 个过期任务")
|
||||
|
||||
return removed
|
||||
|
||||
# ========== SSE 事件广播 ==========
|
||||
|
||||
def subscribe(self, queue: 'AsyncQueue') -> None:
|
||||
"""
|
||||
订阅任务事件
|
||||
|
||||
Args:
|
||||
queue: asyncio.Queue 实例,用于接收事件
|
||||
"""
|
||||
with self._subscribers_lock:
|
||||
self._subscribers.append(queue)
|
||||
# 捕获当前事件循环(应在主线程的 async 上下文中调用)
|
||||
try:
|
||||
self._main_loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# 如果不在 async 上下文中,尝试获取事件循环
|
||||
try:
|
||||
self._main_loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
pass
|
||||
logger.debug(f"[TaskQueue] 新订阅者加入,当前订阅者数: {len(self._subscribers)}")
|
||||
|
||||
def unsubscribe(self, queue: 'AsyncQueue') -> None:
|
||||
"""
|
||||
取消订阅任务事件
|
||||
|
||||
Args:
|
||||
queue: 要取消订阅的 asyncio.Queue 实例
|
||||
"""
|
||||
with self._subscribers_lock:
|
||||
if queue in self._subscribers:
|
||||
self._subscribers.remove(queue)
|
||||
logger.debug(f"[TaskQueue] 订阅者离开,当前订阅者数: {len(self._subscribers)}")
|
||||
|
||||
def _broadcast_event(self, event_type: str, data: Dict[str, Any]) -> None:
|
||||
"""
|
||||
广播事件到所有订阅者
|
||||
|
||||
使用 call_soon_threadsafe 确保跨线程安全
|
||||
|
||||
Args:
|
||||
event_type: 事件类型
|
||||
data: 事件数据
|
||||
"""
|
||||
event = {"type": event_type, "data": data}
|
||||
|
||||
with self._subscribers_lock:
|
||||
subscribers = self._subscribers.copy()
|
||||
loop = self._main_loop
|
||||
|
||||
if not subscribers:
|
||||
return
|
||||
|
||||
if loop is None:
|
||||
logger.warning("[TaskQueue] 无法广播事件:主事件循环未设置")
|
||||
return
|
||||
|
||||
for queue in subscribers:
|
||||
try:
|
||||
# 使用 call_soon_threadsafe 将事件放入 asyncio 队列
|
||||
# 这是从工作线程向主事件循环发送消息的安全方式
|
||||
loop.call_soon_threadsafe(queue.put_nowait, event)
|
||||
except RuntimeError as e:
|
||||
# 事件循环已关闭
|
||||
logger.debug(f"[TaskQueue] 广播事件跳过(循环已关闭): {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[TaskQueue] 广播事件失败: {e}")
|
||||
|
||||
# ========== 清理方法 ==========
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""关闭任务队列"""
|
||||
if self._executor:
|
||||
self._executor.shutdown(wait=True)
|
||||
self._executor = None
|
||||
logger.info("[TaskQueue] 线程池已关闭")
|
||||
|
||||
|
||||
# ========== 便捷函数 ==========
|
||||
|
||||
def get_task_queue() -> AnalysisTaskQueue:
|
||||
"""
|
||||
获取任务队列单例
|
||||
|
||||
Returns:
|
||||
AnalysisTaskQueue 实例
|
||||
"""
|
||||
return AnalysisTaskQueue()
|
||||
@@ -17,7 +17,7 @@ import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Optional, List, Dict, Any, TYPE_CHECKING
|
||||
from typing import Optional, List, Dict, Any, TYPE_CHECKING, Tuple
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
@@ -250,6 +250,7 @@ class DatabaseManager:
|
||||
"""
|
||||
|
||||
_instance: Optional['DatabaseManager'] = None
|
||||
_initialized: bool = False
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
"""单例模式实现"""
|
||||
@@ -265,7 +266,7 @@ class DatabaseManager:
|
||||
Args:
|
||||
db_url: 数据库连接 URL(可选,默认从配置读取)
|
||||
"""
|
||||
if self._initialized:
|
||||
if getattr(self, '_initialized', False):
|
||||
return
|
||||
|
||||
if db_url is None:
|
||||
@@ -306,7 +307,9 @@ class DatabaseManager:
|
||||
def reset_instance(cls) -> None:
|
||||
"""重置单例(用于测试)"""
|
||||
if cls._instance is not None:
|
||||
cls._instance._engine.dispose()
|
||||
if hasattr(cls._instance, '_engine') and cls._instance._engine is not None:
|
||||
cls._instance._engine.dispose()
|
||||
cls._instance._initialized = False
|
||||
cls._instance = None
|
||||
|
||||
@classmethod
|
||||
@@ -335,6 +338,11 @@ class DatabaseManager:
|
||||
# 执行查询
|
||||
session.commit() # 如果需要
|
||||
"""
|
||||
if not getattr(self, '_initialized', False) or not hasattr(self, '_SessionLocal'):
|
||||
raise RuntimeError(
|
||||
"DatabaseManager 未正确初始化。"
|
||||
"请确保通过 DatabaseManager.get_instance() 获取实例。"
|
||||
)
|
||||
session = self._SessionLocal()
|
||||
try:
|
||||
return session
|
||||
@@ -526,6 +534,32 @@ class DatabaseManager:
|
||||
|
||||
return list(results)
|
||||
|
||||
def get_news_intel_by_query_id(self, query_id: str, limit: int = 20) -> List[NewsIntel]:
|
||||
"""
|
||||
根据 query_id 获取新闻情报列表
|
||||
|
||||
Args:
|
||||
query_id: 分析记录唯一标识
|
||||
limit: 返回数量限制
|
||||
|
||||
Returns:
|
||||
NewsIntel 列表(按发布时间或抓取时间倒序)
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
with self.get_session() as session:
|
||||
results = session.execute(
|
||||
select(NewsIntel)
|
||||
.where(NewsIntel.query_id == query_id)
|
||||
.order_by(
|
||||
desc(func.coalesce(NewsIntel.published_date, NewsIntel.fetched_at)),
|
||||
desc(NewsIntel.fetched_at)
|
||||
)
|
||||
.limit(limit)
|
||||
).scalars().all()
|
||||
|
||||
return list(results)
|
||||
|
||||
def save_analysis_history(
|
||||
self,
|
||||
result: Any,
|
||||
@@ -604,6 +638,60 @@ class DatabaseManager:
|
||||
|
||||
return list(results)
|
||||
|
||||
def get_analysis_history_paginated(
|
||||
self,
|
||||
code: Optional[str] = None,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
offset: int = 0,
|
||||
limit: int = 20
|
||||
) -> Tuple[List[AnalysisHistory], int]:
|
||||
"""
|
||||
分页查询分析历史记录(带总数)
|
||||
|
||||
Args:
|
||||
code: 股票代码筛选
|
||||
start_date: 开始日期(含)
|
||||
end_date: 结束日期(含)
|
||||
offset: 偏移量(跳过前 N 条)
|
||||
limit: 每页数量
|
||||
|
||||
Returns:
|
||||
Tuple[List[AnalysisHistory], int]: (记录列表, 总数)
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
with self.get_session() as session:
|
||||
conditions = []
|
||||
|
||||
if code:
|
||||
conditions.append(AnalysisHistory.code == code)
|
||||
if start_date:
|
||||
# created_at >= start_date 00:00:00
|
||||
conditions.append(AnalysisHistory.created_at >= datetime.combine(start_date, datetime.min.time()))
|
||||
if end_date:
|
||||
# created_at < end_date+1 00:00:00 (即 <= end_date 23:59:59)
|
||||
conditions.append(AnalysisHistory.created_at < datetime.combine(end_date + timedelta(days=1), datetime.min.time()))
|
||||
|
||||
# 构建 where 子句
|
||||
where_clause = and_(*conditions) if conditions else True
|
||||
|
||||
# 查询总数
|
||||
total_query = select(func.count(AnalysisHistory.id)).where(where_clause)
|
||||
total = session.execute(total_query).scalar() or 0
|
||||
|
||||
# 查询分页数据
|
||||
data_query = (
|
||||
select(AnalysisHistory)
|
||||
.where(where_clause)
|
||||
.order_by(desc(AnalysisHistory.created_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
results = session.execute(data_query).scalars().all()
|
||||
|
||||
return list(results), total
|
||||
|
||||
def get_data_range(
|
||||
self,
|
||||
code: str,
|
||||
|
||||
Reference in New Issue
Block a user