feat: 优化web

This commit is contained in:
2026-04-23 18:58:13 +08:00
commit 544a2f3428
160 changed files with 27327 additions and 0 deletions
@@ -0,0 +1,190 @@
'use client';
import { useMemo } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import type { MenuNode } from '@/lib/api/types/menu';
import { ClassicCascadeFlyoutPortal } from '@/components/layout/ClassicCascadeFlyout';
import { NavTooltip } from '@/components/layout/nav-tooltip';
import {
NavIconOrLabel,
NavStateGuard,
normalizeHref,
layoutNavRootsFromApi,
subtreeContainsActivePath,
visibleChildren,
} from '@/components/layout/nav-shared';
import { useFlyoutState } from '@/lib/hooks/use-flyout-state';
import { useMenuNavigation } from '@/lib/hooks/use-menu-navigation';
import { useLayoutStore } from '@/stores/layout-store';
const RAIL_W = 'w-[72px]';
export function ClassicCollapsedSidebar({
items,
loading,
error,
authed,
}: {
items: MenuNode[];
loading: boolean;
error: string | null;
authed: boolean;
onMenuNavigate?: (path: string, title: string) => void;
}) {
const pathname = usePathname() ?? '/';
const roots = useMemo(() => layoutNavRootsFromApi(items), [items]);
const toggleClassicNavRail = useLayoutStore((s) => s.toggleClassicNavRail);
const onMenuNavigate = useMenuNavigation();
const {
flyoutRoot,
l1AnchorRect,
openFlyout,
scheduleCloseFlyout,
clearCloseTimer,
closeFlyoutNow,
toggleFlyoutClick,
railScrollRef,
} = useFlyoutState(pathname);
const railShell = (inner: React.ReactNode) => (
<div className={`relative flex h-full min-h-0 shrink-0 flex-col border-r border-neutral-100 bg-white ${RAIL_W}`}>
{inner}
<div className="pointer-events-none absolute inset-0 z-[60] hidden md:block" aria-hidden>
<NavTooltip label="展开侧栏" side="right">
<button
type="button"
className="pointer-events-auto absolute left-full top-1/2 z-[60] flex h-14 w-3.5 -translate-y-1/2 cursor-pointer items-center justify-center rounded-r-full border border-neutral-200 border-l-0 bg-white text-xs text-neutral-500 shadow-sm transition-transform duration-200 ease-out hover:bg-neutral-50 active:scale-95 motion-reduce:transition-none"
aria-label="展开侧栏"
onClick={() => toggleClassicNavRail()}
>
<span aria-hidden></span>
</button>
</NavTooltip>
</div>
</div>
);
if (!authed || loading || error) {
return (
<div className="relative h-full min-h-0 w-[72px] shrink-0 flex-1">
{railShell(
<NavStateGuard authed={authed} loading={loading} error={error} onMenuNavigate={onMenuNavigate} />,
)}
</div>
);
}
return (
<div className="relative h-full min-h-0 w-[72px] shrink-0 flex-1">
{railShell(
<div ref={railScrollRef} 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 flyoutHere = flyoutRoot?.id === node.id;
const routeHere = subtreeContainsActivePath(node, pathname);
const railHighlight = flyoutHere || (routeHere && flyoutRoot === null);
return (
<div
key={node.id}
className="relative"
data-l1-cascade=""
onMouseEnter={(e) => openFlyout(node, e.currentTarget)}
onMouseLeave={scheduleCloseFlyout}
>
<NavTooltip label={node.menu_name}>
<button
type="button"
aria-expanded={flyoutHere}
aria-haspopup="true"
className={[
'flex h-10 w-full items-center justify-center rounded-md text-sm',
railHighlight
? flyoutHere
? 'bg-neutral-200 font-medium text-neutral-900'
: 'bg-neutral-100 text-neutral-900'
: 'text-neutral-700 hover:bg-neutral-100',
].join(' ')}
onClick={(e) =>
toggleFlyoutClick(
node,
(e.currentTarget as HTMLElement).closest('[data-l1-cascade]') as HTMLDivElement | null,
)
}
>
<span className="truncate px-1 text-center leading-tight">
<NavIconOrLabel node={node} />
</span>
</button>
</NavTooltip>
</div>
);
}
const href = normalizeHref(node);
const external = Boolean(node.external_link);
if (href === '#') {
return (
<div key={node.id} className="w-full" onMouseEnter={() => closeFlyoutNow()}>
<NavTooltip label={node.menu_name} delayDuration={0}>
<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>
</div>
);
}
if (external) {
return (
<div key={node.id} className="w-full" onMouseEnter={() => closeFlyoutNow()}>
<NavTooltip label={node.menu_name} delayDuration={0}>
<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>
</div>
);
}
return (
<div key={node.id} className="w-full" onMouseEnter={() => closeFlyoutNow()}>
<NavTooltip label={node.menu_name} delayDuration={0}>
<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);
closeFlyoutNow();
}}
>
<NavIconOrLabel node={node} />
</Link>
</NavTooltip>
</div>
);
})}
</nav>
</div>,
)}
{flyoutRoot && l1AnchorRect && visibleChildren(flyoutRoot).length > 0 ? (
<ClassicCascadeFlyoutPortal
rootNode={flyoutRoot}
anchorRect={l1AnchorRect}
pathname={pathname}
onMenuNavigate={onMenuNavigate}
onHoverEnter={clearCloseTimer}
onHoverLeave={scheduleCloseFlyout}
/>
) : null}
</div>
);
}