fix(web): convert mobile navigation to drawer shell
All checks were successful
Code Review / ai-code-review (push) Successful in 16s
CD Pipeline / tests (push) Successful in 2m47s
CD Pipeline / build-and-deploy (push) Successful in 6m11s
CD Pipeline / post-deploy-checks (push) Successful in 2m53s

This commit is contained in:
Your Name
2026-06-26 20:01:20 +08:00
parent a28786c55d
commit aee743ba67
5 changed files with 138 additions and 34 deletions

View File

@@ -1837,7 +1837,9 @@
},
"sidebar": {
"expand": "展開側欄",
"collapse": "收合側欄"
"collapse": "收合側欄",
"openNavigation": "開啟導航選單",
"closeNavigation": "關閉導航選單"
},
"settings": {
"title": "系統設定",

View File

@@ -1837,7 +1837,9 @@
},
"sidebar": {
"expand": "展開側欄",
"collapse": "收合側欄"
"collapse": "收合側欄",
"openNavigation": "開啟導航選單",
"closeNavigation": "關閉導航選單"
},
"settings": {
"title": "系統設定",

View File

@@ -15,9 +15,11 @@
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { Sidebar } from './sidebar'
import { Header } from './header'
import { DotMatrixBg } from '@/components/ui/dot-matrix-bg'
import { Z_INDEX } from '@/lib/constants/z-index'
import { cn } from '@/lib/utils'
import { useDashboardStore } from '@/stores/dashboard.store'
@@ -50,8 +52,10 @@ export function AppLayout({
showBackground = true,
fullBleed = false,
}: AppLayoutProps) {
const tSidebar = useTranslations('sidebar')
const [collapsed, setCollapsed] = useState(false)
const [mobileShell, setMobileShell] = useState(false)
const [mobileNavigationOpen, setMobileNavigationOpen] = useState(false)
const [mounted, setMounted] = useState(false)
// Phase 19 修復: 全局 SSE 連接
@@ -73,6 +77,8 @@ export function AppLayout({
setMobileShell(isMobile)
if (isMobile) {
setCollapsed(false)
} else {
setMobileNavigationOpen(false)
}
}
@@ -100,6 +106,11 @@ export function AppLayout({
// Save collapsed state to localStorage
const handleToggle = () => {
if (mobileShell) {
setMobileNavigationOpen(value => !value)
return
}
const newState = !collapsed
setCollapsed(newState)
localStorage.setItem(SIDEBAR_STATE_KEY, String(newState))
@@ -110,8 +121,25 @@ export function AppLayout({
const effectiveCollapsed = mounted ? collapsed : false
const shellCollapsed = effectiveCollapsed
const contentOffsetClass = shellCollapsed
? mobileShell ? 'ml-[48px]' : 'ml-[64px]'
: 'ml-[224px]'
? 'ml-0 md:ml-[64px]'
: 'ml-0 md:ml-[224px]'
useEffect(() => {
if (!mobileNavigationOpen) {
return
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setMobileNavigationOpen(false)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [mobileNavigationOpen])
return (
<div className="min-h-screen bg-nothing-gray-50">
@@ -131,13 +159,49 @@ export function AppLayout({
collapsed={shellCollapsed}
compact={mobileShell}
onToggle={handleToggle}
className="hidden md:flex"
/>
{mobileNavigationOpen && (
<div
aria-modal="true"
role="dialog"
className="md:hidden"
style={{
position: 'fixed',
inset: 0,
zIndex: Z_INDEX.SIDEBAR + 1,
}}
>
<button
type="button"
aria-label={tSidebar('closeNavigation')}
onClick={() => setMobileNavigationOpen(false)}
style={{
position: 'absolute',
inset: 0,
border: 0,
background: 'rgba(20, 20, 19, 0.34)',
cursor: 'pointer',
}}
/>
<Sidebar
locale={locale}
collapsed={false}
compact={false}
width="min(304px, calc(100vw - 32px))"
showToggle={false}
onNavigate={() => setMobileNavigationOpen(false)}
/>
</div>
)}
{/* Header */}
<Header
locale={locale}
sidebarCollapsed={shellCollapsed}
compact={mobileShell}
onOpenNavigation={() => setMobileNavigationOpen(true)}
/>
{/* Main Content */}

View File

@@ -17,7 +17,7 @@
import { useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { usePathname } from 'next/navigation'
import { ShieldCheck } from 'lucide-react'
import { Menu, ShieldCheck } from 'lucide-react'
import { Z_INDEX } from '@/lib/constants/z-index'
// =============================================================================
@@ -28,6 +28,7 @@ interface HeaderProps {
locale: string
sidebarCollapsed?: boolean
compact?: boolean
onOpenNavigation?: () => void
className?: string
}
@@ -39,11 +40,13 @@ export function Header({
locale,
sidebarCollapsed = false,
compact = false,
onOpenNavigation,
className,
}: HeaderProps) {
const t = useTranslations('locale')
const tBrand = useTranslations('brand')
const tDashboard = useTranslations('dashboard')
const tSidebar = useTranslations('sidebar')
const pathname = usePathname()
const switchLocale = useCallback((newLocale: string) => {
@@ -51,7 +54,7 @@ export function Header({
window.location.href = newPath
}, [locale, pathname])
const brandWidth = sidebarCollapsed ? 64 : 224
const brandWidth = compact ? 64 : sidebarCollapsed ? 64 : 224
return (
<header
@@ -120,6 +123,29 @@ export function Header({
gap: compact ? 8 : 16,
background: '#faf9f3',
}}>
{compact && (
<button
type="button"
onClick={onOpenNavigation}
aria-label={tSidebar('openNavigation')}
style={{
width: 38,
height: 38,
borderRadius: 8,
border: '0.5px solid #d8d4c8',
background: '#fff',
color: '#334155',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
flexShrink: 0,
}}
>
<Menu size={18} aria-hidden="true" strokeWidth={2.2} />
</button>
)}
{/* Page title */}
<span style={{
fontFamily: 'var(--font-heading), Syne, sans-serif',

View File

@@ -45,7 +45,10 @@ interface SidebarProps {
locale: string
collapsed?: boolean
compact?: boolean
width?: number | string
showToggle?: boolean
onToggle?: () => void
onNavigate?: () => void
className?: string
}
@@ -64,7 +67,10 @@ export function Sidebar({
locale,
collapsed = false,
compact = false,
width,
showToggle = true,
onToggle,
onNavigate,
className,
}: SidebarProps) {
const t = useTranslations('nav')
@@ -98,13 +104,13 @@ export function Sidebar({
item.aliases?.some(alias => isRouteActive(alias)) === true ||
item.relatedPaths?.some(path => isRouteActive(path)) === true
)
const sidebarWidth = compact && collapsed ? 48 : collapsed ? 64 : 224
const sidebarWidth = width ?? (compact && collapsed ? 48 : collapsed ? 64 : 224)
return (
<aside
className={cn(
'fixed left-0 flex flex-col',
className
'fixed left-0 flex-col',
className ?? 'flex'
)}
style={{
top: 68,
@@ -143,6 +149,7 @@ export function Sidebar({
key={item.id}
href={`/${locale}${item.href === '/' ? '' : item.href}`}
title={collapsed ? t(item.labelKey) : undefined}
onClick={onNavigate}
style={{
display: 'flex',
alignItems: 'center',
@@ -196,6 +203,7 @@ export function Sidebar({
key={item.id}
href={`/${locale}${item.href === '/' ? '' : item.href}`}
title={collapsed ? t(item.labelKey) : undefined}
onClick={onNavigate}
style={{
display: 'flex',
alignItems: 'center',
@@ -237,31 +245,33 @@ export function Sidebar({
</div>
{/* 折疊按鈕 */}
<button
onClick={onToggle}
style={{
position: 'absolute',
right: -12,
top: 80,
width: 24,
height: 24,
borderRadius: '50%',
background: '#faf9f3',
border: '0.5px solid #e0ddd4',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.15s',
}}
aria-label={collapsed ? tSidebar('expand') : tSidebar('collapse')}
>
{collapsed ? (
<ChevronRight className="w-3 h-3 text-neutral-400" />
) : (
<ChevronLeft className="w-3 h-3 text-neutral-400" />
)}
</button>
{showToggle && (
<button
onClick={onToggle}
style={{
position: 'absolute',
right: -12,
top: 80,
width: 24,
height: 24,
borderRadius: '50%',
background: '#faf9f3',
border: '0.5px solid #e0ddd4',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.15s',
}}
aria-label={collapsed ? tSidebar('expand') : tSidebar('collapse')}
>
{collapsed ? (
<ChevronRight className="w-3 h-3 text-neutral-400" />
) : (
<ChevronLeft className="w-3 h-3 text-neutral-400" />
)}
</button>
)}
</aside>
)
}