271 lines
8.9 KiB
TypeScript
271 lines
8.9 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
||
import { useNavigate, useLocation } from "react-router-dom";
|
||
import { PanelLeftOpen, PanelLeftClose } from "lucide-react";
|
||
|
||
import { getStoredAuth } from "@/core/auth";
|
||
import { isWujieEnv } from "@/core/embed/wujie";
|
||
import {
|
||
defaultExpandedIds,
|
||
menuItemContainsPath,
|
||
menuItemMatchesPath,
|
||
type MenuItem,
|
||
} from "@/core/page-layout/sidebar-menu";
|
||
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
|
||
import './page-sdebar.css'
|
||
|
||
type PageSidebarDarkProps = {
|
||
/** 是否收起(true 时只渲染左下角浮动入口,sidebar 主体隐藏) */
|
||
collapsed: boolean;
|
||
onToggleCollapsed: () => void;
|
||
/** 全局 Header 隐藏时,侧边栏从视口顶部开始占满高度。 */
|
||
headerHidden?: boolean;
|
||
};
|
||
|
||
export function PageSidebarDark({
|
||
collapsed,
|
||
onToggleCollapsed,
|
||
headerHidden = false,
|
||
}: PageSidebarDarkProps) {
|
||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
||
const inWujieEnv = isWujieEnv();
|
||
// 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 => menuItemContainsPath(item, pathname);
|
||
|
||
// 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 handleSecondItemClick = (item: MenuItem) => {
|
||
const path = item.path ?? findFirstRoutePath(item.children);
|
||
if (path) {
|
||
navigate(path);
|
||
}
|
||
};
|
||
|
||
const handleMenuItemClick = (item: MenuItem) => {
|
||
if (item.children?.length) {
|
||
toggleExpanded(item.id);
|
||
return;
|
||
}
|
||
|
||
if (item.path) {
|
||
navigate(item.path);
|
||
}
|
||
};
|
||
|
||
const activeTopItem =
|
||
visibleMenuItems.find((item) => item.id === activeTopId) ??
|
||
visibleMenuItems.find(hasActiveChild) ??
|
||
visibleMenuItems[0];
|
||
const sidebarMenuItemsToRender = inWujieEnv
|
||
? visibleMenuItems
|
||
: activeTopItem?.children ?? [];
|
||
const isSidebarItemActive = (item: MenuItem) =>
|
||
inWujieEnv
|
||
? item.id === activeTopId
|
||
: item.path || item.aliasPaths?.length
|
||
? menuItemMatchesPath(item, pathname)
|
||
: menuItemContainsPath(item, pathname);
|
||
const handleSidebarItemClick = (item: MenuItem) => {
|
||
if (inWujieEnv) {
|
||
handleTopItemClick(item);
|
||
return;
|
||
}
|
||
|
||
handleSecondItemClick(item);
|
||
};
|
||
const getSidebarLabelClassName = (label: string) => {
|
||
if (!inWujieEnv && Array.from(label.trim()).length === 4) {
|
||
return "item-nav__label item-nav__label--four-char";
|
||
}
|
||
|
||
return "item-nav__label";
|
||
};
|
||
|
||
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 || item.aliasPaths?.length
|
||
? menuItemMatchesPath(item, pathname)
|
||
: 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>
|
||
);
|
||
|
||
// 收起时主侧栏不占宽度,保留左下角入口以便随时恢复。
|
||
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 ${inWujieEnv ? "" : "sidebar-contnet--top-submenu"} ${
|
||
headerHidden ? "sidebar-contnet--header-hidden" : ""
|
||
}`}
|
||
>
|
||
<div className="sidebar-contents">
|
||
{sidebarMenuItemsToRender.map((item) => (
|
||
<div
|
||
key={item.id}
|
||
className="item-nav-group"
|
||
onMouseEnter={inWujieEnv ? () => handleNavHoverEnter(item.id) : undefined}
|
||
onMouseLeave={inWujieEnv ? scheduleNavPanelClose : undefined}
|
||
>
|
||
{inWujieEnv && 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 ${isSidebarItemActive(item) ? "item-nav-active" : ""} ${hoveredTopId === item.id ? "item-nav-hovered" : ""}`}
|
||
onClick={() => handleSidebarItemClick(item)}
|
||
>
|
||
<span className={getSidebarLabelClassName(item.label)}>{item.label}</span>
|
||
</button>
|
||
{inWujieEnv && item.children?.length && hoveredTopId === item.id ? (
|
||
<span className="item-nav-hover-bridge" aria-hidden="true" />
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
<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>
|
||
);
|
||
}
|