diff --git a/backend/app/services/dragon_tiger.py b/backend/app/services/dragon_tiger.py index 0cfba58..1d919f8 100644 --- a/backend/app/services/dragon_tiger.py +++ b/backend/app/services/dragon_tiger.py @@ -208,7 +208,7 @@ def build_recap_context(data_dir: Path) -> str: 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] + )[:5] if top_sell: lines.append("净卖出居前: " + "; ".join( f"{i.get('name')} 净卖{abs(float(i.get('net_value') or 0))/1e8:.2f}亿" @@ -216,11 +216,11 @@ def build_recap_context(data_dir: Path) -> str: 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])) + for i in sorted(org_items, key=lambda x: x.get("org_net_value") or 0, reverse=True)[:5])) 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])) + for h in sorted(hm_items, key=lambda x: x.get('buying') or 0, reverse=True)[:5])) return "\n".join(lines) except Exception as e: # noqa: BLE001 — 摘要失败不影响复盘主流程 logger.debug("龙虎榜复盘摘要构建失败: %s", e) diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index eb18066..67350bd 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -13,8 +13,10 @@ export function fmtPct(v: number | null | undefined, digits = 2): string { export function fmtVolume(v: number | null | undefined): string { if (v == null || Number.isNaN(v)) return '—' - if (v >= 1e8) return `${(v / 1e8).toFixed(2)}亿` - if (v >= 1e4) return `${(v / 1e4).toFixed(2)}万` + const sign = v < 0 ? '-' : '' + const a = Math.abs(v) + if (a >= 1e8) return `${sign}${(a / 1e8).toFixed(2)}亿` + if (a >= 1e4) return `${sign}${(a / 1e4).toFixed(2)}万` return v.toFixed(0) } diff --git a/frontend/src/pages/Review.tsx b/frontend/src/pages/Review.tsx index 3619ce8..53e894d 100644 --- a/frontend/src/pages/Review.tsx +++ b/frontend/src/pages/Review.tsx @@ -12,7 +12,7 @@ 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, Trophy, ChevronDown, + Database, Wand2, Copy, Download, Clock, X, Check, Trophy, ChevronDown, ChevronUp, } from 'lucide-react' import { api, type OverviewMarket, type AiReviewReport, type DragonTigerStockItem } from '@/lib/api' @@ -20,6 +20,7 @@ import { QK } from '@/lib/queryKeys' import { cn } from '@/lib/cn' import { fmtPct, fmtVolume, priceColorClass } from '@/lib/format' import { StockPreviewDialog } from '@/components/StockPreviewDialog' +import { boardTag } from '@/components/stock-table/primitives' import { fmtBigNum } from '@/lib/format' import { PageHeader } from '@/components/PageHeader' import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer' @@ -870,8 +871,9 @@ const _DT_TABS: { key: _DtTabKey; label: string }[] = [ { 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 _dtSorted(items: DragonTigerStockItem[], key: 'net_value' | 'org_net_value', asc = false): DragonTigerStockItem[] { + const s = [...items].sort((a, b) => (b[key] ?? -Infinity) - (a[key] ?? -Infinity)) + return asc ? s.reverse() : s } /** 排名序号色: 前三用暖色系奖牌感, 其余弱化 */ @@ -901,6 +903,11 @@ function _DtPill({ item, idx, value, onOpenStock }: { {item.name ?? item.thscode} + {(() => { const b = boardTag(item.thscode); return b && ( + + {b.label} + + ) })()} {fmtVolume(value ?? null)} @@ -909,7 +916,7 @@ function _DtPill({ item, idx, value, onOpenStock }: { } function _DtSummaryRow({ label, items, pick, onOpenStock }: { - label: string + label?: string items: DragonTigerStockItem[] pick: (i: DragonTigerStockItem) => number | null | undefined onOpenStock: (s: string) => void @@ -917,40 +924,86 @@ function _DtSummaryRow({ label, items, pick, onOpenStock }: { if (!items.length) return null return (
- - {label} + {/* 固定宽度标签槽: 无标签行(净卖)也占位, 保证四行药丸左缘对齐 */} + + {label && ( + + {label} + + )} {items.map((i, idx) => ( - <_DtPill key={i.thscode} item={i} idx={idx} value={pick(i)} onOpenStock={onOpenStock} /> + <_DtPill key={`${i.thscode}-${idx}`} item={i} idx={idx} value={pick(i)} onOpenStock={onOpenStock} /> ))}
) } +/** 可排序列 key — 与 DragonTigerStockItem 数值字段对应 */ +type _DtSortKey = 'change' | 'net_value' | 'net_rate' | 'org_net_value' | 'buy_value' | 'sell_value' + +/** 排序表头: 点击切换列/方向, 箭头指示当前排序 (hover 才显隐未激活箭头) */ +function _DtTh({ label, sortKey, sort, onSort, className }: { + label: string + sortKey: _DtSortKey + sort: { key: _DtSortKey; desc: boolean } + onSort: (k: _DtSortKey) => void + className?: string +}) { + const active = sort.key === sortKey + return ( + + ) +} + function _DtStockTable({ items, tab, onOpenStock }: { items: DragonTigerStockItem[] tab: _DtTabKey onOpenStock: (s: string) => void }) { const isOrg = tab === 'org' + const [sort, setSort] = useState<{ key: _DtSortKey; desc: boolean }>({ key: isOrg ? 'org_net_value' : 'net_value', desc: true }) + const onSort = (k: _DtSortKey) => + setSort(s => (s.key === k ? { key: k, desc: !s.desc } : { key: k, desc: true })) + const sorted = [...items].sort((a, b) => { + const av = a[sort.key] ?? null + const bv = b[sort.key] ?? null + if (av == null && bv == null) return 0 + if (av == null) return 1 // 空值恒垫底, 不随方向翻转 + if (bv == null) return -1 + return (av - bv) * (sort.desc ? -1 : 1) + }) const maxAbs = Math.max(1e-12, ...items.map(i => Math.abs(i.net_value ?? 0))) return (
{/* 表头 */}
# - 涨跌幅 + <_DtTh label="涨跌幅" sortKey="change" sort={sort} onSort={onSort} /> 股票 - 净买额 - 占比 - {isOrg && 机构净买} - 买入额 - 卖出额 + <_DtTh label="净买额" sortKey="net_value" sort={sort} onSort={onSort} className="w-full justify-end" /> + <_DtTh label="占比" sortKey="net_rate" sort={sort} onSort={onSort} className="w-full justify-end" /> + {isOrg && <_DtTh label="机构净买" sortKey="org_net_value" sort={sort} onSort={onSort} className="w-full justify-end" />} + <_DtTh label="买入额" sortKey="buy_value" sort={sort} onSort={onSort} className="w-full justify-end" /> + <_DtTh label="卖出额" sortKey="sell_value" sort={sort} onSort={onSort} className="w-full justify-end" /> 榜期
- {items.map((i, idx) => { + {sorted.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 ( @@ -970,6 +1023,11 @@ function _DtStockTable({ items, tab, onOpenStock }: { {i.name ?? '—'} {i.ticker ?? i.thscode} + {(() => { const b = boardTag(i.thscode); return b && ( + + {b.label} + + ) })()} {i.hot_rank != null && i.hot_rank > 0 && i.hot_rank <= 99 && ( {s.name ?? '—'} - 净买 {fmtVolume(s.buying ?? null)} + {fmtVolume(s.buying ?? null)}
@@ -1059,6 +1117,11 @@ function _DtSeatList({ seats, onOpenStock }: { title={`查看 ${r.name ?? r.thscode} 详情`} > {r.name ?? r.thscode} + {(() => { const b = boardTag(r.thscode); return b && ( + + {b.label} + + ) })()} {fmtVolume(r.hot_money_item_net_value ?? r.net_value ?? null)} @@ -1132,8 +1195,10 @@ function DragonTigerCard({ date, onOpenStock }: { 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 topBuy = _dtSorted(allItems.filter(i => (i.net_value ?? 0) > 0), 'net_value').slice(0, 5) + const topOrg = _dtSorted(orgItems, 'org_net_value').slice(0, 5) + const topSell = _dtSorted(allItems.filter(i => (i.net_value ?? 0) < 0), 'net_value', true).slice(0, 5) + const botOrg = _dtSorted(orgItems.filter(i => (i.org_net_value ?? 0) < 0), 'org_net_value', true).slice(0, 5) const isFallback = d.state === 'fallback_prev' return ( @@ -1171,8 +1236,10 @@ function DragonTigerCard({ date, onOpenStock }: { {/* 收起态: 排名药丸摘要 */} {!expanded && (
- <_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} /> + <_DtSummaryRow label="净买 Top5" items={topBuy} pick={i => i.net_value} onOpenStock={onOpenStock} /> + <_DtSummaryRow items={topSell} pick={i => i.net_value} onOpenStock={onOpenStock} /> + <_DtSummaryRow label="机构 Top5" items={topOrg} pick={i => i.org_net_value} onOpenStock={onOpenStock} /> + <_DtSummaryRow items={botOrg} pick={i => i.org_net_value} onOpenStock={onOpenStock} />
)} @@ -1210,6 +1277,7 @@ function DragonTigerCard({ date, onOpenStock }: { <_DtSeatList seats={seats} onOpenStock={onOpenStock} /> ) : ( <_DtStockTable + key={tab} items={_dtSorted(tab === 'org' ? orgItems : allItems, tab === 'org' ? 'org_net_value' : 'net_value')} tab={tab} onOpenStock={onOpenStock}