import { useEffect, useRef, useState } from 'react' import { NavLink, Outlet, useNavigate } from 'react-router-dom' import { useQuery, useQueryClient } from '@tanstack/react-query' import { motion } from 'framer-motion' import { useQuoteStream } from '@/lib/useQuoteStream' import { ToastContainer } from '@/components/Toast' import { AlertToastContainer } from '@/components/AlertToast' import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost' import { AiReportBubble } from '@/components/financials/AiReportBubble' import { StockAnalysisHost } from '@/components/stock-analysis/StockAnalysisHost' import { StockAnalysisBubble } from '@/components/stock-analysis/StockAnalysisBubble' import { useCapabilities, useSettings, usePreferences, useQuoteStatus, useVersion, } from '@/lib/useSharedQueries' import { useToggleRealtimeQuotes, } from '@/lib/useSharedMutations' import { QK } from '@/lib/queryKeys' import { tierRank } from '@/lib/capability-labels' import { Star, ScanSearch, History, FileText, Settings, Key, Database, Loader2, LayoutDashboard, Tags, TrendingUp, Flame, BarChart3, Sparkles, Layers3, Landmark, Cable, RadioTower, CheckCircle2, BookOpenCheck, ExternalLink, X, Upload, } from 'lucide-react' import { Logo } from './Logo' import { api, type IndexQuote } from '@/lib/api' import { cn } from '@/lib/cn' import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge' // 品牌色 — 只用于 logo / brand 区域,不影响功能语义色 const BRAND = '#8B5CF6' const TICKFLOW_REGISTER_URL = 'https://tickflow.org/auth/register?ref=V3KDKGXPEA' const CORE_INDEXES = [ { symbol: '000001.SH', name: '上证指数' }, { symbol: '399001.SZ', name: '深证成指' }, { symbol: '399006.SZ', name: '创业板指' }, { symbol: '000680.SH', name: '科创综指' }, ] as const type CoreIndex = (typeof CORE_INDEXES)[number] const nav = [ { to: '/', label: '看板', icon: LayoutDashboard }, { to: '/watchlist', label: '自选', icon: Star }, { to: '/screener', label: '策略', icon: ScanSearch }, { to: '/backtest', label: '回测', icon: History }, { to: '/stock-analysis', label: '个股分析', icon: TrendingUp }, { to: '/limit-ladder', label: '连板梯队', icon: Flame }, { to: '/concept-analysis', label: '概念分析', icon: Layers3 }, { to: '/industry-analysis', label: '行业分析', icon: Landmark }, { to: '/financials', label: '财务分析', icon: FileText }, { to: '/monitor', label: '监控中心', icon: RadioTower }, { to: '/review', label: '复盘', icon: BookOpenCheck }, { to: '/indices', label: '指数', icon: BarChart3 }, { to: '/trading', label: '交易', icon: Cable }, { to: '/data', label: '数据', icon: Database }, { to: '/sync', label: '数据同步', icon: Upload }, ] as const function fmtIndexValue(v: number | null | undefined) { if (v == null || Number.isNaN(Number(v))) return '--' return Number(v).toFixed(2) } function fmtIndexPct(v: number | null | undefined) { if (v == null || Number.isNaN(Number(v))) return '--' return `${Number(v) >= 0 ? '+' : ''}${Number(v).toFixed(2)}%` } function indexPctClass(v: number | null | undefined) { if (v == null || Number.isNaN(Number(v))) return 'text-muted' const n = Number(v) if (n === 0) return 'text-foreground' return n > 0 ? 'text-bull' : 'text-bear' } /** 监控中心未读徽标 — 仅在非监控页且有未读时显示。 */ function MonitorBadge({ active }: { active: boolean }) { const unread = useUnreadAlerts() // 尊重用户设置: 可在菜单设置里关闭数字提示 const badgeEnabled = (() => { try { return localStorage.getItem('monitor_badge_enabled') !== '0' } catch { return true } })() if (active || unread <= 0 || !badgeEnabled) return null return ( {unread > 99 ? '99+' : unread} ) } function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; items: CoreIndex[] }) { if (items.length === 0) return null const quoteBySymbol = new Map((rows ?? []).map(q => [q.symbol, q])) return (
{items.map(item => { const q = quoteBySymbol.get(item.symbol) const value = q?.last_price ?? q?.close const pct = q?.change_pct return (
{item.name} {fmtIndexPct(pct)}
{fmtIndexValue(value)}
) })}
) } // ===== 档位卡片 ===== function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) { const base = label.split(' ')[0].split('+')[0].toLowerCase() const isNone = base === 'none' const tierConfig: Record = { none: { desc: '未配置 Key · 仅历史日K', tagBg: { background: 'rgba(113,113,122,0.15)' }, dotStyle: { background: '#52525b' }, labelTextStyle: { color: '#71717a' }, }, free: { desc: '基础日K · 自选实时', tagBg: { background: 'rgba(113,113,122,0.3)' }, dotStyle: { background: '#71717a' }, labelTextStyle: { color: '#a1a1aa' }, }, starter: { desc: '批量同步 · 行情池', tagBg: { background: 'rgba(59,130,246,0.2)' }, dotStyle: { background: '#3b82f6' }, labelTextStyle: { color: '#60a5fa' }, }, pro: { desc: '分钟K · 实时行情 · 盘口', tagBg: { background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(124,58,237,0.15))' }, dotStyle: { background: 'linear-gradient(135deg, #a855f7, #7c3aed)' }, labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' }, }, expert: { desc: 'WebSocket · 财务数据', tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' }, dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' }, labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' }, }, } const t = tierConfig[base] || tierConfig.none // none 档显示英文「None」,无 label 时也显示「None」 const displayLabel = isNone ? 'None' : (label || 'None') return (
TickFlow
{isNone && !hasKey ? '配置 Key 解锁更多能力' : t.desc}
{displayLabel}
) } function AIConfigBadge({ configured, model }: { configured?: boolean; model?: string }) { return (
AI 配置
{configured ? (model || '已接入模型') : '接入策略生成模型'}
) } export function Layout() { // ===== 共享 hooks (替代内联 useQuery) ===== const { data: caps } = useCapabilities() const { data: settingsState } = useSettings() const { data: versionData } = useVersion() const { data: prefs } = usePreferences() // poll=true: 全局唯一开启条件轮询 (非交易时段 60s 兜底, 交易时段靠 SSE) const { data: quoteStatus } = useQuoteStatus({ poll: true }) const { data: analysisMenus } = useQuery({ queryKey: QK.analysisMenus, queryFn: api.analysisMenus, }) // 数据同步状态轮询: 有活跃 job 时「数据」菜单项显示转圈 const { data: pipelineJobs } = useQuery({ queryKey: QK.pipelineJobs, queryFn: () => api.pipelineJobs(1), refetchInterval: (query) => (query.state.data?.active_id ? 2000 : 15000), refetchIntervalInBackground: true, }) const isDataSyncing = !!pipelineJobs?.active_id // 数据同步完成的"瞬时反馈": isDataSyncing 从 true→false 时显示绿色对勾, // 闪烁约 3 秒后自动消失。 const [dataSyncJustDone, setDataSyncJustDone] = useState(false) const prevSyncingRef = useRef(false) useEffect(() => { // 仅在"刚结束"(true→false)且非首次挂载时触发 if (prevSyncingRef.current && !isDataSyncing) { setDataSyncJustDone(true) const t = setTimeout(() => setDataSyncJustDone(false), 3000) prevSyncingRef.current = isDataSyncing return () => clearTimeout(t) } prevSyncingRef.current = isDataSyncing }, [isDataSyncing]) const qc = useQueryClient() const navigate = useNavigate() const version = versionData?.version const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false // Free 档监控限制提示: 可手动关闭, 不持久化 (刷新后恢复显示) const [dismissFreeHint, setDismissFreeHint] = useState(false) const indicesPinned = prefs?.indices_nav_pinned ?? true const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? CORE_INDEXES.map(p => p.symbol) const sidebarIndexes = CORE_INDEXES.filter(item => sidebarIndexSymbols.includes(item.symbol)) // 卡片数据:固定显示时也拉取(即使实时行情关闭) const showSidebarQuotes = indicesPinned || realtimeEnabled const { data: sidebarIndexQuotes } = useQuery({ queryKey: [...QK.indexQuotes, 'sidebar', sidebarIndexSymbols.join(',')] as const, queryFn: () => api.indexQuotes(sidebarIndexes.map(p => p.symbol)), enabled: showSidebarQuotes && sidebarIndexes.length > 0, placeholderData: (prev) => prev, }) // SSE: 行情更新时自动刷新相关 queries + 告警通知 useQuoteStream(realtimeEnabled, prefs?.sse_refresh_pages) const toggleQuote = useToggleRealtimeQuotes() const isRunning = quoteStatus?.running ?? false const isTrading = quoteStatus?.is_trading_hours ?? false const tier = tierRank(caps?.label ?? '') const isNoneTier = tier < 0 const isWatchlistMode = tier === 0 const realtimeModeLabel = isWatchlistMode ? '自选股' : '全市场' // 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒) const alertsTotalQuery = useQuery({ queryKey: ['alerts-total'], queryFn: () => api.alertsList({ days: 7, limit: 1 }), refetchInterval: 15000, refetchIntervalInBackground: true, select: (data) => data.total, }) // 只在拿到真实总数时同步徽标 (避免 data=undefined 时传 0 重置 lastSeen) const alertsTotal = alertsTotalQuery.data useEffect(() => { if (alertsTotal != null) setAlertTotal(alertsTotal) }, [alertsTotal]) // 合并内置页面 + 可见的扩展分析菜单 const analysisNav = (analysisMenus?.items ?? []) .filter(m => m.visible) .map(m => ({ to: `/analysis/${m.id}`, label: m.label, icon: m.icon === 'tags' ? Tags : BarChart3 })) const allNav = [...nav, ...analysisNav] const savedOrder = prefs?.nav_order ?? [] const navItems = savedOrder.length > 0 ? (() => { const byTo = new Map(allNav.map(n => [n.to, n])) const ordered = savedOrder .map(id => byTo.get(id) ?? byTo.get(`/analysis/${id}`)) .filter(Boolean) const seen = new Set(ordered.map(n => n!.to)) return [...ordered as typeof allNav, ...allNav.filter(n => !seen.has(n.to))] })() : allNav const hiddenIds = new Set(prefs?.nav_hidden ?? []) const visibleNavItems = navItems.filter(n => !hiddenIds.has(n.to) && !hiddenIds.has(n.to.replace(/^\/analysis\//, ''))) const handleToggle = async (enabled: boolean) => { // 开启时重新校验档位 if (enabled) { const fresh = await qc.fetchQuery({ queryKey: QK.capabilities, queryFn: api.capabilities, }) const freshTier = tierRank(fresh.label ?? '') if (freshTier < 0) return if (freshTier === 0 && (prefs?.realtime_watchlist_symbols?.length ?? 0) === 0) { navigate('/watchlist') return } } await toggleQuote.mutateAsync(enabled) // 仅在交易时段立即获取一次行情 if (enabled && isTrading) { api.intradayRefresh().catch(() => {}) } } return (
) }