import { useState, useEffect, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' import { X, RefreshCw, Clock, LineChart, Star, RadioTower, Maximize2, Minimize2 } from 'lucide-react' import { api } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { cn } from '@/lib/cn' import { cnSignal } from '@/lib/signals' import { StockPanel, getDefaultRange } from '@/components/StockPanel' import { WatchlistAddMenu } from '@/components/WatchlistAddMenu' import { StockMultiDayIntradayChart } from '@/components/StockMultiDayIntradayChart' import { DatePicker } from '@/components/DatePicker' import { RuleEditor } from '@/components/monitor/RuleEditor' import { PriceAlertDialog } from '@/components/stock-analysis/PriceAlertDialog' import { buildMonitorPriceLines } from '@/lib/price-alerts' import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries' import { setFocusSymbol, clearFocusSymbol } from '@/lib/useQuoteStream' import { useDialogBackdrop } from '@/lib/useDialogBackdrop' import { storage } from '@/lib/storage' interface Props { symbol: string | null name?: string onClose: () => void /** 触发信息 (来自监控触发记录, 有值时在顶栏下方显示) */ triggerInfo?: { price?: number | null changePct?: number | null ts?: number signals?: string[] message?: string } | null } // ===== 板块标识(与 Screener 列表一致)===== // 预设快捷范围(只保留半年和1年) const PRESETS: { label: string; months: number }[] = [ { label: '半年', months: 6 }, { label: '1年', months: 12 }, ] type PreviewView = 'daily' | 'intraday' interface PriceAlertDraft { id: number targetPrice: number currentPrice: number } const INTRADAY_DAY_OPTIONS = [1, 5, 10, 20] as const function loadIntradayDays(): number { const saved = storage.stockPreviewIntradayDays.get(10) return INTRADAY_DAY_OPTIONS.includes(saved as typeof INTRADAY_DAY_OPTIONS[number]) ? saved : 10 } function boardTag(symbol: string): { label: string; color: string } | null { if (/^(300|301)/.test(symbol)) return { label: '创', color: 'text-[#f97316] bg-[#f97316]/12 border-[#f97316]/25' } if (/^688/.test(symbol)) return { label: '科', color: 'text-purple-400 bg-purple-400/12 border-purple-400/25' } if (/^[48]/.test(symbol)) return { label: '北', color: 'text-cyan-400 bg-cyan-400/12 border-cyan-400/25' } return null } export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props) { const [view, setView] = useState('daily') const [intradayDays, setIntradayDays] = useState(loadIntradayDays) const [dateRange, setDateRange] = useState(getDefaultRange) const [showMonitorEditor, setShowMonitorEditor] = useState(false) const [priceAlertDraft, setPriceAlertDraft] = useState(null) const [maximized, setMaximized] = useState(false) const qc = useQueryClient() const backdrop = useDialogBackdrop(onClose) const watchlist = useQuery({ queryKey: QK.watchlist, queryFn: api.watchlistList, enabled: !!symbol, }) const monitorRules = useQuery({ queryKey: QK.monitorRules, queryFn: api.monitorRulesList, enabled: !!symbol, }) const monitorPriceLines = useMemo( () => symbol ? buildMonitorPriceLines(monitorRules.data?.rules ?? [], symbol) : [], [monitorRules.data?.rules, symbol], ) const inWatchlist = (watchlist.data?.symbols ?? []).some((s: any) => s.symbol === symbol) const toggleWatchlist = useMutation({ mutationFn: ({ action, groupId, }: { action: 'add' | 'remove' groupId?: string | null }) => action === 'remove' ? api.watchlistRemove(symbol!) : api.watchlistAdd(symbol!, '', groupId), onSuccess: () => { qc.invalidateQueries({ queryKey: QK.watchlist }) qc.invalidateQueries({ queryKey: ['watchlist-enriched'] }) }, }) // ESC 关闭 useEffect(() => { if (!symbol) return const handler = (e: KeyboardEvent) => { if (e.key === 'Escape' && !priceAlertDraft) onClose() } document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) }, [symbol, onClose, priceAlertDraft]) useEffect(() => { if (symbol) setView('daily') setPriceAlertDraft(null) }, [symbol]) // 焦点股票注册: SSE quotes_updated 推送时精准 invalidate 当前股票日K, // 让对话框日K最后一根蜡烛随实时价变化 (后端只读内存, 不调 TickFlow)。 // 关闭/切股时清除, 避免无谓刷新。 useEffect(() => { if (!symbol) return setFocusSymbol(symbol) return () => clearFocusSymbol() }, [symbol]) // 分时图实时轮询: 复用自选列表的「分时刷新开关 + 间隔」偏好。 // 仅实时行情运行 且 用户开启分时刷新时才轮询; 否则 undefined (定格)。 const { data: prefs } = usePreferences() const { data: quoteStatus } = useQuoteStatus() const realtimeRunning = quoteStatus?.running ?? false const intradayRefreshOn = prefs?.minute_intraday_refresh ?? false const intradayRefetchMs = (intradayRefreshOn && realtimeRunning) ? (prefs?.minute_intraday_refresh_interval ?? 6) * 1000 : undefined const handleRefresh = () => { if (!symbol) return if (view === 'daily') { qc.invalidateQueries({ queryKey: ['kline', symbol] }) } else { qc.invalidateQueries({ queryKey: ['kline-minute-range', symbol] }) qc.invalidateQueries({ queryKey: ['kline-minute', symbol!] }) } } const selectIntradayDays = (days: number) => { setIntradayDays(days) storage.stockPreviewIntradayDays.set(days) } const openPriceAlert = (targetPrice: number, currentPrice: number) => { setPriceAlertDraft({ id: Date.now(), targetPrice, currentPrice }) } return ( {symbol && (
{/* 遮罩 */} {/* 弹窗主体 */} {/* 顶栏 */}
{(() => { const board = symbol ? boardTag(symbol) : null return board ? ( {board.label} ) : null })()} {symbol} {name && {name}}
{/* 区间选择 — 随视图切换 */} {view === 'daily' ? (
{PRESETS.map(p => { const now = new Date() const s = new Date(now) s.setMonth(s.getMonth() - p.months) const expected = s.toISOString().slice(0, 10) const isActive = dateRange.start === expected return ( ) })} setDateRange(prev => ({ ...prev, start: v }))} max={dateRange.end} /> ~ setDateRange(prev => ({ ...prev, end: v }))} min={dateRange.start} />
) : (
{INTRADAY_DAY_OPTIONS.map(days => ( ))}
)} {/* 日K / 分时 切换 */}
{/* 自选 */} {inWatchlist ? ( ) : ( toggleWatchlist.mutate({ action: 'add', groupId })} disabled={toggleWatchlist.isPending} triggerClassName="rounded-btn p-1.5 text-muted transition-colors cursor-pointer hover:bg-elevated hover:text-foreground disabled:opacity-50" ariaLabel={`将 ${symbol} 加入自选`} > )} {/* 加监控 */} {/* 刷新 */} {/* 放大 / 缩小 */}
{/* 触发信息条 (来自监控触发记录) */} {triggerInfo && (
{/* 左: 触发标记 + 时间 */}
⚡ 触发 {triggerInfo.ts && ( {new Date(triggerInfo.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} )}
{/* 中: 价格 + 涨跌幅 */}
{triggerInfo.price != null && ( {triggerInfo.price.toFixed(2)} )} {triggerInfo.changePct != null && ( = 0 ? 'text-danger' : 'text-bear'}`}> {triggerInfo.changePct >= 0 ? '+' : ''}{(triggerInfo.changePct * 100).toFixed(2)}% )}
{/* 右: 消息 + 信号标签 */}
{triggerInfo.message && ( {triggerInfo.message} )} {triggerInfo.signals && triggerInfo.signals.length > 0 && (
{triggerInfo.signals.map((s, j) => ( {cnSignal(s)} ))}
)}
)} {/* 图表内容 */}
{view === 'daily' ? ( ) : ( <> )}
{/* 加监控编辑器弹层 */} {showMonitorEditor && symbol && ( setShowMonitorEditor(false)} >
e.stopPropagation()}> setShowMonitorEditor(false)} onSaved={() => setShowMonitorEditor(false)} />
)}
)} {symbol && priceAlertDraft && ( setPriceAlertDraft(null)} /> )}
) }