Files
stock/local/frontend/src/pages/Watchlist.tsx
T
2026-07-04 16:32:48 +08:00

1189 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <span className="text-muted"></span>
if (typeof val === 'number') {
// int 类型不显示小数
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>
}
// String — 按 extDisplay 配置渲染
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)
if (tags.length === 0) return <span className="text-muted"></span>
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) => (
<span key={i} className="inline-block px-1.5 py-px rounded text-[10px] font-medium leading-tight text-yellow-500 bg-yellow-500/10">
{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>
)}
</>
)
if (inline) {
// 卡片视图:返回 inline 片段
return tagEls
}
// 表格视图:用 <div> 包裹
return <div className={isVertical ? 'flex flex-col items-start gap-0.5' : 'flex flex-wrap gap-0.5'}>{tagEls}</div>
}
/** 渲染扩展数据列的 <td> */
function renderExtCell(
r: any,
col: ColumnConfig,
expandedCells: Set<string>,
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 (
<td className={tdClass} style={style}>
{renderExtValue(val, col, expanded, () => onToggleExpand(cellKey))}
</td>
)
}
// ===== 搜索框组件(紧凑内联式)=====
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<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(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 (
<div ref={containerRef} className="relative">
<div className="relative flex items-center">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted pointer-events-none" />
<input
ref={inputRef}
type="text"
placeholder="搜索…"
value={query}
onChange={(e) => { 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"
/>
</div>
<AnimatePresence>
{open && results.length > 0 && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.12, ease: [0.16, 1, 0.3, 1] }}
className="absolute right-0 top-full mt-1 z-50 w-64 max-h-[320px] overflow-y-auto rounded-card border border-border bg-base shadow-xl"
>
{results.map((r, i) => {
const inWatchlist = existingSymbols.includes(r.symbol)
return (
<div
key={r.symbol}
className={`flex items-center gap-2.5 px-3 py-2 text-xs transition-colors duration-100 ${
i === activeIdx ? 'bg-accent/10 text-accent' : 'hover:bg-elevated text-foreground'
}`}
>
<button
type="button"
onClick={() => handleSelect(r)}
className="flex items-center gap-2.5 flex-1 min-w-0 text-left"
>
<span className="font-mono shrink-0 w-[80px]">{r.symbol}</span>
<span className="truncate text-secondary flex-1">{r.name}</span>
</button>
<button
type="button"
onClick={e => { e.stopPropagation(); onAdd(r.symbol) }}
disabled={inWatchlist}
className={`shrink-0 p-1 rounded transition-colors ${
inWatchlist
? 'text-accent bg-accent/10 cursor-default'
: 'text-muted hover:text-accent hover:bg-accent/10'
}`}
title={inWatchlist ? '已加自选' : '加入自选'}
>
{inWatchlist ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
</button>
</div>
)
})}
</motion.div>
)}
</AnimatePresence>
</div>
)
}
// ===== 实时监控圆点 =====
// 自选页 symbol 列代码后的小圆点, 标识该标的正在被实时行情监控 (Free/低档按自选监控模式)。
// 视觉: 内圈实心点 + 外圈 animate-ping 扩散晕, 语义=「在线/活动」。
// 配色用 accent (电光蓝) 而非绿/红: 项目设计规范规定红绿仅用于价格/K线,
// UI 状态用 accent, 避免与 A 股涨跌色混淆。
// 全市场模式 (Starter+) 不显示 —— 全部都在监控, 标记无信息量。
function RealtimeDot({ title = '实时监控中' }: { title?: string }) {
return (
<span
title={title}
className="relative inline-flex h-2 w-2 shrink-0"
aria-label={title}
>
{/* 外圈: 扩散晕 (ping 动画) */}
<span className="absolute inline-flex h-full w-full rounded-full bg-accent/60 animate-ping motion-reduce:hidden" />
{/* 内圈: 实心点 + 微辉光 */}
<span className="relative inline-flex rounded-full h-2 w-2 bg-accent shadow-[0_0_5px_rgba(61,214,140,0.6)]" />
</span>
)
}
// ===== 卡片组件 =====
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<string>
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 (
<div
className={`relative rounded-lg border border-border bg-surface hover:border-border/80 transition-all duration-200 group cursor-pointer overflow-hidden ${bgGlow}`}
onClick={() => onPreview(r.symbol, name ?? '')}
>
{/* 左侧彩色指示条 */}
<div className={`absolute left-0 top-0 bottom-0 w-[3px] rounded-l-lg ${barColor}`} />
{/* 删除按钮 / 确认区 */}
<div className="absolute top-1.5 right-1.5 z-10">
{confirmRemove === r.symbol ? (
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
<button
onClick={() => onConfirmRemove(r.symbol)}
className="px-1.5 py-0.5 rounded text-[10px] text-danger bg-danger/10 hover:bg-danger/20 transition-colors"
>
确认
</button>
<button onClick={() => onCancelRemove()} className="p-0.5 text-muted hover:text-foreground transition-colors">
<X className="h-3 w-3" />
</button>
</div>
) : (
<button
onClick={e => { e.stopPropagation(); onRequestRemove(r.symbol) }}
className="opacity-0 group-hover:opacity-100 text-muted hover:text-danger transition-all duration-150 p-0.5 rounded hover:bg-elevated"
aria-label="移除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* 卡片内容 */}
<div className="pl-4 pr-2.5 pt-2.5 pb-0">
{/* 第一行: 代码 + 名称 + 板块标识 */}
<div className="flex items-center gap-1.5 min-w-0 mb-2">
<span className="shrink-0 font-mono text-foreground text-xs tracking-wide">
{r.symbol}
</span>
{name && (
<span className="text-xs text-secondary truncate">{name}</span>
)}
{board && (
<span className={`shrink-0 inline-flex items-center justify-center px-1 h-[16px] rounded text-[9px] font-bold leading-none ${board.color}`}>
{board.label}
</span>
)}
{r.consecutive_limit_ups > 0 && (
<span className="shrink-0 inline-flex items-center justify-center px-1 h-[16px] rounded bg-danger/15 text-danger text-[9px] font-bold tabular-nums">
{r.consecutive_limit_ups === 1 ? '首板' : `${r.consecutive_limit_ups}连`}
</span>
)}
{isMonitored && <span className="ml-auto"><RealtimeDot /></span>}
</div>
{/* 第二行: 大价格 + 涨跌幅胶囊 */}
<div className="flex items-end justify-between gap-2 mb-2">
<span className={`text-xl tabular-nums tracking-tighter leading-none ${priceColorClass(pct)}`}>
{fmtPrice(price)}
</span>
{pct != null && (
<span className={`shrink-0 inline-flex items-center px-1.5 py-[2px] rounded text-[11px] tabular-nums ${pctBg}`}>
{isUp ? '+' : ''}{pct.toFixed(2)}%
</span>
)}
</div>
{/* 第三行: 指标 */}
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[10px] text-muted leading-relaxed">
<span title="换手率">换手<span className={`font-mono ml-0.5 ${turnoverColor(r.turnover_rate)}`}>{r.turnover_rate != null ? `${r.turnover_rate.toFixed(2)}%` : '—'}</span></span>
<span title="量比">量比<span className="font-mono ml-0.5">{fmtPrice(r.vol_ratio_5d)}</span></span>
<span title="RSI14">RSI<span className="font-mono ml-0.5">{r.rsi_14 != null ? r.rsi_14.toFixed(1) : '—'}</span></span>
{/* 扩展数据列展示在卡片中 */}
{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 (
<span key={col.id} title={col.label}>
<span className="text-secondary">{fieldName}</span>
<span className="font-mono ml-0.5">
{renderExtValue(val, col, expanded, () => onToggleExpand(cellKey), true)}
</span>
</span>
)
})}
</div>
</div>
{/* 信号标签区 */}
{signals.length > 0 && (
<div className="pl-4 pr-2.5 pt-1.5 pb-2 flex flex-wrap gap-1">
{signals.slice(0, 3).map(s => (
<span key={s.label} className={`inline-block px-1.5 py-[1px] rounded text-[9px] font-medium leading-tight ${signalCls(s.type)}`}>
{s.label}
</span>
))}
{signals.length > 3 && (
<span className="inline-block px-1 py-[1px] rounded text-[9px] text-muted bg-elevated leading-tight">
+{signals.length - 3}
</span>
)}
</div>
)}
{/* 迷你蜡烛图 */}
{showCandle && candleRows.length > 0 && (
<div className="border-t border-border/40 px-3 py-1.5">
<MiniCandlestick rows={candleRows} height={32} />
</div>
)}
</div>
)
}
// ===== 主页面 =====
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<ColumnConfig[]>([...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<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const [expandedCells, setExpandedCells] = useState<Set<string>>(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<string | null>(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<string>(),
[showRealtimeDot, allSymbols, watchlistMonitoredCount],
)
// ===== 筛选 =====
const [filterOpen, setFilterOpen] = useState(false)
const [filters, setFilters] = useState<Record<string, { min?: string; max?: string; text?: string }>>({})
// 板块筛选(持久化)
const [boardFilter, setBoardFilter] = useState<Set<string>>(() => {
const saved = storage.watchlistBoardFilter.get([])
return saved.length > 0 ? new Set(saved) : new Set(BOARDS) // 默认全选
})
const persistBoardFilter = useCallback((next: Set<string>) => {
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<string, { id: string; label: string; col: typeof filterableBuiltinCols[number] }[]> = {}
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 (
<div className="flex flex-col h-full">
<PageHeader
title="自选股"
titleExtra={
<span className="inline-flex items-center gap-1.5">
{/* 计数胶囊: 显示数/总数, mono 字体突出数字 */}
<span className="inline-flex items-baseline gap-0.5 px-2 py-0.5 rounded-md bg-elevated/70 text-[11px]">
<span className="font-mono font-semibold text-secondary tabular-nums">{sortedRows.length}</span>
<span className="text-muted/50">/</span>
<span className="font-mono text-muted tabular-nums">{allSymbols.length}</span>
<span className="text-muted/60 ml-0.5"></span>
</span>
{/* 过滤提示: 仅在有隐藏时出现, 柔和橙色融入整体 */}
{hiddenCount > 0 && (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] font-medium bg-warning/12 text-warning/90 border border-warning/25 whitespace-nowrap"
title={`当前有 ${hiddenCount} 只被筛选条件隐藏,清除筛选可查看全部`}
>
<Filter className="h-2.5 w-2.5" />
已过滤 {hiddenCount}
</span>
)}
</span>
}
right={
<div className="flex items-center gap-2">
{/* 筛选 / 搜索 */}
<button
onClick={() => setFilterOpen(v => !v)}
className={`inline-flex items-center justify-center h-8 w-8 rounded-btn transition-colors duration-150 ease-smooth ${
filterOpen || activeFilterCount > 0
? 'bg-accent/15 text-accent hover:bg-accent/25'
: 'bg-elevated text-secondary hover:bg-elevated/80'
}`}
title={`筛选${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`}
>
<Filter className="h-4 w-4" />
</button>
<StockSearchBox
onPreview={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name) }}
existingSymbols={allSymbols as string[]}
onAdd={(sym) => addMutation.mutate(sym)}
/>
<div className="w-px h-5 bg-border" />
{/* 视图 */}
<button
onClick={toggleView}
className="inline-flex items-center justify-center h-8 w-8 rounded-btn bg-elevated hover:bg-elevated/80 text-secondary hover:text-foreground transition-colors duration-150 ease-smooth"
title={viewMode === 'table' ? '卡片视图' : '列表视图'}
>
{viewMode === 'table' ? <LayoutGrid className="h-4 w-4" /> : <List className="h-4 w-4" />}
</button>
<div className="w-px h-5 bg-border" />
{/* 自定义列 / 刷新 */}
<button
onClick={() => setCustomizerOpen(true)}
className="inline-flex items-center justify-center h-8 w-8 rounded-btn bg-elevated hover:bg-elevated/80 text-secondary hover:text-foreground transition-colors duration-150 ease-smooth"
title="自定义列"
>
<Settings2 className="h-4 w-4" />
</button>
<button
onClick={() => enriched.refetch()}
disabled={enriched.isFetching}
className="inline-flex items-center justify-center h-8 w-8 rounded-btn bg-elevated hover:bg-elevated/80 text-secondary hover:text-foreground transition-colors duration-150 ease-smooth disabled:opacity-50"
title="刷新"
>
<RefreshCw className={`h-4 w-4 ${enriched.isFetching ? 'animate-spin' : ''}`} />
</button>
{allSymbols.length > 0 && (
<>
<div className="w-px h-5 bg-border" />
<button
onClick={() => setConfirmClear(true)}
className="inline-flex items-center justify-center h-8 w-8 rounded-btn bg-danger/10 text-danger hover:bg-danger/20 transition-colors duration-150 ease-smooth"
title="清空自选"
>
<Trash2 className="h-4 w-4" />
</button>
</>
)}
</div>
}
/>
{/* 筛选栏 */}
{filterOpen && (
<div className="px-5 py-2 border-b border-border bg-surface/50 max-h-[184px] overflow-y-auto">
{/* 板块筛选 */}
<div className="mb-2">
<div className="text-[10px] text-muted uppercase tracking-wider mb-0.5">板块</div>
<div className="flex flex-wrap gap-1">
{BOARDS.map(board => {
const active = boardFilter.has(board)
return (
<button
key={board}
onClick={() => toggleBoard(board)}
className={`px-2 py-0.5 rounded text-[11px] transition-colors ${
active
? 'bg-accent/15 text-accent'
: 'bg-elevated text-secondary hover:text-foreground hover:bg-elevated/80'
}`}
>
{board}
</button>
)
})}
</div>
</div>
{COLUMN_GROUPS.map(cat => {
const items = colsByCategory[cat.label]?.filter(i => i.col)
if (!items?.length) return null
return (
<div key={cat.label} className="mb-1.5 last:mb-0">
<div className="text-[10px] text-muted uppercase tracking-wider mb-0.5">{cat.label}</div>
<div className="flex flex-wrap gap-x-2 gap-y-1">
{items.map(item => {
const f = filters[item.id] || {}
const hasFilter = !!f.min || !!f.max || !!f.text
return (
<div key={item.id} className="flex items-center gap-0.5 text-[11px]">
<span className={`whitespace-nowrap ${hasFilter ? 'text-accent' : 'text-secondary'}`}>{item.label}</span>
<input
type="number"
value={f.min ?? ''}
onChange={e => 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`}
/>
<span className="text-muted">~</span>
<input
type="number"
value={f.max ?? ''}
onChange={e => 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`}
/>
</div>
)
})}
</div>
</div>
)
})}
{activeFilterCount > 0 && (
<button onClick={clearFilters} className="mt-1 text-[10px] text-danger hover:text-danger/80 transition-colors">
清除全部筛选
</button>
)}
</div>
)}
{/* 可滚动列表区 — 占满剩余高度,内部独立滚动,表头 sticky 固定 */}
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="px-5 py-3">
{/* 列表 */}
{list.isLoading && <div className="text-sm text-muted">加载中…</div>}
{list.isError && <div className="text-sm text-danger">读取自选失败</div>}
{allSymbols.length === 0 ? (
<EmptyState
icon={Star}
title="自选股为空"
hint="点击右上角搜索按钮查找并预览标的,进入个股详情后可添加到自选。"
/>
) : viewMode === 'table' ? (
<StockDataTable
columns={visibleColumns}
rows={sortedRows}
headerSticky
sort={sort}
onSortToggle={handleSortToggle}
rowKey={(r: any) => 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 (
<span className="inline-flex items-center justify-center gap-1.5">
<span>{col.label}</span>
<button
type="button"
onClick={(event) => { event.stopPropagation(); toggleDailyKChart() }}
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
}}
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 (
<td className="px-1.5 py-1.5">
<div className="flex items-center gap-1 w-full">
<button
type="button"
onClick={() => { setPreviewSymbol(r.symbol); setPreviewName(name ?? '') }}
className="flex items-center gap-1 text-left min-w-0"
>
<span className="font-mono text-foreground text-xs group-hover:text-accent transition-colors duration-150">
{r.symbol}
</span>
{name && (
<span className="text-xs text-secondary truncate group-hover:text-foreground transition-colors duration-150">
{name}
</span>
)}
{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>
) : null}
{monitoredSymbols.has(r.symbol) && <span className="ml-2"><RealtimeDot /></span>}
</button>
{/* 删除入口:默认减号图标,二次确认时替换为确定按钮 */}
<div className="ml-auto pl-1 shrink-0">
{confirmRemove === r.symbol ? (
<div className="flex items-center gap-1">
<button
onClick={() => { remove.mutate(r.symbol); setConfirmRemove(null) }}
className="px-1.5 py-0.5 rounded text-[10px] text-danger bg-danger/10 hover:bg-danger/20 transition-colors"
>
确认
</button>
<button
onClick={() => setConfirmRemove(null)}
className="p-0.5 text-muted hover:text-foreground transition-colors"
>
<X className="h-3 w-3" />
</button>
</div>
) : (
<div className="flex items-center gap-1">
<button
onClick={() => setConfirmRemove(r.symbol)}
className="p-0.5 text-muted hover:text-danger transition-colors duration-150 ease-smooth"
aria-label="移除"
title="移除"
>
<Minus className="h-3.5 w-3.5" />
</button>
<button
onClick={() => moveToTop.mutate(r.symbol)}
disabled={moveToTop.isPending || allSymbols[0] === r.symbol}
className="p-0.5 text-muted hover:text-accent transition-colors duration-150 ease-smooth disabled:opacity-30 disabled:hover:text-muted"
aria-label="移到顶部"
title="移到顶部"
>
<ChevronsUp className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</div>
</td>
)
}
// 实时行情列:price/pct/amount 使用 rt_ 回退(自选页有实时推送)
const numCls = 'px-2 py-1.5 text-right num tabular-nums'
if (key === 'price') {
return <td className={`${numCls} ${priceColorClass(pct)}`}>{fmtPrice(price)}</td>
}
if (key === 'pct') {
return <td className={`${numCls} ${priceColorClass(pct)}`}>{fmtPct(pct)}</td>
}
if (key === 'amount') {
return <td className={`${numCls} text-secondary`}>{fmtBigNum(r.rt_amount ?? r.amount)}</td>
}
if (key === 'turnover') {
return <td className={`${numCls} ${turnoverColor(r.turnover_rate)}`}>{r.turnover_rate != null ? `${r.turnover_rate.toFixed(2)}%` : '—'}</td>
}
// 信号列
if (key === 'signals') {
const signals = getSignals(r)
return (
<td className="px-2 py-1.5">
{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>
)}
</td>
)
}
// 日k列
if (key === 'candle') {
return (
<td
className="px-2 py-1.5"
style={{ width: candleSize.width, minWidth: candleSize.width, maxWidth: candleSize.width, height: candleSize.height }}
>
<MiniCandlestick rows={klineData[r.symbol] ?? []} width={candleSize.width} height={candleSize.height} />
</td>
)
}
// 其余纯数据列 → 共享原语
return renderBuiltinDataCell(r, col)
}}
className="rounded-card overflow-x-auto"
/>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3">
{rows.map((r: any) => (
<StockCard
key={r.symbol}
r={r}
candleRows={klineData[r.symbol] ?? []}
showCandle={dailyKVisible}
onPreview={(sym, name) => { 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)}
/>
))}
</div>
)}
</div>
</div>
{/* 清空确认弹窗 */}
<AnimatePresence>
{confirmClear && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setConfirmClear(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="relative w-[90vw] max-w-[380px] rounded-card border border-border bg-base shadow-2xl p-6"
>
<h3 className="text-sm font-medium text-foreground mb-2">确认清空自选</h3>
<p className="text-xs text-secondary mb-5">
将移除全部 {allSymbols.length} 只自选股,此操作不可恢复。
</p>
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setConfirmClear(false)}
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors"
>
取消
</button>
<button
onClick={() => clearAll.mutate()}
disabled={clearAll.isPending}
className="px-3 py-1.5 rounded-btn bg-danger/15 text-danger hover:bg-danger/25 text-sm font-medium transition-colors disabled:opacity-50"
>
{clearAll.isPending ? '清除中...' : '确认清空'}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{/* 列自定义侧栏 */}
<ColumnCustomizer
columns={columns}
onChange={handleColumnsChange}
open={customizerOpen}
onClose={() => setCustomizerOpen(false)}
/>
<StockPreviewDialog
symbol={previewSymbol}
name={previewName}
onClose={closePreview}
/>
</div>
)
}