321 lines
13 KiB
TypeScript
321 lines
13 KiB
TypeScript
/**
|
||
* 策略结果表格。
|
||
*
|
||
* 表格骨架(表头排序/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<string, string>
|
||
symbolStrategyMap: Map<string, string[]>
|
||
activeStrategy: string | null
|
||
watchlistSet: Set<string>
|
||
onPreview: (symbol: string, name: string) => void
|
||
onToggleWatchlist: (symbol: string, inList: boolean) => void
|
||
watchlistPending: boolean
|
||
/** symbol → 日k 数据,仅当启用日k列时传入 */
|
||
klineData?: Record<string, KlineRow[]>
|
||
/** 日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 <span className="text-muted">—</span>
|
||
|
||
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 (
|
||
<div className={isVertical ? 'flex flex-col items-start gap-0.5' : 'flex flex-wrap gap-0.5'}>
|
||
{visibleTags.map((tag, i) => (
|
||
<span key={i} className={tagClassName}>{tag}</span>
|
||
))}
|
||
{!showAll && hiddenCount > 0 && (
|
||
<button
|
||
onClick={onToggle}
|
||
className="inline-block px-1.5 py-px rounded text-[10px] font-medium leading-tight text-accent bg-accent/10 hover:bg-accent/20 transition-colors"
|
||
>
|
||
+{hiddenCount}
|
||
</button>
|
||
)}
|
||
{showAll && maxTags > 0 && tags.length > maxTags && (
|
||
<button
|
||
onClick={onToggle}
|
||
className="inline-block px-1.5 py-px rounded text-[10px] font-medium leading-tight text-muted hover:text-foreground transition-colors"
|
||
>
|
||
收起
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 <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 cfg = col.extDisplay
|
||
const str = String(val)
|
||
if (cfg?.displayMode === 'text') return <span className="text-foreground">{str}</span>
|
||
|
||
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<Set<string>>(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 (
|
||
<td key={col.id} className={tdClass} style={style}>
|
||
{renderExtValue(val, col, expanded, () => toggleExpand(cellKey))}
|
||
</td>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<td key={col.id} className="px-4 py-2">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => onPreview(r.symbol, r.name ?? '')}
|
||
className={`flex items-center gap-2 text-left ${isExpired ? 'cursor-default' : ''}`}
|
||
>
|
||
{board ? (
|
||
<span className={`shrink-0 inline-flex items-center justify-center w-[18px] h-[18px] rounded text-[9px] font-bold leading-none border ${board.color}`}>
|
||
{board.label}
|
||
</span>
|
||
) : (
|
||
<span className="shrink-0 w-[18px]" />
|
||
)}
|
||
<span className="font-mono text-secondary group-hover:text-accent transition-colors duration-150 leading-snug">
|
||
{r.symbol}
|
||
</span>
|
||
{r.name && (
|
||
<span className="text-[11px] text-muted truncate group-hover:text-secondary transition-colors duration-150 leading-snug">
|
||
{r.name}
|
||
</span>
|
||
)}
|
||
</button>
|
||
{isExpired ? (
|
||
<span className="shrink-0 inline-flex items-center px-1.5 py-px rounded text-[9px] font-medium leading-tight bg-red-500/10 text-red-400/60 border border-red-500/15">
|
||
失效
|
||
</span>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => onToggleWatchlist(r.symbol, inWatchlist)}
|
||
disabled={watchlistPending}
|
||
className={`shrink-0 inline-flex items-center justify-center w-5 h-5 rounded-full border transition-colors cursor-pointer
|
||
disabled:opacity-50
|
||
${inWatchlist
|
||
? 'border-accent/40 bg-accent/10 text-accent'
|
||
: 'border-border text-muted hover:border-accent/40 hover:text-accent'
|
||
}`}
|
||
title={inWatchlist ? '移出自选' : '加入自选'}
|
||
>
|
||
{inWatchlist ? <Check className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
)
|
||
}
|
||
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 (
|
||
<td key={col.id} className="px-3 py-2">
|
||
{renderTagList(tags, col, expanded, () => toggleExpand(cellKey), STRATEGY_TAG_CLS)}
|
||
</td>
|
||
)
|
||
}
|
||
case 'score': {
|
||
const numCls = 'px-3 py-2 text-right num tabular-nums'
|
||
return (
|
||
<td key={col.id} className={numCls}>
|
||
{r.score != null ? (
|
||
<span className={r.score >= 70 ? 'text-accent font-medium' : r.score >= 50 ? 'text-amber-400' : 'text-secondary'}>
|
||
{Number(r.score).toFixed(1)}
|
||
</span>
|
||
) : (
|
||
<span className="text-muted">—</span>
|
||
)}
|
||
</td>
|
||
)
|
||
}
|
||
case 'signals': {
|
||
const signals = getSignals(r)
|
||
return (
|
||
<td key={col.id} className="px-3 py-2">
|
||
{signals.length > 0 ? (
|
||
<div className="flex flex-wrap gap-0.5">
|
||
{signals.slice(0, 3).map((s) => (
|
||
<span key={s.label} className={`inline-block px-1.5 py-px rounded text-[10px] font-medium leading-tight ${signalCls(s.type)}`}>
|
||
{s.label}
|
||
</span>
|
||
))}
|
||
{signals.length > 3 && (
|
||
<span className="text-[10px] text-muted">+{signals.length - 3}</span>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<span className="text-muted text-xs">—</span>
|
||
)}
|
||
</td>
|
||
)
|
||
}
|
||
case 'candle': {
|
||
const candleRows = klineData[r.symbol] ?? []
|
||
// 锁定列宽与行高:minWidth=maxWidth 防止 kline 加载前后整列宽度跳动(闪烁)
|
||
return (
|
||
<td
|
||
key={col.id}
|
||
className="px-3 py-2"
|
||
style={{ width: candleSize.width, minWidth: candleSize.width, maxWidth: candleSize.width, height: candleSize.height }}
|
||
>
|
||
<MiniCandlestick rows={candleRows} width={candleSize.width} height={candleSize.height} />
|
||
</td>
|
||
)
|
||
}
|
||
default:
|
||
// 纯数据列 → 共享原语
|
||
return renderBuiltinDataCell(r, col)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<StockDataTable
|
||
columns={columns}
|
||
rows={rows}
|
||
renderCell={renderCell}
|
||
sort={sort}
|
||
onSortToggle={onSortToggle}
|
||
minWidth={Math.max(900, columns.filter(c => 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 (
|
||
<span className="inline-flex items-center justify-center gap-1.5">
|
||
<span>{col.label}</span>
|
||
<button
|
||
type="button"
|
||
onClick={(event) => { event.stopPropagation(); onToggleDailyKChart() }}
|
||
className={`inline-flex items-center justify-center w-5 h-5 rounded transition-colors ${
|
||
dailyKChartVisible
|
||
? 'text-accent bg-accent/10 hover:bg-accent/20'
|
||
: 'text-muted hover:text-foreground hover:bg-elevated'
|
||
}`}
|
||
title={dailyKChartVisible ? '隐藏日k蜡烛' : '显示日k蜡烛'}
|
||
aria-label={dailyKChartVisible ? '隐藏日k蜡烛' : '显示日k蜡烛'}
|
||
>
|
||
{dailyKChartVisible ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
|
||
</button>
|
||
</span>
|
||
)
|
||
}
|
||
return undefined
|
||
} : undefined}
|
||
/>
|
||
)
|
||
}
|