229 lines
7.5 KiB
TypeScript
229 lines
7.5 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { useNavigate, useLocation } from "react-router-dom";
|
||
import { PanelLeftOpen, PanelLeftClose } from "lucide-react";
|
||
|
||
import { getStoredAuth } from "@/core/auth";
|
||
import {
|
||
defaultExpandedIds,
|
||
filterMenuItemsByRole,
|
||
sidebarMenuItems,
|
||
type MenuItem,
|
||
} from "@/core/page-layout/sidebar-menu";
|
||
import './page-sdebar.css'
|
||
|
||
type PageSidebarDarkProps = {
|
||
/** 是否收起(true 时只渲染左下角浮动入口,sidebar 主体隐藏) */
|
||
collapsed: boolean;
|
||
onToggleCollapsed: () => void;
|
||
};
|
||
|
||
export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDarkProps) {
|
||
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 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);
|
||
}
|
||
// 不在导航时强制关闭 flyout:flyout 绝对定位在 group 盒子之外,强制关闭后鼠标
|
||
// 仍停在 flyout 区域却收不到 group 的 mouseenter,导致连续点击第二个任务时面板
|
||
// 消失且不回来。开合只交给 hover(mouseleave 有 180ms 宽限),即可连续点选。
|
||
}, [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 findFirstRoutePath = (items?: MenuItem[]): string | null => {
|
||
if (!items?.length) {
|
||
return null;
|
||
}
|
||
|
||
for (const child of items) {
|
||
if (child.path) {
|
||
return child.path;
|
||
}
|
||
const nestedPath = findFirstRoutePath(child.children);
|
||
if (nestedPath) {
|
||
return nestedPath;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
const handleTopItemClick = (item: MenuItem) => {
|
||
setActiveTopId(item.id);
|
||
|
||
if (item.children?.length) {
|
||
const firstPath = findFirstRoutePath(item.children);
|
||
if (firstPath) {
|
||
navigate(firstPath);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (item.path) {
|
||
navigate(item.path);
|
||
}
|
||
};
|
||
|
||
const handleMenuItemClick = (item: MenuItem) => {
|
||
if (item.children?.length) {
|
||
toggleExpanded(item.id);
|
||
return;
|
||
}
|
||
|
||
if (item.path) {
|
||
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>
|
||
);
|
||
// collapsed 状态:sidebar 主体完全消失(包括占据的宽度),只渲染左下角浮动入口
|
||
if (collapsed) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
title="展开侧边栏"
|
||
aria-label="展开侧边栏"
|
||
className="sidebar-collapse-btn sidebar-collapse-btn--floating"
|
||
onClick={onToggleCollapsed}
|
||
>
|
||
<PanelLeftOpen className="sidebar-collapse-btn__icon" aria-hidden="true" />
|
||
<span className="sidebar-collapse-btn__label">收起面板</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
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>
|
||
))}
|
||
|
||
{/* 底部"收起面板"按钮:独立样式,mt-auto 推到底 */}
|
||
<button
|
||
type="button"
|
||
title="收起侧边栏"
|
||
aria-label="收起侧边栏"
|
||
className="sidebar-collapse-btn sidebar-collapse-btn--inline"
|
||
onClick={onToggleCollapsed}
|
||
>
|
||
<PanelLeftClose className="sidebar-collapse-btn__icon" aria-hidden="true" />
|
||
<span className="sidebar-collapse-btn__label">收起面板</span>
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|