/** * 策略结果表格。 * * 表格骨架(表头排序/sticky/遍历)由共享的 StockDataTable 承担;本组件只负责 * 策略页特有的单元格内容:symbol 列(含加自选按钮 + 失效行灰显)、strategies、 * score、signals、candle、ext 列。其余纯数据列(价格/指标/财务…)交给共享原语。 */ import { useState, type CSSProperties, type ReactNode } from 'react' import { Check, Plus, Eye, EyeOff } from 'lucide-react' import type { KlineRow } from '@/lib/api' import { fmtPrice } from '@/lib/format' import type { ColumnConfig } from '@/lib/screener-columns' import { getSignals, signalCls } from '@/lib/stock-table' import { boardTag, renderBuiltinDataCell } from '@/components/stock-table/primitives' import { resolveCandleConfig } from '@/lib/list-columns' import { MiniCandlestick } from '@/components/stock-table/MiniCandlestick' import { StockDataTable, type SortState } from '@/components/stock-table/StockDataTable' interface ScreenerTableProps { rows: any[] columns: ColumnConfig[] strategyIdToName: Record symbolStrategyMap: Map activeStrategy: string | null watchlistSet: Set onPreview: (symbol: string, name: string) => void onToggleWatchlist: (symbol: string, inList: boolean) => void watchlistPending: boolean /** symbol → 日k 数据,仅当启用日k列时传入 */ klineData?: Record /** 日k蜡烛图是否显示(表头眼睛开关) */ dailyKChartVisible?: boolean onToggleDailyKChart?: () => void /** 表头排序(受控,由 Screener.tsx 传入) */ sort?: SortState | null onSortToggle?: (colId: string) => void } /** 渲染标签数组(含 maxTags 折叠/展开、横竖排列)。策略列与 ext 列共用。 */ function renderTagList( tags: string[], col: ColumnConfig, expanded: boolean, onToggle: () => void, tagClassName: string, ): ReactNode { if (tags.length === 0) return const cfg = col.extDisplay const maxTags = cfg?.maxTags ?? 0 const showAll = maxTags <= 0 || expanded || tags.length <= maxTags const sliced = showAll ? tags : tags.slice(0, maxTags) const hiddenIndices = maxTags > 0 ? cfg?.hiddenIndices : undefined const visibleTags = hiddenIndices?.length ? sliced.filter((_, i) => !hiddenIndices.includes(i)) : sliced const hiddenCount = tags.length - visibleTags.length const isVertical = cfg?.tagLayout === 'vertical' && !expanded return (
{visibleTags.map((tag, i) => ( {tag} ))} {!showAll && hiddenCount > 0 && ( )} {showAll && maxTags > 0 && tags.length > maxTags && ( )}
) } const EXT_TAG_CLS = 'inline-block px-1.5 py-px rounded text-[10px] font-medium leading-tight text-yellow-500 bg-yellow-500/10' const STRATEGY_TAG_CLS = 'inline-block px-1.5 py-px rounded text-[10px] font-medium leading-tight bg-amber-500/10 text-amber-600 border border-amber-500/20' function renderExtValue( val: any, col: ColumnConfig, expanded: boolean, onToggle: () => void, ): ReactNode { if (val == null || 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 cfg = col.extDisplay const str = String(val) if (cfg?.displayMode === 'text') return {str} const separator = cfg?.separator?.trim() || null const tags = separator ? str.split(separator).map(s => s.trim()).filter(Boolean) : str.split(/[、,,;;\-]/).map(s => s.trim()).filter(Boolean) return renderTagList(tags, col, expanded, onToggle, EXT_TAG_CLS) } export function ScreenerTable({ rows, columns, strategyIdToName, symbolStrategyMap, activeStrategy, watchlistSet, onPreview, onToggleWatchlist, watchlistPending, klineData = {}, dailyKChartVisible = true, onToggleDailyKChart, sort, onSortToggle, }: ScreenerTableProps) { const [expandedCells, setExpandedCells] = useState>(new Set()) // 日k列渲染尺寸(按眼睛开关取开启/收起尺寸) const candleCol = columns.find(c => c.source.type === 'builtin' && c.source.key === 'candle' && c.visible) const candleResolved = resolveCandleConfig(candleCol?.candleConfig) const candleSize = dailyKChartVisible ? { width: candleResolved.enabledWidth, height: candleResolved.enabledHeight } : { width: candleResolved.disabledWidth, height: candleResolved.disabledHeight } const toggleExpand = (key: string) => { setExpandedCells(prev => { const next = new Set(prev) if (next.has(key)) next.delete(key) else next.add(key) return next }) } const renderCell = (r: any, col: ColumnConfig): ReactNode => { // ext 列 if (col.source.type === 'ext') { const { configId, fieldName } = col.source const val = r[`${configId}__${fieldName}`] const cellKey = `${r.symbol}::${col.id}` const expanded = expandedCells.has(cellKey) const tdClass = val == null || Number.isNaN(val) ? 'px-3 py-2 text-center text-muted' : typeof val === 'number' ? 'px-3 py-2 text-right num tabular-nums' : 'px-3 py-2 text-center' const style: CSSProperties = {} if (col.extDisplay?.maxWidth) style.maxWidth = col.extDisplay.maxWidth return ( {renderExtValue(val, col, expanded, () => toggleExpand(cellKey))} ) } const isExpired = !!r._expired const key = col.source.key // 策略页特有 / 需上下文的列 switch (key) { case 'symbol': { const board = boardTag(r.symbol) const inWatchlist = watchlistSet.has(r.symbol) return (
{isExpired ? ( 失效 ) : ( )}
) } case 'strategies': { const strats = symbolStrategyMap.get(r.symbol) ?? (activeStrategy ? [activeStrategy] : []) const tags = strats.map(sid => strategyIdToName[sid] ?? sid) const cellKey = `${r.symbol}::${col.id}` const expanded = expandedCells.has(cellKey) return ( {renderTagList(tags, col, expanded, () => toggleExpand(cellKey), STRATEGY_TAG_CLS)} ) } case 'score': { const numCls = 'px-3 py-2 text-right num tabular-nums' return ( {r.score != null ? ( = 70 ? 'text-accent font-medium' : r.score >= 50 ? 'text-amber-400' : 'text-secondary'}> {Number(r.score).toFixed(1)} ) : ( )} ) } case 'signals': { const signals = getSignals(r) return ( {signals.length > 0 ? (
{signals.slice(0, 3).map((s) => ( {s.label} ))} {signals.length > 3 && ( +{signals.length - 3} )}
) : ( )} ) } case 'candle': { const candleRows = klineData[r.symbol] ?? [] // 锁定列宽与行高:minWidth=maxWidth 防止 kline 加载前后整列宽度跳动(闪烁) return ( ) } default: // 纯数据列 → 共享原语 return renderBuiltinDataCell(r, col) } } return ( c.visible).length * 110)} rowKey={(r: any) => `${r.symbol}${r._expired ? '-expired' : ''}`} rowClassName={(r: any) => r._expired ? 'border-border/50 opacity-40' : 'border-border hover:bg-elevated/50' } // 日k列表头:标签 + 显示/隐藏蜡烛图眼睛按钮(与自选页一致) renderHeaderContent={onToggleDailyKChart ? (col) => { if (col.source.type === 'builtin' && col.source.key === 'candle') { return ( {col.label} ) } return undefined } : undefined} /> ) }