diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index b7bfed3..a1e1c87 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -49,12 +49,15 @@ import { Moon, X, WifiOff, + Menu, + PanelLeft, PanelLeftClose, PanelLeftOpen, } from 'lucide-react' import { Logo } from './Logo' import { api, type CapabilityMatrix, type IndexQuote } from '@/lib/api' import { cn } from '@/lib/cn' +import { useIsDesktop } from '@/lib/useMediaQuery' import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors' import { computeGroupPcts, groupPctColor, groupPctTitle } from '@/lib/watchlistGroupStats' import { fmtPct } from '@/lib/format' @@ -333,6 +336,10 @@ function AIConfigBadge({ configured, model }: { configured?: boolean; model?: st ) } +// 侧边栏桌面三态: expanded(14rem) / rail(3.5rem 图标条) / hidden(0 + 左缘悬浮按钮)。 +// 移动端 (<768px) 不参与三态 — aside 以抽屉呈现 (见 Layout 内 drawerOpen)。 +type NavState = 'expanded' | 'rail' | 'hidden' + export function Layout() { // ===== 共享 hooks (替代内联 useQuery) ===== const { data: settingsState } = useSettings() @@ -365,16 +372,41 @@ export function Layout() { // 自选二级菜单展开状态 — 默认当前在自选页时展开 const [watchlistNavExpanded, setWatchlistNavExpanded] = useState(location.pathname === '/watchlist') - // 侧边栏收起状态 — 持久化到 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 } + // 侧边栏三态 — expanded(14rem) / rail(3.5rem 图标条) / hidden(0, 左缘悬浮按钮唤出)。 + // 仅桌面 (≥768px) 参与三态; 移动端 aside 以抽屉呈现, 由 drawerOpen 控制, 恒渲染完整形态。 + // 持久化到 localStorage; 迁移旧两态键 tf-nav-collapsed (收起 → 图标条)。 + const [navState, setNavState] = useState(() => { + try { + const v = localStorage.getItem('tf-nav-state') + if (v === 'expanded' || v === 'rail' || v === 'hidden') return v + return localStorage.getItem('tf-nav-collapsed') === '1' ? 'rail' : 'expanded' + } catch { return 'expanded' } }) + const [drawerOpen, setDrawerOpen] = useState(false) + const isDesktop = useIsDesktop() + // 桌面 hidden 态的左缘悬浮按钮: hover 1s 以 overlay 预览 (不挤压主区), 点击固定展开 (push) + const [overlayPreview, setOverlayPreview] = useState(false) + const overlayTimer = useRef(undefined) + const setNavStatePersist = (s: NavState) => { + setNavState(s) + try { localStorage.setItem('tf-nav-state', s) } catch {} + } + // 图标条形态仅桌面 rail 态成立 (移动端抽屉与 overlay 预览恒为完整形态) + const railMode = isDesktop && navState === 'rail' + // 路由跳转/切回桌面时关抽屉; ESC 同样关闭 + useEffect(() => { setDrawerOpen(false) }, [location.pathname, isDesktop]) + useEffect(() => { + if (!drawerOpen) return + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setDrawerOpen(false) } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [drawerOpen]) // 分组等权平均涨跌幅 — 复用 watchlist/enriched 查询缓存(与自选页同 key, - // 盘中随 SSE 刷新)。可见性门控: 子菜单实际可见(侧栏展开 + 二级菜单展开) - // 时才拉取, 收起状态下不为隐藏 UI 发请求。 - const navGroupPctVisible = groupsInNav && !navCollapsed && watchlistNavExpanded + // 盘中随 SSE 刷新)。可见性门控: 子菜单实际可见(桌面展开 + 二级菜单展开, 或 + // 移动端抽屉打开) 时才拉取, 收起/隐藏/抽屉关闭下不为隐藏 UI 发请求。 + const sidebarFullyVisible = navState === 'expanded' && (isDesktop || drawerOpen) + const navGroupPctVisible = groupsInNav && sidebarFullyVisible && watchlistNavExpanded const { data: navWatchlist } = useQuery({ queryKey: QK.watchlist, queryFn: api.watchlistList, @@ -425,25 +457,9 @@ export function Layout() { const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false // 自选实时模式限制提示: 可手动关闭, 不持久化 (刷新后恢复显示) const [dismissFreeHint, setDismissFreeHint] = useState(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 - }) + setNavStatePersist(navState === 'expanded' ? 'rail' : navState === 'rail' ? 'hidden' : 'expanded') } // 指数条: 固定核心四只 (产品契约, 不再可配置), 常驻显示 const sidebarIndexes = CORE_INDEXES @@ -583,18 +599,66 @@ export function Layout() { return (
-