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,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,
);
}