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 } 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 } if (typeof val === 'number') { const displayVal = Number.isInteger(val) ? fmtPrice(val, 0) : fmtPrice(val) return {displayVal} } if (typeof val === 'boolean') { return {val ? '是' : '否'} } const str = String(val) // 纯文本模式 if (col.extDisplay?.displayMode === 'text') { return {str} } // 标签模式(默认):按分隔符拆成小标签 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 // 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 ( {shown.map((tag, i) => ( {tag} ))} {!showAll && overflow > 0 && ( )} {showAll && maxTags > 0 && tags.length > maxTags && ( )} ) } 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>(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 ( {f.label} {renderExtInline(val, f, expandedExt.has(cellKey), () => toggleExtExpand(cellKey))} ) } // builtin const value = computeBuiltinValue(f.source.type === 'builtin' ? f.source.key : '') if (value == null) return null return ( {f.label} {value} ) } return (
{/* Row 1: code, name, price, change, change% */}
{symbol} {displayName} {fmtPrice(close)} {isUp ? '+' : ''}{fmtPrice(chg)} {isUp ? '+' : ''}{fmtPrice(chgPct)}% {/* 右侧操作按钮:加自选 + 加监控 + 信息条配置 */}
{onToggleWatchlist && ( )} {onMonitor && ( )}
{/* Row 2: 普通指标(builtin + ext,共一行 flex-wrap) */} {inlineFields.length > 0 && (
{inlineFields.map(renderField)}
)} {/* 单独显示的指标:各占一行 */} {standaloneFields.map(f => { const node = renderField(f) if (node == null) return null return (
{node}
) })} setCustomizerOpen(false)} title="信息条指标" builtinSectionLabel="可选指标" extColumnAlign="left" showStandaloneToggle />
) }