Files
2026-04-23 18:58:13 +08:00

265 lines
7.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;
}