a83da2b8b4
Co-Authored-By: Claude <noreply@anthropic.com>
204 lines
7.5 KiB
TypeScript
204 lines
7.5 KiB
TypeScript
import { NavLink, Outlet } from 'react-router-dom'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { motion } from 'framer-motion'
|
|
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 {
|
|
useSettings,
|
|
usePreferences,
|
|
useVersion,
|
|
} from '@/lib/useSharedQueries'
|
|
import { QK } from '@/lib/queryKeys'
|
|
import {
|
|
FileText,
|
|
Settings,
|
|
LayoutDashboard,
|
|
Tags,
|
|
TrendingUp,
|
|
Flame,
|
|
BarChart3,
|
|
Sparkles,
|
|
Layers3,
|
|
Landmark,
|
|
BookOpenCheck,
|
|
} from 'lucide-react'
|
|
import { Logo } from './Logo'
|
|
import { api } from '@/lib/api'
|
|
import { cn } from '@/lib/cn'
|
|
|
|
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
|
|
const BRAND = '#8B5CF6'
|
|
|
|
const nav = [
|
|
{ to: '/', label: '看板', icon: LayoutDashboard },
|
|
{ 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: '/review', label: '复盘', icon: BookOpenCheck },
|
|
{ to: '/indices', label: '指数', icon: BarChart3 },
|
|
] as const
|
|
|
|
function AIConfigBadge({ configured, model }: { configured?: boolean; model?: string }) {
|
|
return (
|
|
<NavLink
|
|
to="/settings?tab=ai"
|
|
className="mt-2 group block -mx-2.5"
|
|
title="AI 配置"
|
|
>
|
|
<div className="relative overflow-hidden rounded-lg border border-purple-400/20 bg-gradient-to-br from-purple-500/[0.12] via-surface to-surface px-3 py-2 transition-all hover:border-purple-400/35 hover:from-purple-500/[0.16]">
|
|
<div className="absolute -right-5 -top-6 h-14 w-14 rounded-full bg-purple-500/10 blur-2xl" />
|
|
<div className="relative flex items-center gap-2">
|
|
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-purple-400/10 text-purple-300 ring-1 ring-purple-400/20">
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs font-medium text-foreground">AI 配置</span>
|
|
<span className={`h-1.5 w-1.5 rounded-full ${configured ? 'bg-bear' : 'bg-warning'}`} />
|
|
</div>
|
|
<div className="mt-0.5 truncate text-[10px] leading-tight text-muted">
|
|
{configured ? (model || '已接入模型') : '接入策略生成模型'}
|
|
</div>
|
|
</div>
|
|
<Settings className="h-3 w-3 text-muted group-hover:text-purple-300 transition-colors" />
|
|
</div>
|
|
</div>
|
|
</NavLink>
|
|
)
|
|
}
|
|
|
|
export function Layout() {
|
|
// ===== 共享 hooks (替代内联 useQuery) =====
|
|
const { data: settingsState } = useSettings()
|
|
const { data: versionData } = useVersion()
|
|
const { data: prefs } = usePreferences()
|
|
const { data: analysisMenus } = useQuery({
|
|
queryKey: QK.analysisMenus,
|
|
queryFn: api.analysisMenus,
|
|
})
|
|
|
|
const version = versionData?.version
|
|
|
|
// 合并内置页面 + 可见的扩展分析菜单
|
|
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\//, '')))
|
|
|
|
return (
|
|
<div className="h-screen grid grid-cols-[14rem_1fr] bg-base text-foreground overflow-hidden">
|
|
<aside className="border-r border-border bg-surface flex flex-col h-full min-h-0 overflow-hidden">
|
|
<div className="px-5 py-5 border-b border-border shrink-0">
|
|
{/* Brand block — 原创 logo + 等宽 wordmark */}
|
|
<div className="flex items-center gap-2.5">
|
|
<Logo
|
|
size={28}
|
|
className="shrink-0 drop-shadow-[0_0_8px_rgba(139,92,246,0.5)]"
|
|
style={{ color: BRAND }}
|
|
/>
|
|
<div
|
|
className="text-sm font-bold tracking-tight text-foreground leading-tight"
|
|
style={{ textShadow: `0 0 10px ${BRAND}44` }}
|
|
>
|
|
A股工作台
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="mt-3 h-px"
|
|
style={{ background: `linear-gradient(90deg, ${BRAND}88, transparent 80%)` }}
|
|
/>
|
|
|
|
<AIConfigBadge
|
|
configured={settingsState?.ai_configured ?? settingsState?.has_ai_key}
|
|
model={settingsState?.ai_model}
|
|
/>
|
|
</div>
|
|
|
|
<nav className="flex-1 min-h-0 overflow-y-auto px-2 py-3 space-y-0.5">
|
|
{visibleNavItems.map(({ to, label, icon: Icon }) => (
|
|
<NavLink
|
|
key={to}
|
|
to={to}
|
|
className={({ isActive }) =>
|
|
cn(
|
|
'flex items-center gap-3 px-3 py-2 rounded-btn text-sm transition-colors duration-150 ease-smooth',
|
|
isActive
|
|
? 'bg-elevated text-foreground font-medium'
|
|
: 'text-foreground/80 hover:bg-elevated hover:text-foreground',
|
|
)
|
|
}
|
|
>
|
|
{() => (
|
|
<>
|
|
<Icon className="h-4 w-4 shrink-0" />
|
|
<span className="flex-1">{label}</span>
|
|
</>
|
|
)}
|
|
</NavLink>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="border-t border-border px-2 py-3 space-y-0.5 shrink-0">
|
|
<NavLink
|
|
to="/settings"
|
|
className={({ isActive }) =>
|
|
cn(
|
|
'flex items-center justify-between gap-3 px-3 py-2 rounded-btn text-sm transition-colors duration-150 ease-smooth',
|
|
isActive
|
|
? 'bg-elevated text-foreground font-medium'
|
|
: 'text-foreground/80 hover:bg-elevated hover:text-foreground',
|
|
)
|
|
}
|
|
>
|
|
<span className="flex items-center gap-3">
|
|
<Settings className="h-4 w-4 shrink-0" />
|
|
<span>设置</span>
|
|
</span>
|
|
<span className="font-mono text-[10px] text-muted/50 select-none">
|
|
{version ?? ''}
|
|
</span>
|
|
</NavLink>
|
|
</div>
|
|
</aside>
|
|
|
|
<motion.main
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
|
className="h-full overflow-auto scrollbar-gutter-stable"
|
|
>
|
|
<Outlet />
|
|
</motion.main>
|
|
<ToastContainer />
|
|
<AlertToastContainer />
|
|
<AiAnalysisHost />
|
|
<AiReportBubble />
|
|
<StockAnalysisHost />
|
|
<StockAnalysisBubble />
|
|
</div>
|
|
)
|
|
}
|