feat: AI市场分析功能
- 后端:AI分析服务、工具调用、定时任务 - 前端:报告列表、详情页、Mermaid图表支持 - 支持OpenAI API兼容模型 - 收盘后自动分析生成报告
This commit is contained in:
@@ -71,6 +71,20 @@ CREATE TABLE IF NOT EXISTS daily_top_themes (
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, theme_code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
report_type TEXT NOT NULL DEFAULT 'daily',
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
tools_used TEXT,
|
||||
model TEXT NOT NULL,
|
||||
tokens_used INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, report_type)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
+6
-3
@@ -7,8 +7,9 @@ from contextlib import asynccontextmanager
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from database import init_db
|
||||
from routes import stock, collections, shares, themes, core_stocks, fuyao, market_dashboard
|
||||
from routes import stock, collections, shares, themes, core_stocks, fuyao, market_dashboard, ai_analysis
|
||||
from services.daily_collector import collector_loop, cache_cleanup_loop
|
||||
from services.ai_collector import ai_analysis_loop
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -18,12 +19,13 @@ async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
collector_task = asyncio.create_task(collector_loop())
|
||||
cache_cleanup_task = asyncio.create_task(cache_cleanup_loop())
|
||||
ai_analysis_task = asyncio.create_task(ai_analysis_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for t in (collector_task, cache_cleanup_task):
|
||||
for t in (collector_task, cache_cleanup_task, ai_analysis_task):
|
||||
t.cancel()
|
||||
for t in (collector_task, cache_cleanup_task):
|
||||
for t in (collector_task, cache_cleanup_task, ai_analysis_task):
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
@@ -47,6 +49,7 @@ app.include_router(themes.router, prefix="/api/themes")
|
||||
app.include_router(core_stocks.router, prefix="/api/core-stocks")
|
||||
app.include_router(fuyao.router, prefix="/api/v2")
|
||||
app.include_router(market_dashboard.router, prefix="/api")
|
||||
app.include_router(ai_analysis.router, prefix="/api")
|
||||
|
||||
# 生产模式:后端同时托管前端静态文件
|
||||
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""AI 分析报告路由(/api/ai-analysis)
|
||||
|
||||
提供:
|
||||
- 报告列表
|
||||
- 报告详情
|
||||
- 手动触发分析(12小时频率限制)
|
||||
- 重新生成报告
|
||||
- 检查某日是否有报告
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from database import get_connection, dict_from_row
|
||||
|
||||
_CST = timezone(timedelta(hours=8))
|
||||
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _has_ai_report(trade_date: str) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM ai_reports WHERE trade_date = ? AND report_type = 'daily' LIMIT 1",
|
||||
(trade_date,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _can_trigger(trade_date: str) -> tuple[bool, str]:
|
||||
"""检查是否可以触发分析(12小时频率限制)"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT created_at FROM ai_reports WHERE trade_date = ? ORDER BY id DESC LIMIT 1",
|
||||
(trade_date,)
|
||||
).fetchone()
|
||||
if row:
|
||||
last_time = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S")
|
||||
now = datetime.now(_CST)
|
||||
if (now - last_time).total_seconds() < 12 * 3600:
|
||||
return False, "12小时内已触发过,请稍后再试"
|
||||
return True, ""
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/ai-analysis", summary="AI 分析报告列表")
|
||||
async def list_reports():
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, created_at "
|
||||
"FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50"
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
item = dict_from_row(r)
|
||||
item["toolsUsed"] = json.loads(item.pop("tools_used") or "[]")
|
||||
items.append(item)
|
||||
return JSONResponse({"data": items}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/ai-analysis/{report_id}", summary="AI 分析报告详情")
|
||||
async def get_report(report_id: int):
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM ai_reports WHERE id = ?", (report_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="报告不存在")
|
||||
item = dict_from_row(row)
|
||||
item["toolsUsed"] = json.loads(item.pop("tools_used") or "[]")
|
||||
return JSONResponse({"data": item}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/ai-analysis/check/{trade_date}", summary="检查某日是否有 AI 分析报告")
|
||||
async def check_report(trade_date: str):
|
||||
has = _has_ai_report(trade_date)
|
||||
return JSONResponse({"data": {"hasReport": has, "tradeDate": trade_date}})
|
||||
|
||||
|
||||
@router.post("/ai-analysis/trigger", summary="手动触发今日 AI 分析")
|
||||
async def trigger_analysis():
|
||||
now = datetime.now(_CST)
|
||||
trade_date = now.strftime("%Y-%m-%d")
|
||||
|
||||
if _has_ai_report(trade_date):
|
||||
raise HTTPException(status_code=400, detail="今日已有分析报告")
|
||||
|
||||
can, msg = _can_trigger(trade_date)
|
||||
if not can:
|
||||
raise HTTPException(status_code=429, detail=msg)
|
||||
|
||||
from services.ai_service import collect_ai_analysis
|
||||
result = await collect_ai_analysis(trade_date)
|
||||
return JSONResponse({"data": result})
|
||||
|
||||
|
||||
@router.post("/ai-analysis/{report_id}/regenerate", summary="重新生成 AI 分析报告")
|
||||
async def regenerate_report(report_id: int):
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT trade_date FROM ai_reports WHERE id = ?", (report_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="报告不存在")
|
||||
trade_date = row["trade_date"]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
can, msg = _can_trigger(trade_date)
|
||||
if not can:
|
||||
raise HTTPException(status_code=429, detail=msg)
|
||||
|
||||
from services.ai_service import collect_ai_analysis
|
||||
result = await collect_ai_analysis(trade_date)
|
||||
return JSONResponse({"data": result})
|
||||
@@ -0,0 +1,56 @@
|
||||
"""AI 分析定时任务
|
||||
|
||||
收盘后自动触发 AI 分析:
|
||||
- 15:01 collector_loop 采集核心股+题材数据
|
||||
- 15:10 ai_analysis_loop 触发 AI 分析(等数据采集完成)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
from datetime import datetime, time as dtime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from database import get_connection
|
||||
|
||||
_CST = timezone(timedelta(hours=8))
|
||||
|
||||
CHECK_INTERVAL_SECONDS = 300 # 每5分钟检查
|
||||
AI_ANALYSIS_TIME = dtime(15, 10) # 15:10 后触发
|
||||
|
||||
|
||||
def _is_trading_day(d: datetime) -> bool:
|
||||
"""仅按工作日判断"""
|
||||
return d.weekday() < 5
|
||||
|
||||
|
||||
def _has_ai_report(trade_date: str) -> bool:
|
||||
"""检查某日是否已有 AI 分析报告"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM ai_reports WHERE trade_date = ? AND report_type = 'daily' LIMIT 1",
|
||||
(trade_date,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def ai_analysis_loop(stop: Optional[asyncio.Event] = None) -> None:
|
||||
"""后台循环:每个交易日 15:10 后自动触发 AI 分析(幂等)"""
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now(_CST)
|
||||
if _is_trading_day(now) and now.time() >= AI_ANALYSIS_TIME:
|
||||
trade_date = now.strftime("%Y-%m-%d")
|
||||
if not _has_ai_report(trade_date):
|
||||
print(f"[ai-collector] 开始 AI 分析 {trade_date}")
|
||||
from services.ai_service import collect_ai_analysis
|
||||
result = await collect_ai_analysis(trade_date)
|
||||
print(f"[ai-collector] AI 分析完成 {trade_date},token 消耗: {result.get('tokens_used', 0)}")
|
||||
except Exception:
|
||||
print("[ai-collector] AI 分析异常:")
|
||||
traceback.print_exc()
|
||||
if stop is not None and stop.is_set():
|
||||
break
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""AI 分析配置管理(从环境变量读取)"""
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
AI_API_BASE = os.getenv("AI_API_BASE", "https://openteam.oneisall.xyz/v1")
|
||||
AI_API_KEY = os.getenv("AI_API_KEY", "")
|
||||
AI_MODEL = os.getenv("AI_MODEL", "deepseek-v4-flash")
|
||||
AI_MAX_TOKENS = int(os.getenv("AI_MAX_TOKENS", "4096"))
|
||||
AI_TEMPERATURE = float(os.getenv("AI_TEMPERATURE", "0.7"))
|
||||
@@ -0,0 +1,187 @@
|
||||
"""AI 分析核心服务
|
||||
|
||||
负责:
|
||||
1. 调用 OpenAI 兼容 API 进行分析
|
||||
2. Function Calling 循环(AI 可主动获取数据)
|
||||
3. 保存报告到数据库
|
||||
"""
|
||||
|
||||
import json
|
||||
import httpx
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from services.ai_config import AI_API_BASE, AI_API_KEY, AI_MODEL, AI_MAX_TOKENS, AI_TEMPERATURE
|
||||
from services.ai_tools import TOOLS, execute_tool
|
||||
from database import get_connection
|
||||
|
||||
_CST = timezone(timedelta(hours=8))
|
||||
|
||||
SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中发现投资机会。
|
||||
|
||||
你的分析风格:
|
||||
- 数据驱动,基于真实数据而非主观臆断
|
||||
- 逻辑清晰,先总后分,层层递进
|
||||
- 观点明确,给出具体的操作建议
|
||||
- 风险提示,每次推荐都需说明风险点
|
||||
|
||||
可用工具:
|
||||
- get_market_dashboard: 获取市场整体数据(指数/涨跌统计/行业强度/事件情报/市场温度)
|
||||
- get_theme_history: 获取指定日期的题材涨幅排行
|
||||
- get_active_core_stocks: 获取核心股追踪数据(10日涨幅矩阵+所属题材)
|
||||
- get_stock_quote: 获取个股实时行情
|
||||
- get_fund_flow: 获取个股资金流向
|
||||
|
||||
重要规则:
|
||||
1. 你必须先调用工具获取数据,然后基于数据进行分析
|
||||
2. 不要凭空编造数据,所有数据必须来自工具返回
|
||||
3. 如果工具返回空数据,如实说明数据不可用
|
||||
4. 分析完成后给出明确的结论和建议"""
|
||||
|
||||
DAILY_ANALYSIS_PROMPT = """请对 {trade_date} 的A股市场进行收盘分析,生成一份完整的分析报告。
|
||||
|
||||
请先调用以下工具获取数据:
|
||||
1. get_market_dashboard - 获取市场整体数据
|
||||
2. get_theme_history(date="{trade_date}") - 获取今日题材涨幅
|
||||
3. get_active_core_stocks - 获取核心股数据
|
||||
|
||||
然后基于数据生成报告,结构如下:
|
||||
|
||||
## 一、市场总览
|
||||
- 主要指数表现(上证、深证、创业板)
|
||||
- 涨跌家数统计
|
||||
- 市场温度评估
|
||||
|
||||
## 二、题材热点分析
|
||||
- 今日涨幅前5题材
|
||||
- 持续活跃的题材
|
||||
- 新兴热点题材
|
||||
|
||||
## 三、核心股追踪
|
||||
- 连板股分析
|
||||
- 核心股表现
|
||||
- 龙头股辨识
|
||||
|
||||
## 四、关注方向
|
||||
- 明日值得关注的题材方向
|
||||
- 潜在的交易机会
|
||||
|
||||
## 五、风险提示
|
||||
- 需要警惕的风险因素
|
||||
- 操作建议
|
||||
|
||||
请用 Markdown 格式输出,适当使用表格展示数据对比。"""
|
||||
|
||||
|
||||
async def call_llm(messages: list, tools: list = None) -> dict:
|
||||
"""调用 OpenAI 兼容 API"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
payload = {
|
||||
"model": AI_MODEL,
|
||||
"messages": messages,
|
||||
"max_tokens": AI_MAX_TOKENS,
|
||||
"temperature": AI_TEMPERATURE,
|
||||
}
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
payload["tool_choice"] = "auto"
|
||||
|
||||
resp = await client.post(
|
||||
f"{AI_API_BASE}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {AI_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def collect_ai_analysis(trade_date: str) -> dict:
|
||||
"""AI 分析主流程
|
||||
|
||||
Args:
|
||||
trade_date: 交易日 YYYY-MM-DD
|
||||
|
||||
Returns:
|
||||
{"id": int, "tokens_used": int, "tools_used": list}
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": DAILY_ANALYSIS_PROMPT.format(trade_date=trade_date)}
|
||||
]
|
||||
|
||||
tools_used = []
|
||||
total_tokens = 0
|
||||
final_content = ""
|
||||
was_truncated = False
|
||||
|
||||
for i in range(8):
|
||||
response = await call_llm(messages, tools=TOOLS)
|
||||
total_tokens += response.get("usage", {}).get("total_tokens", 0)
|
||||
|
||||
choice = response["choices"][0]
|
||||
message = choice["message"]
|
||||
messages.append(message)
|
||||
|
||||
finish_reason = choice.get("finish_reason", "")
|
||||
|
||||
if finish_reason == "stop":
|
||||
final_content = message.get("content") or ""
|
||||
break
|
||||
|
||||
if finish_reason == "length":
|
||||
# 响应被截断,保存已生成的内容
|
||||
was_truncated = True
|
||||
final_content = message.get("content") or ""
|
||||
if not final_content:
|
||||
# 如果当前消息没有内容,尝试从之前的消息中提取
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "assistant" and msg.get("content"):
|
||||
final_content = msg["content"]
|
||||
break
|
||||
print(f"[ai-service] 警告:响应被截断 (finish_reason=length)")
|
||||
break
|
||||
|
||||
if finish_reason == "tool_calls":
|
||||
for tool_call in message.get("tool_calls", []):
|
||||
func_name = tool_call["function"]["name"]
|
||||
func_args = json.loads(tool_call["function"]["arguments"])
|
||||
tools_used.append(func_name)
|
||||
|
||||
result = await execute_tool(func_name, func_args)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call["id"],
|
||||
"content": result
|
||||
})
|
||||
|
||||
summary = final_content[:200].replace("\n", " ") if final_content else ""
|
||||
report_id = _save_report(trade_date, final_content, summary, tools_used, total_tokens)
|
||||
|
||||
return {"id": report_id, "tokens_used": total_tokens, "tools_used": tools_used, "truncated": was_truncated}
|
||||
|
||||
|
||||
def _save_report(trade_date: str, content: str, summary: str, tools_used: list, tokens_used: int) -> int:
|
||||
"""保存报告到数据库"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"""INSERT OR REPLACE INTO ai_reports
|
||||
(trade_date, report_type, title, content, summary, tools_used, model, tokens_used)
|
||||
VALUES (?, 'daily', ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
trade_date,
|
||||
f"{trade_date} A股收盘分析",
|
||||
content,
|
||||
summary,
|
||||
json.dumps(tools_used),
|
||||
AI_MODEL,
|
||||
tokens_used,
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Function Calling 工具定义和执行器
|
||||
|
||||
让 AI 可以主动调用现有 API 获取市场数据:
|
||||
- get_market_dashboard: 市场看板
|
||||
- get_theme_history: 题材热点历史
|
||||
- get_active_core_stocks: 核心股追踪
|
||||
- get_stock_quote: 个股实时行情
|
||||
- get_fund_flow: 资金流向
|
||||
"""
|
||||
|
||||
import json
|
||||
from services.ai_config import AI_API_BASE, AI_API_KEY, AI_MODEL, AI_MAX_TOKENS, AI_TEMPERATURE
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_market_dashboard",
|
||||
"description": "获取A股市场看板数据,包含主要指数行情、涨跌统计、行业强度、概念热度、事件情报、市场温度评分",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_theme_history",
|
||||
"description": "获取指定交易日的题材涨幅排行前20",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date": {"type": "string", "description": "交易日 YYYY-MM-DD"}
|
||||
},
|
||||
"required": ["date"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_active_core_stocks",
|
||||
"description": "获取活跃核心股列表,包含最近10日涨幅矩阵和所属题材",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_stock_quote",
|
||||
"description": "获取单只股票实时行情(价格、涨跌幅、成交量、换手率等)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "description": "6位股票代码,如 600519"}
|
||||
},
|
||||
"required": ["code"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_fund_flow",
|
||||
"description": "获取个股资金流向数据(主力净流入、超大单/大单/中单/小单流入流出)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "description": "6位股票代码"},
|
||||
"name": {"type": "string", "description": "股票名称"},
|
||||
"days": {"type": "integer", "description": "获取天数,默认30"}
|
||||
},
|
||||
"required": ["code", "name"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def execute_tool(tool_name: str, arguments: dict) -> str:
|
||||
"""执行工具调用,返回 JSON 字符串"""
|
||||
try:
|
||||
if tool_name == "get_market_dashboard":
|
||||
return await _get_market_dashboard()
|
||||
elif tool_name == "get_theme_history":
|
||||
return await _get_theme_history(arguments.get("date", ""))
|
||||
elif tool_name == "get_active_core_stocks":
|
||||
return await _get_active_core_stocks()
|
||||
elif tool_name == "get_stock_quote":
|
||||
return await _get_stock_quote(arguments.get("code", ""))
|
||||
elif tool_name == "get_fund_flow":
|
||||
return await _get_fund_flow(
|
||||
arguments.get("code", ""),
|
||||
arguments.get("name", ""),
|
||||
arguments.get("days", 30)
|
||||
)
|
||||
else:
|
||||
return json.dumps({"error": f"未知工具: {tool_name}"})
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
async def _get_market_dashboard() -> str:
|
||||
"""获取市场看板数据"""
|
||||
from routes.market_dashboard import _build_dashboard
|
||||
data = await _build_dashboard()
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
async def _get_theme_history(date: str) -> str:
|
||||
"""获取指定交易日题材涨幅前20"""
|
||||
from database import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM daily_top_themes WHERE trade_date = ? ORDER BY rank ASC",
|
||||
(date,)
|
||||
).fetchall()
|
||||
items = [dict(r) for r in rows]
|
||||
return json.dumps({"date": date, "items": items}, ensure_ascii=False)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def _get_active_core_stocks() -> str:
|
||||
"""获取活跃核心股列表"""
|
||||
from database import get_connection
|
||||
from datetime import date as date_cls
|
||||
conn = get_connection()
|
||||
try:
|
||||
# 最近10个有数据的交易日
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT 10"
|
||||
).fetchall()
|
||||
dates = [r["trade_date"] for r in reversed(rows)]
|
||||
|
||||
if not dates:
|
||||
return json.dumps({"dates": [], "stocks": []}, ensure_ascii=False)
|
||||
|
||||
placeholders = ",".join("?" * len(dates))
|
||||
rows = conn.execute(
|
||||
f"""SELECT trade_date, stock_code, stock_name, f3, cover_count FROM daily_core_stocks
|
||||
WHERE trade_date IN ({placeholders})
|
||||
ORDER BY trade_date DESC, rank ASC""",
|
||||
dates,
|
||||
).fetchall()
|
||||
|
||||
stock_days = {}
|
||||
for r in rows:
|
||||
code = r["stock_code"]
|
||||
s = stock_days.setdefault(code, {
|
||||
"stockCode": code,
|
||||
"stockName": r["stock_name"],
|
||||
"coverCount": r["cover_count"],
|
||||
"dailyGains": {},
|
||||
"appearCount": 0,
|
||||
"lastAppear": None,
|
||||
})
|
||||
s["dailyGains"][r["trade_date"]] = r["f3"]
|
||||
s["appearCount"] += 1
|
||||
if s["lastAppear"] is None or r["trade_date"] > s["lastAppear"]:
|
||||
s["lastAppear"] = r["trade_date"]
|
||||
|
||||
stocks = list(stock_days.values())
|
||||
stocks.sort(key=lambda x: x.get("lastAppear") or "", reverse=True)
|
||||
stocks.sort(key=lambda x: -x["appearCount"])
|
||||
|
||||
themes_rows = conn.execute(
|
||||
f"""SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes
|
||||
WHERE trade_date IN ({placeholders})""",
|
||||
dates,
|
||||
).fetchall()
|
||||
themes_by_stock = {}
|
||||
for t in themes_rows:
|
||||
per = themes_by_stock.setdefault(t["stock_code"], {})
|
||||
per.setdefault(t["theme_code"], {"theme_code": t["theme_code"], "theme_name": t["theme_name"]})
|
||||
for s in stocks:
|
||||
s["themes"] = list(themes_by_stock.get(s["stockCode"], {}).values())
|
||||
|
||||
latest = dates[-1] if dates else None
|
||||
for s in stocks:
|
||||
if s.get("lastAppear") and latest:
|
||||
try:
|
||||
d1 = date_cls.fromisoformat(latest)
|
||||
d2 = date_cls.fromisoformat(s["lastAppear"])
|
||||
s["daysSinceLastAppear"] = (d1 - d2).days
|
||||
except ValueError:
|
||||
s["daysSinceLastAppear"] = 0
|
||||
else:
|
||||
s["daysSinceLastAppear"] = 0
|
||||
|
||||
return json.dumps({"dates": dates, "stocks": stocks}, ensure_ascii=False)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def _get_stock_quote(code: str) -> str:
|
||||
"""获取个股实时行情"""
|
||||
from services.tencent import fetch_quote
|
||||
data = await fetch_quote(code)
|
||||
if not data:
|
||||
return json.dumps({"error": f"未找到股票 {code} 的数据"})
|
||||
return json.dumps(data, ensure_ascii=False)
|
||||
|
||||
|
||||
async def _get_fund_flow(code: str, name: str, days: int) -> str:
|
||||
"""获取个股资金流向"""
|
||||
from services.tencent import fetch_quote, get_market_prefix
|
||||
from database import get_connection
|
||||
|
||||
# 先尝试从本地数据库获取历史资金流向
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM cache WHERE key LIKE ? AND expires_at > datetime('now','localtime')",
|
||||
(f"fund_flow:{code}%",)
|
||||
).fetchall()
|
||||
if rows:
|
||||
return rows[0]["value"]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 降级:通过腾讯获取基础行情数据
|
||||
quote = await fetch_quote(code)
|
||||
if not quote:
|
||||
return json.dumps({"error": f"未找到股票 {code} 的数据"})
|
||||
|
||||
return json.dumps({
|
||||
"code": code,
|
||||
"name": name or quote.get("name", ""),
|
||||
"currentPrice": quote.get("currentPrice", 0),
|
||||
"changePercent": quote.get("changePercent", 0),
|
||||
"volume": quote.get("volume", 0),
|
||||
"amount": quote.get("amount", 0),
|
||||
"note": "资金流向详细数据需通过东方财富API获取"
|
||||
}, ensure_ascii=False)
|
||||
@@ -51,10 +51,12 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"lightweight-charts": "^5.2.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"mermaid": "^11.17.2",
|
||||
"react": "^19.2.7",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.12.1",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
Generated
+1337
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import mermaid from "mermaid";
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: "dark",
|
||||
themeVariables: {
|
||||
primaryColor: "#21262d",
|
||||
primaryTextColor: "#e6edf3",
|
||||
primaryBorderColor: "#30363d",
|
||||
lineColor: "#58a6ff",
|
||||
secondaryColor: "#161b22",
|
||||
tertiaryColor: "#0d1117",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "14px",
|
||||
},
|
||||
});
|
||||
|
||||
interface MermaidProps {
|
||||
chart: string;
|
||||
}
|
||||
|
||||
export function Mermaid({ chart }: MermaidProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [svg, setSvg] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
mermaid
|
||||
.render(id, chart)
|
||||
.then(({ svg }) => setSvg(svg))
|
||||
.catch((err) => setError(err.message || "图表渲染失败"));
|
||||
}, [chart]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<pre className="bg-[#0d1117] border border-[#f85149]/30 rounded p-3 text-sm text-[#f85149] overflow-x-auto">
|
||||
{error}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="bg-[#0d1117] rounded p-3 overflow-x-auto my-2 flex justify-center [&>svg]:max-w-full"
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// AI 分析报告 API 客户端
|
||||
|
||||
import { getApiBaseUrl } from "./api-client";
|
||||
|
||||
const API_BASE = getApiBaseUrl();
|
||||
|
||||
export interface AiReport {
|
||||
id: number;
|
||||
trade_date: string;
|
||||
report_type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
summary: string | null;
|
||||
toolsUsed: string[];
|
||||
model: string;
|
||||
tokens_used: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function fetchAiReports(): Promise<AiReport[]> {
|
||||
const resp = await fetch(`${API_BASE}/api/ai-analysis`);
|
||||
if (!resp.ok) throw new Error(`请求失败 (${resp.status})`);
|
||||
const result = await resp.json();
|
||||
return result.data || [];
|
||||
}
|
||||
|
||||
export async function fetchAiReport(id: number): Promise<AiReport> {
|
||||
const resp = await fetch(`${API_BASE}/api/ai-analysis/${id}`);
|
||||
if (!resp.ok) throw new Error(`请求失败 (${resp.status})`);
|
||||
const result = await resp.json();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function checkAiReport(tradeDate: string): Promise<boolean> {
|
||||
const resp = await fetch(`${API_BASE}/api/ai-analysis/check/${tradeDate}`);
|
||||
if (!resp.ok) return false;
|
||||
const result = await resp.json();
|
||||
return result.data?.hasReport || false;
|
||||
}
|
||||
|
||||
export async function triggerAiAnalysis(): Promise<{ id: number; tokens_used: number }> {
|
||||
const resp = await fetch(`${API_BASE}/api/ai-analysis/trigger`, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || "触发失败");
|
||||
}
|
||||
const result = await resp.json();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function regenerateAiReport(id: number): Promise<{ id: number; tokens_used: number }> {
|
||||
const resp = await fetch(`${API_BASE}/api/ai-analysis/${id}/regenerate`, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || "重新生成失败");
|
||||
}
|
||||
const result = await resp.json();
|
||||
return result.data;
|
||||
}
|
||||
Executable → Regular
+68
@@ -14,10 +14,13 @@ import { Route as ThemeHistoryRouteImport } from './routes/theme-history'
|
||||
import { Route as HotMapRouteImport } from './routes/hot-map'
|
||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||
import { Route as CoreStocksRouteImport } from './routes/core-stocks'
|
||||
import { Route as AiAnalysisRouteImport } from './routes/ai-analysis'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as ThemeCodeRouteImport } from './routes/theme.$code'
|
||||
import { Route as StockCodeRouteImport } from './routes/stock.$code'
|
||||
import { Route as ShareCodeRouteImport } from './routes/share.$code'
|
||||
import { Route as AiAnalysisIndexRouteImport } from './routes/ai-analysis._index'
|
||||
import { Route as AiAnalysisIdRouteImport } from './routes/ai-analysis.$id'
|
||||
|
||||
const ThemesRoute = ThemesRouteImport.update({
|
||||
id: '/themes',
|
||||
@@ -44,6 +47,11 @@ const CoreStocksRoute = CoreStocksRouteImport.update({
|
||||
path: '/core-stocks',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AiAnalysisRoute = AiAnalysisRouteImport.update({
|
||||
id: '/ai-analysis',
|
||||
path: '/ai-analysis',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -64,25 +72,38 @@ const ShareCodeRoute = ShareCodeRouteImport.update({
|
||||
path: '/share/$code',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AiAnalysisIndexRoute = AiAnalysisIndexRouteImport.update({
|
||||
id: '/_index',
|
||||
getParentRoute: () => AiAnalysisRoute,
|
||||
} as any)
|
||||
const AiAnalysisIdRoute = AiAnalysisIdRouteImport.update({
|
||||
id: '/$id',
|
||||
path: '/$id',
|
||||
getParentRoute: () => AiAnalysisRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/ai-analysis': typeof AiAnalysisRouteWithChildren
|
||||
'/core-stocks': typeof CoreStocksRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/hot-map': typeof HotMapRoute
|
||||
'/theme-history': typeof ThemeHistoryRoute
|
||||
'/themes': typeof ThemesRoute
|
||||
'/ai-analysis/$id': typeof AiAnalysisIdRoute
|
||||
'/share/$code': typeof ShareCodeRoute
|
||||
'/stock/$code': typeof StockCodeRoute
|
||||
'/theme/$code': typeof ThemeCodeRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/ai-analysis': typeof AiAnalysisRouteWithChildren
|
||||
'/core-stocks': typeof CoreStocksRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/hot-map': typeof HotMapRoute
|
||||
'/theme-history': typeof ThemeHistoryRoute
|
||||
'/themes': typeof ThemesRoute
|
||||
'/ai-analysis/$id': typeof AiAnalysisIdRoute
|
||||
'/share/$code': typeof ShareCodeRoute
|
||||
'/stock/$code': typeof StockCodeRoute
|
||||
'/theme/$code': typeof ThemeCodeRoute
|
||||
@@ -90,11 +111,14 @@ export interface FileRoutesByTo {
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/ai-analysis': typeof AiAnalysisRouteWithChildren
|
||||
'/core-stocks': typeof CoreStocksRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/hot-map': typeof HotMapRoute
|
||||
'/theme-history': typeof ThemeHistoryRoute
|
||||
'/themes': typeof ThemesRoute
|
||||
'/ai-analysis/$id': typeof AiAnalysisIdRoute
|
||||
'/ai-analysis/_index': typeof AiAnalysisIndexRoute
|
||||
'/share/$code': typeof ShareCodeRoute
|
||||
'/stock/$code': typeof StockCodeRoute
|
||||
'/theme/$code': typeof ThemeCodeRoute
|
||||
@@ -103,33 +127,40 @@ export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/ai-analysis'
|
||||
| '/core-stocks'
|
||||
| '/dashboard'
|
||||
| '/hot-map'
|
||||
| '/theme-history'
|
||||
| '/themes'
|
||||
| '/ai-analysis/$id'
|
||||
| '/share/$code'
|
||||
| '/stock/$code'
|
||||
| '/theme/$code'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/ai-analysis'
|
||||
| '/core-stocks'
|
||||
| '/dashboard'
|
||||
| '/hot-map'
|
||||
| '/theme-history'
|
||||
| '/themes'
|
||||
| '/ai-analysis/$id'
|
||||
| '/share/$code'
|
||||
| '/stock/$code'
|
||||
| '/theme/$code'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/ai-analysis'
|
||||
| '/core-stocks'
|
||||
| '/dashboard'
|
||||
| '/hot-map'
|
||||
| '/theme-history'
|
||||
| '/themes'
|
||||
| '/ai-analysis/$id'
|
||||
| '/ai-analysis/_index'
|
||||
| '/share/$code'
|
||||
| '/stock/$code'
|
||||
| '/theme/$code'
|
||||
@@ -137,6 +168,7 @@ export interface FileRouteTypes {
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AiAnalysisRoute: typeof AiAnalysisRouteWithChildren
|
||||
CoreStocksRoute: typeof CoreStocksRoute
|
||||
DashboardRoute: typeof DashboardRoute
|
||||
HotMapRoute: typeof HotMapRoute
|
||||
@@ -184,6 +216,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof CoreStocksRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/ai-analysis': {
|
||||
id: '/ai-analysis'
|
||||
path: '/ai-analysis'
|
||||
fullPath: '/ai-analysis'
|
||||
preLoaderRoute: typeof AiAnalysisRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
@@ -212,11 +251,40 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ShareCodeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/ai-analysis/_index': {
|
||||
id: '/ai-analysis/_index'
|
||||
path: ''
|
||||
fullPath: '/ai-analysis'
|
||||
preLoaderRoute: typeof AiAnalysisIndexRouteImport
|
||||
parentRoute: typeof AiAnalysisRoute
|
||||
}
|
||||
'/ai-analysis/$id': {
|
||||
id: '/ai-analysis/$id'
|
||||
path: '/$id'
|
||||
fullPath: '/ai-analysis/$id'
|
||||
preLoaderRoute: typeof AiAnalysisIdRouteImport
|
||||
parentRoute: typeof AiAnalysisRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AiAnalysisRouteChildren {
|
||||
AiAnalysisIdRoute: typeof AiAnalysisIdRoute
|
||||
AiAnalysisIndexRoute: typeof AiAnalysisIndexRoute
|
||||
}
|
||||
|
||||
const AiAnalysisRouteChildren: AiAnalysisRouteChildren = {
|
||||
AiAnalysisIdRoute: AiAnalysisIdRoute,
|
||||
AiAnalysisIndexRoute: AiAnalysisIndexRoute,
|
||||
}
|
||||
|
||||
const AiAnalysisRouteWithChildren = AiAnalysisRoute._addFileChildren(
|
||||
AiAnalysisRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AiAnalysisRoute: AiAnalysisRouteWithChildren,
|
||||
CoreStocksRoute: CoreStocksRoute,
|
||||
DashboardRoute: DashboardRoute,
|
||||
HotMapRoute: HotMapRoute,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import * as React from "react";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowLeft, BrainCircuit, Clock, Loader2, AlertCircle, RefreshCw, Wrench } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import Markdown from "react-markdown";
|
||||
import { Mermaid } from "../components/Mermaid";
|
||||
import { fetchAiReport, regenerateAiReport } from "../lib/ai-analysis-api";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent } from "../components/ui/card";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/ai-analysis/$id")({
|
||||
component: AiReportDetailPage,
|
||||
});
|
||||
|
||||
function AiReportDetailPage() {
|
||||
const { id } = Route.useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const reportId = Number(id);
|
||||
|
||||
const { data: report, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["ai-report", reportId],
|
||||
queryFn: () => fetchAiReport(reportId),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: () => regenerateAiReport(reportId),
|
||||
onSuccess: () => {
|
||||
toast.success("重新生成完成");
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-report", reportId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-reports"] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || "重新生成失败");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d1117] text-[#e6edf3]">
|
||||
<div className="max-w-4xl mx-auto px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/ai-analysis" className="text-[#8b949e] hover:text-[#e6edf3] transition-colors">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-xl md:text-2xl font-bold flex items-center gap-2">
|
||||
<BrainCircuit className="h-5 w-5 text-[#58a6ff]" />
|
||||
{report?.title || "AI 分析报告"}
|
||||
</h1>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => regenerateMutation.mutate()}
|
||||
disabled={regenerateMutation.isPending}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{regenerateMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
重新生成
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center gap-3 py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-[#58a6ff]" />
|
||||
<p className="text-sm text-[#8b949e]">加载中...</p>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex flex-col items-center gap-3 py-20">
|
||||
<AlertCircle className="h-6 w-6 text-[#f85149]" />
|
||||
<p className="text-sm text-[#8b949e]">报告加载失败</p>
|
||||
<button onClick={() => refetch()} className="text-xs text-[#58a6ff] hover:underline">
|
||||
点击重试
|
||||
</button>
|
||||
</div>
|
||||
) : report ? (
|
||||
<>
|
||||
{/* Meta Info */}
|
||||
<Card className="bg-[#161b22] border-[#30363d] mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-[#8b949e]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{report.created_at}
|
||||
</span>
|
||||
<span>{report.model}</span>
|
||||
<span>{report.tokens_used?.toLocaleString()} tokens</span>
|
||||
{report.toolsUsed && report.toolsUsed.length > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
{report.toolsUsed.length} 个工具
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Report Content */}
|
||||
<Card className="bg-[#161b22] border-[#30363d]">
|
||||
<CardContent className="p-4 md:p-6 ai-report-content">
|
||||
<Markdown
|
||||
components={{
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
if (match && match[1] === "mermaid") {
|
||||
return <Mermaid chart={String(children).replace(/\n$/, "")} />;
|
||||
}
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{report.content}
|
||||
</Markdown>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<p className="text-xs text-[#6e7681] text-center mt-6">
|
||||
本报告由 AI 生成,仅供参考,不构成投资建议
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { Clock, Zap, Loader2, AlertCircle, BrainCircuit } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchAiReports, triggerAiAnalysis } from "../lib/ai-analysis-api";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent } from "../components/ui/card";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/ai-analysis/_index")({
|
||||
component: AiAnalysisIndex,
|
||||
});
|
||||
|
||||
function AiAnalysisIndex() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: reports, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["ai-reports"],
|
||||
queryFn: fetchAiReports,
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const triggerMutation = useMutation({
|
||||
mutationFn: triggerAiAnalysis,
|
||||
onSuccess: () => {
|
||||
toast.success("AI 分析已开始,请稍候...");
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-reports"] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || "触发失败");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-end mb-6">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => triggerMutation.mutate()}
|
||||
disabled={triggerMutation.isPending}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{triggerMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
)}
|
||||
立即分析
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center gap-3 py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-[#58a6ff]" />
|
||||
<p className="text-sm text-[#8b949e]">加载中...</p>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex flex-col items-center gap-3 py-20">
|
||||
<AlertCircle className="h-6 w-6 text-[#f85149]" />
|
||||
<p className="text-sm text-[#8b949e]">数据加载失败</p>
|
||||
<button onClick={() => refetch()} className="text-xs text-[#58a6ff] hover:underline">
|
||||
点击重试
|
||||
</button>
|
||||
</div>
|
||||
) : reports && reports.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{reports.map((report) => (
|
||||
<Card key={report.id} className="bg-[#161b22] border-[#30363d] hover:border-[#58a6ff]/50 transition-colors">
|
||||
<CardContent className="p-4 md:p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-[#e6edf3] mb-1">{report.title}</h3>
|
||||
{report.summary && (
|
||||
<p className="text-sm text-[#8b949e] line-clamp-2 mb-2">{report.summary}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-[#8b949e]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{report.created_at}
|
||||
</span>
|
||||
<span>{report.model}</span>
|
||||
<span>{report.tokens_used?.toLocaleString()} tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/ai-analysis/$id" params={{ id: String(report.id) }} className="text-xs text-[#58a6ff] shrink-0 hover:underline">
|
||||
查看 →
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 py-20">
|
||||
<BrainCircuit className="h-10 w-10 text-[#30363d]" />
|
||||
<p className="text-sm text-[#8b949e]">暂无分析报告</p>
|
||||
<p className="text-xs text-[#6e7681]">点击"立即分析"生成今日报告</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
import { Link, Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowLeft, BrainCircuit } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/ai-analysis")({
|
||||
component: AiAnalysisLayout,
|
||||
});
|
||||
|
||||
function AiAnalysisLayout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d1117] text-[#e6edf3]">
|
||||
<div className="max-w-4xl mx-auto px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link to="/" className="text-[#8b949e] hover:text-[#e6edf3] transition-colors">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-xl md:text-2xl font-bold flex items-center gap-2">
|
||||
<BrainCircuit className="h-5 w-5 text-[#58a6ff]" />
|
||||
AI 市场分析
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Child routes render here */}
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Search, Plus, Share2, Trash2, TrendingUp, Loader2, Flame, Network, BarChart3 } from "lucide-react";
|
||||
import { Search, Plus, Share2, Trash2, TrendingUp, Loader2, Flame, Network, BarChart3, BrainCircuit } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
@@ -230,6 +230,12 @@ function Index() {
|
||||
热点穿透
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/ai-analysis">
|
||||
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
|
||||
<BrainCircuit className="h-3.5 w-3.5" />
|
||||
AI 分析
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -146,3 +146,96 @@
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
}
|
||||
|
||||
/* AI Report Content Styles */
|
||||
.ai-report-content {
|
||||
color: #c9d1d9;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ai-report-content h1,
|
||||
.ai-report-content h2,
|
||||
.ai-report-content h3,
|
||||
.ai-report-content h4 {
|
||||
color: #e6edf3;
|
||||
font-weight: 600;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.ai-report-content h1 { font-size: 1.5em; }
|
||||
.ai-report-content h2 { font-size: 1.3em; }
|
||||
.ai-report-content h3 { font-size: 1.15em; }
|
||||
|
||||
.ai-report-content p {
|
||||
margin-bottom: 0.8em;
|
||||
}
|
||||
|
||||
.ai-report-content strong {
|
||||
color: #e6edf3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-report-content ul,
|
||||
.ai-report-content ol {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.ai-report-content li {
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.ai-report-content blockquote {
|
||||
border-left: 3px solid #30363d;
|
||||
padding-left: 1em;
|
||||
margin: 0.8em 0;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.ai-report-content code {
|
||||
background: #0d1117;
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
color: #79c0ff;
|
||||
}
|
||||
|
||||
.ai-report-content pre {
|
||||
background: #0d1117;
|
||||
padding: 1em;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
|
||||
.ai-report-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.ai-report-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
|
||||
.ai-report-content th,
|
||||
.ai-report-content td {
|
||||
border: 1px solid #30363d;
|
||||
padding: 0.5em 0.8em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ai-report-content th {
|
||||
background: #21262d;
|
||||
color: #e6edf3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-report-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid #30363d;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user