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 (
{ setQuery(e.target.value); setOpen(true); setActiveIdx(-1) }} onFocus={() => { if (query.trim()) setOpen(true) }} onKeyDown={handleKeyDown} className="w-44 h-8 pl-8 pr-2.5 rounded-btn bg-elevated border border-border text-xs text-foreground placeholder:text-muted focus:outline-none focus:border-accent/50 focus:w-56 transition-all duration-200" />
{open && results.length > 0 && ( {results.map((r, i) => { const inWatchlist = existingSymbols.includes(r.symbol) 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 (
{cat.label}
{items.map(item => { const f = filters[item.id] || {} const hasFilter = !!f.min || !!f.max || !!f.text return (
{item.label} updateFilter(item.id, { min: e.target.value })} placeholder="min" className={`w-12 h-5 rounded border text-[10px] px-1 placeholder:text-muted focus:outline-none ${ hasFilter ? 'border-accent/30 bg-accent/5' : 'border-border bg-elevated' } text-foreground focus:border-accent/50`} /> ~ updateFilter(item.id, { max: e.target.value })} placeholder="max" className={`w-12 h-5 rounded border text-[10px] px-1 placeholder:text-muted focus:outline-none ${ hasFilter ? 'border-accent/30 bg-accent/5' : 'border-border bg-elevated' } text-foreground focus:border-accent/50`} />
) })}
) })} {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)} />
) }