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}