feat(ui): 侧边栏桌面三态 + 移动端抽屉 (落地 #166 设计)

桌面 (≥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。
This commit is contained in:
shy3130
2026-09-02 12:40:56 +08:00
parent 22add7179c
commit bd84d4c0ae
2 changed files with 158 additions and 62 deletions
+136 -62
View File
@@ -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<NavState>(() => {
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<number | undefined>(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 (
<div
className="h-screen grid bg-base text-foreground overflow-hidden transition-[grid-template-columns] duration-200 ease-smooth"
style={{ gridTemplateColumns: navCollapsed ? '3.5rem 1fr' : '14rem 1fr' }}
style={{ gridTemplateColumns: isDesktop && !overlayPreview ? (navState === 'expanded' ? '14rem 1fr' : navState === 'rail' ? '3.5rem 1fr' : '0 1fr') : '1fr' }}
>
<aside className="border-r border-border bg-surface flex flex-col h-full min-h-0 overflow-hidden">
<div className={cn('border-b border-border shrink-0', navCollapsed ? 'px-2 pt-3 pb-2' : 'px-4 pt-4 pb-3')}>
{/* 移动端抽屉遮罩 */}
{!isDesktop && drawerOpen && (
<div
className="fixed inset-0 z-40 bg-black/50"
onClick={() => setDrawerOpen(false)}
aria-hidden="true"
/>
)}
{/* 移动端汉堡按钮 / 桌面 hidden 态左缘悬浮按钮 (hover 1s overlay 预览, 点击固定展开) */}
{(!isDesktop || navState === 'hidden') && !overlayPreview && (
<button
onClick={() => {
window.clearTimeout(overlayTimer.current)
setOverlayPreview(false)
if (isDesktop) setNavStatePersist('expanded')
else setDrawerOpen(true)
}}
onMouseEnter={() => {
if (!isDesktop) return
window.clearTimeout(overlayTimer.current)
overlayTimer.current = window.setTimeout(() => setOverlayPreview(true), 1000)
}}
onMouseLeave={() => window.clearTimeout(overlayTimer.current)}
className={cn(
'fixed z-30 rounded-btn border border-border bg-surface/90 text-muted shadow-lg backdrop-blur-sm',
'hover:text-foreground hover:bg-elevated transition-colors duration-150 ease-smooth',
isDesktop ? 'left-1.5 top-1/2 -translate-y-1/2 p-2' : 'left-3 top-3 p-2',
)}
title={isDesktop ? '展开菜单' : '打开菜单'}
>
{isDesktop
? <PanelLeftOpen className="h-4 w-4 shrink-0" />
: <Menu className="h-4 w-4 shrink-0" />}
</button>
)}
<aside
onMouseLeave={() => { if (overlayPreview) setOverlayPreview(false) }}
className={cn(
'bg-surface flex flex-col min-h-0 overflow-hidden',
isDesktop
? cn('h-full', navState === 'hidden' && !overlayPreview ? 'border-r-0' : 'border-r border-border')
: cn(
'fixed inset-y-0 left-0 z-50 w-[80vw] max-w-[320px] border-r border-border shadow-2xl',
'transition-transform duration-200 ease-smooth',
drawerOpen ? 'translate-x-0' : '-translate-x-full',
),
overlayPreview && 'fixed inset-y-0 left-0 z-50 w-56 shadow-2xl border-r border-border',
)}
>
<div className={cn('border-b border-border shrink-0', railMode ? 'px-2 pt-3 pb-2' : 'px-4 pt-4 pb-3')}>
{/* Brand block — 收起时只显 logo 居中 */}
<div className={cn('flex', navCollapsed ? 'flex-col items-center gap-2' : 'items-center gap-2')}>
<div className={cn('flex', railMode ? 'flex-col items-center gap-2' : 'items-center gap-2')}>
<Logo
size={navCollapsed ? 24 : 26}
size={railMode ? 24 : 26}
className="shrink-0 drop-shadow-[0_0_8px_rgba(139,92,246,0.4)]"
style={{ color: BRAND }}
/>
{!navCollapsed && (
{!railMode && (
<div
className="font-bold text-[11px] uppercase tracking-[0.14em] text-foreground whitespace-nowrap"
style={{ textShadow: `0 0 10px ${BRAND}44` }}
@@ -602,24 +666,34 @@ export function Layout() {
Tick Stock Panel
</div>
)}
{/* 收起/展开 按钮 */}
<button
onClick={toggleNavCollapsed}
className={cn(
'flex items-center rounded-btn text-muted hover:text-foreground hover:bg-elevated/60 transition-colors duration-150 ease-smooth',
navCollapsed ? 'justify-center p-1.5' : 'ml-auto p-1.5',
)}
title={navCollapsed ? '展开菜单' : '收起菜单'}
>
{navCollapsed
? <PanelLeftOpen className="h-3.5 w-3.5 shrink-0" />
: <PanelLeftClose className="h-3.5 w-3.5 shrink-0" />
}
</button>
{/* 收起/展开 按钮 (桌面三态循环) / 移动端抽屉关闭按钮 */}
{isDesktop ? (
<button
onClick={toggleNavCollapsed}
className={cn(
'flex items-center rounded-btn text-muted hover:text-foreground hover:bg-elevated/60 transition-colors duration-150 ease-smooth',
railMode ? 'justify-center p-1.5' : 'ml-auto p-1.5',
)}
title={railMode ? '隐藏菜单 (再点击左缘按钮可唤出)' : '收起菜单'}
>
{railMode
? <PanelLeft className="h-3.5 w-3.5 shrink-0" />
: <PanelLeftClose className="h-3.5 w-3.5 shrink-0" />
}
</button>
) : (
<button
onClick={() => setDrawerOpen(false)}
className="ml-auto flex items-center rounded-btn p-1.5 text-muted hover:text-foreground hover:bg-elevated/60 transition-colors duration-150 ease-smooth"
title="关闭菜单"
>
<X className="h-4 w-4 shrink-0" />
</button>
)}
</div>
{/* 状态卡 — 收起时隐藏 */}
{!navCollapsed && (
{!railMode && (
<div className="mt-2.5 border-t border-border/60 pt-1">
<DataSourceHealthBadge matrix={matrix} />
<div className="mx-2 border-t border-border/45" aria-hidden="true" />
@@ -634,7 +708,7 @@ export function Layout() {
<nav className="flex-1 min-h-0 overflow-y-auto px-2 py-3 space-y-0.5">
{visibleNavItems.map(({ to, label, icon: Icon, badge }) => {
// 「自选」项 — 开启分组侧栏且未整体收起时, 渲染为可展开父项 + 二级分组
const isWatchlistExpandable = to === '/watchlist' && groupsInNav && !navCollapsed && watchlistGroups.length > 0
const isWatchlistExpandable = to === '/watchlist' && groupsInNav && !railMode && watchlistGroups.length > 0
return (
<div key={to}>
{isWatchlistExpandable ? (
@@ -665,11 +739,11 @@ export function Layout() {
/* 普通菜单项 */
<NavLink
to={to}
title={navCollapsed ? label : undefined}
title={railMode ? label : undefined}
className={({ isActive }) =>
cn(
'group relative flex items-center rounded-btn text-sm transition-all duration-150 ease-smooth',
navCollapsed ? 'justify-center px-0 py-2' : 'gap-3 px-3 py-2',
railMode ? 'justify-center px-0 py-2' : 'gap-3 px-3 py-2',
isActive
? 'bg-elevated text-foreground font-medium'
: 'text-foreground/75 hover:bg-elevated/70 hover:text-foreground',
@@ -686,21 +760,21 @@ export function Layout() {
)}
/>
<Icon className={cn('h-4 w-4 shrink-0 transition-colors', isActive ? 'text-accent' : 'text-foreground/60 group-hover:text-foreground/85')} />
{!navCollapsed && <span className="flex-1">{label}</span>}
{!navCollapsed && badge && (
{!railMode && <span className="flex-1">{label}</span>}
{!railMode && badge && (
<span className="ml-auto inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400 shrink-0">
{badge}
</span>
)}
{/* 数据同步状态: 同步中转圈, 刚完成显示绿色对勾闪烁 3 秒 */}
{to === '/data' && isDataSyncing && !navCollapsed && (
{to === '/data' && isDataSyncing && !railMode && (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
)}
{to === '/data' && !isDataSyncing && dataSyncJustDone && !navCollapsed && (
{to === '/data' && !isDataSyncing && dataSyncJustDone && !railMode && (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-bull animate-pulse" />
)}
{/* 监控中心徽标: 仅非监控页且有未读时显示 */}
{to === '/monitor' && !navCollapsed && <MonitorBadge active={isActive} />}
{to === '/monitor' && !railMode && <MonitorBadge active={isActive} />}
</>
)}
</NavLink>
@@ -762,13 +836,13 @@ export function Layout() {
})}
<ExtensionSlot
name="layout.navigation.extra"
context={{ collapsed: navCollapsed, pathname: location.pathname }}
context={{ collapsed: railMode, pathname: location.pathname }}
compact
/>
</nav>
{/* 全局行情开关 — 收起时只显示状态指示点 */}
{navCollapsed ? (
{railMode ? (
<div className="border-t border-border px-2 py-2.5 shrink-0 flex justify-center">
<button
onClick={() => handleToggle(!realtimeEnabled)}
@@ -880,16 +954,16 @@ export function Layout() {
</div>
)}
<div className={cn('border-t border-border py-3 shrink-0', navCollapsed ? 'px-2 flex flex-col items-center gap-1' : 'px-2')}>
<div className={navCollapsed ? 'flex flex-col items-center gap-1' : 'flex items-center gap-1'}>
<div className={cn('border-t border-border py-3 shrink-0', railMode ? 'px-2 flex flex-col items-center gap-1' : 'px-2')}>
<div className={railMode ? 'flex flex-col items-center gap-1' : 'flex items-center gap-1'}>
<ThemeToggle />
<NavLink
to="/settings"
title={navCollapsed ? '设置' : undefined}
title={railMode ? '设置' : undefined}
className={({ isActive }) =>
cn(
'group relative flex items-center rounded-btn text-sm transition-all duration-150 ease-smooth',
navCollapsed ? 'justify-center px-0 py-2' : 'flex-1 gap-3 px-3 py-2',
railMode ? 'justify-center px-0 py-2' : 'flex-1 gap-3 px-3 py-2',
isActive
? 'bg-elevated text-foreground font-medium'
: 'text-foreground/75 hover:bg-elevated/70 hover:text-foreground',
@@ -905,8 +979,8 @@ export function Layout() {
)}
/>
<Settings className={cn('h-4 w-4 shrink-0 transition-colors', isActive ? 'text-accent' : 'text-foreground/60 group-hover:text-foreground/85')} />
{!navCollapsed && <span></span>}
{!navCollapsed && version && (
{!railMode && <span></span>}
{!railMode && version && (
<span className="ml-auto font-mono text-[10px] text-muted/50 select-none shrink-0">
{version}
</span>
+22
View File
@@ -0,0 +1,22 @@
import { useSyncExternalStore } from 'react'
/**
* 响应式媒体查询 hook — 视口变化触发重渲染, 服务器端渲染恒返回 false。
* 侧边栏用它区分桌面三态 (展开/图标条/隐藏) 与移动端抽屉两种交互模型。
*/
export function useMediaQuery(query: string): boolean {
return useSyncExternalStore(
(onChange) => {
const mql = window.matchMedia(query)
mql.addEventListener('change', onChange)
return () => mql.removeEventListener('change', onChange)
},
() => window.matchMedia(query).matches,
() => false,
)
}
/** ≥768px 视口 (桌面布局)。 */
export function useIsDesktop(): boolean {
return useMediaQuery('(min-width: 768px)')
}