import { useEffect, useRef, useState, Suspense } from 'react'
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useQuoteStream, useQuoteStreamStatus } 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,
Pickaxe,
FileText,
Settings,
Key,
Database,
Loader2,
LayoutDashboard,
Tags,
TrendingUp,
Flame,
BarChart3,
Gauge,
Sparkles,
Layers3,
Landmark,
RadioTower,
CheckCircle2,
BookOpenCheck,
ExternalLink,
ChevronRight,
ChevronDown,
Sun,
Moon,
X,
WifiOff,
PanelLeftClose,
PanelLeftOpen,
} from 'lucide-react'
import { Logo } from './Logo'
import { api, type IndexQuote } from '@/lib/api'
import { cn } from '@/lib/cn'
import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors'
import { toggleTheme, useTheme } from '@/lib/theme'
import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge'
import { ExtensionSlot } from '@/extensions/ExtensionSlot'
import { getFrontendExtensionNavigation } from '@/extensions/registry'
// 品牌色 — 只用于 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: '/mining', label: '挖掘', icon: Pickaxe },
{ 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: '/regime', label: '市场环境', icon: Gauge },
{ to: '/review', label: '复盘', icon: BookOpenCheck },
{ to: '/indices', label: '指数', icon: BarChart3 },
{ to: '/data', label: '数据', icon: Database },
] as const
/** 亮/暗主题切换 — 状态存 localStorage, 生效见 lib/theme.ts */
function ThemeToggle() {
const theme = useTheme()
const dark = theme === 'dark'
return (
)
}
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, providerName, isTickflow }: { label: string; hasKey?: boolean; providerName: string; isTickflow: boolean }) {
const base = label.split(' ')[0].split('+')[0].toLowerCase()
const isNone = base === 'none'
const tierConfig: Record = {
none: {
desc: '未配置 Key · 仅历史日K',
dotStyle: { background: '#52525b' },
tagBg: { background: 'rgba(113,113,122,0.15)' },
labelTextStyle: { color: '#71717a' },
},
free: {
desc: '基础日K · 自选实时',
dotStyle: { background: '#71717a' },
tagBg: { background: 'rgba(113,113,122,0.3)' },
labelTextStyle: { color: '#a1a1aa' },
},
starter: {
desc: '批量同步 · 行情池',
dotStyle: { background: '#3b82f6' },
tagBg: { background: 'rgba(59,130,246,0.2)' },
labelTextStyle: { color: '#60a5fa' },
},
pro: {
desc: '分钟K · 实时行情 · 盘口',
dotStyle: { background: 'linear-gradient(135deg, #a855f7, #7c3aed)' },
tagBg: { background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(124,58,237,0.15))' },
labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
},
expert: {
desc: 'WebSocket · 财务数据',
dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' },
tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' },
labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
},
}
const t = tierConfig[base] || tierConfig.none
const displayLabel = isNone ? 'None' : (label || 'None')
const descText = isNone && !hasKey ? '配置 Key 解锁更多能力' : t.desc
return (
{providerName || '数据源'}
{isTickflow && (
{displayLabel}
)}
)
}
function AIConfigBadge({ configured, model }: { configured?: boolean; model?: string }) {
const descText = configured ? (model || '已接入模型') : '接入策略生成模型'
return (
{configured ? (
{model || '已接入模型'}
) : (
<>
AI 配置
未配置
>
)}
)
}
export function Layout() {
// ===== 共享 hooks (替代内联 useQuery) =====
const { data: caps } = useCapabilities()
const { data: settingsState } = useSettings()
const { data: versionData } = useVersion()
const { data: prefs } = usePreferences()
// 数据源列表 (用于实时行情状态显示当前数据源名称)
const { data: dataSources } = useQuery({
queryKey: QK.dataSources,
queryFn: api.dataSources,
staleTime: 60_000,
})
// poll=true: 全局唯一开启条件轮询 (非交易时段 60s 兜底, 交易时段靠 SSE)
const { data: quoteStatus } = useQuoteStatus({ poll: true })
const { data: analysisMenus } = useQuery({
queryKey: QK.analysisMenus,
queryFn: api.analysisMenus,
})
// 自选分组 — 仅当用户开启「显示在侧边栏」时拉取
const groupsInNav = prefs?.watchlist_groups_in_nav ?? false
const location = useLocation()
const { data: watchlistGroupsData } = useQuery({
queryKey: QK.watchlistGroups,
queryFn: api.watchlistGroups,
enabled: groupsInNav,
staleTime: 60_000,
})
const watchlistGroups = watchlistGroupsData?.groups ?? []
// 自选二级菜单展开状态 — 默认当前在自选页时展开
const [watchlistNavExpanded, setWatchlistNavExpanded] = useState(location.pathname === '/watchlist')
// 数据同步状态轮询: 有活跃 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)
// 侧边栏收起状态 — 持久化到 localStorage
const [navCollapsed, setNavCollapsed] = useState(() => {
if (typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches) return true
try { return localStorage.getItem('tf-nav-collapsed') === '1' } catch { return false }
})
useEffect(() => {
const compact = window.matchMedia('(max-width: 767px)')
const syncSidebarWithViewport = (event: MediaQueryListEvent | MediaQueryList) => {
if (event.matches) {
setNavCollapsed(true)
return
}
try { setNavCollapsed(localStorage.getItem('tf-nav-collapsed') === '1') } catch {}
}
syncSidebarWithViewport(compact)
compact.addEventListener('change', syncSidebarWithViewport)
return () => compact.removeEventListener('change', syncSidebarWithViewport)
}, [])
const toggleNavCollapsed = () => {
setNavCollapsed(prev => {
const next = !prev
try { localStorage.setItem('tf-nav-collapsed', next ? '1' : '0') } catch {}
return next
})
}
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)
// 实时 SSE 连接状态 — 断开时底部显示提示, 提示可能漏策略告警
const streamStatus = useQuoteStreamStatus()
const toggleQuote = useToggleRealtimeQuotes()
const isRunning = quoteStatus?.running ?? false
const isTrading = quoteStatus?.is_trading_hours ?? false
// 管道/数据修正运行期间实时行情被临时暂停 — 此时禁止开启
const isPaused = quoteStatus?.paused ?? false
const tier = tierRank(caps?.label ?? '')
const isNoneTier = tier < 0
const isWatchlistMode = tier === 0
const realtimeModeLabel = isWatchlistMode ? '自选股' : '全市场'
// 当前实时行情数据源名称 (custom 时显示源名, tickflow 时不显示)
const realtimeProvider = prefs?.realtime_data_provider
const realtimeProviderName = realtimeProvider && realtimeProvider !== 'tickflow'
? (dataSources?.custom?.find(s => s.name === realtimeProvider)?.display_name || realtimeProvider)
: null
const realtimeToggleDisabled = toggleQuote.isPending || isPaused
const realtimeActive = realtimeEnabled && isRunning && isTrading
const realtimeStatusLabel = toggleQuote.isPending
? '正在更新'
: isPaused
? '同步期间暂停'
: realtimeActive
? '运行中'
: realtimeEnabled
? (isTrading ? '正在连接' : '等待交易时段')
: '已关闭'
const realtimeStatusClass = realtimeActive
? 'text-accent'
: realtimeEnabled || isPaused
? 'text-warning/80'
: 'text-muted'
const realtimeIndicatorClass = realtimeActive
? 'bg-accent animate-pulse'
: realtimeEnabled || isPaused
? 'bg-warning/70'
: 'bg-muted'
const realtimeToggleTitle = isPaused
? '数据同步运行中,实时行情已临时暂停'
: toggleQuote.isPending
? '正在更新实时行情设置'
: realtimeEnabled
? '关闭实时行情'
: '开启实时行情'
// 当前主数据源 (用于侧边栏数据源状态卡)
const activeProvider = prefs?.daily_data_provider || 'tickflow'
const activeProviderName = activeProvider === 'tickflow'
? 'TickFlow'
: (dataSources?.custom?.find(s => s.name === activeProvider)?.display_name || activeProvider)
const isCustomActive = activeProvider !== 'tickflow'
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒; 后台标签页由 SSE 事件驱动, 不轮询)
const alertsTotalQuery = useQuery({
queryKey: ['alerts-total'],
queryFn: () => api.alertsList({ days: 7, limit: 1 }),
refetchInterval: 15000,
select: (data) => data.total,
})
// 只在拿到真实总数时同步徽标 (避免 data=undefined 时传 0 重置 lastSeen)
const alertsTotal = alertsTotalQuery.data
useEffect(() => {
if (alertsTotal != null) setAlertTotal(alertsTotal)
}, [alertsTotal])
// 合并内置页面 + 可见的扩展分析菜单
type NavItem = { to: string; label: string; icon: typeof Gauge; badge?: string }
const analysisNav: NavItem[] = (analysisMenus?.items ?? [])
.filter(m => m.visible)
.map(m => ({ to: `/analysis/${m.id}`, label: m.label, icon: m.icon === 'tags' ? Tags : BarChart3 }))
const extensionNav: NavItem[] = getFrontendExtensionNavigation().map(item => ({
to: item.route.path,
label: item.label,
icon: item.icon,
badge: item.badge,
}))
const allNav: NavItem[] = [...nav, ...analysisNav, ...extensionNav]
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)) as typeof allNav
const seen = new Set(ordered.map(n => n.to))
const merged = [...ordered]
for (const item of allNav) {
if (seen.has(item.to)) continue
// 未保存过排序的新条目: 内置页插回默认位置(排在已保存的默认前驱之后),
// 分析/扩展菜单仍追加到末尾
const defaultIndex = nav.findIndex(n => n.to === item.to)
let anchor = -1
if (defaultIndex > 0) {
for (let i = defaultIndex - 1; i >= 0 && anchor < 0; i -= 1) {
anchor = merged.findIndex(n => n.to === nav[i].to)
}
}
if (anchor >= 0) merged.splice(anchor + 1, 0, item)
else if (defaultIndex >= 0) merged.unshift(item)
else merged.push(item)
}
return merged
})()
: 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 (
{streamStatus === 'reconnecting' && (
与服务连接已断开 · 正在重连
)}
}
>
)
}