feat: 优化web
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user