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
+179
View File
@@ -0,0 +1,179 @@
'use client';
import * as Dialog from '@radix-ui/react-dialog';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
import { ClassicCollapsedSidebar } from '@/components/layout/ClassicCollapsedSidebar';
import { IconSidebarLayout } from '@/components/layout/IconSidebarLayout';
import { NavTooltip } from '@/components/layout/nav-tooltip';
import { SidebarNav } from '@/components/layout/SidebarNav';
import { TenantSwitcher } from '@/components/layout/TenantSwitcher';
import { UserMenu } from '@/components/layout/UserMenu';
import { useMenuNavigation } from '@/lib/hooks/use-menu-navigation';
import { useNavMenu } from '@/lib/hooks/use-nav-menu';
import { redirectToAuthorize } from '@/lib/oauth/browser';
import { useAuthStore } from '@/stores/auth-store';
import { useLayoutStore } from '@/stores/layout-store';
export function AppChrome({ children }: { children: React.ReactNode }) {
const authed = useAuthStore((s) => Boolean(s.accessToken));
const sidebarMode = useLayoutStore((s) => s.sidebarMode);
const classicNavRailCollapsed = useLayoutStore((s) => s.classicNavRailCollapsed);
const toggleClassicNavRail = useLayoutStore((s) => s.toggleClassicNavRail);
const { items: nav, loading: navLoading, error: navError } = useNavMenu();
const onMenuNavigate = useMenuNavigation();
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [showClassicNarrowUi, setShowClassicNarrowUi] = useState(classicNavRailCollapsed);
const classicAsideRef = useRef<HTMLElement>(null);
useEffect(() => {
const mq = window.matchMedia('(min-width: 768px)');
const onChange = () => {
if (mq.matches) setMobileNavOpen(false);
};
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
useEffect(() => {
if (!classicNavRailCollapsed) setShowClassicNarrowUi(false);
}, [classicNavRailCollapsed]);
useEffect(() => {
if (!classicNavRailCollapsed || showClassicNarrowUi) return;
const el = classicAsideRef.current;
const finish = () => {
if (useLayoutStore.getState().classicNavRailCollapsed) setShowClassicNarrowUi(true);
};
const onEnd = (e: TransitionEvent) => {
if (e.propertyName === 'width') {
finish();
el?.removeEventListener('transitionend', onEnd);
}
};
el?.addEventListener('transitionend', onEnd);
const t = window.setTimeout(finish, 600);
return () => {
el?.removeEventListener('transitionend', onEnd);
window.clearTimeout(t);
};
}, [classicNavRailCollapsed, showClassicNarrowUi]);
const classicAsideMotion =
'min-w-0 shrink-0 transition-[width] duration-500 ease-in-out motion-reduce:transition-none';
const asideClass =
sidebarMode === 'icon'
? 'hidden min-h-0 shrink-0 overflow-hidden border-r border-neutral-200 bg-white md:flex md:flex-col'
: `relative hidden h-full min-h-0 flex-col overflow-visible border-r border-neutral-200 bg-white md:flex ${classicAsideMotion}`;
const classicAsideStyle =
sidebarMode !== 'icon' ? { width: classicNavRailCollapsed ? '72px' : '14rem' } : undefined;
const showExpandedClassicChrome = !classicNavRailCollapsed || !showClassicNarrowUi;
const sidebarProps = {
items: nav,
loading: navLoading,
error: navError,
authed,
onMenuNavigate,
};
return (
<div className="flex h-dvh min-h-0 flex-col overflow-hidden">
<header className="flex shrink-0 items-center justify-between border-b border-neutral-200 bg-white px-4 py-3">
<div className="flex min-w-0 flex-1 items-center gap-2 md:gap-4">
<button
type="button"
className="rounded-md p-2 text-neutral-700 hover:bg-neutral-100 md:hidden"
aria-label="打开导航菜单"
aria-expanded={mobileNavOpen}
onClick={() => setMobileNavOpen(true)}
>
<span className="text-lg leading-none" aria-hidden>
</span>
</button>
<Link href="/dashboard" className="shrink-0 font-semibold text-neutral-800">
Smart Admin
</Link>
{authed ? <TenantSwitcher /> : null}
</div>
<div className="flex min-w-0 items-center gap-2 text-sm">
{authed ? (
<UserMenu />
) : (
<>
<Link className="text-blue-600 underline" href="/login">
</Link>
<button
type="button"
className="rounded bg-neutral-900 px-2 py-1 text-white"
onClick={() => void redirectToAuthorize()}
>
OAuth
</button>
</>
)}
</div>
</header>
<div className="flex min-h-0 flex-1 items-stretch">
<aside ref={classicAsideRef} className={asideClass} style={classicAsideStyle} aria-label="侧栏">
{sidebarMode === 'icon' ? (
<IconSidebarLayout {...sidebarProps} />
) : showExpandedClassicChrome ? (
<div className="relative flex h-full min-h-0 flex-1 flex-col overflow-visible">
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
<SidebarNav {...sidebarProps} />
</div>
<div className="pointer-events-none absolute inset-0 z-50 hidden md:block" aria-hidden>
<NavTooltip label="收起侧栏(仅一级菜单)" side="right">
<button
type="button"
className="pointer-events-auto absolute left-full top-1/2 z-50 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-expanded={true}
aria-label="收起侧栏,仅显示一级菜单"
onClick={() => toggleClassicNavRail()}
>
<span aria-hidden></span>
</button>
</NavTooltip>
</div>
</div>
) : (
<ClassicCollapsedSidebar {...sidebarProps} />
)}
</aside>
<div className="flex min-w-0 flex-1 flex-col bg-neutral-100">{children}</div>
</div>
<Dialog.Root open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-100 bg-black/40 md:hidden" />
<Dialog.Content className="fixed left-0 top-0 z-100 flex h-dvh w-56 max-w-[85vw] min-h-0 flex-col border-r border-neutral-200 bg-white p-0 shadow-lg outline-none focus:outline-none md:hidden">
<Dialog.Title className="sr-only"></Dialog.Title>
<Dialog.Description className="sr-only"></Dialog.Description>
<button
type="button"
className="absolute right-2 top-2 z-10 rounded-md p-1.5 text-neutral-600 hover:bg-neutral-100"
aria-label="关闭菜单"
onClick={() => setMobileNavOpen(false)}
>
<span aria-hidden></span>
</button>
<div className="min-h-0 flex-1 overflow-y-auto pt-10">
<SidebarNav {...sidebarProps} onInternalNavigate={() => setMobileNavOpen(false)} />
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</div>
);
}
@@ -0,0 +1,22 @@
'use client';
import { AppChrome } from '@/components/layout/AppChrome';
import { RequireAuth } from '@/components/layout/RequireAuth';
import { TabStrip } from '@/components/layout/TabStrip';
/** 登录后带侧栏 + 多标签主工作区布局(dashboard 与 (iam) 等业务页共用) */
export function AuthenticatedLayout({ children }: { children: React.ReactNode }) {
return (
<RequireAuth>
<AppChrome>
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden">
<TabStrip />
{/* 与 TabStrip 间距及距底 12pxTailwind p-3 */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto px-3 pt-3 pb-3">
{children}
</div>
</div>
</AppChrome>
</RequireAuth>
);
}
@@ -0,0 +1,214 @@
'use client';
import Link from 'next/link';
import { createPortal } from 'react-dom';
import { useCallback, useEffect, useState } from 'react';
import type { MenuNode } from '@/lib/api/types/menu';
import { NavTooltip } from '@/components/layout/nav-tooltip';
import {
isLayoutOverviewNode,
normalizeHref,
isActivePath,
visibleChildren,
linkClass,
} from '@/components/layout/nav-shared';
function CascadeFolderPanel({
title,
nodes,
pathname,
onMenuNavigate,
depth = 0,
}: {
title: string;
nodes: MenuNode[];
pathname: string;
onMenuNavigate?: (path: string, title: string) => void;
depth?: number;
}) {
const [openId, setOpenId] = useState<string | null>(null);
const handleFolderKey = useCallback(
(e: React.KeyboardEvent, nodeId: string) => {
if (e.key === 'Enter' || e.key === 'ArrowRight') {
e.preventDefault();
setOpenId(nodeId);
} else if (e.key === 'Escape') {
setOpenId(null);
}
},
[],
);
if (nodes.length === 0) {
return null;
}
return (
<div className="w-56 overflow-visible rounded-md border border-neutral-200 bg-white py-1 shadow-xl">
<div className="border-b border-neutral-100 px-3 py-2 text-sm font-semibold text-neutral-900">{title}</div>
<div className="relative overflow-visible py-1">
{nodes.map((node) => {
if (node.menu_type === 3) {
return null;
}
const kids = visibleChildren(node);
const hasKids = kids.length > 0;
const pad = 8;
const label = (
<span className="flex min-w-0 items-center gap-2">
{node.icon ? (
<span className="shrink-0 text-neutral-500" aria-hidden>
{node.icon}
</span>
) : null}
<span className="truncate">{node.menu_name}</span>
</span>
);
if (hasKids) {
return (
<div
key={node.id}
className="relative"
onMouseEnter={() => setOpenId(node.id)}
>
<NavTooltip label={node.menu_name} delayDuration={0}>
<button
type="button"
className="flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm text-neutral-800 hover:bg-neutral-100"
style={{ paddingLeft: pad }}
aria-expanded={openId === node.id}
onKeyDown={(e) => handleFolderKey(e, node.id)}
>
{label}
<span className="shrink-0 text-neutral-400" aria-hidden>
</span>
</button>
</NavTooltip>
{openId === node.id ? (
<div
key={node.id}
className="cascade-flyout-sub absolute left-full top-0 ml-0 overflow-visible pl-0"
style={{ zIndex: 50 + depth }}
>
<CascadeFolderPanel
title={node.menu_name}
nodes={kids}
pathname={pathname}
onMenuNavigate={onMenuNavigate}
depth={depth + 1}
/>
</div>
) : null}
</div>
);
}
const href = normalizeHref(node);
const external = Boolean(node.external_link);
const active = isLayoutOverviewNode(node)
? (pathname.replace(/\/$/, '') || '/') === '/dashboard'
: isActivePath(pathname, href);
if (href === '#') {
return (
<NavTooltip key={node.id} label={node.menu_name} delayDuration={0}>
<div className="rounded-md px-2 py-2 text-sm text-neutral-500" style={{ paddingLeft: pad }}>
{label}
</div>
</NavTooltip>
);
}
if (external) {
return (
<NavTooltip key={node.id} label={node.menu_name} delayDuration={0}>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={linkClass(false)}
style={{ paddingLeft: pad }}
>
{label}
</a>
</NavTooltip>
);
}
return (
<NavTooltip key={node.id} label={node.menu_name} delayDuration={0}>
<Link
href={href}
className={linkClass(active)}
style={{ paddingLeft: pad }}
onClick={() => onMenuNavigate?.(href, node.menu_name)}
aria-current={active ? 'page' : undefined}
>
{label}
</Link>
</NavTooltip>
);
})}
</div>
</div>
);
}
/** L2 起:fixed 根与一级行顶对齐;更深层级在面板内 absolute,与对应行顶对齐;无内部滚动 */
export function ClassicCascadeFlyoutPortal({
rootNode,
anchorRect,
pathname,
onMenuNavigate,
onHoverEnter,
onHoverLeave,
}: {
rootNode: MenuNode;
anchorRect: DOMRectReadOnly;
pathname: string;
onMenuNavigate?: (path: string, title: string) => void;
onHoverEnter: () => void;
onHoverLeave: () => void;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted || typeof document === 'undefined') {
return null;
}
const nodes = visibleChildren(rootNode);
if (nodes.length === 0) {
return null;
}
/** 左缘与一级导航右缘对齐,不向左叠在窄轨上(不再用负 overlap) */
const left = anchorRect.right;
return createPortal(
<div
className="pointer-events-auto fixed z-50 outline-none"
style={{
top: anchorRect.top,
left,
maxWidth: `calc(100vw - ${left}px - 8px)`,
}}
onMouseEnter={onHoverEnter}
onMouseLeave={onHoverLeave}
>
<div key={rootNode.id} className="cascade-flyout-panel">
<CascadeFolderPanel
title={rootNode.menu_name}
nodes={nodes}
pathname={pathname}
onMenuNavigate={onMenuNavigate}
/>
</div>
</div>,
document.body,
);
}
@@ -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>
);
}
+180
View File
@@ -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>
);
}
+18
View File
@@ -0,0 +1,18 @@
'use client';
export function NavHeader({ onCloseMobile }: { onCloseMobile?: () => void }) {
return (
<div className="sticky top-0 flex items-center justify-between border-b border-neutral-100 bg-white px-3 py-0.5">
{onCloseMobile ? (
<button
type="button"
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 md:hidden"
aria-label="关闭菜单"
onClick={onCloseMobile}
>
<span aria-hidden></span>
</button>
) : null}
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useAuthStore } from '@/stores/auth-store';
export function RequireAuth({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const accessToken = useAuthStore((s) => s.accessToken);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted) {
return;
}
if (!accessToken) {
const from = pathname || '/dashboard';
router.replace(`/login?from=${encodeURIComponent(from)}`);
}
}, [mounted, accessToken, pathname, router]);
if (!mounted) {
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-100 text-sm text-neutral-500">
</div>
);
}
if (!accessToken) {
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-100 text-sm text-neutral-500">
</div>
);
}
return <>{children}</>;
}
+68
View File
@@ -0,0 +1,68 @@
'use client';
import { usePathname } from 'next/navigation';
import type { MenuNode } from '@/lib/api/types/menu';
import {
isLayoutOverviewNode,
NavTreeItem,
layoutNavRootsFromApi,
} from '@/components/layout/nav-shared';
export function SidebarNav(props: {
items: MenuNode[];
loading: boolean;
error: string | null;
authed: boolean;
onInternalNavigate?: () => void;
onMenuNavigate?: (path: string, title: string) => void;
}) {
const pathname = usePathname() || '';
if (!props.authed) {
return <div className="px-3 py-4 text-sm text-neutral-500"></div>;
}
if (props.loading) {
return <div className="px-3 py-4 text-sm text-neutral-500"></div>;
}
if (props.error) {
const roots = layoutNavRootsFromApi([]);
return (
<nav className="space-y-0.5 px-3 pb-4 py-1" aria-label="侧栏导航">
{roots.map((n) => (
<NavTreeItem
key={n.id}
node={n}
depth={0}
pathname={pathname}
onInternalNavigate={props.onInternalNavigate}
onMenuNavigate={props.onMenuNavigate}
/>
))}
<div className="px-2 py-2 text-sm text-red-600" title={props.error}>
</div>
</nav>
);
}
const roots = layoutNavRootsFromApi(props.items);
const onlyOverview = roots.length === 1 && isLayoutOverviewNode(roots[0]);
return (
<nav className="space-y-0.5 px-3 pb-4 py-1" aria-label="侧栏导航">
{roots.map((n) => (
<NavTreeItem
key={n.id}
node={n}
depth={0}
pathname={pathname}
onInternalNavigate={props.onInternalNavigate}
onMenuNavigate={props.onMenuNavigate}
/>
))}
{onlyOverview ? <div className="px-2 py-2 text-sm text-neutral-500"></div> : null}
</nav>
);
}
+99
View File
@@ -0,0 +1,99 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef } from 'react';
import { useTabStore } from '@/stores/tab-store';
/** 多页签条:与路由联动;左右滚动占位(后续可接完整滚动实现)。 */
export function TabStrip() {
const router = useRouter();
const pathname = usePathname() ?? '';
const scrollRef = useRef<HTMLDivElement>(null);
const { tabs, activeId, close, activate } = useTabStore();
useEffect(() => {
if (!pathname.startsWith('/dashboard')) {
return;
}
useTabStore.getState().syncFromPath(pathname);
}, [pathname]);
const onTabClick = useCallback(
(id: string, path: string) => {
activate(id);
router.push(path);
},
[activate, router],
);
const scrollBy = (delta: number) => {
scrollRef.current?.scrollBy({ left: delta, behavior: 'smooth' });
};
return (
<div className="flex items-end gap-0 border-b border-neutral-200 bg-white px-1 pb-0 pt-1">
<button
type="button"
className="mb-px shrink-0 cursor-pointer rounded px-1 py-1 text-neutral-500 hover:bg-neutral-100 disabled:opacity-40"
aria-label="页签向左滚动"
onClick={() => scrollBy(-120)}
>
</button>
<div
ref={scrollRef}
role="tablist"
className="flex min-w-0 flex-1 items-end gap-2 overflow-x-auto"
>
{tabs.map((t) => {
const isActive = activeId === t.id;
return (
<button
key={t.id}
type="button"
role="tab"
aria-selected={isActive}
tabIndex={isActive ? 0 : -1}
onClick={() => onTabClick(t.id, t.path)}
className={`flex shrink-0 cursor-pointer items-end gap-1 rounded-t px-3 pb-1.5 pt-2 text-sm ${
isActive
? 'bg-neutral-100 font-medium text-neutral-900'
: 'text-neutral-600'
}`}
>
<span>{t.title}</span>
{!t.pinned ? (
<span
className="cursor-pointer text-neutral-400 hover:text-neutral-700"
onClick={(e) => {
e.stopPropagation();
const next = close(t.id);
if (next) router.push(next);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
e.preventDefault();
const next = close(t.id);
if (next) router.push(next);
}
}}
>
×
</span>
) : null}
</button>
);
})}
</div>
<button
type="button"
className="mb-px shrink-0 cursor-pointer rounded px-1 py-1 text-neutral-500 hover:bg-neutral-100 disabled:opacity-40"
aria-label="页签向右滚动"
onClick={() => scrollBy(120)}
>
</button>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
'use client';
import * as Select from '@radix-ui/react-select';
import { useCallback, useEffect, useState } from 'react';
import { iamTenant } from '@/lib/api/iam';
import type { IamTenant } from '@/lib/api/types/tenant';
import { useTenantStore } from '@/stores/tenant-store';
import { useTabStore } from '@/stores/tab-store';
/** Radix Select 不允许空字符串作 value,用哨兵表示「默认租户」 */
const DEFAULT_TENANT_VALUE = '__tenant_default__';
export function TenantSwitcher() {
const tenantId = useTenantStore((s) => s.tenantId);
const setTenantId = useTenantStore((s) => s.setTenantId);
const resetTabsForTenantSwitch = useTabStore((s) => s.resetForTenantSwitch);
const [rows, setRows] = useState<IamTenant[] | null>(null);
const [loadErr, setLoadErr] = useState(false);
const load = useCallback(async () => {
try {
const data = await iamTenant.list();
setRows(data.items ?? []);
setLoadErr(false);
} catch {
setLoadErr(true);
setRows([]);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const short = tenantId ? (tenantId.length > 10 ? `${tenantId.slice(0, 8)}` : tenantId) : '默认';
if (loadErr || !rows || rows.length === 0) {
return (
<div
className="hidden max-w-[160px] truncate text-xs text-neutral-500 md:block"
title={tenantId ?? ''}
>
{short}
</div>
);
}
return (
<div className="hidden items-center gap-1 md:flex">
<span className="text-xs text-neutral-500" id="tenant-switcher-label">
</span>
<Select.Root
value={tenantId ?? DEFAULT_TENANT_VALUE}
onValueChange={(v) => {
setTenantId(v === DEFAULT_TENANT_VALUE ? null : v);
resetTabsForTenantSwitch();
}}
>
<Select.Trigger
className="inline-flex max-w-[140px] items-center justify-between gap-1 truncate rounded border border-neutral-200 bg-white px-1.5 py-0.5 text-xs text-neutral-800 outline-none hover:bg-neutral-50 focus-visible:ring-2 focus-visible:ring-neutral-400"
aria-labelledby="tenant-switcher-label"
title={tenantId ?? '默认'}
>
<Select.Value placeholder="默认" />
<Select.Icon aria-hidden>
<span className="text-[10px] text-neutral-500"></span>
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content
className="z-[200] max-h-[min(320px,70vh)] overflow-hidden rounded-md border border-neutral-200 bg-white shadow-lg"
position="popper"
sideOffset={4}
align="start"
>
<Select.Viewport className="p-1">
<Select.Item
value={DEFAULT_TENANT_VALUE}
className="relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-xs text-neutral-800 outline-none data-highlighted:bg-neutral-100"
>
<Select.ItemText></Select.ItemText>
</Select.Item>
{rows.map((r) => (
<Select.Item
key={r.id}
value={r.id}
className="relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-xs text-neutral-800 outline-none data-highlighted:bg-neutral-100"
>
<Select.ItemText className="truncate">
{r.tenant_name || r.tenant_code || r.id}
</Select.ItemText>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
'use client';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import Link from 'next/link';
import { useState } from 'react';
import { avatarInitials, useUserProfile } from '@/lib/hooks/use-user-profile';
import { useMenuNavigation } from '@/lib/hooks/use-menu-navigation';
import { useAuthStore } from '@/stores/auth-store';
import { useLayoutStore } from '@/stores/layout-store';
import { useTabStore } from '@/stores/tab-store';
export function UserMenu() {
const logout = useAuthStore((s) => s.logout);
const sidebarMode = useLayoutStore((s) => s.sidebarMode);
const setSidebarMode = useLayoutStore((s) => s.setSidebarMode);
const { profile, userSub, loading, label } = useUserProfile();
const [open, setOpen] = useState(false);
const onMenuNavigate = useMenuNavigation();
const initials = avatarInitials(profile, userSub);
return (
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="flex max-w-[min(100vw-8rem,14rem)] items-center gap-2 rounded-lg border-0 bg-white px-2 py-1.5 text-left text-sm text-neutral-800 outline-none hover:bg-neutral-50 data-[state=open]:bg-neutral-50"
aria-label="用户菜单"
>
{profile?.avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={profile.avatar} alt="" className="h-8 w-8 shrink-0 rounded-full object-cover" />
) : (
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-xs font-medium text-neutral-700"
aria-hidden
>
{initials}
</span>
)}
<span className="min-w-0 flex-1 truncate font-medium">{label}</span>
<span className="shrink-0 text-neutral-400" aria-hidden>
{open ? '▴' : '▾'}
</span>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="z-50 w-60 rounded-lg border border-neutral-200 bg-white py-1 shadow-lg outline-none"
sideOffset={4}
align="end"
collisionPadding={8}
>
<div className="border-b border-neutral-100 px-3 py-2">
<p className="truncate text-sm font-medium text-neutral-900">{label}</p>
{profile?.email ? (
<p className="mt-0.5 truncate text-xs text-neutral-500">{profile.email}</p>
) : null}
{!profile?.email && userSub ? (
<p className="mt-0.5 truncate font-mono text-xs text-neutral-400" title={userSub}>
ID {userSub}
</p>
) : null}
</div>
<DropdownMenu.Item asChild>
<Link
href="/dashboard"
className="block cursor-pointer px-3 py-2 text-sm text-neutral-800 outline-none data-highlighted:bg-neutral-50"
onClick={() => onMenuNavigate('/dashboard', '概览')}
>
</Link>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<Link
href="/dashboard/account"
className="block cursor-pointer px-3 py-2 text-sm text-neutral-800 outline-none data-highlighted:bg-neutral-50"
onClick={() => onMenuNavigate('/dashboard/account', '个人中心')}
>
</Link>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-neutral-100" />
<div className="border-b border-neutral-100 px-3 py-2">
<p className="mb-1 text-xs text-neutral-500"></p>
<div className="flex gap-1">
<button
type="button"
className={`flex-1 rounded border px-2 py-1 text-xs ${
sidebarMode === 'classic'
? 'border-neutral-900 bg-neutral-900 text-white'
: 'border-neutral-200 bg-white text-neutral-700'
}`}
onClick={() => setSidebarMode('classic')}
>
</button>
<button
type="button"
className={`flex-1 rounded border px-2 py-1 text-xs ${
sidebarMode === 'icon'
? 'border-neutral-900 bg-neutral-900 text-white'
: 'border-neutral-200 bg-white text-neutral-700'
}`}
onClick={() => setSidebarMode('icon')}
>
</button>
</div>
</div>
<DropdownMenu.Item
className="cursor-pointer px-3 py-2 text-left text-sm text-red-600 outline-none data-highlighted:bg-red-50"
onSelect={() => {
void logout();
}}
>
退
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
+264
View File
@@ -0,0 +1,264 @@
'use client';
import * as Collapsible from '@radix-ui/react-collapsible';
import Link from 'next/link';
import type { MenuNode } from '@/lib/api/types/menu';
import { NavTooltip } from '@/components/layout/nav-tooltip';
/** 布局固定「概览」,与 IAM nav 拼成同一棵树(不入库) */
export const LAYOUT_OVERVIEW_MENU_ID = '__layout_overview__';
export function isLayoutOverviewNode(node: MenuNode): boolean {
return node.id === LAYOUT_OVERVIEW_MENU_ID;
}
export const layoutOverviewMenuNode: MenuNode = {
id: LAYOUT_OVERVIEW_MENU_ID,
parent_id: '',
menu_name: '概览',
menu_type: 2,
path: '/dashboard',
icon: '📊',
};
/** 将布局「概览」与 IAM nav 合并为侧栏顶层列表,便于组好完整结构再一次性渲染 */
export function layoutNavRootsFromApi(items: MenuNode[]): MenuNode[] {
return [layoutOverviewMenuNode, ...items.filter((n) => n.menu_type !== 3)];
}
export function normalizeHref(node: MenuNode): string {
if (node.external_link) {
return node.external_link;
}
const p = node.path?.trim();
if (!p) {
return '#';
}
if (p.startsWith('http://') || p.startsWith('https://')) {
return p;
}
return p.startsWith('/') ? p : `/${p}`;
}
export function isActivePath(pathname: string, href: string): boolean {
if (!href || href === '#' || href.startsWith('http://') || href.startsWith('https://')) {
return false;
}
if (pathname === href) {
return true;
}
if (href !== '/' && pathname.startsWith(`${href}/`)) {
return true;
}
return false;
}
export function linkClass(active: boolean): string {
return [
'block rounded-md px-2 py-2 text-sm',
active
? 'bg-neutral-200 font-medium text-neutral-900'
: 'text-neutral-800 hover:bg-neutral-100',
].join(' ');
}
/** 经典树:目录可展开,叶子为链接(用于经典侧栏与图标模式右侧浮层) */
export function NavTreeItem({
node,
depth,
pathname,
onInternalNavigate,
onMenuNavigate,
/** 图标模式浮层:二级及以下项之间额外增加 2px(space-y-0.5 */
iconFlyout = false,
}: {
node: MenuNode;
depth: number;
pathname: string;
onInternalNavigate?: () => void;
onMenuNavigate?: (path: string, title: string) => void;
iconFlyout?: boolean;
}) {
if (node.menu_type === 3) {
return null;
}
const children = (node.children ?? []).filter((c) => c.menu_type !== 3);
const hasChildren = children.length > 0;
const pad = 10 + depth * 10;
const label = (
<span className="flex min-w-0 items-center gap-2">
{node.icon ? (
<span className="shrink-0 text-neutral-500" aria-hidden>
{node.icon}
</span>
) : null}
<span className="truncate">{node.menu_name}</span>
</span>
);
if (hasChildren) {
return (
<Collapsible.Root defaultOpen className="min-w-0 select-none">
<Collapsible.Trigger asChild>
<button
type="button"
className="group flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm text-neutral-800 hover:bg-neutral-100"
style={{ paddingLeft: pad }}
>
{label}
<span
className="shrink-0 text-neutral-400 transition-transform duration-200 ease-out group-aria-expanded:rotate-0 motion-reduce:transition-none -rotate-90"
aria-hidden
>
</span>
</button>
</Collapsible.Trigger>
<Collapsible.Content
className="grid overflow-hidden transition-[grid-template-rows] duration-200 ease-in-out motion-reduce:transition-none data-[state=open]:grid-rows-[1fr] data-[state=closed]:grid-rows-[0fr]"
>
<div className="min-h-0">
<div
className={`ml-3 border-l border-neutral-200 pl-1${iconFlyout ? ' space-y-0.5' : ''}`}
>
{children.map((c) => (
<NavTreeItem
key={c.id}
node={c}
depth={depth + 1}
pathname={pathname}
iconFlyout={iconFlyout}
onInternalNavigate={onInternalNavigate}
onMenuNavigate={onMenuNavigate}
/>
))}
</div>
</div>
</Collapsible.Content>
</Collapsible.Root>
);
}
const href = normalizeHref(node);
const external = Boolean(node.external_link);
const active = isLayoutOverviewNode(node)
? (pathname.replace(/\/$/, '') || '/') === '/dashboard'
: isActivePath(pathname, href);
if (href === '#') {
return (
<div className="rounded-md px-2 py-2 text-sm text-neutral-500" style={{ paddingLeft: pad }}>
{label}
</div>
);
}
if (external) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={linkClass(false)}
style={{ paddingLeft: pad }}
>
{label}
</a>
);
}
return (
<Link
href={href}
className={linkClass(active)}
style={{ paddingLeft: pad }}
onClick={() => {
onMenuNavigate?.(href, node.menu_name);
onInternalNavigate?.();
}}
aria-current={active ? 'page' : undefined}
>
{label}
</Link>
);
}
export function visibleChildren(node: MenuNode): MenuNode[] {
return (node.children ?? []).filter((c) => c.menu_type !== 3);
}
/** 当前路径是否落在该节点子树内(用于图标模式自动展开浮层) */
export function subtreeContainsActivePath(node: MenuNode, pathname: string): boolean {
if (isLayoutOverviewNode(node)) {
const p = pathname.replace(/\/$/, '') || '/';
return p === '/dashboard';
}
const href = normalizeHref(node);
if (!node.external_link && href !== '#' && isActivePath(pathname, href)) {
return true;
}
for (const c of visibleChildren(node)) {
if (subtreeContainsActivePath(c, pathname)) {
return true;
}
}
return false;
}
/** 窄轨图标/文字切换:有 icon 显示 icon,否则显示菜单名 */
export function NavIconOrLabel({ node, className }: { node: MenuNode; className?: string }) {
if (node.icon) {
return (
<span className={className ?? 'block text-base'} aria-hidden>
{node.icon}
</span>
);
}
return <span className="line-clamp-2 px-1 text-center text-[11px] font-medium">{node.menu_name}</span>;
}
/** 侧栏未认证/加载中/出错三态守卫 */
export function NavStateGuard({
authed,
loading,
error,
onMenuNavigate,
children,
}: {
authed: boolean;
loading: boolean;
error: string | null;
onMenuNavigate: (path: string, title: string) => void;
children?: React.ReactNode;
}) {
if (!authed) {
return <div className="px-2 py-4 text-center text-[11px] text-neutral-500"></div>;
}
if (loading) {
return <div className="px-2 py-4 text-center text-[11px] text-neutral-500"></div>;
}
if (error) {
return (
<div className="min-h-0 flex-1 overflow-y-auto px-1 py-1">
<NavTooltip label="概览">
<Link
href="/dashboard"
className="flex h-10 w-full items-center justify-center rounded-md text-neutral-700 hover:bg-neutral-100"
onClick={() => onMenuNavigate('/dashboard', '概览')}
>
<span className="text-base" aria-hidden>
📊
</span>
<span className="sr-only"></span>
</Link>
</NavTooltip>
<NavTooltip label={error}>
<p className="mt-2 cursor-default px-1 text-center text-[11px] text-red-600"></p>
</NavTooltip>
</div>
);
}
return children;
}
+37
View File
@@ -0,0 +1,37 @@
'use client';
import * as Tooltip from '@radix-ui/react-tooltip';
import type { ReactElement } from 'react';
type NavTooltipProps = {
label: string;
children: ReactElement;
/** 窄轨叶子项可设 0,与原先即时提示接近 */
delayDuration?: number;
side?: 'top' | 'right' | 'bottom' | 'left';
};
/**
* 侧栏图标/收起态等:替代原生 `title`,统一键盘可达与延时(受全局 TooltipProvider 影响可被 Root 覆盖)。
*/
export function NavTooltip({ label, children, delayDuration = 300, side = 'right' }: NavTooltipProps) {
if (!label.trim()) {
return children;
}
return (
<Tooltip.Root delayDuration={delayDuration}>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side={side}
sideOffset={6}
className="z-[300] max-w-[min(280px,calc(100vw-16px))] rounded-md bg-neutral-900 px-2.5 py-1.5 text-xs font-medium text-white shadow-lg"
>
{label}
<Tooltip.Arrow className="fill-neutral-900" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
);
}