176 lines
5.7 KiB
TypeScript
176 lines
5.7 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useNavigate, useLocation } from "react-router-dom";
|
|
|
|
import { getStoredAuth } from "@/core/auth";
|
|
import {
|
|
defaultExpandedIds,
|
|
filterMenuItemsByRole,
|
|
sidebarMenuItems,
|
|
type MenuItem,
|
|
} from "@/core/page-layout/sidebar-menu";
|
|
import './page-sdebar.css'
|
|
|
|
export function PageSidebarDark() {
|
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
|
const visibleMenuItems = useMemo(
|
|
() => filterMenuItemsByRole(sidebarMenuItems, !!isAdmin),
|
|
[isAdmin],
|
|
);
|
|
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
|
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(defaultExpandedIds));
|
|
const navigate = useNavigate();
|
|
const { pathname } = useLocation();
|
|
|
|
// pathname 变化时取消任何未触发的 hover 关闭计时(cleanup 在卸载时也跑)
|
|
useEffect(() => {
|
|
return () => {
|
|
if (hoverCloseTimerRef.current !== null) {
|
|
window.clearTimeout(hoverCloseTimerRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const handleNavHoverEnter = (id: string) => {
|
|
if (hoverCloseTimerRef.current !== null) {
|
|
window.clearTimeout(hoverCloseTimerRef.current);
|
|
hoverCloseTimerRef.current = null;
|
|
}
|
|
setHoveredTopId(id);
|
|
};
|
|
|
|
const scheduleNavPanelClose = () => {
|
|
if (hoverCloseTimerRef.current !== null) {
|
|
window.clearTimeout(hoverCloseTimerRef.current);
|
|
}
|
|
hoverCloseTimerRef.current = window.setTimeout(() => {
|
|
setHoveredTopId(null);
|
|
hoverCloseTimerRef.current = null;
|
|
}, 180);
|
|
};
|
|
|
|
const closeNavPanel = () => {
|
|
if (hoverCloseTimerRef.current !== null) {
|
|
window.clearTimeout(hoverCloseTimerRef.current);
|
|
hoverCloseTimerRef.current = null;
|
|
}
|
|
setHoveredTopId(null);
|
|
};
|
|
|
|
const hasActiveChild = (item: MenuItem): boolean => {
|
|
if (item.path && pathname === item.path) {
|
|
return true;
|
|
}
|
|
|
|
return item.children?.some(hasActiveChild) ?? false;
|
|
};
|
|
|
|
// lazy init:仅在首次 render 计算一次 active id;之后由下面的 useEffect 跟随 pathname 同步
|
|
const [activeTopId, setActiveTopId] = useState<string>(
|
|
() => visibleMenuItems.find(hasActiveChild)?.id ?? visibleMenuItems[0]?.id ?? "qa-center",
|
|
);
|
|
const [hoveredTopId, setHoveredTopId] = useState<string | null>(null);
|
|
const hoverCloseTimerRef = useRef<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
const matchedTopItem = visibleMenuItems.find(hasActiveChild);
|
|
if (matchedTopItem) {
|
|
setActiveTopId(matchedTopItem.id);
|
|
}
|
|
closeNavPanel();
|
|
}, [pathname, visibleMenuItems]);
|
|
|
|
const toggleExpanded = (id: string) => {
|
|
setExpandedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) {
|
|
next.delete(id);
|
|
} else {
|
|
next.add(id);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleTopItemClick = (item: MenuItem) => {
|
|
setActiveTopId(item.id);
|
|
|
|
if (!item.children?.length && item.path) {
|
|
closeNavPanel();
|
|
navigate(item.path);
|
|
}
|
|
};
|
|
|
|
const handleMenuItemClick = (item: MenuItem) => {
|
|
if (item.children?.length) {
|
|
toggleExpanded(item.id);
|
|
return;
|
|
}
|
|
|
|
if (item.path) {
|
|
closeNavPanel();
|
|
navigate(item.path);
|
|
}
|
|
};
|
|
|
|
const renderFlyoutMenu = (items: MenuItem[], level = 0) => (
|
|
<div className={`sidebar-flyout-list sidebar-flyout-list--level-${level}`}>
|
|
{items.map((item) => {
|
|
const hasChildren = !!item.children?.length;
|
|
const isExpanded = expandedIds.has(item.id);
|
|
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
|
|
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
className={`sidebar-flyout-node sidebar-flyout-node--level-${level} ${hasChildren ? "sidebar-flyout-node--has-children" : ""} ${isExpanded ? "sidebar-flyout-node--expanded" : ""}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
className={`sidebar-flyout-item ${isSelected ? "sidebar-flyout-item--active" : ""} ${hasChildren ? "sidebar-flyout-item--parent" : "sidebar-flyout-item--leaf"}`}
|
|
onClick={() => handleMenuItemClick(item)}
|
|
>
|
|
{hasChildren ? <span className="sidebar-flyout-arrow" aria-hidden="true" /> : null}
|
|
<span className="sidebar-flyout-label">{item.label}</span>
|
|
</button>
|
|
{hasChildren && isExpanded ? renderFlyoutMenu(item.children ?? [], level + 1) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
return (
|
|
<aside className="sidebar-contnet">
|
|
<div className="sidebar-contents">
|
|
{visibleMenuItems.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="item-nav-group"
|
|
onMouseEnter={() => handleNavHoverEnter(item.id)}
|
|
onMouseLeave={scheduleNavPanelClose}
|
|
>
|
|
{item.children?.length ? (
|
|
<div
|
|
className={`item-nav-content ${hoveredTopId === item.id ? "item-nav-content--visible" : ""}`}
|
|
>
|
|
{renderFlyoutMenu(item.children)}
|
|
</div>
|
|
) : null}
|
|
<div className="item-nav-wrap">
|
|
<button
|
|
type="button"
|
|
className={`item-nav ${item.id === activeTopId ? "item-nav-active" : ""} ${hoveredTopId === item.id ? "item-nav-hovered" : ""}`}
|
|
onClick={() => handleTopItemClick(item)}
|
|
>
|
|
<span className="item-nav__label">{item.label}</span>
|
|
</button>
|
|
{item.children?.length && hoveredTopId === item.id ? (
|
|
<span className="item-nav-hover-bridge" aria-hidden="true" />
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|