feat(recap): 龙虎榜卡片信息密度与可读性增强

- 摘要行扩为净买/净卖/机构净买/机构净卖各 Top5, 四行药丸左缘固定槽对齐
- fmtVolume 负数按绝对值分级补万/亿单位 (净卖席位原显示裸数字)
- 个股名称后复用 boardTag 加创/科/北板块徽章 (表格/药丸/游资chips)
- 三榜表格数值列头可点击排序 (升降切换, 空值恒垫底, 切tab重置)
- 游资席位金额去掉「净买」前缀
- AI 复盘提示词龙虎榜摘要四段统一 Top5
This commit is contained in:
shy3130
2026-08-30 19:05:30 +08:00
parent 381038e94f
commit cff3b07527
3 changed files with 95 additions and 25 deletions
+3 -3
View File
@@ -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)
+4 -2
View File
@@ -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)
}
+88 -20
View File
@@ -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 }: {
<span className="text-[11px] text-foreground/90 transition-colors group-hover:text-accent">
{item.name ?? item.thscode}
</span>
{(() => { const b = boardTag(item.thscode); return b && (
<span className={`inline-flex items-center rounded border px-1 text-[8px] font-bold leading-tight ${b.color}`}>
{b.label}
</span>
) })()}
<span className={cn('font-mono text-[10px] tabular-nums', priceColorClass(value ?? null))}>
{fmtVolume(value ?? null)}
</span>
@@ -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 (
<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 className="flex w-16 shrink-0">
{label && (
<span className="rounded-full bg-base/70 px-2 py-0.5 text-[9px] font-medium tracking-wide text-muted">
{label}
</span>
)}
</span>
{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} />
))}
</div>
)
}
/** 可排序列 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 (
<button
type="button"
onClick={() => onSort(sortKey)}
className={cn('inline-flex items-center gap-0.5 transition-colors hover:text-foreground', active && 'text-foreground', className)}
>
{label}
{active ? (
sort.desc
? <ChevronDown className="h-2.5 w-2.5 opacity-80" />
: <ChevronUp className="h-2.5 w-2.5 opacity-80" />
) : (
<ChevronDown className="h-2.5 w-2.5 opacity-0 transition-opacity group-hover:opacity-40" />
)}
</button>
)
}
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 (
<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',
'group 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="w-14 shrink-0"><_DtTh label="涨跌幅" sortKey="change" sort={sort} onSort={onSort} /></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-24 shrink-0"><_DtTh label="净买额" sortKey="net_value" sort={sort} onSort={onSort} className="w-full justify-end" /></span>
<span className="w-14 shrink-0"><_DtTh label="占比" sortKey="net_rate" sort={sort} onSort={onSort} className="w-full justify-end" /></span>
{isOrg && <span className="w-20 shrink-0"><_DtTh label="机构净买" sortKey="org_net_value" sort={sort} onSort={onSort} className="w-full justify-end" /></span>}
<span className="w-20 shrink-0"><_DtTh label="买入额" sortKey="buy_value" sort={sort} onSort={onSort} className="w-full justify-end" /></span>
<span className="w-20 shrink-0"><_DtTh label="卖出额" sortKey="sell_value" sort={sort} onSort={onSort} className="w-full justify-end" /></span>
<span className="w-11 shrink-0 text-right"></span>
</div>
{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 }: {
<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>
{(() => { const b = boardTag(i.thscode); return b && (
<span className={`ml-1 inline-flex items-center rounded border px-1 text-[8px] font-bold leading-tight ${b.color}`}>
{b.label}
</span>
) })()}
{i.hot_rank != null && i.hot_rank > 0 && i.hot_rank <= 99 && (
<span
className={cn(
@@ -1046,7 +1104,7 @@ function _DtSeatList({ seats, onOpenStock }: {
<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)}
{fmtVolume(s.buying ?? null)}
</span>
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1.5">
@@ -1059,6 +1117,11 @@ function _DtSeatList({ seats, onOpenStock }: {
title={`查看 ${r.name ?? r.thscode} 详情`}
>
<span>{r.name ?? r.thscode}</span>
{(() => { const b = boardTag(r.thscode); return b && (
<span className={`inline-flex items-center self-center rounded border px-1 text-[8px] font-bold leading-tight ${b.color}`}>
{b.label}
</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>
@@ -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 && (
<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} />
<_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} />
</div>
)}
@@ -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}