mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 20:14:16 +08:00
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:
@@ -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 流式返回。
|
||||
|
||||
@@ -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 有效)。
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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) == ""
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user