mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
Merge pull request #13 from shy3130/feat/ai-financial-analysis
feat(financials): AI 财务分析 — 流式生成 + 全局胶囊 + 历史报告
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import sys
|
||||
|
||||
__version__ = "0.1.44"
|
||||
__version__ = "0.1.45"
|
||||
|
||||
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
|
||||
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
|
||||
|
||||
@@ -5,8 +5,12 @@ import logging
|
||||
|
||||
import polars as pl
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services.financial_sync import get_financial_df
|
||||
from app.services.financial_analyzer import analyze_financials_stream
|
||||
from app.services import ai_reports
|
||||
from app.tickflow.capabilities import Cap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -130,3 +134,84 @@ def sync_table(request: Request, table: str):
|
||||
result = fs.trigger(target)
|
||||
|
||||
return {"status": "ok", "synced": result}
|
||||
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
"""AI 财务分析请求。"""
|
||||
symbol: str
|
||||
focus: str = "" # 可选:用户追加的分析关注点
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze_financials(request: Request, req: AnalyzeRequest):
|
||||
"""AI 财务分析 — SSE 流式返回。
|
||||
|
||||
后端读取该标的 4 张财务表 → 注入 CFA 分析师级提示词 → 流式调用 LLM →
|
||||
逐 chunk 以 SSE 形式推给前端(JSON per line, 非 text/event-stream,
|
||||
以便前端用 ReadableStream 逐行解析,更简单可靠)。
|
||||
"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
|
||||
if not req.symbol:
|
||||
raise HTTPException(400, "symbol 不能为空")
|
||||
|
||||
data_dir = request.app.state.repo.store.data_dir
|
||||
|
||||
async def stream_gen():
|
||||
async for chunk in analyze_financials_stream(data_dir, req.symbol, req.focus):
|
||||
yield chunk + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_gen(),
|
||||
media_type="application/x-ndjson",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# AI 报告 CRUD(历史报告持久化)
|
||||
# ================================================================
|
||||
|
||||
class SaveReportRequest(BaseModel):
|
||||
"""保存一条 AI 财务分析报告。"""
|
||||
symbol: str
|
||||
name: str = ""
|
||||
focus: str = ""
|
||||
content: str
|
||||
periods: int | None = None
|
||||
summary: str = ""
|
||||
|
||||
|
||||
@router.get("/reports")
|
||||
def list_reports(request: Request):
|
||||
"""获取全部历史报告(按时间降序,后端已裁剪到上限)。无需 FINANCIAL 能力读取列表元信息。"""
|
||||
capset = request.app.state.capabilities
|
||||
if not capset.has(Cap.FINANCIAL):
|
||||
return {"reports": []}
|
||||
return {"reports": ai_reports.list_reports()}
|
||||
|
||||
|
||||
@router.post("/reports")
|
||||
def save_report(request: Request, req: SaveReportRequest):
|
||||
"""保存一条报告。"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
report = ai_reports.save_report({
|
||||
"symbol": req.symbol,
|
||||
"name": req.name,
|
||||
"focus": req.focus,
|
||||
"content": req.content,
|
||||
"periods": req.periods,
|
||||
"summary": req.summary,
|
||||
})
|
||||
return {"ok": True, "report": report}
|
||||
|
||||
|
||||
@router.delete("/reports/{report_id}")
|
||||
def delete_report(request: Request, report_id: str):
|
||||
"""删除一条报告。"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
ok = ai_reports.delete_report(report_id)
|
||||
return {"ok": ok}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""AI 财务分析报告持久化存储。
|
||||
|
||||
存储位置: data/user_data/ai_reports.json (数组,按 created_at 降序)
|
||||
保留最近 MAX_REPORTS 条;超出自动裁剪最旧的。
|
||||
|
||||
每条报告结构:
|
||||
{
|
||||
"id": "rpt_xxx", # 唯一 id
|
||||
"symbol": "600519.SH",
|
||||
"name": "贵州茅台",
|
||||
"focus": "", # 用户追加的关心点(可为空)
|
||||
"content": "# ...markdown", # 报告正文
|
||||
"periods": 4, # 基于几期数据生成
|
||||
"summary": "metrics: 1期...", # 数据摘要
|
||||
"created_at": "2026-06-25T10:00:00"
|
||||
}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_REPORTS = 20
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
from app.config import settings
|
||||
p = settings.data_dir / "user_data" / "ai_reports.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def list_reports() -> list[dict]:
|
||||
"""返回全部报告(按 created_at 降序)。"""
|
||||
p = _path()
|
||||
if not p.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
return sorted(data, key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("ai_reports.json malformed: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
def _save_all(reports: list[dict]) -> None:
|
||||
"""全量写入(裁剪到 MAX_REPORTS)。"""
|
||||
# 保持降序
|
||||
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
if len(reports) > MAX_REPORTS:
|
||||
reports = reports[:MAX_REPORTS]
|
||||
_path().write_text(
|
||||
json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def save_report(report: dict) -> dict:
|
||||
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。
|
||||
|
||||
自动补全 id 与 created_at(若缺),并裁剪到上限。
|
||||
"""
|
||||
reports = list_reports()
|
||||
if not report.get("id"):
|
||||
report["id"] = f"rpt_{int(time.time() * 1000)}_{report.get('symbol', 'x')}"
|
||||
if not report.get("created_at"):
|
||||
report["created_at"] = _now_iso()
|
||||
reports.append(report)
|
||||
_save_all(reports)
|
||||
logger.info("AI report saved: %s (%s), total %d", report.get("symbol"), report.get("id"), len(reports))
|
||||
return report
|
||||
|
||||
|
||||
def delete_report(report_id: str) -> bool:
|
||||
"""删除指定报告。返回是否删除成功。"""
|
||||
reports = list_reports()
|
||||
before = len(reports)
|
||||
reports = [r for r in reports if r.get("id") != report_id]
|
||||
if len(reports) < before:
|
||||
_save_all(reports)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def clear_reports() -> int:
|
||||
"""清空全部报告。返回删除数量。"""
|
||||
reports = list_reports()
|
||||
n = len(reports)
|
||||
if n > 0:
|
||||
_save_all([])
|
||||
return n
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""当前本地时间 ISO 字符串(带秒精度,前端 toLocaleString 友好)。"""
|
||||
from datetime import datetime
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
@@ -0,0 +1,209 @@
|
||||
"""AI 财务分析服务 — 读取个股财务数据 → 构建专业提示词 → 流式调用 LLM。
|
||||
|
||||
职责: 拉取单只标的的 4 张财务表 → 转成紧凑 JSON → 拼装 CFA 分析师级系统提示词
|
||||
→ 流式调用 OpenAI 兼容 API → 逐 chunk 吐给前端。
|
||||
|
||||
不知道: HTTP、前端、配置持久化。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.services.financial_sync import get_financial_df
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 最多注入的报告期数(最新 N 期),避免上下文爆炸 / token 浪费
|
||||
_MAX_PERIODS = 4
|
||||
|
||||
|
||||
def _load_stock_financials(data_dir: Path, symbol: str) -> dict[str, list[dict]]:
|
||||
"""读取该标的的 4 张财务表,返回 {table: [records...]}(按 period_end 降序,截取最新 N 期)。
|
||||
|
||||
数值统一做 NaN/Inf → null 清洗,保证 JSON 序列化不报错。
|
||||
"""
|
||||
result: dict[str, list[dict]] = {}
|
||||
for table in ("metrics", "income", "balance_sheet", "cash_flow"):
|
||||
df = get_financial_df(data_dir, table)
|
||||
if df.is_empty():
|
||||
result[table] = []
|
||||
continue
|
||||
df = df.filter(pl.col("symbol") == symbol)
|
||||
if df.is_empty():
|
||||
result[table] = []
|
||||
continue
|
||||
# 按 period_end 降序,截取最新 N 期
|
||||
if "period_end" in df.columns:
|
||||
df = df.sort("period_end", descending=True).head(_MAX_PERIODS)
|
||||
# 清洗 NaN/Inf,转成 JSON 安全的 dict 列表
|
||||
rows = []
|
||||
for rec in df.to_dicts():
|
||||
clean = {}
|
||||
for k, v in rec.items():
|
||||
if k == "symbol":
|
||||
continue # 不需要重复回传 symbol
|
||||
if isinstance(v, float):
|
||||
import math
|
||||
clean[k] = None if not math.isfinite(v) else v
|
||||
else:
|
||||
clean[k] = v
|
||||
rows.append(clean)
|
||||
result[table] = rows
|
||||
return result
|
||||
|
||||
|
||||
def _summarize(fins: dict[str, list[dict]]) -> str:
|
||||
"""生成一行业务摘要,便于 LLM 快速把握数据全貌(行数/期数)。"""
|
||||
parts = []
|
||||
for table in ("metrics", "income", "balance_sheet", "cash_flow"):
|
||||
rows = fins.get(table, [])
|
||||
if rows:
|
||||
periods = [r.get("period_end") for r in rows if r.get("period_end")]
|
||||
parts.append(f"{table}: {len(rows)}期 ({', '.join(str(p) for p in periods[:3])})")
|
||||
else:
|
||||
parts.append(f"{table}: 无数据")
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 系统提示词 —— CFA 分析师级,九维分析框架
|
||||
# ================================================================
|
||||
|
||||
_SYSTEM_PROMPT = """你是一位拥有 15 年 A 股投研经验的资深财务分析师(CFA + CPA),服务于专业机构投资者。你的任务是:基于提供的上市公司财务数据,产出一份**严谨、专业、可直接用于投资决策**的财务分析报告。
|
||||
|
||||
## 输出规范
|
||||
|
||||
用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。
|
||||
|
||||
### 1. 📌 核心摘要(1-2 句)
|
||||
用一句话概括该公司的财务画像:盈利质量、成长动能、财务健康度的最关键判断。结尾用【综合评级:★★★☆☆】给出 1-5 星评级。
|
||||
|
||||
### 2. ✅ 亮点(2-3 条)
|
||||
列出最值得关注的**积极信号**,每条用加粗短语领起,配数据支撑。例如盈利高增、ROE 持续提升、现金流充沛等。
|
||||
|
||||
### 3. ⚠️ 风险提示(2-3 条)
|
||||
客观指出**潜在风险或值得警惕的信号**,例如应收激增、存货堆积、经营现金流与净利润背离、债务攀升等。宁可保守,不要回避。
|
||||
|
||||
### 4. 📊 分项诊断
|
||||
用**表格**呈现各维度的诊断结论,列为「维度 / 关键指标 / 判断」。维度包括:
|
||||
- **盈利能力**:ROE / ROA / 毛利率 / 净利率
|
||||
- **成长性**:营收同比 / 净利润同比
|
||||
- **偿债能力**:资产负债率 / 流动比率(用资产/负债估算)
|
||||
- **现金流**:经营现金流净额 / 与净利润的匹配度
|
||||
- **营运效率**:存货周转率等(有数据时)
|
||||
|
||||
每个判断给「优秀 / 良好 / 一般 / 偏弱 / 警惕」之一,并一句话说明依据。
|
||||
|
||||
### 5. 🎯 综合评估与展望
|
||||
2-3 段总结:该公司当前的财务状态(优秀/稳健/承压/恶化)、核心驱动力、未来需重点跟踪的指标。**结尾给出"投资参考"**:从纯财务质量角度,该股属于(高质量蓝筹 / 稳健成长 / 周期波动 / 财务承压 / 高风险)中的哪一类。
|
||||
|
||||
## 分析准则(务必遵守)
|
||||
|
||||
1. **数据说话**:每个判断必须引用具体数值(如"营收同比 +28.5%"),严禁空泛套话
|
||||
2. **纵向对比**:利用多期数据看趋势(改善/恶化),而非只看单期
|
||||
3. **交叉验证**:经营现金流 vs 净利润(是否造血)、毛利率 vs 费用率(盈利结构)、负债 vs 资产(杠杆)
|
||||
4. **行业常识**:对照 A 股常识判断水平(如 ROE>15% 优秀,资产负债率>70% 偏高,毛利率<20% 偏低)
|
||||
5. **诚实中立**:数据不支持时直言"数据不足,无法判断",绝不编造或过度演绎
|
||||
6. **简明有力**:避免冗长,用专业投资者能扫读的密度输出,总字数 800-1500 字
|
||||
|
||||
## 重要免责
|
||||
报告末尾附一行:"> ⚠️ 本报告由 AI 基于公开财务数据生成,仅供参考,不构成任何投资建议。"
|
||||
|
||||
现在请基于下方数据进行分析。"""
|
||||
|
||||
|
||||
def _build_user_prompt(fins: dict[str, list[dict]], symbol: str, focus: str) -> str:
|
||||
"""构建用户消息:标的代码 + 数据 JSON + 可选关注点。"""
|
||||
data_json = json.dumps(fins, ensure_ascii=False, indent=2)
|
||||
lines = [
|
||||
f"标的标准代码: {symbol}",
|
||||
f"数据概览: {_summarize(fins)}",
|
||||
"",
|
||||
"以下是该标的最新财务数据(JSON 格式,金额单位为元,比率类指标为百分点):",
|
||||
"```json",
|
||||
data_json,
|
||||
"```",
|
||||
]
|
||||
if focus.strip():
|
||||
lines.extend([
|
||||
"",
|
||||
f"本次分析请特别关注: {focus.strip()}",
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def analyze_financials_stream(
|
||||
data_dir: Path,
|
||||
symbol: str,
|
||||
focus: str = "",
|
||||
) -> AsyncIterator[str]:
|
||||
"""流式分析:yield 出每个文本 chunk。
|
||||
|
||||
- 启动时先 yield 一条 {"type":"meta",...} 让前端显示数据摘要
|
||||
- 之后逐 chunk yield {"type":"delta","content":"..."}
|
||||
- 出错时 yield {"type":"error","message":"..."}
|
||||
- 结束 yield {"type":"done"}
|
||||
"""
|
||||
# 1. 加载数据
|
||||
fins = _load_stock_financials(data_dir, symbol)
|
||||
total_rows = sum(len(v) for v in fins.values())
|
||||
if total_rows == 0:
|
||||
yield json.dumps({"type": "error", "message": f"标的 {symbol} 暂无任何财务数据,请先同步财务表"}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
# 2. meta
|
||||
yield json.dumps({
|
||||
"type": "meta",
|
||||
"symbol": symbol,
|
||||
"summary": _summarize(fins),
|
||||
"periods": total_rows,
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# 3. 调用 LLM 流式
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
from app import secrets_store
|
||||
from app.config import settings
|
||||
|
||||
ai_key = secrets_store.get_ai_key()
|
||||
if not ai_key:
|
||||
yield json.dumps({"type": "error", "message": "AI API Key 未配置,请在「设置 → AI」中配置"}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent
|
||||
client = AsyncOpenAI(
|
||||
api_key=ai_key,
|
||||
base_url=secrets_store.get_ai_config("ai_base_url", "https://api.alysc.top"),
|
||||
timeout=180.0,
|
||||
max_retries=2,
|
||||
default_headers={"User-Agent": user_agent},
|
||||
)
|
||||
|
||||
user_prompt = _build_user_prompt(fins, symbol, focus)
|
||||
stream = await client.chat.completions.create(
|
||||
model=secrets_store.get_ai_config("ai_model", "gpt-5.5"),
|
||||
messages=[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.4,
|
||||
max_tokens=4000,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
async for chunk in stream:
|
||||
delta = chunk.choices[0].delta if chunk.choices else None
|
||||
if delta and delta.content:
|
||||
yield json.dumps({"type": "delta", "content": delta.content}, ensure_ascii=False)
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.exception("AI financial analysis failed for %s: %s", symbol, e)
|
||||
yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
yield json.dumps({"type": "done"}, ensure_ascii=False)
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -207,9 +207,43 @@ class FinancialScheduler:
|
||||
self._data_dir = data_dir
|
||||
self._capset = capset
|
||||
self._running = True
|
||||
# 从持久化恢复上次同步时间: 重启后前端仍能显示真实最后同步时间,而非"尚未同步"
|
||||
try:
|
||||
from app.services import preferences
|
||||
restored = dict(preferences.get_financial_sync_times())
|
||||
# 老用户迁移兜底: 若某表在 preferences 无记录但 parquet 已存在(升级前同步过),
|
||||
# 用 parquet 文件的修改时间作为同步时间并补写持久化。
|
||||
for table in FINANCIAL_TABLES:
|
||||
if table in restored:
|
||||
continue
|
||||
parquet = data_dir / "financials" / table / "part.parquet"
|
||||
if parquet.exists():
|
||||
mtime = datetime.fromtimestamp(parquet.stat().st_mtime, tz=timezone.utc).isoformat()
|
||||
restored[table] = mtime
|
||||
preferences.set_financial_sync_time(table, mtime)
|
||||
logger.info("FinancialScheduler backfilled last_sync for %s from parquet mtime", table)
|
||||
self._last_sync = restored
|
||||
if self._last_sync:
|
||||
logger.info("FinancialScheduler restored last_sync: %s", list(self._last_sync.keys()))
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("restore financial_sync_times failed: %s", e)
|
||||
self._task = asyncio.create_task(self._run_loop())
|
||||
logger.info("FinancialScheduler started")
|
||||
|
||||
def _record_sync(self, table: str) -> None:
|
||||
"""记录一张表的同步完成时间: 更新内存 + 持久化到 preferences.json。
|
||||
|
||||
持久化确保即使重启,前端 /status 仍返回真实的最后同步时间,
|
||||
不会错误地显示"尚未同步"。
|
||||
"""
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
self._last_sync[table] = ts
|
||||
try:
|
||||
from app.services import preferences
|
||||
preferences.set_financial_sync_time(table, ts)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("persist financial_sync_time(%s) failed: %s", table, e)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._task:
|
||||
@@ -229,7 +263,7 @@ class FinancialScheduler:
|
||||
# 每周: 只同步 metrics
|
||||
try:
|
||||
rows = sync_metrics(self._data_dir, self._capset)
|
||||
self._last_sync["metrics"] = datetime.now(timezone.utc).isoformat()
|
||||
self._record_sync("metrics")
|
||||
logger.info("FinancialScheduler: metrics synced, %d rows", rows)
|
||||
except Exception as e:
|
||||
logger.warning("FinancialScheduler: metrics sync failed: %s", e)
|
||||
@@ -259,14 +293,14 @@ class FinancialScheduler:
|
||||
if not fn:
|
||||
return {}
|
||||
rows = fn(self._data_dir, self._capset)
|
||||
self._last_sync[table] = datetime.now(timezone.utc).isoformat()
|
||||
self._record_sync(table)
|
||||
return {table: rows}
|
||||
# 全部同步
|
||||
symbols = _get_symbols(self._data_dir)
|
||||
result: dict[str, int] = {}
|
||||
for t in FINANCIAL_TABLES:
|
||||
result[t] = _sync_table(t, symbols, self._data_dir, self._capset, latest_only=True)
|
||||
self._last_sync[t] = datetime.now(timezone.utc).isoformat()
|
||||
self._record_sync(t)
|
||||
_refresh_financials_views(self._data_dir)
|
||||
return result
|
||||
|
||||
|
||||
@@ -311,3 +311,18 @@ def set_onboarding_completed(done: bool = True) -> bool:
|
||||
"""标记首次使用向导完成状态。"""
|
||||
save({"onboarding_completed": bool(done)})
|
||||
return bool(done)
|
||||
|
||||
|
||||
# ===== 财务数据同步时间(持久化,重启不丢失) =====
|
||||
# 结构: { "metrics": "2026-06-25T10:00:00+08:00", "income": ..., ... }
|
||||
|
||||
def get_financial_sync_times() -> dict[str, str]:
|
||||
"""返回各财务表的最后同步时间(ISO 字符串)。未同步过的表不在返回值中。"""
|
||||
return load().get("financial_sync_times", {}) or {}
|
||||
|
||||
|
||||
def set_financial_sync_time(table: str, iso_ts: str) -> None:
|
||||
"""更新单张财务表的最后同步时间(合并写入,不清除其他表)。"""
|
||||
times = get_financial_sync_times()
|
||||
times[table] = iso_ts
|
||||
save({"financial_sync_times": times})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "tickflow-stock-panel-backend"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配"
|
||||
readme = "../README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tickflow-stock-panel-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.44",
|
||||
"version": "0.1.45",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -5,6 +5,8 @@ import { motion } from 'framer-motion'
|
||||
import { useQuoteStream } from '@/lib/useQuoteStream'
|
||||
import { ToastContainer } from '@/components/Toast'
|
||||
import { AlertToastContainer } from '@/components/AlertToast'
|
||||
import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost'
|
||||
import { AiReportBubble } from '@/components/financials/AiReportBubble'
|
||||
import {
|
||||
useCapabilities,
|
||||
useSettings,
|
||||
@@ -65,7 +67,7 @@ const nav = [
|
||||
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
|
||||
{ to: '/industry-analysis', label: '行业分析', icon: Landmark },
|
||||
{ to: '/stock-analysis', label: '个股分析', icon: TrendingUp },
|
||||
{ to: '/financials', label: '财务', icon: FileText },
|
||||
{ to: '/financials', label: '财务分析', icon: FileText },
|
||||
{ to: '/indices', label: '指数', icon: BarChart3 },
|
||||
{ to: '/trading', label: '交易', icon: Cable },
|
||||
{ to: '/monitor', label: '监控中心', icon: RadioTower },
|
||||
@@ -529,6 +531,8 @@ export function Layout() {
|
||||
</motion.main>
|
||||
<ToastContainer />
|
||||
<AlertToastContainer />
|
||||
<AiAnalysisHost />
|
||||
<AiReportBubble />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
X, Sparkles, Loader2, AlertTriangle, Copy, Check, RefreshCw,
|
||||
Database, Settings2, Send, Wand2, Minimize2, History,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import {
|
||||
type ActiveTask, type HistoryReport,
|
||||
minimizeDialog, closeDialog, startAnalysis,
|
||||
} from '@/lib/aiReportStore'
|
||||
|
||||
interface Props {
|
||||
/** 当前展示的任务;活跃任务或历史报告 */
|
||||
task: ActiveTask | HistoryReport | null
|
||||
mode: 'active' | 'history' | null
|
||||
minimized: boolean
|
||||
}
|
||||
|
||||
type Phase = 'loading' | 'streaming' | 'done' | 'error'
|
||||
|
||||
// 统一字段读取:活跃任务有 phase/createdAt,历史报告没有(按 done 处理)
|
||||
function getPhase(task: ActiveTask | HistoryReport | null): Phase {
|
||||
if (!task) return 'loading'
|
||||
if ('phase' in task) return task.phase
|
||||
return 'done' // 历史报告视为已完成
|
||||
}
|
||||
function getContent(task: ActiveTask | HistoryReport | null): string {
|
||||
return task?.content ?? ''
|
||||
}
|
||||
function getMeta(task: ActiveTask | HistoryReport | null) {
|
||||
if (!task) return null
|
||||
if ('meta' in task) return task.meta
|
||||
// 历史报告
|
||||
return { summary: task.summary, periods: task.periods }
|
||||
}
|
||||
|
||||
export function AiAnalysisDialog({ task, mode, minimized }: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const focusInputRef = useRef<HTMLInputElement>(null)
|
||||
const [focus, setFocus] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const phase = getPhase(task)
|
||||
const content = getContent(task)
|
||||
const meta = getMeta(task)
|
||||
const isHistory = mode === 'history'
|
||||
const isWorking = phase === 'loading' || phase === 'streaming'
|
||||
const open = !!task && !minimized
|
||||
|
||||
// 流式时自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (open && phase === 'streaming' && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [content, phase, open])
|
||||
|
||||
// 切换任务时回填 focus
|
||||
useEffect(() => {
|
||||
setFocus(task && 'focus' in task ? task.focus : '')
|
||||
}, [task])
|
||||
|
||||
const handleStartNew = useCallback(async () => {
|
||||
if (!task) return
|
||||
const name = 'name' in task ? task.name : ''
|
||||
await startAnalysis(task.symbol, name, focus.trim())
|
||||
}, [task, focus])
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!content) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const error = task && 'error' in task ? task.error : ''
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget && !isWorking) closeDialog() }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 12 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.96, y: 12 }}
|
||||
transition={{ type: 'spring', damping: 26, stiffness: 320 }}
|
||||
className="w-full max-w-3xl max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* ===== 头部 ===== */}
|
||||
<div className="relative px-5 py-3.5 border-b border-border/50 bg-gradient-to-r from-purple-500/[0.06] via-fuchsia-500/[0.04] to-transparent">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 shrink-0">
|
||||
{isHistory
|
||||
? <History className="h-4.5 w-4.5 text-purple-300" />
|
||||
: <Sparkles className="h-4.5 w-4.5 text-purple-300" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground truncate">
|
||||
{isHistory ? '历史分析报告' : 'AI 财务分析'}
|
||||
</span>
|
||||
{task && <span className="text-xs text-secondary truncate">{task.name}</span>}
|
||||
{task && <span className="text-[10px] font-mono text-muted shrink-0">{task.symbol}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-muted">
|
||||
{meta?.summary ? (
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
<Database className="h-2.5 w-2.5 shrink-0" />
|
||||
<span className="truncate">{meta.summary}</span>
|
||||
</span>
|
||||
) : isWorking ? <span>正在准备数据…</span> : null}
|
||||
{phase === 'streaming' && (
|
||||
<span className="flex items-center gap-1 text-purple-300 shrink-0">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-purple-400 animate-pulse" />生成中
|
||||
</span>
|
||||
)}
|
||||
{isHistory && task && 'created_at' in task && (
|
||||
<span className="shrink-0">{fmtRelative(task.created_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 右侧操作按钮 */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{/* 复制:仅在内容就绪且非生成中显示 */}
|
||||
{content && !isWorking && (
|
||||
<button onClick={handleCopy} title="复制全文"
|
||||
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
|
||||
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
{/* 生成中:仅最小化(后台继续生成),无关闭按钮 */}
|
||||
{!isHistory && isWorking && (
|
||||
<button onClick={minimizeDialog} title="最小化为气泡,后台继续生成"
|
||||
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{/* 完成态/历史报告:显示关闭按钮 */}
|
||||
{(!isWorking || isHistory) && (
|
||||
<button onClick={closeDialog} title="关闭"
|
||||
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 内容区 ===== */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-4 min-h-[280px]">
|
||||
{/* 加载态 */}
|
||||
{phase === 'loading' && !content && (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<div className="relative">
|
||||
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 flex items-center justify-center">
|
||||
<Sparkles className="h-4.5 w-4.5 text-purple-300 animate-pulse" />
|
||||
</div>
|
||||
<Loader2 className="absolute -inset-1 h-12 w-12 text-purple-400/40 animate-spin" style={{ animationDuration: '3s' }} />
|
||||
</div>
|
||||
<div className="text-xs text-secondary">AI 正在分析财务数据…</div>
|
||||
<div className="text-[10px] text-muted">读取利润表 / 资负表 / 现金流 / 核心指标,生成专业报告</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误态 */}
|
||||
{phase === 'error' && (
|
||||
<div className="flex flex-col items-center justify-center py-14 gap-3">
|
||||
<div className="h-11 w-11 rounded-full bg-danger/10 flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-danger" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">分析失败</div>
|
||||
<div className="text-xs text-secondary text-center max-w-md px-4">{error}</div>
|
||||
{error.includes('AI') && (
|
||||
<button onClick={() => { window.location.href = '/settings?tab=ai' }}
|
||||
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
|
||||
<Settings2 className="h-3.5 w-3.5" /> 去配置 AI
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleStartNew}
|
||||
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-purple-500/15 border border-purple-400/30 text-xs text-purple-300 hover:bg-purple-500/20 transition-colors">
|
||||
<RefreshCw className="h-3.5 w-3.5" /> 重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 报告内容 */}
|
||||
{(content || phase === 'streaming') && (
|
||||
<div className="relative">
|
||||
<MarkdownRenderer content={content} />
|
||||
{phase === 'streaming' && (
|
||||
<span className="inline-block w-1.5 h-3.5 bg-purple-400 ml-0.5 align-middle animate-pulse rounded-sm" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== 底部:自定义关注点输入 ===== */}
|
||||
<div className="border-t border-border/50 bg-surface/60 px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted shrink-0">
|
||||
<Wand2 className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">关注重点</span>
|
||||
</div>
|
||||
<input
|
||||
ref={focusInputRef}
|
||||
type="text"
|
||||
value={focus}
|
||||
onChange={e => setFocus(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && (phase === 'done' || phase === 'error' || isHistory)) handleStartNew() }}
|
||||
disabled={isWorking}
|
||||
placeholder={isHistory ? '修改关注重点,回车重新生成' : (phase === 'done' ? '如:重点看债务风险…回车重新分析' : '可留空,留空则全面分析')}
|
||||
className={cn(
|
||||
'flex-1 h-8 px-3 rounded-lg bg-base ring-1 ring-border/30 text-xs text-foreground placeholder:text-muted/40',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-400/30 transition-shadow disabled:opacity-50',
|
||||
)}
|
||||
/>
|
||||
{isHistory ? (
|
||||
<button
|
||||
onClick={handleStartNew}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 text-xs font-medium text-purple-300 hover:from-purple-500/30 hover:to-fuchsia-500/20 transition-all shrink-0"
|
||||
title="以此关注点重新生成新报告"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />重新生成
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStartNew}
|
||||
disabled={isWorking}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 text-xs font-medium text-purple-300 hover:from-purple-500/30 hover:to-fuchsia-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-all shrink-0"
|
||||
title={focus.trim() ? '按关注重点重新分析' : '重新分析'}
|
||||
>
|
||||
{isWorking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : phase === 'done' ? <RefreshCw className="h-3.5 w-3.5" /> : <Send className="h-3.5 w-3.5" />}
|
||||
{phase === 'done' ? '重新分析' : '分析'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[10px] text-muted/50 leading-relaxed">
|
||||
{isHistory
|
||||
? '历史报告为静态记录;修改关注重点后将作为新任务重新生成。报告仅供参考,不构成投资建议。'
|
||||
: '报告由项目已配置的 AI 模型基于本地财务数据生成;可在输入框追加关注点后重新生成。报告仅供参考,不构成投资建议。'}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 小工具 =====
|
||||
function fmtRelative(iso: string): string {
|
||||
try {
|
||||
const t = new Date(iso).getTime()
|
||||
const diff = Date.now() - t
|
||||
if (diff < 60_000) return '刚刚'
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
|
||||
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
|
||||
if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
|
||||
return new Date(iso).toLocaleDateString('zh-CN')
|
||||
} catch { return '' }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useDialogTask, useDialogState } from '@/lib/aiReportStore'
|
||||
import { AiAnalysisDialog } from './AiAnalysisDialog'
|
||||
|
||||
/**
|
||||
* AI 分析对话框宿主 —— 单点挂载在 Layout。
|
||||
*
|
||||
* 从 store 读取当前对话框状态(任务 + 最小化),把 AiAnalysisDialog 作为纯视图渲染。
|
||||
* 一次挂载,全局生效:任意页面发起的分析都会显示在这个对话框里。
|
||||
*/
|
||||
export function AiAnalysisHost() {
|
||||
const { task, mode } = useDialogTask()
|
||||
const { minimized } = useDialogState()
|
||||
return <AiAnalysisDialog task={task} mode={mode} minimized={minimized} />
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Loader2, Check, AlertCircle } from 'lucide-react'
|
||||
import { useActiveTasks, restoreDialog } from '@/lib/aiReportStore'
|
||||
import type { ActiveTask } from '@/lib/aiReportStore'
|
||||
|
||||
/**
|
||||
* AI 分析任务全局气泡容器 —— 玻璃拟态卡片,挂在网页右侧。
|
||||
*
|
||||
* 拖拽丝滑的关键(60fps):
|
||||
* - 位置用 transform: translate3d 存储(走 GPU 合成层,不触发 layout/paint)
|
||||
* - 拖动期间直接操作 DOM.style.transform,完全绕开 React setState 重渲染
|
||||
* - 拖动结束才同步一次 state + 持久化 localStorage
|
||||
* - 拖动时给容器加 .dragging 类,禁用所有 transition,消除回弹延迟
|
||||
*
|
||||
* 视觉:
|
||||
* - 玻璃拟态(frosted glass):半透明 + backdrop-blur + 细边框 + 内发光
|
||||
* - 固定宽度,内容居中,多任务竖向堆叠
|
||||
* - 生成中:柔和呼吸光环(非刺眼 ping)
|
||||
* - hover:展开操作区,带平滑过渡
|
||||
*/
|
||||
|
||||
const BUBBLE_W = 148 // 卡片固定宽度(紧凑单行版)
|
||||
const EDGE_MARGIN = 12 // 距视口边缘最小间距
|
||||
|
||||
export function AiReportBubble() {
|
||||
const activeTasks = useActiveTasks()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
// pos 只在拖拽结束时更新一次(用于初始化/持久化),拖拽过程不触发它
|
||||
const [pos, setPos] = useState<{ x: number; y: number }>(() => loadPos())
|
||||
|
||||
// ===== 拖拽(纯 DOM 操作,60fps) =====
|
||||
const draggingRef = useRef(false)
|
||||
const dragData = useRef({ mx: 0, my: 0, ox: 0, oy: 0 }) // 鼠标起点 + 元素起点
|
||||
const movedRef = useRef(false) // 本次是否真的移动了(区分点击)
|
||||
// 记录 pointerdown 时命中的卡片回调(松手时若未拖动则触发它 = 点击)
|
||||
const clickTargetRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const applyTransform = useCallback((x: number, y: number) => {
|
||||
const el = containerRef.current
|
||||
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0)`
|
||||
}, [])
|
||||
|
||||
const clamp = useCallback((x: number, y: number) => {
|
||||
const maxX = window.innerWidth - BUBBLE_W - EDGE_MARGIN
|
||||
const maxY = window.innerHeight - 80
|
||||
return {
|
||||
x: Math.max(EDGE_MARGIN, Math.min(maxX, x)),
|
||||
y: Math.max(EDGE_MARGIN, Math.min(maxY, y)),
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
draggingRef.current = true
|
||||
movedRef.current = false
|
||||
dragData.current = { mx: e.clientX, my: e.clientY, ox: pos.x, oy: pos.y }
|
||||
const el = containerRef.current
|
||||
if (el) el.classList.add('dragging')
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
}, [pos.x, pos.y])
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!draggingRef.current) return
|
||||
const dx = e.clientX - dragData.current.mx
|
||||
const dy = e.clientY - dragData.current.my
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) movedRef.current = true
|
||||
const nx = dragData.current.ox + dx
|
||||
const ny = dragData.current.oy + dy
|
||||
const c = clamp(nx, ny)
|
||||
applyTransform(c.x, c.y) // ← 直接改 DOM,不走 React,丝滑
|
||||
}, [clamp, applyTransform])
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
if (!draggingRef.current) return
|
||||
draggingRef.current = false
|
||||
const el = containerRef.current
|
||||
if (el) el.classList.remove('dragging')
|
||||
if (movedRef.current) {
|
||||
// 拖动结束 → 持久化位置
|
||||
setPos(prev => {
|
||||
const transform = el?.style.transform ?? ''
|
||||
const m = transform.match(/translate3d\(([-\d.]+)px,\s*([-\d.]+)px/)
|
||||
const finalPos = m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : prev
|
||||
savePos(finalPos)
|
||||
return finalPos
|
||||
})
|
||||
} else {
|
||||
// 未移动 → 视为点击,触发卡片回调
|
||||
const fn = clickTargetRef.current
|
||||
clickTargetRef.current = null
|
||||
fn?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 窗口尺寸变化时确保不越界
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
setPos(prev => {
|
||||
const c = clamp(prev.x, prev.y)
|
||||
if (c.x !== prev.x || c.y !== prev.y) {
|
||||
applyTransform(c.x, c.y)
|
||||
return c
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [clamp, applyTransform])
|
||||
|
||||
// 初始化 transform(pos 变化时同步,如 resize / 首次挂载)
|
||||
useEffect(() => {
|
||||
applyTransform(pos.x, pos.y)
|
||||
}, [pos.x, pos.y, applyTransform])
|
||||
|
||||
if (activeTasks.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="ai-bubble-root fixed z-[60] select-none cursor-grab active:cursor-grabbing"
|
||||
style={{
|
||||
width: `${BUBBLE_W}px`,
|
||||
transform: `translate3d(${pos.x}px, ${pos.y}px, 0)`,
|
||||
touchAction: 'none',
|
||||
// 拖动时禁用过渡(通过 .dragging 类控制);静止时用 transition 让 resize/吸附有动画
|
||||
transition: 'transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{activeTasks.map((task, i) => (
|
||||
<BubbleItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isLast={i === activeTasks.length - 1}
|
||||
onPointerDown={() => { clickTargetRef.current = () => restoreDialog(task.id) }}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 内联样式:拖动时禁用过渡,确保 1:1 跟手 */}
|
||||
<style>{`
|
||||
.ai-bubble-root.dragging { transition: none !important; }
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 单个胶囊卡片(紧凑玻璃拟态) =====
|
||||
function BubbleItem({ task, isLast, onPointerDown }: {
|
||||
task: ActiveTask
|
||||
isLast: boolean
|
||||
onPointerDown: () => void
|
||||
}) {
|
||||
const isWorking = task.phase === 'loading' || task.phase === 'streaming'
|
||||
const isError = task.phase === 'error'
|
||||
|
||||
// 状态配色
|
||||
const accent = isWorking
|
||||
? 'from-purple-500/25 to-fuchsia-500/20 text-purple-300 border-purple-300/40 shadow-[0_6px_24px_-10px_rgba(168,85,247,0.5)]'
|
||||
: isError
|
||||
? 'from-red-500/20 to-red-500/10 text-red-300 border-red-300/40 shadow-[0_6px_20px_-10px_rgba(239,68,68,0.4)]'
|
||||
: 'from-emerald-500/20 to-emerald-500/10 text-emerald-300 border-emerald-300/40 shadow-[0_6px_20px_-10px_rgba(16,185,129,0.35)]'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: -8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: -8 }}
|
||||
transition={{ type: 'spring', damping: 22, stiffness: 300 }}
|
||||
className={isLast ? '' : 'mb-1.5'}
|
||||
>
|
||||
<div
|
||||
onPointerDown={onPointerDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={isWorking ? '生成中,点击恢复对话框' : isError ? '分析失败,点击重试' : '点击查看报告'}
|
||||
className={`group relative flex w-full cursor-pointer items-center gap-1.5 overflow-hidden rounded-lg border bg-gradient-to-br px-2 py-1.5 backdrop-blur-xl transition-all duration-200 hover:scale-[1.02] active:scale-[0.99] ${accent}`}
|
||||
>
|
||||
{/* 生成中:顶部进度流光 */}
|
||||
{isWorking && (
|
||||
<div className="absolute inset-x-0 top-0 h-px overflow-hidden">
|
||||
<div className="h-full w-1/2 bg-gradient-to-r from-transparent via-purple-200 to-transparent animate-bubble-progress" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 状态图标 */}
|
||||
<span className="flex h-4 w-4 items-center justify-center shrink-0">
|
||||
{isWorking ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : isError ? (
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* 标的名(单行) */}
|
||||
<span className="flex-1 min-w-0 text-[11px] font-medium text-foreground leading-none truncate">
|
||||
{task.name || task.symbol}
|
||||
</span>
|
||||
|
||||
{/* 状态后缀 */}
|
||||
<span className="shrink-0 text-[9px] leading-none">
|
||||
{isWorking ? (
|
||||
<span className="text-purple-300/80">分析中</span>
|
||||
) : isError ? (
|
||||
<span className="text-red-300/80">失败</span>
|
||||
) : (
|
||||
<span className="text-emerald-300/80">点击查看</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 内联关键帧:进度条流动 */}
|
||||
<style>{`
|
||||
@keyframes bubble-progress {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(300%); }
|
||||
}
|
||||
.animate-bubble-progress { animation: bubble-progress 1.6s ease-in-out infinite; }
|
||||
`}</style>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 位置持久化 =====
|
||||
const POS_KEY = 'ai_bubble_pos'
|
||||
function loadPos(): { x: number; y: number } {
|
||||
// 默认:右下角(距右边缘 EDGE_MARGIN,距底部留出空间避开右下角元素)
|
||||
const defaultX = Math.max(EDGE_MARGIN, window.innerWidth - BUBBLE_W - EDGE_MARGIN)
|
||||
const defaultY = Math.max(EDGE_MARGIN, window.innerHeight - 200)
|
||||
try {
|
||||
const v = localStorage.getItem(POS_KEY)
|
||||
if (v) {
|
||||
const p = JSON.parse(v)
|
||||
if (typeof p.x === 'number' && typeof p.y === 'number') {
|
||||
// 钳制到当前视口(防止保存的位置在缩小后的窗口外)
|
||||
return {
|
||||
x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth - BUBBLE_W - EDGE_MARGIN, p.x)),
|
||||
y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight - 80, p.y)),
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return { x: defaultX, y: defaultY }
|
||||
}
|
||||
function savePos(p: { x: number; y: number }) {
|
||||
try { localStorage.setItem(POS_KEY, JSON.stringify(p)) } catch { /* ignore */ }
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { Fragment, type ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* 轻量 Markdown 渲染器 — 零依赖,专为 AI 财务分析报告设计。
|
||||
*
|
||||
* 支持的语法(AI 财务分析提示词约束的子集,足够用):
|
||||
* - 标题 # ## ### ####
|
||||
* - 加粗 **text**
|
||||
* - 行内代码 `code`
|
||||
* - 无序列表 - / *
|
||||
* - 有序列表 1.
|
||||
* - 表格 | a | b |
|
||||
* - 引用 >
|
||||
* - 分隔线 --- / ***
|
||||
* - 段落
|
||||
*
|
||||
* 不追求完整 GFM,只覆盖 AI 报告会产出的结构。
|
||||
*/
|
||||
|
||||
// ===== 行内格式:加粗 / 行内代码 / 星号评级 =====
|
||||
|
||||
function renderInline(text: string, keyBase: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = []
|
||||
// 正则:匹配 **加粗** 或 `代码` 或 ★ 评级
|
||||
const re = /(\*\*([^*]+)\*\*)|(`([^`]+)`)/g
|
||||
let last = 0
|
||||
let m: RegExpExecArray | null
|
||||
let i = 0
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (m.index > last) nodes.push(<Fragment key={`${keyBase}-t-${i}`}>{text.slice(last, m.index)}</Fragment>)
|
||||
if (m[1]) {
|
||||
// 加粗
|
||||
nodes.push(<strong key={`${keyBase}-b-${i}`} className="font-semibold text-foreground">{m[2]}</strong>)
|
||||
} else if (m[3]) {
|
||||
// 行内代码
|
||||
nodes.push(
|
||||
<code key={`${keyBase}-c-${i}`} className="px-1 py-0.5 rounded bg-elevated text-[0.85em] font-mono text-accent">
|
||||
{m[4]}
|
||||
</code>,
|
||||
)
|
||||
}
|
||||
last = m.index + m[0].length
|
||||
i++
|
||||
}
|
||||
if (last < text.length) nodes.push(<Fragment key={`${keyBase}-t-end`}>{text.slice(last)}</Fragment>)
|
||||
return nodes
|
||||
}
|
||||
|
||||
// ===== 表格解析 =====
|
||||
|
||||
function parseTable(lines: string[], start: number): { rows: string[][]; consumed: number } | null {
|
||||
// 找到连续的表格行(以 | 开头)
|
||||
const tableLines: string[] = []
|
||||
let idx = start
|
||||
while (idx < lines.length && lines[idx].trim().startsWith('|')) {
|
||||
tableLines.push(lines[idx].trim())
|
||||
idx++
|
||||
}
|
||||
if (tableLines.length < 2) return null
|
||||
// 第二行必须是分隔行 |---|---|
|
||||
if (!/^|[\s-:|]+$/.test(tableLines[1]) && !tableLines[1].split('|').every(c => /^[\s-:]*$/.test(c))) {
|
||||
return null
|
||||
}
|
||||
const parseRow = (line: string) =>
|
||||
line.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim())
|
||||
const header = parseRow(tableLines[0])
|
||||
const body = tableLines.slice(2).map(parseRow)
|
||||
return { rows: [header, ...body], consumed: tableLines.length }
|
||||
}
|
||||
|
||||
// ===== 主渲染 =====
|
||||
|
||||
export function MarkdownRenderer({ content }: { content: string }) {
|
||||
const lines = content.replace(/\r\n/g, '\n').split('\n')
|
||||
const blocks: ReactNode[] = []
|
||||
let i = 0
|
||||
let key = 0
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
const trimmed = line.trim()
|
||||
|
||||
// 空行
|
||||
if (!trimmed) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// 分隔线
|
||||
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
|
||||
blocks.push(<hr key={key++} className="my-3 border-border/40" />)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// 标题
|
||||
const hMatch = trimmed.match(/^(#{1,4})\s+(.+)$/)
|
||||
if (hMatch) {
|
||||
const level = hMatch[1].length
|
||||
const text = hMatch[2]
|
||||
const sizeCls = level === 1 ? 'text-base' : level === 2 ? 'text-sm' : 'text-xs'
|
||||
const mtCls = level <= 2 ? 'mt-4' : 'mt-3'
|
||||
blocks.push(
|
||||
<div key={key++} className={`${sizeCls} ${mtCls} mb-2 font-semibold text-foreground flex items-center gap-1.5`}>
|
||||
{renderInline(text, `h-${key}`)}
|
||||
</div>,
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// 引用
|
||||
if (trimmed.startsWith('>')) {
|
||||
const quoteLines: string[] = []
|
||||
while (i < lines.length && lines[i].trim().startsWith('>')) {
|
||||
quoteLines.push(lines[i].trim().replace(/^>\s?/, ''))
|
||||
i++
|
||||
}
|
||||
blocks.push(
|
||||
<blockquote key={key++} className="my-2 pl-3 border-l-2 border-amber-400/40 bg-amber-400/[0.04] py-1.5 pr-2 rounded-r text-xs text-secondary">
|
||||
{renderInline(quoteLines.join(' '), `q-${key}`)}
|
||||
</blockquote>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 表格
|
||||
if (trimmed.startsWith('|')) {
|
||||
const table = parseTable(lines, i)
|
||||
if (table) {
|
||||
const [header, ...body] = table.rows
|
||||
const ncol = header.length
|
||||
blocks.push(
|
||||
<div key={key++} className="my-3 overflow-hidden rounded-btn border border-border/30">
|
||||
<table className="w-full text-xs border-collapse table-fixed">
|
||||
<colgroup>
|
||||
{/* 首列(维度)较窄;末列(判断/说明)最宽并允许折行 */}
|
||||
<col className="w-auto" />
|
||||
{Array.from({ length: ncol - 1 }).map((_, ci) => (
|
||||
<col key={ci} className={ci === ncol - 2 ? 'w-1/2' : 'w-auto'} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="bg-elevated/50">
|
||||
{header.map((cell, ci) => (
|
||||
<th key={ci} className="px-2.5 py-1.5 text-left font-medium text-secondary border-b border-border/40 whitespace-nowrap">
|
||||
{renderInline(cell, `th-${key}-${ci}`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{body.map((row, ri) => (
|
||||
<tr key={ri} className="border-b border-border/20 last:border-0 hover:bg-elevated/20">
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className="px-2.5 py-1.5 text-foreground/90 align-top break-words">
|
||||
{renderInline(cell, `td-${key}-${ri}-${ci}`)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>,
|
||||
)
|
||||
i += table.consumed
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 无序列表
|
||||
if (/^[-*]\s+/.test(trimmed)) {
|
||||
const items: string[] = []
|
||||
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
|
||||
items.push(lines[i].replace(/^\s*[-*]\s+/, ''))
|
||||
i++
|
||||
}
|
||||
blocks.push(
|
||||
<ul key={key++} className="my-1.5 space-y-1">
|
||||
{items.map((item, ii) => (
|
||||
<li key={ii} className="flex items-start gap-2 text-xs text-foreground/90 leading-relaxed">
|
||||
<span className="mt-1.5 h-1 w-1 rounded-full bg-accent/60 shrink-0" />
|
||||
<span>{renderInline(item, `li-${key}-${ii}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 有序列表
|
||||
if (/^\d+\.\s+/.test(trimmed)) {
|
||||
const items: string[] = []
|
||||
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
|
||||
items.push(lines[i].replace(/^\s*\d+\.\s+/, ''))
|
||||
i++
|
||||
}
|
||||
blocks.push(
|
||||
<ol key={key++} className="my-1.5 space-y-1">
|
||||
{items.map((item, ii) => (
|
||||
<li key={ii} className="flex items-start gap-2 text-xs text-foreground/90 leading-relaxed">
|
||||
<span className="mt-0.5 h-4 w-4 rounded-full bg-accent/10 text-accent text-[10px] font-mono flex items-center justify-center shrink-0">
|
||||
{ii + 1}
|
||||
</span>
|
||||
<span className="flex-1">{renderInline(item, `ol-${key}-${ii}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 普通段落
|
||||
blocks.push(
|
||||
<p key={key++} className="my-1.5 text-xs text-foreground/90 leading-relaxed">
|
||||
{renderInline(trimmed, `p-${key}`)}
|
||||
</p>,
|
||||
)
|
||||
i++
|
||||
}
|
||||
|
||||
return <div className="space-y-0">{blocks}</div>
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect } from 'react'
|
||||
import { History, Trash2, FileText, Clock, Sparkles, Loader2 } from 'lucide-react'
|
||||
import { useHistoryReports, openHistoryReport, deleteReport, loadHistory } from '@/lib/aiReportStore'
|
||||
import { useActiveTasks } from '@/lib/aiReportStore'
|
||||
|
||||
/**
|
||||
* AI 财务分析历史报告面板 —— 显示在财务页底部。
|
||||
*
|
||||
* - 列出最近 20 条报告(后端裁剪)
|
||||
* - 点击查看 → 打开到对话框(历史模式)
|
||||
* - 显示正在生成中的对应标的(若该标的有活跃任务,标注)
|
||||
* - 支持删除单条
|
||||
*/
|
||||
export function ReportHistoryPanel() {
|
||||
const { reports, loaded } = useHistoryReports()
|
||||
const activeTasks = useActiveTasks()
|
||||
|
||||
// 首次挂载拉取一次
|
||||
useEffect(() => { loadHistory() }, [])
|
||||
|
||||
// 活跃任务的 symbol 集合(用于在历史列表里标注"生成中")
|
||||
const activeSymbols = new Set(activeTasks.map(t => t.symbol))
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="rounded-card border border-border/40 bg-surface px-4 py-6 text-center">
|
||||
<Loader2 />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (reports.length === 0) {
|
||||
return (
|
||||
<div className="rounded-card border border-dashed border-border/50 bg-surface/50 px-6 py-8 text-center">
|
||||
<History className="mx-auto h-6 w-6 text-muted/40" />
|
||||
<div className="mt-2 text-xs text-muted">暂无历史分析报告</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted/60">选择个股后点击「AI 财务分析」生成,报告会自动保存在此</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-surface overflow-hidden">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border/50 bg-elevated/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="h-3.5 w-3.5 text-secondary" />
|
||||
<span className="text-xs font-medium text-foreground">历史分析报告</span>
|
||||
<span className="text-[10px] text-muted">{reports.length}/20</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted/60">点击查看 · 报告最多保留 20 条</span>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className="divide-y divide-border/30 max-h-80 overflow-y-auto">
|
||||
{reports.map(r => {
|
||||
const isGenerating = activeSymbols.has(r.symbol)
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
className="group flex items-center gap-3 px-4 py-2.5 hover:bg-elevated/30 transition-colors cursor-pointer"
|
||||
onClick={() => openHistoryReport(r.id)}
|
||||
>
|
||||
{/* 图标 */}
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-lg shrink-0 ${
|
||||
isGenerating
|
||||
? 'bg-purple-400/10 text-purple-300'
|
||||
: 'bg-elevated text-secondary group-hover:text-accent'
|
||||
}`}>
|
||||
{isGenerating
|
||||
? <Sparkles className="h-3.5 w-3.5 animate-pulse" />
|
||||
: <FileText className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
|
||||
{/* 主信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-foreground truncate">{r.name || r.symbol}</span>
|
||||
<span className="text-[10px] font-mono text-muted shrink-0">{r.symbol}</span>
|
||||
{r.focus && (
|
||||
<span className="hidden sm:inline-block px-1.5 py-px rounded bg-purple-400/10 text-purple-300 text-[9px] shrink-0">
|
||||
{r.focus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 摘要 */}
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[10px] text-muted/70 flex items-center gap-1">
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
{fmtRelative(r.created_at)}
|
||||
</span>
|
||||
{r.summary && (
|
||||
<span className="text-[10px] text-muted/50 truncate">{r.summary}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); deleteReport(r.id) }}
|
||||
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-lg hover:bg-danger/10 text-muted hover:text-danger transition-all shrink-0"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 小工具 =====
|
||||
function fmtRelative(iso: string): string {
|
||||
try {
|
||||
const t = new Date(iso).getTime()
|
||||
const diff = Date.now() - t
|
||||
if (diff < 60_000) return '刚刚'
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
|
||||
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
|
||||
if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
|
||||
return new Date(iso).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
|
||||
} catch { return '' }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { CalendarDays, TrendingUp, FileText, Wallet, Activity, Sparkles } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { CalendarDays, TrendingUp, FileText, Wallet, Activity, Sparkles, AlertTriangle, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
useFinancialMetrics,
|
||||
useFinancialIncome,
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
} from '@/lib/useFinancials'
|
||||
import { fmtPrice, fmtBigNum, fmtDate } from '@/lib/format'
|
||||
import { Skeleton } from '@/components/data/Skeleton'
|
||||
import { startAnalysis, findLatestHistoryReport, openHistoryReport } from '@/lib/aiReportStore'
|
||||
import { toast } from '@/components/Toast'
|
||||
|
||||
interface Props {
|
||||
symbol: string
|
||||
@@ -112,11 +115,33 @@ function formatValue(v: number | null | undefined, fmt: FmtType): string {
|
||||
|
||||
export function StockFinancialDetail({ symbol, name }: Props) {
|
||||
const [tab, setTab] = useState<TabKey>('metrics')
|
||||
// AI 财务分析占位: 功能开发中, 点击提示
|
||||
const [showDevToast, setShowDevToast] = useState(false)
|
||||
const handleAiAnalysis = () => {
|
||||
setShowDevToast(true)
|
||||
setTimeout(() => setShowDevToast(false), 2500)
|
||||
// AI 分析:点击时检查历史,若已有同标的报告则二次确认
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [confirmReport, setConfirmReport] = useState<{ id: string; created_at: string; focus: string } | null>(null)
|
||||
|
||||
const handleAiClick = async () => {
|
||||
if (checking) return
|
||||
setChecking(true)
|
||||
try {
|
||||
const latest = await findLatestHistoryReport(symbol)
|
||||
if (latest) {
|
||||
// 有历史报告 → 弹二次确认
|
||||
setConfirmReport({ id: latest.id, created_at: latest.created_at, focus: latest.focus })
|
||||
} else {
|
||||
// 无历史 → 直接分析
|
||||
await doAnalysis()
|
||||
}
|
||||
} catch {
|
||||
// 查询失败不阻塞,直接分析
|
||||
await doAnalysis()
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const doAnalysis = async () => {
|
||||
const r = await startAnalysis(symbol, name)
|
||||
if (r.error) toast(r.error, 'error')
|
||||
}
|
||||
|
||||
const metrics = useFinancialMetrics(symbol)
|
||||
@@ -143,7 +168,7 @@ export function StockFinancialDetail({ symbol, name }: Props) {
|
||||
const latestAnnounce = rows[0]?.announce_date ?? metrics.data?.data?.[0]?.announce_date ?? null
|
||||
|
||||
return (
|
||||
<div className="relative rounded-card border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-card border border-border bg-surface overflow-hidden">
|
||||
{/* 头部:标的 + 报告期 */}
|
||||
<div className="px-5 py-4 border-b border-border flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-baseline gap-2 min-w-0">
|
||||
@@ -151,6 +176,15 @@ export function StockFinancialDetail({ symbol, name }: Props) {
|
||||
<span className="text-xs font-mono text-muted">{symbol}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<button
|
||||
onClick={handleAiClick}
|
||||
disabled={checking}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-btn text-[11px] font-medium border border-purple-400/30 bg-purple-400/10 text-purple-300 hover:bg-purple-400/20 hover:border-purple-400/40 transition-all shrink-0 disabled:opacity-50"
|
||||
title="AI 财务分析"
|
||||
>
|
||||
{checking ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkles className="h-3 w-3" />}
|
||||
AI 财务分析
|
||||
</button>
|
||||
{latestPeriod && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-secondary">
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
@@ -160,14 +194,6 @@ export function StockFinancialDetail({ symbol, name }: Props) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleAiAnalysis}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-btn text-[11px] font-medium border border-purple-400/30 bg-purple-400/10 text-purple-300 hover:bg-purple-400/20 transition-colors shrink-0"
|
||||
title="AI 财务分析(开发中)"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
AI 财务分析
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -241,12 +267,78 @@ export function StockFinancialDetail({ symbol, name }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI 分析开发中提示 */}
|
||||
{showDevToast && (
|
||||
<div className="absolute top-16 right-6 z-50 rounded-btn border border-purple-400/40 bg-purple-400/15 px-3 py-2 text-xs text-purple-200 shadow-lg backdrop-blur-sm animate-pulse">
|
||||
✨ AI 财务分析功能开发中,敬请期待
|
||||
</div>
|
||||
)}
|
||||
{/* AI 分析二次确认:已有该标的历史报告 */}
|
||||
<AnimatePresence>
|
||||
{confirmReport && (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setConfirmReport(null)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative w-[90vw] max-w-[400px] rounded-card border border-border bg-base shadow-2xl p-6"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 h-10 w-10 rounded-full bg-purple-400/12 flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-purple-300" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-1.5">该个股已有分析报告</h3>
|
||||
<p className="text-xs text-secondary leading-relaxed">
|
||||
<span className="font-medium text-foreground">{name}</span>
|
||||
<span className="font-mono text-muted"> {symbol}</span> 在
|
||||
<span className="text-purple-300 font-medium"> {fmtReportTime(confirmReport.created_at)} </span>
|
||||
已生成过 AI 财务分析报告。
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] text-muted">
|
||||
您可以查看历史报告,或基于最新数据重新生成一份。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-5">
|
||||
<button
|
||||
onClick={() => setConfirmReport(null)}
|
||||
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-xs transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirmReport) openHistoryReport(confirmReport.id); setConfirmReport(null) }}
|
||||
className="px-3 py-1.5 rounded-btn border border-border text-secondary hover:text-foreground text-xs font-medium transition-colors"
|
||||
>
|
||||
查看历史报告
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { doAnalysis(); setConfirmReport(null) }}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-purple-500/80 to-fuchsia-500/80 text-white text-xs font-medium hover:from-purple-500 hover:to-fuchsia-500 transition-all"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
重新分析
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 历史报告时间友好显示
|
||||
function fmtReportTime(iso: string): string {
|
||||
try {
|
||||
const t = new Date(iso).getTime()
|
||||
const diff = Date.now() - t
|
||||
if (diff < 60_000) return '刚刚'
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
|
||||
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
|
||||
if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
|
||||
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { api } from './api'
|
||||
|
||||
/**
|
||||
* AI 财务分析 —— 全局任务/报告 store(与 UI 解耦)。
|
||||
*
|
||||
* 设计要点:
|
||||
* 1. 流式接收逻辑在这里,与弹窗组件解耦 → 用户关闭/最小化弹窗,后台流照常累积。
|
||||
* 2. useSyncExternalStore 订阅 → 任意组件(弹窗、气泡、历史面板)实时同步。
|
||||
* 3. "活跃任务"上限 MAX_ACTIVE=3:同时进行的任务最多 3 个,超出拒绝新建。
|
||||
* (历史报告名额 MAX_REPORTS=20 在后端裁剪,与活跃任务名额分离)
|
||||
* 4. 同 symbol 已有活跃任务 → 直接聚焦那个,不新建第 2 个。
|
||||
* 5. 任务完成(收到 done 或 content 非空且流结束)→ 自动存后端 + 移入历史 + 弹窗可恢复为"历史模式"。
|
||||
*/
|
||||
|
||||
export type Phase = 'loading' | 'streaming' | 'done' | 'error'
|
||||
|
||||
export interface ActiveTask {
|
||||
id: string // 任务 id(前端生成,与最终 report id 解耦)
|
||||
symbol: string
|
||||
name: string
|
||||
focus: string
|
||||
phase: Phase
|
||||
content: string // 累积的 Markdown
|
||||
error: string
|
||||
meta: { summary?: string; periods?: number } | null
|
||||
createdAt: number // ms 时间戳
|
||||
savedReportId?: string // 完成后存到后端的报告 id
|
||||
doneAt?: number // 进入 done/error 态的时间戳(用于气泡过期清理)
|
||||
dismissed?: boolean // 用户已从气泡点击查看过 → 不再在气泡显示
|
||||
}
|
||||
|
||||
export interface HistoryReport {
|
||||
id: string
|
||||
symbol: string
|
||||
name: string
|
||||
focus: string
|
||||
content: string
|
||||
periods?: number
|
||||
summary?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const MAX_ACTIVE = 3
|
||||
|
||||
// ===== 全局状态 =====
|
||||
let activeTasks: ActiveTask[] = []
|
||||
let history: HistoryReport[] = []
|
||||
let historyLoaded = false
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
// 当前"前台"展示的任务:
|
||||
// - 活跃任务 id(正在生成/刚完成,对话框打开)
|
||||
// - 或 'history:<id>'(查看历史报告)
|
||||
// - 或 null(对话框关闭/最小化)
|
||||
let activeDialogTaskId: string | null = null
|
||||
let dialogMinimized = false // 对话框是否最小化为气泡
|
||||
|
||||
function emit() { listeners.forEach(fn => fn()) }
|
||||
|
||||
function subscribe(fn: () => void) {
|
||||
listeners.add(fn)
|
||||
return () => { listeners.delete(fn) }
|
||||
}
|
||||
|
||||
// 快照必须返回稳定引用:只有内容真正变化时才返回新数组/对象。
|
||||
// useSyncExternalStore 用 Object.is 比较,getSnapshot 必须缓存。
|
||||
let _activeSnap: ActiveTask[] = []
|
||||
let _historySnap: HistoryReport[] = []
|
||||
interface DialogSnap { taskId: string | null; minimized: boolean }
|
||||
let _dialogSnap: DialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
|
||||
|
||||
function rebuildSnap() {
|
||||
_activeSnap = activeTasks
|
||||
_historySnap = history
|
||||
_dialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
|
||||
}
|
||||
|
||||
function getActiveSnapshot() { return _activeSnap }
|
||||
function getHistorySnapshot() { return _historySnap }
|
||||
function getDialogSnapshot() { return _dialogSnap }
|
||||
|
||||
function patchTask(id: string, patch: Partial<ActiveTask>) {
|
||||
activeTasks = activeTasks.map(t => {
|
||||
if (t.id !== id) return t
|
||||
const next = { ...t, ...patch }
|
||||
// 首次进入 done/error 态时记录 doneAt
|
||||
if ((patch.phase === 'done' || patch.phase === 'error') && t.phase !== patch.phase && !next.doneAt) {
|
||||
next.doneAt = Date.now()
|
||||
}
|
||||
return next
|
||||
})
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
// ===== 公开:查询 hooks =====
|
||||
|
||||
export function useBubbleTasks(): ActiveTask[] {
|
||||
const all = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
|
||||
// 同时订阅对话框状态:最小化/打开/关闭会改变气泡可见性,需独立触发重渲染。
|
||||
// (否则最小化时 activeTasks 引用未变,useSyncExternalStore 不会重渲染,胶囊不出现)
|
||||
useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
|
||||
const ds = _dialogSnap
|
||||
return all.filter(t => {
|
||||
// 进行中:始终显示(dismissed 仅作用于完成态,不影响生成中的任务再次最小化)
|
||||
if (t.phase === 'loading' || t.phase === 'streaming') {
|
||||
// 除非对话框正打开看着它(非最小化)
|
||||
return !(ds.taskId === t.id && !ds.minimized)
|
||||
}
|
||||
// 完成/失败态:常驻显示,直到用户主动点击查看(dismissed)。
|
||||
// 不设自动过期 —— 胶囊是持续可见的状态指示器,历史报告列表是查看入口。
|
||||
if (t.dismissed) return false // 用户已点击查看过 → 移除
|
||||
if (!ds.minimized && ds.taskId === t.id) return false // 对话框正展示 → 不重复
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** 兼容旧调用名(Layout 等处可能引用) */
|
||||
export const useActiveTasks = useBubbleTasks
|
||||
|
||||
export function useHistoryReports(): { reports: HistoryReport[]; loaded: boolean } {
|
||||
const reports = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
|
||||
return { reports, loaded: historyLoaded }
|
||||
}
|
||||
|
||||
export function useDialogState() {
|
||||
return useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
|
||||
}
|
||||
|
||||
/** 当前对话框要展示的任务(活跃或历史),null=未打开。 */
|
||||
export function useDialogTask(): { task: ActiveTask | HistoryReport | null; mode: 'active' | 'history' | null } {
|
||||
const ds = useDialogState()
|
||||
const active = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
|
||||
const hist = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
|
||||
if (!ds.taskId) return { task: null, mode: null }
|
||||
if (ds.taskId.startsWith('history:')) {
|
||||
const rid = ds.taskId.slice('history:'.length)
|
||||
return { task: hist.find(r => r.id === rid) ?? null, mode: 'history' }
|
||||
}
|
||||
return { task: active.find(t => t.id === ds.taskId) ?? null, mode: 'active' }
|
||||
}
|
||||
|
||||
// ===== 公开:动作 =====
|
||||
|
||||
/** 拉取历史报告(惰性,首次需要时调用)。 */
|
||||
export async function loadHistory(): Promise<void> {
|
||||
try {
|
||||
const res = await api.financialReportsList()
|
||||
history = res.reports ?? []
|
||||
historyLoaded = true
|
||||
rebuildSnap()
|
||||
emit()
|
||||
} catch {
|
||||
// 静默失败,列表会显示空
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询某只股票最近一次的历史分析报告(用于二次确认提示)。
|
||||
* 若历史未加载,先触发拉取。
|
||||
* @returns 最近一条报告,或 null
|
||||
*/
|
||||
export async function findLatestHistoryReport(symbol: string): Promise<HistoryReport | null> {
|
||||
if (!historyLoaded) await loadHistory()
|
||||
// history 已按 created_at 降序,取第一条匹配
|
||||
return history.find(r => r.symbol === symbol) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动一个新的 AI 分析任务。
|
||||
* @returns 任务 id;若超出上限或已有活跃任务,返回 { error }。
|
||||
*/
|
||||
export async function startAnalysis(symbol: string, name: string, focus = ''): Promise<{ id?: string; error?: string }> {
|
||||
// 同 symbol 已有活跃任务 → 直接聚焦它
|
||||
const existing = activeTasks.find(t => t.symbol === symbol && (t.phase === 'loading' || t.phase === 'streaming'))
|
||||
if (existing) {
|
||||
activeDialogTaskId = existing.id
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
return { id: existing.id }
|
||||
}
|
||||
// 上限检查
|
||||
const ongoing = activeTasks.filter(t => t.phase === 'loading' || t.phase === 'streaming')
|
||||
if (ongoing.length >= MAX_ACTIVE) {
|
||||
return { error: `同时进行的分析任务不能超过 ${MAX_ACTIVE} 个,请等待现有任务完成` }
|
||||
}
|
||||
|
||||
const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
|
||||
const task: ActiveTask = {
|
||||
id, symbol, name, focus,
|
||||
phase: 'loading', content: '', error: '',
|
||||
meta: null, createdAt: Date.now(),
|
||||
}
|
||||
activeTasks = [...activeTasks, task]
|
||||
activeDialogTaskId = id
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
|
||||
// 启动流式接收(后台运行,不阻塞)
|
||||
runStream(id, symbol, focus)
|
||||
return { id }
|
||||
}
|
||||
|
||||
async function runStream(id: string, symbol: string, focus: string) {
|
||||
try {
|
||||
let firstDelta = true
|
||||
for await (const chunk of api.financialAnalyzeStream(symbol, focus)) {
|
||||
// 任务可能已被取消(不在列表里了)→ 终止
|
||||
const cur = activeTasks.find(t => t.id === id)
|
||||
if (!cur) return
|
||||
switch (chunk.type) {
|
||||
case 'meta':
|
||||
patchTask(id, { meta: { summary: chunk.summary, periods: chunk.periods } })
|
||||
break
|
||||
case 'delta':
|
||||
if (firstDelta) { patchTask(id, { phase: 'streaming' }); firstDelta = false }
|
||||
patchTask(id, { content: cur.content + (chunk.content ?? '') })
|
||||
break
|
||||
case 'error':
|
||||
patchTask(id, { phase: 'error', error: chunk.message ?? '分析失败' })
|
||||
return
|
||||
case 'done':
|
||||
// 标记完成,稍后持久化(content 可能还在最后几个 delta 里,以 done 时为准)
|
||||
patchTask(id, { phase: 'done' })
|
||||
break
|
||||
}
|
||||
}
|
||||
// 流正常结束 → 持久化报告
|
||||
const final = activeTasks.find(t => t.id === id)
|
||||
if (final && final.phase !== 'error' && final.content) {
|
||||
try {
|
||||
const res = await api.financialReportSave({
|
||||
symbol: final.symbol,
|
||||
name: final.name,
|
||||
focus: final.focus,
|
||||
content: final.content,
|
||||
periods: final.meta?.periods,
|
||||
summary: final.meta?.summary ?? '',
|
||||
})
|
||||
if (res.report) {
|
||||
patchTask(id, { savedReportId: res.report.id })
|
||||
// 加到历史列表头部
|
||||
history = [res.report, ...history.filter(r => r.id !== res.report.id)]
|
||||
historyLoaded = true
|
||||
rebuildSnap()
|
||||
emit()
|
||||
// 任务完成:不自动弹出对话框,只在胶囊显示"已完成"态,用户想看再点。
|
||||
// (若对话框正打开看此任务,内容已实时更新;最小化/在别处则胶囊亮起完成态)
|
||||
}
|
||||
} catch {
|
||||
// 持久化失败不影响前端已展示的内容
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.message ?? '分析失败')
|
||||
patchTask(id, {
|
||||
phase: 'error',
|
||||
error: msg.includes('API Key') || msg.includes('api_key')
|
||||
? 'AI API Key 未配置或无效,请在「设置 → AI」中配置'
|
||||
: msg,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开对话框(活跃任务或历史报告)。 */
|
||||
export function openDialog(taskId: string) {
|
||||
activeDialogTaskId = taskId
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
/** 最小化对话框 → 变成气泡。 */
|
||||
export function minimizeDialog() {
|
||||
dialogMinimized = true
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
/** 关闭对话框(活跃任务继续在后台跑,仅移除对话框视图)。
|
||||
* 对历史报告:仅关闭视图。
|
||||
*/
|
||||
export function closeDialog() {
|
||||
activeDialogTaskId = null
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
/** 从气泡恢复对话框。
|
||||
* 仅对已完成/失败的任务标记 dismissed(看过结果就不必再弹);
|
||||
* 生成中的任务不标记 —— 用户再次最小化时气泡应重新出现。
|
||||
*/
|
||||
export function restoreDialog(taskId: string) {
|
||||
const t = activeTasks.find(x => x.id === taskId)
|
||||
if (t && (t.phase === 'done' || t.phase === 'error')) {
|
||||
patchTask(taskId, { dismissed: true })
|
||||
}
|
||||
activeDialogTaskId = taskId
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
/** 重试一个失败/已完成的任务(以新任务方式重新分析)。 */
|
||||
export async function retryAnalysis(task: { symbol: string; name: string; focus: string }): Promise<{ error?: string }> {
|
||||
return startAnalysis(task.symbol, task.name, task.focus)
|
||||
}
|
||||
|
||||
/** 删除历史报告。 */
|
||||
export async function deleteReport(reportId: string): Promise<void> {
|
||||
try {
|
||||
await api.financialReportDelete(reportId)
|
||||
history = history.filter(r => r.id !== reportId)
|
||||
rebuildSnap()
|
||||
emit()
|
||||
} catch {
|
||||
// 静默
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开历史报告到对话框。 */
|
||||
export function openHistoryReport(reportId: string) {
|
||||
activeDialogTaskId = `history:${reportId}`
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
@@ -104,6 +104,18 @@ export interface FinancialCashFlowRecord {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/** AI 财务分析历史报告 */
|
||||
export interface AiFinancialReport {
|
||||
id: string
|
||||
symbol: string
|
||||
name: string
|
||||
focus: string
|
||||
content: string
|
||||
periods?: number
|
||||
summary?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// ===== Kline =====
|
||||
export interface MinuteKlineRow {
|
||||
datetime: string
|
||||
@@ -1172,6 +1184,80 @@ export const api = {
|
||||
`/api/financials/sync/${table}`, { method: 'POST' },
|
||||
),
|
||||
|
||||
/** AI 分析报告 CRUD */
|
||||
financialReportsList: () =>
|
||||
request<{ reports: AiFinancialReport[] }>('/api/financials/reports'),
|
||||
|
||||
financialReportSave: (r: {
|
||||
symbol: string; name?: string; focus?: string; content: string
|
||||
periods?: number; summary?: string
|
||||
}) =>
|
||||
request<{ ok: boolean; report: AiFinancialReport }>('/api/financials/reports', {
|
||||
method: 'POST', body: JSON.stringify(r),
|
||||
}),
|
||||
|
||||
financialReportDelete: (reportId: string) =>
|
||||
request<{ ok: boolean }>(`/api/financials/reports/${encodeURIComponent(reportId)}`, { method: 'DELETE' }),
|
||||
|
||||
/**
|
||||
* AI 财务分析 — 流式调用。
|
||||
*
|
||||
* 返回一个可逐行读取的 async generator,每行是 JSON:
|
||||
* {type:"meta",symbol,summary,periods}
|
||||
* {type:"delta",content:"..."} ← 文本片段,逐个累加
|
||||
* {type:"error",message:"..."}
|
||||
* {type:"done"}
|
||||
*
|
||||
* 用 ReadableStream 解析(而非 SSE EventSource),支持 POST body 且更简单。
|
||||
*/
|
||||
async *financialAnalyzeStream(symbol: string, focus?: string): AsyncGenerator<{
|
||||
type: 'meta' | 'delta' | 'error' | 'done'
|
||||
symbol?: string
|
||||
summary?: string
|
||||
periods?: number
|
||||
content?: string
|
||||
message?: string
|
||||
}> {
|
||||
const res = await fetch('/api/financials/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ symbol, focus: focus ?? '' }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
let detail = ''
|
||||
try { const j = JSON.parse(await res.text()); detail = j.detail ?? j.message ?? '' } catch { /* ignore */ }
|
||||
const msg = detail || `${res.status} ${res.statusText}`
|
||||
toast(msg, 'error')
|
||||
throw new Error(msg)
|
||||
}
|
||||
if (!res.body) throw new Error('响应无 body')
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
// 按行分割(保留最后不完整的行在 buf)
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
const s = line.trim()
|
||||
if (!s) continue
|
||||
try {
|
||||
yield JSON.parse(s)
|
||||
} catch {
|
||||
// 忽略无法解析的行
|
||||
}
|
||||
}
|
||||
}
|
||||
// 处理残余
|
||||
if (buf.trim()) {
|
||||
try { yield JSON.parse(buf.trim()) } catch { /* ignore */ }
|
||||
}
|
||||
},
|
||||
|
||||
// ===== Strategy Engine =====
|
||||
strategyList: () =>
|
||||
request<{ strategies: StrategyDetail[] }>('/api/strategies'),
|
||||
|
||||
@@ -77,7 +77,7 @@ const MOCK_NAV = [
|
||||
{ icon: History, label: '回测' },
|
||||
{ icon: SignalIcon, label: '信号' },
|
||||
{ icon: Eye, label: '监控' },
|
||||
{ icon: FileText, label: '财务' },
|
||||
{ icon: FileText, label: '财务分析' },
|
||||
]
|
||||
|
||||
export function Branding() {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCapabilities } from '@/lib/useSharedQueries'
|
||||
import { useFinancialStatus, useFinancialSync } from '@/lib/useFinancials'
|
||||
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
|
||||
import { StockFinancialDetail } from '@/components/financials/StockFinancialDetail'
|
||||
import { ReportHistoryPanel } from '@/components/financials/ReportHistoryPanel'
|
||||
import { fmtBigNum } from '@/lib/format'
|
||||
import { toast } from '@/components/Toast'
|
||||
|
||||
@@ -52,7 +53,7 @@ export function Financials() {
|
||||
if (!hasFinancial) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="财务" subtitle="利润表 / 资负表 / 现金流 / 关键指标 · Expert" />
|
||||
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析 · Expert" />
|
||||
<div className="px-8 py-10">
|
||||
<div className="mx-auto max-w-md rounded-card border border-warning/30 bg-warning/[0.04] p-8 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-warning/10">
|
||||
@@ -124,8 +125,8 @@ export function Financials() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="财务"
|
||||
subtitle="利润表 / 资负表 / 现金流 / 关键指标 · Expert"
|
||||
title="财务分析"
|
||||
subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析 · Expert"
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
{syncing && (
|
||||
@@ -287,6 +288,9 @@ export function Financials() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI 历史分析报告 */}
|
||||
{available && <ReportHistoryPanel />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ const BUILTIN_PAGES: NavEntry[] = [
|
||||
{ id: '/concept-analysis', label: '概念分析', type: 'builtin', visible: true },
|
||||
{ id: '/industry-analysis', label: '行业分析', type: 'builtin', visible: true },
|
||||
{ id: '/stock-analysis', label: '个股分析', type: 'builtin', visible: true },
|
||||
{ id: '/financials', label: '财务', type: 'builtin', visible: true },
|
||||
{ id: '/financials', label: '财务分析', type: 'builtin', visible: true },
|
||||
{ id: '/indices', label: '指数', type: 'builtin', visible: true },
|
||||
{ id: '/trading', label: '交易', type: 'builtin', visible: true },
|
||||
{ id: '/monitor', label: '监控中心', type: 'builtin', visible: true },
|
||||
|
||||
Reference in New Issue
Block a user