feat(recap): 复盘页龙虎榜卡片与 AI 复盘资金动向注入

- fuyao dragon-tiger-list 三榜接入 (非路由数据集, tickflow 无对应能力)
- services.dragon_tiger: 本地日K分区做交易日回退(规避接口非交易日报错), 历史日 JSON 按日缓存(不可变), 当日未发布自动回退上一期
- 卡片视觉对齐市场摘要条: 奖杯徽章/排名药丸/分段式tab/净买额比例条/席位奖牌, 收起两行摘要展开三榜明细, 点股票开日K预览
- AI 复盘 prompt 注入龙虎榜摘要段 (净买/净卖/机构/游资 Top), 未配置 fuyao 时空串不占段
- displayDate 联动: 历史复盘显示当期榜单
This commit is contained in:
shy3130
2026-08-30 19:05:29 +08:00
parent 9d290f003d
commit 381038e94f
8 changed files with 873 additions and 8 deletions
+25 -3
View File
@@ -1,4 +1,4 @@
"""AI 大盘复盘 API — 流式复盘 + 报告持久化。
"""AI 大盘复盘 API — 流式复盘 + 报告持久化 + 龙虎榜
路由前缀: /api/market-recap
@@ -7,16 +7,18 @@
GET /reports 历史复盘列表
POST /reports 保存一条复盘报告
DELETE /reports/{report_id} 删除一条复盘报告
GET /dragon-tiger 龙虎榜三榜 (fuyao 专有, 历史按日缓存)
"""
from __future__ import annotations
import logging
from datetime import date as date_cls
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.services import market_recap_reports
from app.services import dragon_tiger, market_recap_reports
from app.services.market_recap import recap_market_stream
logger = logging.getLogger(__name__)
@@ -30,6 +32,26 @@ class AnalyzeRequest(BaseModel):
focus: str = "" # 可选:用户追加的复盘关注点
@router.get("/dragon-tiger")
def get_dragon_tiger(
request: Request,
date: str | None = Query(default=None, description="复盘目标日 YYYY-MM-DD, 缺省取最近已发布交易日"),
):
"""龙虎榜三榜 (全部/机构/游资)。fuyao 专有, 未配置时 state=source_unavailable。
非交易日/当日未发布由服务层自动回退到上一交易日 (state=fallback_prev)。
"""
target = None
if date:
try:
target = date_cls.fromisoformat(date)
except ValueError:
raise HTTPException(400, f"date 格式应为 YYYY-MM-DD, 收到: {date}")
return dragon_tiger.get_dragon_tiger(
request.app.state.repo.store.data_dir, target
)
@router.post("/analyze")
async def analyze_market(request: Request, req: AnalyzeRequest):
"""AI 大盘复盘 — NDJSON 流式返回。
+14
View File
@@ -217,6 +217,20 @@ class FuyaoClient:
rows = data.get("item")
return rows if isinstance(rows, list) else []
def dragon_tiger_list(self, board_type: str = "all", date: str | None = None) -> dict:
"""龙虎榜榜单 (特色数据)。board_type: all | org | hot_money。
返回 data 原始容器: {trade_date, count, stock_count, stock_items[],
hot_money_items[]}。省略 date 时服务端自动取最近已发布交易日;
显式传非交易日返回 code=1002 (由调用方做交易日回退)。
实测字段(2026-08): stock_items 12 个基础字段, org 榜额外带 4 个机构字段;
文档中的 limit_reason / amount 实际不返回。
"""
params: dict = {"board_type": board_type}
if date:
params["date"] = date
return self._get("/api/a-share/special-data/dragon-tiger-list", params)
# ---- 市场 dump ----
def dump_download_url(self, dump_kind: str) -> dict:
"""获取 dump 预签名下载信息(约 300s 有效)。
+8
View File
@@ -887,6 +887,14 @@ class FuyaoProvider:
if d is not None
}
def dragon_tiger(self, board_type: str = "all", date: str | None = None) -> dict:
"""龙虎榜单榜 (复盘页卡片 + AI 复盘上下文)。返回原始 data 容器。
非路由数据集 (tickflow 无对应能力), 不进 plugin.yaml datasets,
由 services.dragon_tiger 统一做三榜聚合/缓存/交易日回退。
"""
return self._get_client().dragon_tiger_list(board_type, date)
def _derive_bps(self, symbols: list[str]) -> dict[str, float]:
"""估值快照 pb_mrq 与行情快照最新价同源同刻 → bps = price / pb_mrq。
+227
View File
@@ -0,0 +1,227 @@
"""龙虎榜数据服务 (fuyao 专有) — 复盘页卡片 + AI 复盘上下文。
非路由数据集: tickflow 无对应能力, 直接经 custom_sources 调 fuyao provider;
fuyao 未配置时返回 source_unavailable 状态, 前端降级提示。
数据契约 (实测 2026-08, 文档的 limit_reason/amount 实际不返回):
- 三榜一次取齐: all/org 为股票表 (org 额外带机构 4 字段), hot_money 为席位表
- 历史榜单不可变 → 按日落 JSON 缓存 (data/dragon_tiger/date=YYYY-MM-DD.json)
- 当日榜单盘中未发布 → 自动回退上一交易日并标记 state=fallback_prev
日期解析: 接口对显式非交易日报 code=1002, 本层用本地 kline_daily 分区日期
把目标日回退到「≤目标日的最近交易日」, 规避报错。
"""
from __future__ import annotations
import contextlib
import json
import logging
import re
from datetime import date as date_cls
from pathlib import Path
from app.market_time import cn_today
logger = logging.getLogger(__name__)
_BOARDS = ("all", "org", "hot_money")
_DATE_DIR_RE = re.compile(r"^date=(\d{4}-\d{2}-\d{2})$")
def _local_trading_days(data_dir: Path) -> list[date_cls]:
"""本地日K分区日期 = 已知交易日集合 (升序)。扫描失败返回空。"""
root = data_dir / "kline_daily"
out: list[date_cls] = []
try:
for d in root.iterdir():
m = _DATE_DIR_RE.match(d.name)
if d.is_dir() and m:
try:
out.append(date_cls.fromisoformat(m.group(1)))
except ValueError:
continue
except OSError:
return []
return sorted(out)
def resolve_trade_date(data_dir: Path, target: date_cls | None) -> date_cls | None:
"""目标日 → ≤目标日的最近本地交易日。None 或本地无更早分区 → None (由 fuyao 默认)。
目标日早于全部本地分区 (极老的历史复盘) → 原样返回, 由调用方试显式日期,
失败如实报 no_data, 不静默给错日期的数据。
"""
if target is None:
return None
days = _local_trading_days(data_dir)
if not days:
return target
candidates = [d for d in days if d <= target]
return max(candidates) if candidates else target
def _prev_trading_day(data_dir: Path, d: date_cls) -> date_cls | None:
days = _local_trading_days(data_dir)
earlier = [x for x in days if x < d]
return max(earlier) if earlier else None
def _provider():
from app.data_providers import custom as custom_sources
if not custom_sources.is_custom_provider("fuyao"):
return None
return custom_sources.get_provider("fuyao")
def _fetch_boards(provider, d: date_cls) -> dict:
"""三榜取齐。任一榜失败抛 FuyaoError (整日失败, 不留半套缓存)。"""
return {
bt: _boards_of(provider, bt, d.isoformat()) for bt in _BOARDS
}
def _boards_of(provider, board_type: str, iso: str | None) -> dict:
data = provider.dragon_tiger(board_type, iso)
return {
"trade_date": data.get("trade_date"),
"stock_count": data.get("stock_count"),
"count": data.get("count"),
"stock_items": data.get("stock_items") or [],
"hot_money_items": data.get("hot_money_items") or [],
}
def _cache_path(data_dir: Path, d: date_cls) -> Path:
return data_dir / "dragon_tiger" / f"date={d.isoformat()}.json"
def _load_cache(path: Path) -> dict | None:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
def _store_cache(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".part")
tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
tmp.replace(path)
def get_dragon_tiger(data_dir: Path, target: date_cls | None = None) -> dict:
"""取龙虎榜 (三榜)。返回给前端的统一容器。
state: ok (正常) | fallback_prev (当日未发布, 已回退上一期并带上一期数据)
| source_unavailable (未配置 fuyao) | no_data (拉取失败)
"""
# 无目标日 → 最近本地交易日 (走缓存判定; 本地无任何分区才让 fuyao 自取默认)
if target is None:
days = _local_trading_days(data_dir)
target = max(days) if days else None
trade_date = resolve_trade_date(data_dir, target)
today = cn_today()
# 历史日缓存优先 (纯本地, 不触发插件注册表加载)
if trade_date is not None and trade_date < today:
cached = _load_cache(_cache_path(data_dir, trade_date))
if cached is not None:
return cached
provider = _provider()
if provider is None:
return {"state": "source_unavailable"}
from app.plugins.fuyao.client import FuyaoError
# 日期未知 (target=None / 本地无分区) → 省略 date 让 fuyao 取最近已发布交易日
explicit = trade_date.isoformat() if trade_date is not None else None
try:
raw = {bt: _boards_of(provider, bt, explicit) for bt in _BOARDS}
try:
actual = date_cls.fromisoformat(str(raw["all"].get("trade_date")))
except ValueError:
actual = None
payload = {
"state": "ok",
"requested_date": explicit,
"trade_date": raw["all"].get("trade_date"),
**raw,
}
# 历史日不可变 → 落缓存; 当日不缓存 (盘中 fallback / 盘后补充都以现拉为准)
if actual is not None and actual < today:
with contextlib.suppress(OSError):
_store_cache(_cache_path(data_dir, actual), payload)
return payload
except FuyaoError as e:
# 显式日期失败 (当日未发布 / 边界日) → 回退上一交易日一次
if trade_date is not None:
prev = _prev_trading_day(data_dir, trade_date)
if prev is not None:
try:
cached_prev = _load_cache(_cache_path(data_dir, prev))
if cached_prev is not None:
base = dict(cached_prev)
base.pop("state", None)
return {**base, "state": "fallback_prev",
"requested_date": explicit}
raw_prev = _fetch_boards(provider, prev)
base = {
"requested_date": prev.isoformat(),
"trade_date": prev.isoformat(),
**raw_prev,
}
with contextlib.suppress(OSError):
_store_cache(_cache_path(data_dir, prev), {**base, "state": "ok"})
return {**base, "state": "fallback_prev",
"requested_date": explicit}
except FuyaoError:
pass
logger.warning("龙虎榜拉取失败: %s", e)
return {"state": "no_data", "message": str(e)}
def build_recap_context(data_dir: Path) -> str:
"""AI 复盘的龙虎榜摘要段 (纯文本, 失败返回空串不影响复盘)。"""
try:
payload = get_dragon_tiger(data_dir, None)
if payload.get("state") not in ("ok", "fallback_prev"):
return ""
items = payload.get("all", {}).get("stock_items") or []
org_items = payload.get("org", {}).get("stock_items") or []
hm_items = payload.get("hot_money", {}).get("hot_money_items") or []
trade_date = payload.get("trade_date") or ""
lines = [f"(数据日期: {trade_date})"]
top_buy = sorted(
[i for i in items if (i.get("net_value") or 0) > 0],
key=lambda x: x.get("net_value") or 0, reverse=True,
)[:5]
if top_buy:
lines.append("净买入居前: " + "; ".join(
f"{i.get('name')}({i.get('thscode')}) 净买{float(i.get('net_value') or 0)/1e8:.2f}亿"
f" 涨跌{(i.get('change') or 0)*100:.1f}%"
+ (" [3日榜]" if i.get("range_days") == 3 else "")
for i in top_buy))
top_sell = sorted(
[i for i in items if (i.get("net_value") or 0) < 0],
key=lambda x: x.get("net_value") or 0,
)[:3]
if top_sell:
lines.append("净卖出居前: " + "; ".join(
f"{i.get('name')} 净卖{abs(float(i.get('net_value') or 0))/1e8:.2f}亿"
for i in top_sell))
if org_items:
lines.append("机构净买居前: " + "; ".join(
f"{i.get('name')} 机构净买{float(i.get('org_net_value') or 0)/1e8:.2f}亿"
for i in sorted(org_items, key=lambda x: x.get("org_net_value") or 0, reverse=True)[:3]))
if hm_items:
lines.append("活跃游资: " + "; ".join(
f"{h.get('name')} 净买{float(h.get('buying') or 0)/1e8:.2f}亿"
for h in sorted(hm_items, key=lambda x: x.get('buying') or 0, reverse=True)[:3]))
return "\n".join(lines)
except Exception as e: # noqa: BLE001 — 摘要失败不影响复盘主流程
logger.debug("龙虎榜复盘摘要构建失败: %s", e)
return ""
+15 -3
View File
@@ -179,8 +179,8 @@ def _build_emotion_block(overview: dict) -> str:
return "\n".join(lines)
def _build_user_prompt(overview: dict, news: list[dict], focus: str) -> str:
"""构建用户消息:复盘日期 + 市场数据精简切片 + 新闻 + 关注点。"""
def _build_user_prompt(overview: dict, news: list[dict], focus: str, lhb_context: str = "") -> str:
"""构建用户消息:复盘日期 + 市场数据精简切片 + 龙虎榜(可选) + 新闻 + 关注点。"""
as_of = overview.get("as_of") or "今日"
parts: list[str] = [
@@ -202,6 +202,14 @@ def _build_user_prompt(overview: dict, news: list[dict], focus: str) -> str:
_build_sector_block(overview.get("industry_rank"), "行业"),
]
# 龙虎榜资金动向 (fuyao 数据源, 摘要自带数据日期; 无数据源/失败时为空不占段)
if lhb_context:
parts.extend([
"",
"## 龙虎榜资金动向",
lhb_context,
])
if news:
news_lines = []
for i, n in enumerate(news[:8], 1):
@@ -300,7 +308,11 @@ async def recap_market_stream(
try:
from app.services.ai_provider import stream_ai_text
user_prompt = _build_user_prompt(overview, news or [], focus)
# 龙虎榜摘要 (fuyao 专有): 拉取失败/未配置 → 空串, 复盘主流程不受影响
from app.services import dragon_tiger as dragon_tiger_svc
lhb_ctx = dragon_tiger_svc.build_recap_context(repo.store.data_dir)
user_prompt = _build_user_prompt(overview, news or [], focus, lhb_ctx)
got_content = False
async for delta in stream_ai_text(
[
+157
View File
@@ -0,0 +1,157 @@
"""龙虎榜服务测试 (不依赖真实网络)。
覆盖: 交易日回退 (非交易日/目标日解析)、历史日 JSON 缓存命中与落盘、
fuyao 未配置降级、当日未发布 fallback_prev、彻底失败 no_data、
AI 复盘摘要段构建。
日期用 2026-08-27/28 (写作时为过去交易日), 与仓库既有绝对日期测试风格一致。
"""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
import pytest
from app.plugins.fuyao.client import FuyaoError
from app.services import dragon_tiger as dt
def _mk_days(data_dir: Path, *days: str) -> None:
for d in days:
(data_dir / "kline_daily" / f"date={d}").mkdir(parents=True, exist_ok=True)
@pytest.fixture()
def data_dir(tmp_path: Path) -> Path:
_mk_days(tmp_path, "2026-08-26", "2026-08-27", "2026-08-28")
return tmp_path
class _FakeProvider:
"""按 (board, date) 记录调用; fail_dates 中的日期抛 FuyaoError。"""
def __init__(self, fail_dates: set[str] | None = None):
self.calls: list[tuple] = []
self.fail_dates = fail_dates or set()
def dragon_tiger(self, board_type: str, date: str | None) -> dict:
self.calls.append((board_type, date))
if date in self.fail_dates:
raise FuyaoError(f"code=3002: {date} 未就绪")
iso = date or "2026-08-28"
return {
"trade_date": iso,
"count": 2,
"stock_count": 2,
"stock_items": [
{"thscode": "600519.SH", "ticker": "600519", "name": "贵州茅台",
"change": 0.05, "net_value": 1.5e8, "net_rate": 0.01,
"buy_value": 2e8, "sell_value": 0.5e8, "hot_rank": 3, "range_days": 1,
"org_net_value": 0.8e8},
{"thscode": "000858.SZ", "ticker": "000858", "name": "五粮液",
"change": -0.03, "net_value": -0.6e8, "net_rate": -0.004,
"buy_value": 0.4e8, "sell_value": 1e8, "range_days": 3},
],
"hot_money_items": [
{"name": "宁波桑田路", "buying": 1.9e8,
"rows": [{"thscode": "600519.SH", "name": "贵州茅台",
"hot_money_item_net_value": 1.2e8}]},
],
}
def _use_provider(monkeypatch, provider) -> _FakeProvider:
monkeypatch.setattr(dt, "_provider", lambda: provider)
return provider
# ---- 交易日解析 ----
def test_resolve_rolls_back_non_trading_day(data_dir):
# 周日 08-30 → 最近分区 08-28
assert dt.resolve_trade_date(data_dir, date(2026, 8, 30)) == date(2026, 8, 28)
# 08-27 是交易日 → 原样
assert dt.resolve_trade_date(data_dir, date(2026, 8, 27)) == date(2026, 8, 27)
def test_resolve_older_than_all_partitions_returns_target(data_dir):
# 早于全部本地分区 → 原样返回, 由调用方显式尝试 (失败如实 no_data)
assert dt.resolve_trade_date(data_dir, date(2020, 1, 1)) == date(2020, 1, 1)
# ---- 状态与缓存 ----
def test_source_unavailable_without_fuyao(data_dir, monkeypatch):
monkeypatch.setattr(dt, "_provider", lambda: None)
out = dt.get_dragon_tiger(data_dir, None)
assert out["state"] == "source_unavailable"
def test_fetch_stores_cache_then_hits_cache(data_dir, monkeypatch):
provider = _use_provider(monkeypatch, _FakeProvider())
out = dt.get_dragon_tiger(data_dir, None) # 默认 → 最近分区 08-28
assert out["state"] == "ok"
assert out["trade_date"] == "2026-08-28"
assert len(provider.calls) == 3 # 三榜一次取齐
cache = data_dir / "dragon_tiger" / "date=2026-08-28.json"
assert cache.exists()
provider.calls.clear()
out2 = dt.get_dragon_tiger(data_dir, date(2026, 8, 30)) # 周日 → 08-28 → 命中缓存
assert out2["state"] == "ok" and out2["trade_date"] == "2026-08-28"
assert provider.calls == [] # 纯本地, 不打网络
def test_explicit_history_date_uses_cache(data_dir, monkeypatch):
provider = _use_provider(monkeypatch, _FakeProvider())
dt.get_dragon_tiger(data_dir, date(2026, 8, 27))
assert provider.calls[0] == ("all", "2026-08-27")
provider.calls.clear()
dt.get_dragon_tiger(data_dir, date(2026, 8, 27))
assert provider.calls == []
def test_unpublished_today_falls_back_to_prev(data_dir, monkeypatch):
# 08-28 拉取失败 (未发布/未就绪) → 回退 08-27, state=fallback_prev
provider = _use_provider(monkeypatch, _FakeProvider(fail_dates={"2026-08-28"}))
out = dt.get_dragon_tiger(data_dir, date(2026, 8, 28))
assert out["state"] == "fallback_prev"
assert out["trade_date"] == "2026-08-27"
assert out["requested_date"] == "2026-08-28"
# 回退日缓存以 ok 状态落盘 (不污染直查)
cached = json.loads((data_dir / "dragon_tiger" / "date=2026-08-27.json").read_text(encoding="utf-8"))
assert cached["state"] == "ok"
def test_total_failure_returns_no_data(data_dir, monkeypatch):
provider = _use_provider(monkeypatch, _FakeProvider(fail_dates={"2026-08-28", "2026-08-27"}))
out = dt.get_dragon_tiger(data_dir, date(2026, 8, 28))
assert out["state"] == "no_data"
assert "2026-08-28" in out.get("message", "")
def test_corrupt_cache_refetches(data_dir, monkeypatch):
provider = _use_provider(monkeypatch, _FakeProvider())
cache = data_dir / "dragon_tiger" / "date=2026-08-28.json"
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text("{broken json", encoding="utf-8")
out = dt.get_dragon_tiger(data_dir, date(2026, 8, 28))
assert out["state"] == "ok"
assert provider.calls # 缓存损坏 → 重新拉取
# ---- AI 复盘摘要 ----
def test_build_recap_context_contains_summary(data_dir, monkeypatch):
_use_provider(monkeypatch, _FakeProvider())
ctx = dt.build_recap_context(data_dir)
assert "净买入居前" in ctx and "贵州茅台" in ctx
assert "机构净买居前" in ctx
assert "宁波桑田路" in ctx
def test_build_recap_context_empty_without_source(data_dir, monkeypatch):
monkeypatch.setattr(dt, "_provider", lambda: None)
assert dt.build_recap_context(data_dir) == ""
+43
View File
@@ -608,6 +608,43 @@ export interface AiReviewReport {
created_at: string
}
// ===== 龙虎榜 (fuyao 专有, 复盘页) =====
export interface DragonTigerStockItem {
thscode: string
ticker?: string | null
name?: string | null
change?: number | null // 当日涨跌幅 (小数制)
net_value?: number | null // 龙虎榜净买入 (元)
net_rate?: number | null // 净买占比 (小数制)
buy_value?: number | null // 买入额 (元)
sell_value?: number | null // 卖出额 (元)
hot_rank?: number | null // 同花顺人气排名 (小=靠前)
range_days?: number | null // 1=当日榜 3=3日榜
hot_money_net_value?: number | null
hot_money_item_net_value?: number | null // 游资榜 rows 专用: 该席位在该股的净买入
org_net_value?: number | null
org_net_rate?: number | null
org_buy_num?: number | null
org_sell_num?: number | null
concept_list?: { name?: string }[] | null
}
export interface DragonTigerHotMoney {
name?: string | null
buying?: number | null // 席位合计净买入 (元)
rows?: DragonTigerStockItem[] | null // 关联股票 (字段同 stock item)
}
export interface DragonTigerPayload {
state: 'ok' | 'fallback_prev' | 'source_unavailable' | 'no_data'
requested_date?: string | null
trade_date?: string | null
message?: string
all?: { trade_date?: string | null; stock_count?: number | null; count?: number | null; stock_items?: DragonTigerStockItem[] }
org?: { trade_date?: string | null; stock_count?: number | null; count?: number | null; stock_items?: DragonTigerStockItem[] }
hot_money?: { trade_date?: string | null; count?: number | null; hot_money_items?: DragonTigerHotMoney[] }
}
// ===== Strategy Engine =====
export interface StrategyParamDef {
id: string
@@ -2768,6 +2805,12 @@ export const api = {
reviewReportsList: () =>
request<{ reports: AiReviewReport[] }>('/api/market-recap/reports'),
/** 龙虎榜三榜 (fuyao 专有; 历史日按服务端缓存, 当日未发布自动回退上一期) */
dragonTiger: (date?: string) =>
request<DragonTigerPayload>(
`/api/market-recap/dragon-tiger${date ? `?date=${encodeURIComponent(date)}` : ''}`,
),
reviewReportSave: (r: {
as_of: string; focus?: string; content: string
summary?: string; emotion_score?: number | null; emotion_label?: string
+384 -2
View File
@@ -12,12 +12,14 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import {
BookOpenCheck, RefreshCw, Sparkles, Trash2, History, ChevronRight, AlertTriangle,
Database, Wand2, Copy, Download, Clock, X, Check,
Database, Wand2, Copy, Download, Clock, X, Check, Trophy, ChevronDown,
} from 'lucide-react'
import { api, type OverviewMarket, type AiReviewReport } from '@/lib/api'
import { api, type OverviewMarket, type AiReviewReport, type DragonTigerStockItem } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { cn } from '@/lib/cn'
import { fmtPct, fmtVolume, priceColorClass } from '@/lib/format'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { fmtBigNum } from '@/lib/format'
import { PageHeader } from '@/components/PageHeader'
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
@@ -216,6 +218,9 @@ export function Review() {
const isGenerating = phase === 'loading' || phase === 'streaming'
const displayDate = viewing?.as_of ?? meta?.as_of ?? marketQuery.data?.as_of ?? asOf ?? '最新'
// 龙虎榜跟随复盘目标日 (历史复盘联动当期榜单); 非 ISO 日期(如"最新")取最近一期
const dtDate = /^\d{4}-\d{2}-\d{2}$/.test(displayDate ?? '') ? displayDate : undefined
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const data = marketQuery.data
// 主区域显示的内容:viewing(查看历史)优先于 store 的生成 content,
// 这样点历史报告不会覆盖后台生成中的流。
@@ -302,6 +307,9 @@ export function Review() {
{/* ===== 市场摘要条(轻量上下文,非重复看板)===== */}
<MarketSummaryBar data={data} />
{/* ===== 龙虎榜 (fuyao 专有, 资金动向上下文; 复盘日联动) ===== */}
<DragonTigerCard date={dtDate} onOpenStock={setPreviewSymbol} />
{/* ===== 关注点输入 ===== */}
<div className="flex items-center gap-2 rounded-card border border-border bg-surface/80 px-3.5 py-2.5 transition-colors focus-within:border-accent/40">
<Wand2 className="h-3.5 w-3.5 shrink-0 text-accent" />
@@ -502,6 +510,13 @@ export function Review() {
</motion.div>
)}
</AnimatePresence>
{/* 个股日 K 详情 (龙虎榜单点击打开) */}
<StockPreviewDialog
symbol={previewSymbol}
triggerInfo={null}
onClose={() => setPreviewSymbol(null)}
/>
</>
)
}
@@ -840,3 +855,370 @@ function HistoryPanel({
</div>
)
}
// ================================================================
// 龙虎榜卡片 (fuyao 专有) —— 资金动向上下文, 市场摘要条之下
// 视觉对齐 MarketSummaryBar: 8x8 徽章 + rounded-card + surface 底。
// 收起态: 奖杯徽章头部 + 净买/机构 Top3 排名药丸;
// 展开态: 分段式三榜 tab, 股票表净买额带比例条, 游资席位带奖牌。
// 状态: 当日未发布自动显示上一期 (fallback_prev); fuyao 未配置降级提示。
// ================================================================
type _DtTabKey = 'all' | 'org' | 'hot_money'
const _DT_TABS: { key: _DtTabKey; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'org', label: '机构' },
{ key: 'hot_money', label: '游资' },
]
function _dtSorted(items: DragonTigerStockItem[], key: 'net_value' | 'org_net_value'): DragonTigerStockItem[] {
return [...items].sort((a, b) => (b[key] ?? -Infinity) - (a[key] ?? -Infinity))
}
/** 排名序号色: 前三用暖色系奖牌感, 其余弱化 */
function _rankCls(idx: number): string {
if (idx === 0) return 'bg-amber-500/20 text-amber-400'
if (idx === 1) return 'bg-zinc-400/15 text-zinc-300'
if (idx === 2) return 'bg-orange-700/20 text-orange-400/90'
return 'bg-elevated text-muted/70'
}
function _DtPill({ item, idx, value, onOpenStock }: {
item: DragonTigerStockItem
idx: number
value: number | null | undefined
onOpenStock: (s: string) => void
}) {
return (
<button
type="button"
onClick={() => onOpenStock(item.thscode)}
className="group inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-elevated/40 py-0.5 pl-0.5 pr-2.5 transition-all hover:border-accent/40 hover:bg-elevated/70"
title={`查看 ${item.name ?? item.thscode} · 净买 ${fmtVolume(value ?? null)}`}
>
<span className={cn('grid h-[18px] w-[18px] min-w-[18px] place-items-center rounded-full font-mono text-[9px] font-bold leading-none', _rankCls(idx))}>
{idx + 1}
</span>
<span className="text-[11px] text-foreground/90 transition-colors group-hover:text-accent">
{item.name ?? item.thscode}
</span>
<span className={cn('font-mono text-[10px] tabular-nums', priceColorClass(value ?? null))}>
{fmtVolume(value ?? null)}
</span>
</button>
)
}
function _DtSummaryRow({ label, items, pick, onOpenStock }: {
label: string
items: DragonTigerStockItem[]
pick: (i: DragonTigerStockItem) => number | null | undefined
onOpenStock: (s: string) => void
}) {
if (!items.length) return null
return (
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="shrink-0 rounded-full bg-base/70 px-2 py-0.5 text-[9px] font-medium tracking-wide text-muted">
{label}
</span>
{items.map((i, idx) => (
<_DtPill key={i.thscode} item={i} idx={idx} value={pick(i)} onOpenStock={onOpenStock} />
))}
</div>
)
}
function _DtStockTable({ items, tab, onOpenStock }: {
items: DragonTigerStockItem[]
tab: _DtTabKey
onOpenStock: (s: string) => void
}) {
const isOrg = tab === 'org'
const maxAbs = Math.max(1e-12, ...items.map(i => Math.abs(i.net_value ?? 0)))
return (
<div className="overflow-hidden rounded-btn border border-border/60">
{/* 表头 */}
<div className={cn(
'flex items-center gap-2 bg-elevated/50 px-2.5 py-1.5 text-[9px] font-medium uppercase tracking-wider text-muted/70',
)}>
<span className="w-5 shrink-0 text-center">#</span>
<span className="w-14 shrink-0"></span>
<span className="min-w-0 flex-1"></span>
<span className="w-24 shrink-0 text-right"></span>
<span className="w-14 shrink-0 text-right"></span>
{isOrg && <span className="w-20 shrink-0 text-right"></span>}
<span className="w-20 shrink-0 text-right"></span>
<span className="w-20 shrink-0 text-right"></span>
<span className="w-11 shrink-0 text-right"></span>
</div>
{items.map((i, idx) => {
const barPct = Math.max(2, Math.min(100, (Math.abs(i.net_value ?? 0) / maxAbs) * 100))
const positive = (i.net_value ?? 0) >= 0
return (
<button
key={`${i.thscode}-${i.range_days ?? 1}`}
type="button"
onClick={() => onOpenStock(i.thscode)}
className="flex w-full items-center gap-2 border-t border-border/30 px-2.5 py-2 text-left text-[11px] transition-colors hover:bg-accent/[0.05]"
title={`查看 ${i.name ?? i.thscode} 详情`}
>
<span className={cn('w-5 shrink-0 text-center font-mono text-[9px] tabular-nums', idx < 3 ? 'text-amber-400/90' : 'text-muted/50')}>
{idx + 1}
</span>
<span className={cn('w-14 shrink-0 font-mono tabular-nums', priceColorClass(i.change ?? null))}>
{fmtPct(i.change ?? null, 1)}
</span>
<span className="min-w-0 flex-1">
<span className="text-foreground">{i.name ?? '—'}</span>
<span className="ml-1.5 font-mono text-[9px] text-muted">{i.ticker ?? i.thscode}</span>
{i.hot_rank != null && i.hot_rank > 0 && i.hot_rank <= 99 && (
<span
className={cn(
'ml-1.5 inline-flex items-center rounded px-1 text-[8px] font-medium leading-tight',
i.hot_rank <= 10
? 'border border-amber-500/30 bg-amber-500/10 text-amber-500'
: 'border border-border bg-elevated/50 text-muted/80',
)}
title={`同花顺人气第 ${i.hot_rank}`}
>
{i.hot_rank}
</span>
)}
</span>
{/* 净买额: 比例条 + 数值 (红买绿卖, 长度=绝对值占榜首比例) */}
<span className="relative w-24 shrink-0">
<span
aria-hidden
className={cn(
'absolute inset-y-[3px] right-0 rounded-[3px]',
positive ? 'bg-bull/10' : 'bg-bear/10',
)}
style={{ width: `${barPct}%` }}
/>
<span className={cn('relative block text-right font-mono tabular-nums', priceColorClass(i.net_value ?? null))}>
{fmtVolume(i.net_value ?? null)}
</span>
</span>
<span className="w-14 shrink-0 text-right font-mono tabular-nums text-secondary">
{i.net_rate == null ? '—' : `${(i.net_rate * 100).toFixed(1)}%`}
</span>
{isOrg && (
<span className={cn('w-20 shrink-0 text-right font-mono tabular-nums', priceColorClass(i.org_net_value ?? null))}>
{fmtVolume(i.org_net_value ?? null)}
</span>
)}
<span className="w-20 shrink-0 text-right font-mono tabular-nums text-muted">{fmtVolume(i.buy_value ?? null)}</span>
<span className="w-20 shrink-0 text-right font-mono tabular-nums text-muted">{fmtVolume(i.sell_value ?? null)}</span>
<span className="w-11 shrink-0 text-right">
<span className={cn(
'inline-flex rounded px-1 text-[8px] leading-tight',
i.range_days === 3
? 'border border-sky-500/25 bg-sky-500/10 text-sky-400/90'
: 'border border-border/60 bg-elevated/50 text-muted/80',
)}>
{i.range_days === 3 ? '3日' : '当日'}
</span>
</span>
</button>
)
})}
</div>
)
}
function _DtSeatList({ seats, onOpenStock }: {
seats: { name?: string | null; buying?: number | null; rows?: DragonTigerStockItem[] | null }[]
onOpenStock: (s: string) => void
}) {
if (!seats.length) {
return <p className="py-4 text-center text-[11px] text-muted"></p>
}
return (
<div className="grid gap-1.5">
{seats.map((s, idx) => (
<div
key={`${s.name}-${idx}`}
className="flex items-start gap-2.5 rounded-btn border border-border/50 bg-elevated/30 px-2.5 py-2 transition-colors hover:border-accent/25"
>
<span className={cn('grid h-5 w-5 shrink-0 place-items-center rounded-full font-mono text-[9px] font-bold leading-none', _rankCls(idx))}>
{idx + 1}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-0.5">
<span className="text-[11px] font-medium text-foreground" title={s.name ?? ''}>{s.name ?? '—'}</span>
<span className={cn('font-mono text-[10px] tabular-nums', priceColorClass(s.buying ?? null))}>
{fmtVolume(s.buying ?? null)}
</span>
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1.5">
{(s.rows ?? []).map(r => (
<button
key={`${s.name}-${r.thscode}`}
type="button"
onClick={() => onOpenStock(r.thscode)}
className="inline-flex items-baseline gap-1.5 rounded-full border border-border/60 bg-surface/60 px-2 py-0.5 text-[10px] transition-all hover:border-accent/40 hover:text-accent"
title={`查看 ${r.name ?? r.thscode} 详情`}
>
<span>{r.name ?? r.thscode}</span>
<span className={cn('font-mono text-[9px] tabular-nums', priceColorClass(r.hot_money_item_net_value ?? r.net_value ?? null))}>
{fmtVolume(r.hot_money_item_net_value ?? r.net_value ?? null)}
</span>
</button>
))}
</div>
</div>
</div>
))}
</div>
)
}
function DragonTigerCard({ date, onOpenStock }: {
date?: string
onOpenStock: (symbol: string) => void
}) {
const [expanded, setExpanded] = useState(false)
const [tab, setTab] = useState<_DtTabKey>('all')
const q = useQuery({
queryKey: ['dragon-tiger', date ?? 'latest'],
queryFn: () => api.dragonTiger(date),
staleTime: 5 * 60_000,
retry: 1,
})
const d = q.data
if (q.isLoading) {
return (
<div className="flex items-center gap-3 rounded-card border border-border bg-surface/80 px-4 py-3">
<span className="grid h-8 w-8 shrink-0 animate-pulse place-items-center rounded bg-elevated">
<Trophy className="h-4 w-4 text-muted/50" />
</span>
<div className="flex items-center gap-2">
<span className="h-3 w-14 animate-pulse rounded-full bg-elevated/80" />
<span className="h-3 w-24 animate-pulse rounded-full bg-elevated/60" />
<span className="h-3 w-20 animate-pulse rounded-full bg-elevated/40" />
</div>
</div>
)
}
// fuyao 未配置: 显式降级提示 (不用死区块, 给配置入口)
if (d?.state === 'source_unavailable') {
return (
<div className="flex items-center gap-3 rounded-card border border-dashed border-border bg-surface/50 px-4 py-3">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded bg-elevated/60">
<Trophy className="h-4 w-4 text-muted/50" />
</span>
<span className="text-[11px] text-muted"> fuyao ()</span>
<Link to="/settings?tab=data-sources" className="ml-auto inline-flex items-center gap-0.5 text-[10px] text-accent hover:underline">
<ChevronRight className="h-3 w-3" />
</Link>
</div>
)
}
// 拉取失败: 轻提示 + 重试 (不占复盘页视觉重量)
if (!d || d.state === 'no_data') {
return (
<div className="flex items-center gap-3 rounded-card border border-border bg-surface/50 px-4 py-3">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded bg-elevated/60">
<Trophy className="h-4 w-4 text-muted/50" />
</span>
<span className="text-[11px] text-muted">{d?.message ? ` (${d.message.slice(0, 40)})` : ''}</span>
<button onClick={() => q.refetch()} className="ml-auto text-[10px] text-accent hover:underline"></button>
</div>
)
}
const allItems = d.all?.stock_items ?? []
const orgItems = d.org?.stock_items ?? []
const seats = d.hot_money?.hot_money_items ?? []
const topBuy = _dtSorted(allItems.filter(i => (i.net_value ?? 0) > 0), 'net_value').slice(0, 3)
const topOrg = _dtSorted(orgItems, 'org_net_value').slice(0, 3)
const isFallback = d.state === 'fallback_prev'
return (
<div className="rounded-card border border-border bg-surface/80">
{/* 头部: 奖杯徽章 + 概要, 常驻可点击展开 */}
<button
type="button"
onClick={() => setExpanded(v => !v)}
className="group flex w-full items-center gap-3 px-4 py-3 text-left"
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded bg-amber-500/15 text-amber-500 ring-1 ring-amber-500/20">
<Trophy className={cn('h-4 w-4 transition-transform group-hover:scale-110', expanded && 'scale-110')} />
</span>
<span className="leading-tight">
<span className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-foreground"></span>
{isFallback && (
<span
className="rounded border border-warning/30 bg-warning/10 px-1.5 py-px text-[9px] leading-tight text-warning"
title="当日榜单未发布(约17:00后), 已自动显示上一期"
>
·
</span>
)}
</span>
<span className="mt-0.5 block text-[10px] text-muted">
{d.trade_date} · {d.all?.stock_count ?? allItems.length} · {expanded ? '收起' : '查看三榜明细'}
</span>
</span>
<span className="ml-auto flex items-center gap-1.5">
<ChevronDown className={cn('h-4 w-4 text-muted transition-transform duration-200 group-hover:text-secondary', expanded && 'rotate-180')} />
</span>
</button>
{/* 收起态: 排名药丸摘要 */}
{!expanded && (
<div className="flex flex-col gap-1.5 px-4 pb-3">
<_DtSummaryRow label="净买 Top3" items={topBuy} pick={i => i.net_value} onOpenStock={onOpenStock} />
<_DtSummaryRow label="机构 Top3" items={topOrg} pick={i => i.org_net_value} onOpenStock={onOpenStock} />
</div>
)}
{/* 展开态: 分段式 tab + 三榜内容 */}
<AnimatePresence initial={false}>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="border-t border-border/60 px-4 py-3">
{/* 分段式 tab */}
<div className="mb-2.5 inline-flex items-center gap-0.5 rounded-full border border-border/50 bg-base/70 p-0.5">
{_DT_TABS.map(t => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={cn(
'rounded-full px-3 py-1 text-[11px] transition-all',
tab === t.key
? 'bg-accent/15 font-medium text-accent shadow-sm'
: 'text-secondary hover:text-foreground',
)}
>
{t.label}
</button>
))}
</div>
{tab === 'hot_money' ? (
<_DtSeatList seats={seats} onOpenStock={onOpenStock} />
) : (
<_DtStockTable
items={_dtSorted(tab === 'org' ? orgItems : allItems, tab === 'org' ? 'org_net_value' : 'net_value')}
tab={tab}
onOpenStock={onOpenStock}
/>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}