feat: 优化web
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import type { MenuNode } from '@/lib/api/types/menu';
|
||||
import { NavTooltip } from '@/components/layout/nav-tooltip';
|
||||
import {
|
||||
NavIconOrLabel,
|
||||
NavStateGuard,
|
||||
NavTreeItem,
|
||||
normalizeHref,
|
||||
layoutNavRootsFromApi,
|
||||
subtreeContainsActivePath,
|
||||
visibleChildren,
|
||||
} from '@/components/layout/nav-shared';
|
||||
import { useMenuNavigation } from '@/lib/hooks/use-menu-navigation';
|
||||
|
||||
export function IconSidebarLayout({
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
authed,
|
||||
onCloseMobile,
|
||||
}: {
|
||||
items: MenuNode[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
authed: boolean;
|
||||
onMenuNavigate?: (path: string, title: string) => void;
|
||||
onCloseMobile?: () => void;
|
||||
}) {
|
||||
const pathname = usePathname() ?? '/';
|
||||
const roots = useMemo(() => layoutNavRootsFromApi(items), [items]);
|
||||
const onMenuNavigate = useMenuNavigation();
|
||||
|
||||
const findFlyoutRootForPath = useCallback(
|
||||
(path: string): MenuNode | null => {
|
||||
for (const r of roots) {
|
||||
const kids = visibleChildren(r);
|
||||
if (kids.length === 0) continue;
|
||||
if (subtreeContainsActivePath(r, path)) return r;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[roots],
|
||||
);
|
||||
|
||||
const [flyoutRoot, setFlyoutRoot] = useState<MenuNode | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const next = findFlyoutRootForPath(pathname);
|
||||
setFlyoutRoot((prev) => {
|
||||
if (next) return next;
|
||||
if (prev && subtreeContainsActivePath(prev, pathname)) return prev;
|
||||
return null;
|
||||
});
|
||||
}, [pathname, findFlyoutRootForPath]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setFlyoutRoot(null);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
const toggleFlyout = (root: MenuNode) => {
|
||||
setFlyoutRoot((r) => (r?.id === root.id ? null : root));
|
||||
};
|
||||
|
||||
const railShell = (inner: React.ReactNode) => (
|
||||
<div className="flex h-full min-h-0 w-[72px] shrink-0 flex-col border-r border-neutral-100 bg-white">
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!authed || loading || error) {
|
||||
return railShell(
|
||||
<NavStateGuard authed={authed} loading={loading} error={error} onMenuNavigate={onMenuNavigate} />,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
<div className="flex w-[72px] shrink-0 flex-col border-r border-neutral-100 bg-white">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-1 py-1">
|
||||
<nav className="flex flex-col items-stretch gap-0.5" aria-label="主导航">
|
||||
{roots.map((node) => {
|
||||
const kids = visibleChildren(node);
|
||||
if (kids.length > 0) {
|
||||
const open = flyoutRoot?.id === node.id;
|
||||
return (
|
||||
<NavTooltip key={node.id} label={node.menu_name}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
className={[
|
||||
'flex h-10 w-full items-center justify-center rounded-md text-sm',
|
||||
open
|
||||
? 'bg-neutral-200 text-neutral-900'
|
||||
: 'text-neutral-700 hover:bg-neutral-100',
|
||||
].join(' ')}
|
||||
onClick={() => toggleFlyout(node)}
|
||||
>
|
||||
<span className="truncate px-1 text-center leading-tight">
|
||||
<NavIconOrLabel node={node} />
|
||||
</span>
|
||||
</button>
|
||||
</NavTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const href = normalizeHref(node);
|
||||
const external = Boolean(node.external_link);
|
||||
if (href === '#') {
|
||||
return (
|
||||
<NavTooltip key={node.id} label={node.menu_name}>
|
||||
<div className="flex h-10 items-center justify-center rounded-md px-1 text-center text-[11px] text-neutral-400">
|
||||
<NavIconOrLabel node={node} className="text-base" />
|
||||
</div>
|
||||
</NavTooltip>
|
||||
);
|
||||
}
|
||||
if (external) {
|
||||
return (
|
||||
<NavTooltip key={node.id} label={node.menu_name}>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-10 items-center justify-center rounded-md text-neutral-700 hover:bg-neutral-100"
|
||||
>
|
||||
<NavIconOrLabel node={node} />
|
||||
</a>
|
||||
</NavTooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NavTooltip key={node.id} label={node.menu_name}>
|
||||
<Link
|
||||
href={href}
|
||||
className="flex h-10 items-center justify-center rounded-md text-neutral-700 hover:bg-neutral-100"
|
||||
onClick={() => {
|
||||
onMenuNavigate(href, node.menu_name);
|
||||
onCloseMobile?.();
|
||||
setFlyoutRoot(null);
|
||||
}}
|
||||
>
|
||||
<NavIconOrLabel node={node} />
|
||||
</Link>
|
||||
</NavTooltip>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{flyoutRoot && visibleChildren(flyoutRoot).length > 0 ? (
|
||||
<div className="flex w-56 shrink-0 flex-col border-r border-neutral-100 bg-white shadow-sm">
|
||||
<div className="sticky top-0 z-10 flex h-10 shrink-0 items-center border-b border-neutral-100 bg-white px-3">
|
||||
<div className="truncate text-sm font-semibold text-neutral-900">{flyoutRoot.menu_name}</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-0.5 overflow-y-auto px-2 py-2">
|
||||
{visibleChildren(flyoutRoot).map((c) => (
|
||||
<NavTreeItem
|
||||
key={c.id}
|
||||
node={c}
|
||||
depth={0}
|
||||
pathname={pathname}
|
||||
iconFlyout
|
||||
onMenuNavigate={onMenuNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user