复制 local 代码到 serve
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* 策略结果表格。
|
||||
*
|
||||
* 表格骨架(表头排序/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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user