import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp } from 'lucide-react'
import { api, type KlineRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtPrice, fmtPct, fmtBigNum, priceColorClass } from '@/lib/format'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { ColumnCustomizer } from '@/components/ColumnCustomizer'
import { StockDataTable } from '@/components/stock-table/StockDataTable'
import { useTableSort } from '@/components/stock-table/useTableSort'
import { MiniCandlestick } from '@/components/stock-table/MiniCandlestick'
import { boardTag, renderBuiltinDataCell } from '@/components/stock-table/primitives'
import { getSignals, signalCls, getSortValue, UNSORTABLE_KEYS } from '@/lib/stock-table'
import { resolveCandleConfig } from '@/lib/list-columns'
import { useQuoteStatus } from '@/lib/useSharedQueries'
import {
type ColumnConfig,
BUILTIN_COLUMNS,
COLUMN_GROUPS,
loadColumnConfig,
saveColumnConfig,
buildExtColumnsParam,
} from '@/lib/watchlist-columns'
// ===== 板块标识(筛选/卡片用) =====
// 注: boardTag(创/科/北 标签)已移至共享 @/components/stock-table/primitives
const BOARDS = ['沪主板', '深主板', '创业板', '科创板', '北交所'] as const
type BoardType = typeof BOARDS[number]
function getBoardType(symbol: string): BoardType | null {
if (/^(300|301)/.test(symbol)) return '创业板'
if (/^688/.test(symbol)) return '科创板'
if (/\.BJ$/.test(symbol)) return '北交所'
if (/^60[0135]/.test(symbol)) return '沪主板'
if (/^00[012]/.test(symbol)) return '深主板'
return null
}
// ===== 换手率分档色(卡片/表格用) =====
function turnoverColor(rate: number | null | undefined): string {
if (rate == null || Number.isNaN(rate)) return 'text-[#888]'
if (rate < 5) return 'text-[#888]'
if (rate < 10) return 'text-[#d4a800]'
if (rate < 20) return 'text-[#f97316]'
if (rate < 35) return 'text-[#d94a3d]'
return 'text-[#b84a8a]'
}
// ===== 动态列渲染 =====
// 表头/单元格渲染已共享化:纯数据列由 @/components/stock-table/primitives 的
// renderBuiltinDataCell 处理;symbol/signals/candle/ext 等需上下文的列由下方
// 表格 renderCell 回调处理。表格骨架使用 StockDataTable。
/** 渲染扩展数据列的值(含分隔/标签/展开配置) */
function renderExtValue(
val: any,
col: ColumnConfig,
expanded: boolean,
onToggle: () => void,
inline?: boolean,
): React.ReactNode {
if (val == null || Number.isNaN(val)) return —
if (typeof val === 'number') {
// int 类型不显示小数
const displayVal = Number.isInteger(val) ? fmtPrice(val, 0) : fmtPrice(val)
return {displayVal}
}
if (typeof val === 'boolean') {
return {val ? '是' : '否'}
}
// String — 按 extDisplay 配置渲染
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)
if (tags.length === 0) return —
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 = !inline && cfg?.tagLayout === 'vertical' && !expanded
const tagEls = (
<>
{visibleTags.map((tag, i) => (
{tag}
))}
{!showAll && hiddenCount > 0 && (
)}
{showAll && maxTags > 0 && tags.length > maxTags && (
)}
>
)
if (inline) {
// 卡片视图:返回 inline 片段
return tagEls
}
// 表格视图:用
包裹
return
{tagEls}
}
/** 渲染扩展数据列的
*/
function renderExtCell(
r: any,
col: ColumnConfig,
expandedCells: Set,
onToggleExpand: (key: string) => void,
): React.ReactNode {
if (col.source.type !== 'ext') return null
const { configId, fieldName } = col.source
const val = r[`${configId}__${fieldName}`]
const cellKey = `${r.symbol}::${col.id}`
const expanded = expandedCells.has(cellKey)
const style: React.CSSProperties = {}
if (col.extDisplay?.maxWidth) {
style.maxWidth = col.extDisplay.maxWidth
}
// 根据值类型决定 td class
const tdClass = val == null || Number.isNaN(val)
? 'px-2 py-1.5 text-right num tabular-nums text-muted'
: typeof val === 'number'
? 'px-2 py-1.5 text-right num tabular-nums'
: typeof val === 'boolean'
? 'px-2 py-1.5 text-right'
: 'px-2 py-1.5'
return (
|
{renderExtValue(val, col, expanded, () => onToggleExpand(cellKey))}
|
)
}
// ===== 搜索框组件(紧凑内联式)=====
function StockSearchBox({
onPreview,
existingSymbols,
onAdd,
}: {
onPreview: (symbol: string, name: string) => void
existingSymbols: string[]
onAdd: (symbol: string) => void
}) {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const containerRef = useRef(null)
const inputRef = useRef(null)
const [activeIdx, setActiveIdx] = useState(-1)
const search = useQuery({
queryKey: QK.instrumentSearch(query),
queryFn: () => api.instrumentSearch(query),
enabled: query.trim().length > 0,
staleTime: 30_000,
})
const results = search.data?.results ?? []
useEffect(() => {
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Escape') { setOpen(false); return }
if (!open || results.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIdx(i => Math.min(i + 1, results.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIdx(i => Math.max(i - 1, -1))
} else if (e.key === 'Enter') {
e.preventDefault()
if (activeIdx >= 0) handleSelect(results[activeIdx])
else if (results.length > 0) handleSelect(results[0])
}
}
function handleSelect(r: { symbol: string; name: string }) {
onPreview(r.symbol, r.name)
setQuery('')
setOpen(false)
setActiveIdx(-1)
}
return (
)
}
// ===== 实时监控圆点 =====
// 自选页 symbol 列代码后的小圆点, 标识该标的正在被实时行情监控 (Free/低档按自选监控模式)。
// 视觉: 内圈实心点 + 外圈 animate-ping 扩散晕, 语义=「在线/活动」。
// 配色用 accent (电光蓝) 而非绿/红: 项目设计规范规定红绿仅用于价格/K线,
// UI 状态用 accent, 避免与 A 股涨跌色混淆。
// 全市场模式 (Starter+) 不显示 —— 全部都在监控, 标记无信息量。
function RealtimeDot({ title = '实时监控中' }: { title?: string }) {
return (
{/* 外圈: 扩散晕 (ping 动画) */}
{/* 内圈: 实心点 + 微辉光 */}
)
}
// ===== 卡片组件 =====
function StockCard({
r,
candleRows,
showCandle,
onPreview,
onConfirmRemove,
onCancelRemove,
onRequestRemove,
confirmRemove,
extCols,
expandedCells,
onToggleExpand,
isMonitored,
}: {
r: any
candleRows: KlineRow[]
showCandle: boolean
onPreview: (symbol: string, name: string) => void
onConfirmRemove: (symbol: string) => void
onCancelRemove: () => void
onRequestRemove: (symbol: string) => void
confirmRemove: string | null
extCols: ColumnConfig[]
expandedCells: Set
onToggleExpand: (key: string) => void
isMonitored?: boolean
}) {
const board = boardTag(r.symbol)
const price = r.rt_price ?? r.close
const pct = r.rt_pct ?? r.change_pct
const name = r.rt_name ?? r.name
const signals = getSignals(r)
const isUp = (pct ?? 0) > 0
const isDown = (pct ?? 0) < 0
// 动态背景渐变: 涨=红底, 跌=绿底, 平=无色
const bgGlow = isUp
? 'bg-gradient-to-br from-bull/[0.06] via-transparent to-bull/[0.02]'
: isDown
? 'bg-gradient-to-br from-bear/[0.06] via-transparent to-bear/[0.02]'
: ''
// 左侧指示条颜色
const barColor = isUp ? 'bg-bull/70' : isDown ? 'bg-bear/70' : 'bg-muted/30'
// 涨跌幅标签背景
const pctBg = isUp ? 'bg-bull/12 text-bull' : isDown ? 'bg-bear/12 text-bear' : 'bg-elevated text-secondary'
return (
onPreview(r.symbol, name ?? '')}
>
{/* 左侧彩色指示条 */}
{/* 删除按钮 / 确认区 */}
{confirmRemove === r.symbol ? (
e.stopPropagation()}>
) : (
)}
{/* 卡片内容 */}
{/* 第一行: 代码 + 名称 + 板块标识 */}
{r.symbol}
{name && (
{name}
)}
{board && (
{board.label}
)}
{r.consecutive_limit_ups > 0 && (
{r.consecutive_limit_ups === 1 ? '首板' : `${r.consecutive_limit_ups}连`}
)}
{isMonitored && }
{/* 第二行: 大价格 + 涨跌幅胶囊 */}
{fmtPrice(price)}
{pct != null && (
{isUp ? '+' : ''}{pct.toFixed(2)}%
)}
{/* 第三行: 指标 */}
换手{r.turnover_rate != null ? `${r.turnover_rate.toFixed(2)}%` : '—'}
量比{fmtPrice(r.vol_ratio_5d)}
RSI{r.rsi_14 != null ? r.rsi_14.toFixed(1) : '—'}
{/* 扩展数据列展示在卡片中 */}
{extCols.map(col => {
if (col.source.type !== 'ext') return null
const { configId, fieldName } = col.source
const val = r[`${configId}__${fieldName}`]
if (val == null) return null
const cellKey = `${r.symbol}::${col.id}`
const expanded = expandedCells.has(cellKey)
return (
{fieldName}
{renderExtValue(val, col, expanded, () => onToggleExpand(cellKey), true)}
)
})}
{/* 信号标签区 */}
{signals.length > 0 && (
{signals.slice(0, 3).map(s => (
{s.label}
))}
{signals.length > 3 && (
+{signals.length - 3}
)}
)}
{/* 迷你蜡烛图 */}
{showCandle && candleRows.length > 0 && (
)}
)
}
// ===== 主页面 =====
export function Watchlist() {
const qc = useQueryClient()
const [viewMode, setViewMode] = useState<'table' | 'card'>(() => {
return (storage.watchlistView.get('table') as 'table' | 'card')
})
const [dailyKChartVisible, setDailyKChartVisible] = useState(() => {
return storage.watchlistCandle.get(true)
})
// 列配置 — 从后端/localStorage 异步加载
const [columns, setColumns] = useState([...BUILTIN_COLUMNS])
const [customizerOpen, setCustomizerOpen] = useState(false)
const columnsLoaded = useRef(false)
useEffect(() => {
if (columnsLoaded.current) return
columnsLoaded.current = true
loadColumnConfig().then(setColumns)
}, [])
const handleColumnsChange = useCallback((next: ColumnConfig[]) => {
setColumns(next)
saveColumnConfig(next)
}, [])
const candleColumn = useMemo(() =>
columns.find(c => c.source.type === 'builtin' && c.source.key === 'candle' && c.visible),
[columns],
)
const candleColumnEnabled = !!candleColumn
// 日k列渲染配置(来自列定制,已钳制边界)
const candleResolved = useMemo(() => resolveCandleConfig(candleColumn?.candleConfig), [candleColumn])
const candleDays = candleResolved.days
const candleSize = dailyKChartVisible
? { width: candleResolved.enabledWidth, height: candleResolved.enabledHeight }
: { width: candleResolved.disabledWidth, height: candleResolved.disabledHeight }
const dailyKVisible = candleColumnEnabled && dailyKChartVisible
// 计算可见列(列是否出现只由自定义列配置决定)
const visibleColumns = useMemo(() => {
return columns.filter(c => c.visible)
}, [columns])
// 计算 ext 列参数
const extColumnsParam = useMemo(() => buildExtColumnsParam(columns), [columns])
const toggleView = useCallback(() => {
setViewMode(v => {
const next = v === 'table' ? 'card' : 'table'
storage.watchlistView.set(next)
return next
})
}, [])
const toggleDailyKChart = useCallback(() => {
setDailyKChartVisible(v => {
const next = !v
storage.watchlistCandle.set(next)
return next
})
}, [])
const [previewSymbol, setPreviewSymbol] = useState(null)
const [previewName, setPreviewName] = useState('')
const [expandedCells, setExpandedCells] = useState>(new Set())
const closePreview = useCallback(() => {
setPreviewSymbol(null)
setPreviewName('')
}, [])
const handleToggleExpand = useCallback((cellKey: string) => {
setExpandedCells(prev => {
const next = new Set(prev)
if (next.has(cellKey)) next.delete(cellKey)
else next.add(cellKey)
return next
})
}, [])
const list = useQuery({
queryKey: QK.watchlist,
queryFn: api.watchlistList,
})
// enriched 数据 — 传入 ext_columns 参数
const enriched = useQuery({
queryKey: QK.watchlistEnriched(extColumnsParam),
queryFn: () => api.watchlistEnriched(extColumnsParam || undefined),
enabled: (list.data?.symbols.length ?? 0) > 0,
})
const symbols = enriched.data?.rows?.map((r: any) => r.symbol) ?? []
const symbolsKey = symbols.join(',')
// 批量日k数据 (天数由列配置决定)
const klineBatch = useQuery({
queryKey: QK.watchlistKlineBatch(`${symbolsKey}|${candleDays}`),
queryFn: () => api.klineDailyBatch(symbols, candleDays),
enabled: dailyKVisible && symbols.length > 0,
staleTime: 5 * 60_000, // 5 分钟内不重请求
})
const klineData = dailyKVisible ? (klineBatch.data?.data ?? {}) : {}
const addMutation = useMutation({
mutationFn: (sym: string) => api.watchlistAdd(sym),
onSuccess: (data) => {
qc.setQueryData(QK.watchlist, data)
qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] })
},
})
const remove = useMutation({
mutationFn: (sym: string) => api.watchlistRemove(sym),
onSuccess: (_data, sym) => {
// 1. 立即从 enriched 缓存中移除该股票,UI 即时更新
qc.setQueryData(['watchlist-enriched', extColumnsParam], (old: any) => {
if (!old?.rows) return old
return { ...old, rows: old.rows.filter((r: any) => r.symbol !== sym) }
})
// 2. 清除 list 缓存,触发后台 refetch
qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
qc.invalidateQueries({ queryKey: QK.watchlistKlineBatch('') })
},
})
const moveToTop = useMutation({
mutationFn: (sym: string) => api.watchlistMoveToTop(sym),
onSuccess: (data) => {
qc.setQueryData(QK.watchlist, data)
qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] })
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
},
})
const clearAll = useMutation({
mutationFn: () => api.watchlistClear(),
onSuccess: () => {
setConfirmClear(false)
// 立即清空 enriched 缓存
qc.setQueryData(['watchlist-enriched', extColumnsParam], { rows: [], as_of: null, elapsed_ms: 0 })
qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
qc.invalidateQueries({ queryKey: QK.watchlistKlineBatch('') })
},
})
// 二次确认状态
const [confirmClear, setConfirmClear] = useState(false)
const [confirmRemove, setConfirmRemove] = useState(null)
const allSymbols = list.data?.symbols?.map(s => s.symbol) ?? []
const rows = enriched.data?.rows ?? []
// 实时监控圆点: 仅 Free/低档 "按自选股实时监控" 模式 (mode === 'watchlist') 下显示;
// Starter+ 全市场模式 (mode === 'full_market') 全部标的都在监控, 标圆点无意义, 故不显示。
// 后端 Free 档实际只监控自选页前 N 个 (N = watchlist_symbol_count), 顺序与 allSymbols 一致。
const quoteStatus = useQuoteStatus()
const realtimeRunning = quoteStatus.data?.running ?? false
const realtimeMode = quoteStatus.data?.mode
const watchlistMonitoredCount = quoteStatus.data?.watchlist_symbol_count ?? 0
const showRealtimeDot = realtimeRunning && realtimeMode === 'watchlist'
// 真正被监控的标的集合 (自选列表前 watchlistMonitoredCount 个)
const monitoredSymbols = useMemo(
() => showRealtimeDot ? new Set(allSymbols.slice(0, watchlistMonitoredCount)) : new Set(),
[showRealtimeDot, allSymbols, watchlistMonitoredCount],
)
// ===== 筛选 =====
const [filterOpen, setFilterOpen] = useState(false)
const [filters, setFilters] = useState>({})
// 板块筛选(持久化)
const [boardFilter, setBoardFilter] = useState>(() => {
const saved = storage.watchlistBoardFilter.get([])
return saved.length > 0 ? new Set(saved) : new Set(BOARDS) // 默认全选
})
const persistBoardFilter = useCallback((next: Set) => {
setBoardFilter(next)
storage.watchlistBoardFilter.set([...next])
}, [])
const toggleBoard = useCallback((board: string) => {
setBoardFilter(prev => {
const next = new Set(prev)
if (next.has(board)) next.delete(board)
else next.add(board)
persistBoardFilter(next)
return next
})
}, [persistBoardFilter])
const updateFilter = useCallback((colId: string, patch: { min?: string; max?: string; text?: string }) => {
setFilters(prev => {
const next = { ...prev }
const existing = next[colId] || {}
const merged = { ...existing, ...patch }
if (!merged.min && !merged.max && !merged.text) {
delete next[colId]
} else {
next[colId] = merged
}
return next
})
}, [])
const clearFilters = useCallback(() => setFilters({}), [])
// 可筛选的内置列
const filterableBuiltinCols = useMemo(
() => columns.filter(c => c.source.type === 'builtin' && !UNSORTABLE_KEYS.has(c.source.key) && c.id !== 'builtin:symbol'),
[columns],
)
// 按类别索引(复用列配置的分组定义)
const colsByCategory = useMemo(() => {
const map: Record = {}
for (const cat of COLUMN_GROUPS) {
map[cat.label] = []
for (const key of cat.keys) {
const col = filterableBuiltinCols.find(c => c.source.type === 'builtin' && c.source.key === key)
if (col) map[cat.label].push({ id: col.id, label: col.label, col })
}
}
return map
}, [filterableBuiltinCols])
// 筛选 + 排序
const filteredRows = useMemo(() => {
// 板块筛选(全选时跳过)
let result = rows
if (boardFilter.size > 0 && boardFilter.size < BOARDS.length) {
result = result.filter(r => {
const board = getBoardType(r.symbol)
return board != null && boardFilter.has(board)
})
}
// 数值/文本筛选
const activeFilters = Object.entries(filters).filter(([, v]) => v.min || v.max || v.text)
if (activeFilters.length > 0) {
result = result.filter(r => {
for (const [colId, f] of activeFilters) {
const col = columns.find(c => c.id === colId)
if (!col) continue
const val = getSortValue(r, col)
if (val == null) return false
if (typeof val === 'number') {
if (f.min && val < Number(f.min)) return false
if (f.max && val > Number(f.max)) return false
} else {
if (f.text && !String(val).includes(f.text)) return false
}
}
return true
})
}
return result
}, [rows, filters, columns, boardFilter])
const activeFilterCount = Object.values(filters).filter(v => v.min || v.max || v.text).length
// 排序(复用共享三态排序 hook)
const { sort, toggle: handleSortToggle, sortRows } = useTableSort()
const sortedRows = useMemo(
() => sortRows(filteredRows, columns),
[filteredRows, sortRows, columns],
)
// 可见的 ext 列(卡片视图使用)
const visibleExtCols = useMemo(
() => visibleColumns.filter(c => c.source.type === 'ext'),
[visibleColumns]
)
// 被过滤掉的个股数 (筛选/板块过滤导致的隐藏)
const hiddenCount = Math.max(0, allSymbols.length - sortedRows.length)
return (
{/* 计数胶囊: 显示数/总数, mono 字体突出数字 */}
{sortedRows.length}
/
{allSymbols.length}
只
{/* 过滤提示: 仅在有隐藏时出现, 柔和橙色融入整体 */}
{hiddenCount > 0 && (
已过滤 {hiddenCount}
)}
}
right={
{/* 筛选 / 搜索 */}
{ setPreviewSymbol(sym); setPreviewName(name) }}
existingSymbols={allSymbols as string[]}
onAdd={(sym) => addMutation.mutate(sym)}
/>
{/* 视图 */}
{/* 自定义列 / 刷新 */}
{allSymbols.length > 0 && (
<>
>
)}
}
/>
{/* 筛选栏 */}
{filterOpen && (
{/* 板块筛选 */}
板块
{BOARDS.map(board => {
const active = boardFilter.has(board)
return (
)
})}
{COLUMN_GROUPS.map(cat => {
const items = colsByCategory[cat.label]?.filter(i => i.col)
if (!items?.length) return null
return (
)
})}
{activeFilterCount > 0 && (
)}
)}
{/* 可滚动列表区 — 占满剩余高度,内部独立滚动,表头 sticky 固定 */}
{/* 列表 */}
{list.isLoading && 加载中… }
{list.isError && 读取自选失败 }
{allSymbols.length === 0 ? (
) : viewMode === 'table' ? (
r.symbol}
rowClassName={() => 'border-t border-border hover:bg-elevated/50 transition-colors duration-150 ease-smooth'}
// 日k列表头:标签 + 显示/隐藏眼睛按钮
renderHeaderContent={(col) => {
if (col.source.type === 'builtin' && col.source.key === 'candle') {
return (
{col.label}
)
}
return undefined
}}
renderCell={(r: any, col: ColumnConfig) => {
// ext 列
if (col.source.type === 'ext') {
return renderExtCell(r, col, expandedCells, handleToggleExpand)
}
const key = col.source.key
const price = r.rt_price ?? r.close
const pct = r.rt_pct ?? r.change_pct
const name = r.rt_name ?? r.name
// 自选页 symbol 列:预览 + 内嵌删除(减号图标,二次确认)
if (key === 'symbol') {
const board = boardTag(r.symbol)
return (
{/* 删除入口:默认减号图标,二次确认时替换为确定按钮 */}
{confirmRemove === r.symbol ? (
) : (
)}
|
)
}
// 实时行情列:price/pct/amount 使用 rt_ 回退(自选页有实时推送)
const numCls = 'px-2 py-1.5 text-right num tabular-nums'
if (key === 'price') {
return {fmtPrice(price)} |
}
if (key === 'pct') {
return {fmtPct(pct)} |
}
if (key === 'amount') {
return {fmtBigNum(r.rt_amount ?? r.amount)} |
}
if (key === 'turnover') {
return {r.turnover_rate != null ? `${r.turnover_rate.toFixed(2)}%` : '—'} |
}
// 信号列
if (key === 'signals') {
const signals = getSignals(r)
return (
{signals.length > 0 && (
{signals.slice(0, 3).map((s) => (
{s.label}
))}
{signals.length > 3 && (
+{signals.length - 3}
)}
)}
|
)
}
// 日k列
if (key === 'candle') {
return (
|
)
}
// 其余纯数据列 → 共享原语
return renderBuiltinDataCell(r, col)
}}
className="rounded-card overflow-x-auto"
/>
) : (
{rows.map((r: any) => (
{ setPreviewSymbol(sym); setPreviewName(name) }}
onConfirmRemove={(sym) => { remove.mutate(sym); setConfirmRemove(null) }}
onCancelRemove={() => setConfirmRemove(null)}
onRequestRemove={(sym) => setConfirmRemove(sym)}
confirmRemove={confirmRemove}
extCols={visibleExtCols}
expandedCells={expandedCells}
onToggleExpand={handleToggleExpand}
isMonitored={monitoredSymbols.has(r.symbol)}
/>
))}
)}
{/* 清空确认弹窗 */}
{confirmClear && (
setConfirmClear(false)}
/>
确认清空自选
将移除全部 {allSymbols.length} 只自选股,此操作不可恢复。
)}
{/* 列自定义侧栏 */}
setCustomizerOpen(false)}
/>
)
}
|