From bd84d4c0ae12202b33eaabfab611ee3b29782bf4 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Wed, 2 Sep 2026 12:40:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E4=BE=A7=E8=BE=B9=E6=A0=8F?= =?UTF-8?q?=E6=A1=8C=E9=9D=A2=E4=B8=89=E6=80=81=20+=20=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E6=8A=BD=E5=B1=89=20(=E8=90=BD=E5=9C=B0=20#166=20?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 桌面 (≥768px): 展开 14rem → 图标条 3.5rem → 隐藏 0 + 左缘悬浮按钮 (hover 1s 以 overlay 预览不挤压主区, 点击固定展开 push); 三态持久化 localStorage tf-nav-state, 自动迁移旧 tf-nav-collapsed 两态键。 移动端 (<768px): aside 换壳为 fixed 抽屉 (80vw / max 320px + 半透明 遮罩), 左上角汉堡唤出, ESC/路由跳转/点导航/点遮罩关闭; 原移动端 强制收起为图标条的旧逻辑移除, 抽屉内恒为完整形态。 数据挂起门控对齐: 图标条/隐藏/抽屉关闭态均不为侧栏隐藏 UI 发请求。 交互设计取自 PR #166 (intfoo), 贴现有 navCollapsed 体系实现而非 合并其平行 sidebar 组件树 (其桌面两态与 main 现有能力重复)。 验证: tsc 零错误 + build 通过 + 浏览器验收 8 项 PASS (桌面三态 两轮循环含隐藏恢复、移动抽屉开合/遮罩/ESC); 修复验收发现的 overlayPreview 残留导致的"恢复后侧栏 fixed 覆盖主区"bug。 --- frontend/src/components/Layout.tsx | 198 ++++++++++++++++++++--------- frontend/src/lib/useMediaQuery.ts | 22 ++++ 2 files changed, 158 insertions(+), 62 deletions(-) create mode 100644 frontend/src/lib/useMediaQuery.ts 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 (
-