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:
Krane
2026-02-05 20:56:38 +08:00
committed by GitHub
parent 13b556e79e
commit 9847157980
91 changed files with 13527 additions and 117 deletions

View File

@@ -194,7 +194,7 @@ python main.py
> 📖 完整环境变量、定时任务配置请参考 [完整配置指南](docs/full-guide.md)
## 🖥️ 本地 WebUI可选
## 🖥️ 本地 WebUI可选 - 将在后续的版本弃用
```bash
python main.py --webui # 启动 WebUI + 执行分析
@@ -205,6 +205,29 @@ python main.py --webui-only # 仅启动 WebUI
> 详细说明请参考 [完整指南 - WebUI](docs/full-guide.md#本地-webui-管理界面)
## 🧩 FastAPI Web 服务(可选)
![img.png](sources/fastapi_server.png)
```bash
cd ./apps/dsa-web # 进入 React Web 目录
npm install
npm run build # 编译 React Web 页面 会在根目录生成 /static 文件夹
cd ../.. # 返回项目根目录
python main.py --serve # 启动 FastAPI + 执行分析
python main.py --serve-only # 仅启动 FastAPI
python main.py --serve-only --host 0.0.0.0 --port 8000 # 指定启动端口
```
访问 `http://127.0.0.1:8000` 即可使用该页面(注意一定要执行 `npm install` 步骤,否则没有页面)
也可以使用下面命令单独启动:
```bash
uvicorn server:app --reload --host 0.0.0.0 --port 8000
```
## 🗺️ Roadmap
查看已支持的功能和未来规划:[更新日志](docs/CHANGELOG.md)

12
api/__init__.py Normal file
View File

@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
"""
===================================
API 模块初始化
===================================
职责:
1. 导出 API 模块的公共接口
2. 统一版本管理
"""
__version__ = "1.0.0"

164
api/app.py Normal file
View 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
View 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()

View File

@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
"""
===================================
API 中间件模块初始化
===================================
职责:
1. 导出所有中间件
"""
from api.middlewares.error_handler import ErrorHandlerMiddleware
__all__ = ["ErrorHandlerMiddleware"]

View 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
View 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"]

View 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"]

View 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
)

View 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
View 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 defFastAPI 自动在线程池中执行
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 defFastAPI 自动在线程池中执行
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
View 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 defFastAPI 自动在线程池中执行
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 defFastAPI 自动在线程池中执行
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
View 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"]
)

View 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
View 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
View 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
View 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
View 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
View 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?

View 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
View 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

File diff suppressed because it is too large Load Diff

39
apps/dsa-web/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
}

View 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
View 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
View 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;

View 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;
}
}

View 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)),
};
},
};

View 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;

View 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;
}

View 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

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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';

View 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>
);
};

View File

@@ -0,0 +1 @@
export { HistoryList } from './HistoryList';

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -0,0 +1,5 @@
export * from './ReportSummary';
export * from './ReportOverview';
export * from './ReportStrategy';
export * from './ReportNews';
export * from './ReportDetails';

View 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;

View File

@@ -0,0 +1,2 @@
export { TaskPanel } from './TaskPanel';
export { default as TaskPanelDefault } from './TaskPanel';

View File

@@ -0,0 +1,7 @@
export { useTaskStream } from './useTaskStream';
export type {
SSEEventType,
SSEEvent,
UseTaskStreamOptions,
UseTaskStreamResult,
} from './useTaskStream';

View 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
View 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
View 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>,
)

View 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;

View 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;

View 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,
}),
}));

View File

@@ -0,0 +1 @@
export * from './analysisStore';

View 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
};

View 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');

View 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;
};

View 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,
};
};

View 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: [],
}

View 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"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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"]
}

View 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,
},
})

View File

@@ -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"]

View File

@@ -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}"

View 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"
]
}
}
}
}

View File

@@ -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. 常见问题

View File

@@ -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
```
---

210
main.py
View File

@@ -41,84 +41,18 @@ import sys
import time
import uuid
from datetime import datetime, timezone, timedelta
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import List, Optional
from src.feishu_doc import FeishuDocManager
from src.config import get_config, Config
from src.feishu_doc import FeishuDocManager
from src.logging_config import setup_logging
from src.notification import NotificationService
from src.core.pipeline import StockAnalysisPipeline
from src.core.market_review import run_market_review
from src.search_service import SearchService
from src.analyzer import GeminiAnalyzer
# 配置日志格式
LOG_FORMAT = '%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s'
LOG_DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
def setup_logging(debug: bool = False, log_dir: str = "./logs") -> None:
"""
配置日志系统(同时输出到控制台和文件)
Args:
debug: 是否启用调试模式
log_dir: 日志文件目录
"""
level = logging.DEBUG if debug else logging.INFO
# 创建日志目录
log_path = Path(log_dir)
log_path.mkdir(parents=True, exist_ok=True)
# 日志文件路径(按日期分文件)
today_str = datetime.now().strftime('%Y%m%d')
log_file = log_path / f"stock_analysis_{today_str}.log"
debug_log_file = log_path / f"stock_analysis_debug_{today_str}.log"
# 创建根 logger
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG) # 根 logger 设为 DEBUG由 handler 控制输出级别
# Handler 1: 控制台输出
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(level)
console_handler.setFormatter(logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT))
root_logger.addHandler(console_handler)
# Handler 2: 常规日志文件INFO 级别10MB 轮转)
file_handler = RotatingFileHandler(
log_file,
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=5,
encoding='utf-8'
)
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT))
root_logger.addHandler(file_handler)
# Handler 3: 调试日志文件DEBUG 级别,包含所有详细信息)
debug_handler = RotatingFileHandler(
debug_log_file,
maxBytes=50 * 1024 * 1024, # 50MB
backupCount=3,
encoding='utf-8'
)
debug_handler.setLevel(logging.DEBUG)
debug_handler.setFormatter(logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT))
root_logger.addHandler(debug_handler)
# 降低第三方库的日志级别
logging.getLogger('urllib3').setLevel(logging.WARNING)
logging.getLogger('sqlalchemy').setLevel(logging.WARNING)
logging.getLogger('google').setLevel(logging.WARNING)
logging.getLogger('httpx').setLevel(logging.WARNING)
logging.info(f"日志系统初始化完成,日志目录: {log_path.absolute()}")
logging.info(f"常规日志: {log_file}")
logging.info(f"调试日志: {debug_log_file}")
logger = logging.getLogger(__name__)
@@ -140,72 +74,98 @@ def parse_arguments() -> argparse.Namespace:
python main.py --market-review # 仅运行大盘复盘
'''
)
parser.add_argument(
'--debug',
action='store_true',
help='启用调试模式,输出详细日志'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='仅获取数据,不进行 AI 分析'
)
parser.add_argument(
'--stocks',
type=str,
help='指定要分析的股票代码,逗号分隔(覆盖配置文件)'
)
parser.add_argument(
'--no-notify',
action='store_true',
help='不发送推送通知'
)
parser.add_argument(
'--single-notify',
action='store_true',
help='启用单股推送模式:每分析完一只股票立即推送,而不是汇总推送'
)
parser.add_argument(
'--workers',
type=int,
default=None,
help='并发线程数(默认使用配置值)'
)
parser.add_argument(
'--schedule',
action='store_true',
help='启用定时任务模式,每日定时执行'
)
parser.add_argument(
'--market-review',
action='store_true',
help='仅运行大盘复盘分析'
)
parser.add_argument(
'--no-market-review',
action='store_true',
help='跳过大盘复盘分析'
)
parser.add_argument(
'--webui',
action='store_true',
help='启动本地配置 WebUI'
help='启动本地配置 WebUI(旧版 Gradio'
)
parser.add_argument(
'--webui-only',
action='store_true',
help='仅启动 WebUI 服务,不自动执行分析(通过 /analysis API 手动触发)'
help='仅启动 WebUI 服务,不自动执行分析'
)
parser.add_argument(
'--serve',
action='store_true',
help='启动 FastAPI 后端服务(同时执行分析任务)'
)
parser.add_argument(
'--serve-only',
action='store_true',
help='仅启动 FastAPI 后端服务,不自动执行分析'
)
parser.add_argument(
'--port',
type=int,
default=8000,
help='FastAPI 服务端口(默认 8000'
)
parser.add_argument(
'--host',
type=str,
default='0.0.0.0',
help='FastAPI 服务监听地址(默认 0.0.0.0'
)
parser.add_argument(
@@ -213,7 +173,7 @@ def parse_arguments() -> argparse.Namespace:
action='store_true',
help='不保存分析上下文快照'
)
return parser.parse_args()
@@ -224,14 +184,14 @@ def run_full_analysis(
):
"""
执行完整的分析流程(个股 + 大盘复盘)
这是定时任务调用的主函数
"""
try:
# 命令行参数 --single-notify 覆盖配置(#55
if getattr(args, 'single_notify', False):
config.single_stock_notify = True
# 创建调度器
save_context_snapshot = None
if getattr(args, 'no_context_snapshot', False):
@@ -244,7 +204,7 @@ def run_full_analysis(
query_source="cli",
save_context_snapshot=save_context_snapshot
)
# 1. 运行个股分析
results = pipeline.run(
stock_codes=stock_codes,
@@ -271,7 +231,7 @@ def run_full_analysis(
# 如果有结果,赋值给 market_report 用于后续飞书文档生成
if review_result:
market_report = review_result
# 输出摘要
if results:
logger.info("\n===== 分析结果摘要 =====")
@@ -281,7 +241,7 @@ def run_full_analysis(
f"{emoji} {r.name}({r.code}): {r.operation_advice} | "
f"评分 {r.sentiment_score} | {r.trend_prediction}"
)
logger.info("\n任务执行完成")
# === 新增:生成飞书云文档 ===
@@ -317,11 +277,38 @@ def run_full_analysis(
except Exception as e:
logger.error(f"飞书文档生成失败: {e}")
except Exception as e:
logger.exception(f"分析流程执行失败: {e}")
def start_api_server(host: str, port: int, config: Config) -> None:
"""
在后台线程启动 FastAPI 服务
Args:
host: 监听地址
port: 监听端口
config: 配置对象
"""
import threading
import uvicorn
def run_server():
level_name = (config.log_level or "INFO").lower()
uvicorn.run(
"api.app:app",
host=host,
port=port,
log_level=level_name,
log_config=None,
)
thread = threading.Thread(target=run_server, daemon=True)
thread.start()
logger.info(f"FastAPI 服务已启动: http://{host}:{port}")
def start_bot_stream_clients(config: Config) -> None:
"""Start bot stream clients when enabled in config."""
# 启动钉钉 Stream 客户端
@@ -358,18 +345,18 @@ def start_bot_stream_clients(config: Config) -> None:
def main() -> int:
"""
主入口函数
Returns:
退出码0 表示成功)
"""
# 解析命令行参数
args = parse_arguments()
# 加载配置(在设置日志前加载,以获取日志目录)
config = get_config()
# 配置日志(输出到控制台和文件)
setup_logging(debug=args.debug, log_dir=config.log_dir)
setup_logging(log_prefix="stock_analysis", debug=args.debug, log_dir=config.log_dir)
logger.info("=" * 60)
logger.info("A股自选股智能分析系统 启动")
@@ -391,14 +378,28 @@ def main() -> int:
# 优先级: 命令行参数 > 配置文件
start_webui = (args.webui or args.webui_only or config.webui_enabled) and os.getenv("GITHUB_ACTIONS") != "true"
bot_clients_started = False
if start_webui:
try:
from webui import run_server_in_thread
run_server_in_thread(host=config.webui_host, port=config.webui_port)
start_bot_stream_clients(config)
bot_clients_started = True
except Exception as e:
logger.error(f"启动 WebUI 失败: {e}")
# === 启动 FastAPI 服务 (如果启用) ===
start_serve = (args.serve or args.serve_only) and os.getenv("GITHUB_ACTIONS") != "true"
if start_serve:
try:
start_api_server(host=args.host, port=args.port, config=config)
bot_clients_started = True
except Exception as e:
logger.error(f"启动 FastAPI 服务失败: {e}")
if bot_clients_started:
start_bot_stream_clients(config)
# === 仅 WebUI 模式:不自动执行分析 ===
if args.webui_only:
logger.info("模式: 仅 WebUI 服务")
@@ -411,6 +412,20 @@ def main() -> int:
except KeyboardInterrupt:
logger.info("\n用户中断,程序退出")
return 0
# === 仅 FastAPI 服务模式:不自动执行分析 ===
if args.serve_only:
logger.info("模式: 仅 FastAPI 服务")
logger.info(f"API 服务运行中: http://{args.host}:{args.port}")
logger.info("通过 /api/v1/analysis/stock/{code} 接口触发分析")
logger.info(f"API 文档: http://{args.host}:{args.port}/docs")
logger.info("按 Ctrl+C 退出...")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
logger.info("\n用户中断,程序退出")
return 0
try:
# 模式1: 仅大盘复盘
@@ -468,11 +483,12 @@ def main() -> int:
logger.info("\n程序执行完成")
# 如果启用了 WebUI 且是非定时任务模式,保持程序运行以便访问 WebUI
if start_webui and not (args.schedule or config.schedule_enabled):
logger.info("WebUI 运行中 (按 Ctrl+C 退出)...")
# 如果启用了服务且是非定时任务模式,保持程序运行
keep_running = (start_webui or start_serve) and not (args.schedule or config.schedule_enabled)
if keep_running:
service_name = "API 服务" if start_serve else "WebUI"
logger.info(f"{service_name} 运行中 (按 Ctrl+C 退出)...")
try:
# 简单的保持活跃循环
while True:
time.sleep(1)
except KeyboardInterrupt:

View File

@@ -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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

View File

@@ -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:

View File

@@ -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
View 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}")

View 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",
]

View 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

View 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
View 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",
]

View 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 "极度悲观"

View 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 "极度悲观"

View 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
View 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()

View File

@@ -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,