Files
stock/frontend/src/components/StockInfoBar.tsx
T
2026-07-01 22:07:55 +08:00

279 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, type ReactNode } from 'react'
import { Settings2, RadioTower, Star } from 'lucide-react'
import type { KlineRow, FinancialMetricRecord } from '@/lib/api'
import { fmtPrice, fmtBigNum, fmtVolume } from '@/lib/format'
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
import { INFO_GROUPS, type ColumnConfig } from '@/lib/stock-info-fields'
const BULL = '#C74040'
const BEAR = '#2D9B65'
interface Props {
symbol: string
name?: string
stockInfo?: { name?: string; total_shares?: number; float_shares?: number; ext?: Record<string, unknown> }
rows: KlineRow[]
/** 信息条字段配置(由 StockPanel 提升,受控) */
fields: ColumnConfig[]
onFieldsChange: (fields: ColumnConfig[]) => void
/** 财务指标最新一期(来自 useFinancialMetrics,受 Cap.FINANCIAL 门控) */
financialMetrics?: FinancialMetricRecord
/** 加监控回调 (个股弹窗传入, 有值时渲染 RadioTower 图标) */
onMonitor?: () => void
/** 加自选回调 + 是否已自选 (有 onToggle 时渲染 Star 图标) */
inWatchlist?: boolean
onToggleWatchlist?: () => void
}
/**
* 精简渲染扩展数据值(信息条专用)。
* 仍尊重 extDisplay 配置:text=纯文本,tag(默认)=按分隔符拆成小标签 + maxTags 截断。
* 与自选列表的差异:标签模式无 maxWidth/排列方向,但保留 +N 展开交互。
*/
function renderExtInline(
val: unknown,
col: ColumnConfig,
expanded: boolean,
onToggle: () => void,
): ReactNode {
if (val == null || (typeof val === 'number' && Number.isNaN(val))) {
return <span className="text-muted"></span>
}
if (typeof val === 'number') {
const displayVal = Number.isInteger(val) ? fmtPrice(val, 0) : fmtPrice(val)
return <span className="tabular-nums">{displayVal}</span>
}
if (typeof val === 'boolean') {
return <span className={val ? 'text-bull' : 'text-muted'}>{val ? '是' : '否'}</span>
}
const str = String(val)
// 纯文本模式
if (col.extDisplay?.displayMode === 'text') {
return <span>{str}</span>
}
// 标签模式(默认):按分隔符拆成小标签
const sep = col.extDisplay?.separator?.trim() || null
const tags = sep
? str.split(sep).map(s => s.trim()).filter(Boolean)
: str.split(/[、,;\-]/).map(s => s.trim()).filter(Boolean)
if (tags.length === 0) return <span className="text-muted"></span>
// maxTags 截断 + 展开交互:收起时显示前 N 个 + +N,展开时显示全部 + 收起
const maxTags = col.extDisplay?.maxTags ?? 0
const hiddenIndices = maxTags > 0 ? col.extDisplay?.hiddenIndices : undefined
const showAll = maxTags <= 0 || expanded
const sliced = showAll ? tags : tags.slice(0, maxTags)
const shown = hiddenIndices?.length ? sliced.filter((_, i) => !hiddenIndices.includes(i)) : sliced
const overflow = tags.length - shown.length
return (
<span className="inline-flex flex-wrap items-center gap-0.5">
{shown.map((tag, i) => (
<span key={i} className="inline-block px-1 rounded text-[10px] leading-tight text-yellow-500 bg-yellow-500/10">
{tag}
</span>
))}
{!showAll && overflow > 0 && (
<button
onClick={onToggle}
className="inline-block px-1 rounded text-[10px] leading-tight text-accent bg-accent/10 hover:bg-accent/20 transition-colors"
>
+{overflow}
</button>
)}
{showAll && maxTags > 0 && tags.length > maxTags && (
<button
onClick={onToggle}
className="inline-block px-1 rounded text-[10px] leading-tight text-muted hover:text-foreground transition-colors"
>
收起
</button>
)}
</span>
)
}
export function StockInfoBar({ symbol, name, stockInfo, rows, fields, onFieldsChange, financialMetrics, onMonitor, inWatchlist, onToggleWatchlist }: Props) {
// 弹窗开关:纯本地状态,与数据/配置无关,放早期 return 之前
const [customizerOpen, setCustomizerOpen] = useState(false)
// ext 标签展开状态:按 symbol::colId,切股/切字段时互不干扰
const [expandedExt, setExpandedExt] = useState<Set<string>>(new Set())
const toggleExtExpand = (key: string) => {
setExpandedExt(prev => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
if (rows.length === 0) return null
const latest = rows[rows.length - 1]
const prev = rows.length >= 2 ? rows[rows.length - 2] : null
const close = Number(latest.close)
const chg = prev ? close - Number(prev.close) : 0
const chgPct = prev ? chg / Number(prev.close) * 100 : 0
const isUp = chg >= 0
const clr = isUp ? BULL : BEAR
const totalShares = stockInfo?.total_shares
const floatShares = stockInfo?.float_shares
const marketCap = totalShares ? close * totalShares : null
const floatMarketCap = floatShares ? close * floatShares : null
const turnoverRate = floatShares && latest.volume
? (Number(latest.volume) * 100 / floatShares * 100)
: null
const displayName = stockInfo?.name ?? name ?? ''
const extData = stockInfo?.ext ?? {}
// 按指标 key 计算格式化值,无数据返回 null(渲染时跳过,与原行为一致)。
// 普通函数:依赖行情值每次 render 都变,useCallback 无收益;且必须定义在早期 return 之后。
const computeBuiltinValue = (key: string): string | null => {
switch (key) {
case 'market_cap': return marketCap != null ? fmtBigNum(marketCap) : null
case 'float_market_cap': return floatMarketCap != null ? fmtBigNum(floatMarketCap) : null
case 'turnover': return turnoverRate != null ? `${turnoverRate.toFixed(2)}%` : null
case 'volume': return latest.volume != null ? fmtVolume(Number(latest.volume)) : null
case 'amplitude': {
const prevClose = prev ? Number(prev.close) : null
if (prevClose == null || prevClose === 0) return null
const hi = Number(latest.high)
const lo = Number(latest.low)
return `${((hi - lo) / prevClose * 100).toFixed(2)}%`
}
case 'open': return fmtPrice(Number(latest.open))
case 'high': return fmtPrice(Number(latest.high))
case 'low': return fmtPrice(Number(latest.low))
// 财务指标:百分比字段存储为百分点(12.3 表示 12.3%),直接 toFixed(2) + %
case 'eps': return financialMetrics?.eps_basic != null ? fmtPrice(financialMetrics.eps_basic) : null
case 'bps': return financialMetrics?.bps != null ? fmtPrice(financialMetrics.bps) : null
case 'roe': return financialMetrics?.roe != null ? `${financialMetrics.roe.toFixed(2)}%` : null
case 'gross_margin':return financialMetrics?.gross_margin != null ? `${financialMetrics.gross_margin.toFixed(2)}%` : null
case 'net_margin': return financialMetrics?.net_margin != null ? `${financialMetrics.net_margin.toFixed(2)}%` : null
case 'debt_ratio': return financialMetrics?.debt_to_asset_ratio != null ? `${financialMetrics.debt_to_asset_ratio.toFixed(2)}%` : null
case 'revenue_yoy': return financialMetrics?.revenue_yoy != null ? `${financialMetrics.revenue_yoy.toFixed(2)}%` : null
case 'net_income_yoy': return financialMetrics?.net_income_yoy != null ? `${financialMetrics.net_income_yoy.toFixed(2)}%` : null
// PE/PB 后端无此字段,用现价现算(PE 基于最新一期 EPS,非严格 TTM)
case 'pe_ttm': {
const eps = financialMetrics?.eps_basic
return eps && eps !== 0 ? fmtPrice(close / eps) : null
}
case 'pb': {
const bps = financialMetrics?.bps
return bps && bps !== 0 ? fmtPrice(close / bps) : null
}
default: return null
}
}
const visibleFields = fields.filter(f => f.visible)
// 按是否单独显示分组:普通列共一行,standalone 列各占一行
const inlineFields = visibleFields.filter(f => !f.standalone)
const standaloneFields = visibleFields.filter(f => f.standalone)
// 渲染单个字段(builtin / ext 通用)
const renderField = (f: ColumnConfig): ReactNode => {
if (f.source.type === 'ext') {
const { configId, fieldName } = f.source
const val = extData[`${configId}__${fieldName}`]
// 无值的 ext 字段整体跳过(与 builtin 无数据行为一致)
if (val == null || (typeof val === 'number' && Number.isNaN(val))) return null
const cellKey = `${symbol}::${f.id}`
return (
<span key={f.id} className="inline-flex items-center gap-1">
<span>{f.label}</span>
<span className="text-secondary">
{renderExtInline(val, f, expandedExt.has(cellKey), () => toggleExtExpand(cellKey))}
</span>
</span>
)
}
// builtin
const value = computeBuiltinValue(f.source.type === 'builtin' ? f.source.key : '')
if (value == null) return null
return (
<span key={f.id}>
{f.label} <span className="text-secondary">{value}</span>
</span>
)
}
return (
<div className="px-2 pb-3 font-mono text-[12px] select-none space-y-1">
{/* Row 1: code, name, price, change, change% */}
<div className="flex items-baseline gap-x-3 flex-wrap">
<span className="text-foreground font-bold text-sm tracking-wide">{symbol}</span>
<span className="text-secondary font-medium">{displayName}</span>
<span style={{ color: clr }} className="text-lg font-bold tabular-nums">
{fmtPrice(close)}
</span>
<span style={{ color: clr }} className="tabular-nums">
{isUp ? '+' : ''}{fmtPrice(chg)}
</span>
<span style={{ color: clr }} className="tabular-nums">
{isUp ? '+' : ''}{fmtPrice(chgPct)}%
</span>
{/* 右侧操作按钮:加自选 + 加监控 + 信息条配置 */}
<div className="ml-auto self-center flex items-center gap-1">
{onToggleWatchlist && (
<button
onClick={onToggleWatchlist}
className={`p-1 rounded-btn transition-colors cursor-pointer ${inWatchlist ? 'text-[#FACC15]' : 'text-muted hover:text-foreground hover:bg-elevated'}`}
title={inWatchlist ? '移出自选' : '加自选'}
>
<Star className="h-3.5 w-3.5" />
</button>
)}
{onMonitor && (
<button
onClick={onMonitor}
className="p-1 rounded-btn text-amber-400 hover:bg-amber-400/10 transition-colors cursor-pointer"
title="加监控"
>
<RadioTower className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={() => setCustomizerOpen(true)}
className="p-1 rounded-btn text-muted hover:text-foreground hover:bg-elevated transition-colors"
title="自定义信息条"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* Row 2: 普通指标(builtin + ext,共一行 flex-wrap */}
{inlineFields.length > 0 && (
<div className="flex items-center gap-x-4 gap-y-1 text-[11px] flex-wrap text-muted">
{inlineFields.map(renderField)}
</div>
)}
{/* 单独显示的指标:各占一行 */}
{standaloneFields.map(f => {
const node = renderField(f)
if (node == null) return null
return (
<div key={f.id} className="flex items-center gap-x-4 text-[11px] flex-wrap text-muted">
{node}
</div>
)
})}
<ListColumnCustomizer
columns={fields}
groups={INFO_GROUPS}
onChange={onFieldsChange}
open={customizerOpen}
onClose={() => setCustomizerOpen(false)}
title="信息条指标"
builtinSectionLabel="可选指标"
extColumnAlign="left"
showStandaloneToggle
/>
</div>
)
}