425 lines
15 KiB
TypeScript
425 lines
15 KiB
TypeScript
import { Bell, ChevronDown, User } from "lucide-react";
|
||
import { useState } from "react";
|
||
import { useLocation, useNavigate } from "react-router-dom";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import "@/components/page-sdebar.css";
|
||
import {
|
||
CompactShortcutButtons,
|
||
LayoutModeSwitcher,
|
||
ThemeSwitcher,
|
||
} from "@/components/workspace/appearance-controls";
|
||
import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
||
import { getStoredAuth } from "@/core/auth";
|
||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||
import { getHeaderBrandAsset, getHeaderBrandFallback } from "@/core/branding/brand-assets";
|
||
import { isWujieEnv } from "@/core/embed/wujie";
|
||
import { readRuntimeEnv } from "@/env";
|
||
import { useUnreadCount } from "@/core/notifications";
|
||
import { useMyPosition } from "@/core/positions";
|
||
import {
|
||
getActiveThirdLevelItems,
|
||
menuItemContainsPath,
|
||
menuItemMatchesPath,
|
||
type MenuItem,
|
||
} from "@/core/page-layout/sidebar-menu";
|
||
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
|
||
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
import { useTheme } from "../contexts/ThemeContext";
|
||
|
||
const iconButtonClass = (isDark: boolean, isPaleLightTheme: boolean, isCustomTheme: boolean) =>
|
||
cn(
|
||
"size-8 shrink-0 rounded-sm",
|
||
isCustomTheme
|
||
? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30"
|
||
: isDark
|
||
? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
|
||
: isPaleLightTheme
|
||
? "text-[var(--foreground)] hover:bg-white/55 hover:text-[var(--foreground)] active:bg-white/75"
|
||
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
|
||
);
|
||
|
||
// 深色顶栏菜单左右分组(设计稿:左 问答中心/任务管理/应用中心,右 综合管理/资源管理)
|
||
const LEFT_MENU_IDS = ["qa-center", "task-management", "light-app-center"];
|
||
const RIGHT_MENU_IDS = ["comprehensive-management", "resource-management"];
|
||
|
||
type HeaderProps = {
|
||
collapsed?: boolean;
|
||
};
|
||
|
||
const Header = ({ collapsed }: HeaderProps) => {
|
||
const { theme, baseMode } = useTheme();
|
||
const isDark = baseMode === "dark";
|
||
const isCustomTheme = theme.startsWith("custom-theme-");
|
||
const isPaleLightTheme = theme === "iceblue" || theme === "lightyellow";
|
||
const headerTone = isCustomTheme
|
||
? "banner-custom"
|
||
: isDark
|
||
? "banner-dark"
|
||
: isPaleLightTheme
|
||
? "content"
|
||
: "banner-light";
|
||
const headerTextClass = isCustomTheme
|
||
? "text-[var(--navigation-foreground)]"
|
||
: isPaleLightTheme
|
||
? "text-[var(--foreground)]"
|
||
: "text-white";
|
||
const darkLogoPosition =
|
||
readRuntimeEnv("VITE_DARK_LOGO_POSITION") === "left" ? "left" : "center";
|
||
const isDarkLogoOnLeft = darkLogoPosition === "left";
|
||
const brandTheme = theme;
|
||
const configuredBrandLogo = getHeaderBrandAsset(brandTheme);
|
||
const fallbackBrandLogo = getHeaderBrandFallback(brandTheme);
|
||
const [failedBrandLogo, setFailedBrandLogo] = useState<string | null>(null);
|
||
const brandLogo =
|
||
failedBrandLogo === configuredBrandLogo ? fallbackBrandLogo : configuredBrandLogo;
|
||
const handleBrandLogoError = () => {
|
||
if (brandLogo !== fallbackBrandLogo) {
|
||
setFailedBrandLogo(brandLogo);
|
||
}
|
||
};
|
||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||
const { data: unreadCount = 0 } = useUnreadCount();
|
||
const navigate = useNavigate();
|
||
const { pathname } = useLocation();
|
||
const liveAccount = useLiveAccount();
|
||
const auth = getStoredAuth();
|
||
const isAdmin = auth?.system_role === "admin";
|
||
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
||
const desensitizeText = useDisplayDesensitizer();
|
||
const { data: myPosition } = useMyPosition();
|
||
const positionName = myPosition?.name?.trim() || "";
|
||
|
||
const displayName = liveAccount.displayName;
|
||
// 深色侧栏收起只影响左侧导航,不应让顶栏回退到浅色的图标布局。
|
||
const showTopNav = isDark && !isWujieEnv();
|
||
// 浅色(红/蓝)顶栏:一级导航正常横排进顶栏,不做深色那种围绕基座的特殊处理。
|
||
// 与 collapsed 解耦(折叠只影响侧边栏,一级导航始终可见);wujie 嵌入下隐藏,
|
||
// 此时一级导航回落到侧边栏(见 page-sidebar.tsx)。
|
||
const showLightTopNav = !isDark && !isWujieEnv();
|
||
|
||
const hasActiveChild = (item: MenuItem): boolean => menuItemContainsPath(item, pathname);
|
||
|
||
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 thirdSubNavItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
|
||
|
||
const leftMenuItems = visibleMenuItems.filter((i) => LEFT_MENU_IDS.includes(i.id));
|
||
const rightMenuItems = visibleMenuItems.filter((i) => RIGHT_MENU_IDS.includes(i.id));
|
||
|
||
const handleNavItemClick = (item: MenuItem) => {
|
||
const path = item.path ?? findFirstRoutePath(item.children);
|
||
if (path) {
|
||
navigate(path);
|
||
}
|
||
};
|
||
|
||
const renderTopItem = (item: MenuItem) => {
|
||
const isSelected = item.path || item.aliasPaths?.length
|
||
? menuItemMatchesPath(item, pathname)
|
||
: hasActiveChild(item);
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className={`top-submenu-item ${isSelected ? "top-submenu-item--active" : ""}`}
|
||
onClick={() => handleNavItemClick(item)}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
);
|
||
};
|
||
|
||
const renderLightTopItem = (item: MenuItem) => {
|
||
const isSelected = item.path || item.aliasPaths?.length
|
||
? menuItemMatchesPath(item, pathname)
|
||
: hasActiveChild(item);
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className={`light-topnav-item ${isSelected ? "light-topnav-item--active" : ""}`}
|
||
onClick={() => handleNavItemClick(item)}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
);
|
||
};
|
||
|
||
// 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用
|
||
const actionCluster = (
|
||
<>
|
||
<CompactShortcutButtons tone={headerTone} />
|
||
<ThemeSwitcher tone={headerTone} />
|
||
|
||
<LayoutModeSwitcher tone={headerTone} />
|
||
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={() => setNotificationOpen(true)}
|
||
className={cn(iconButtonClass(isDark, isPaleLightTheme, isCustomTheme), "relative")}
|
||
title="通知"
|
||
>
|
||
<Bell className="size-4" />
|
||
{unreadCount > 0 && (
|
||
<span
|
||
className={cn(
|
||
"absolute top-1 right-1 flex h-4 min-w-4 items-center justify-center rounded-sm px-1 text-[10px] leading-none font-medium",
|
||
isDark
|
||
? "bg-cyan-500 text-white"
|
||
: "bg-white tabular-nums text-[var(--brand-900)]",
|
||
)}
|
||
>
|
||
{unreadCount > 99 ? "99+" : unreadCount}
|
||
</span>
|
||
)}
|
||
</Button>
|
||
|
||
<div
|
||
className={cn(
|
||
"mx-1 h-8 w-px",
|
||
isCustomTheme
|
||
? "bg-[var(--navigation-foreground)]/25"
|
||
: isDark
|
||
? "bg-blue-800/30"
|
||
: isPaleLightTheme
|
||
? "bg-[var(--brand-900)]/25"
|
||
: "bg-white/30",
|
||
)}
|
||
aria-hidden
|
||
/>
|
||
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
className={cn(
|
||
"h-8 gap-2 rounded-sm px-2",
|
||
isCustomTheme
|
||
? "text-[var(--navigation-foreground)] hover:bg-white/20 active:bg-white/30"
|
||
: isDark
|
||
? "text-slate-200 hover:bg-slate-800/50"
|
||
: isPaleLightTheme
|
||
? "text-[var(--foreground)] hover:bg-white/55 active:bg-white/75"
|
||
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"flex size-8 shrink-0 items-center justify-center rounded-sm",
|
||
isCustomTheme
|
||
? "bg-white/25"
|
||
: isDark
|
||
? "bg-gradient-to-br from-blue-500 to-cyan-400"
|
||
: "bg-white",
|
||
)}
|
||
>
|
||
<User
|
||
className={cn(
|
||
"size-4",
|
||
isDark && !isCustomTheme ? "text-white" : "text-[var(--brand-900)]",
|
||
)}
|
||
/>
|
||
</div>
|
||
<div className="hidden text-left sm:block">
|
||
<p className={cn("text-sm leading-tight font-medium", headerTextClass)}>
|
||
{displayName}
|
||
</p>
|
||
{positionName && (
|
||
<p className={cn("text-[11px] leading-tight", isCustomTheme ? "text-[var(--navigation-foreground)]/75" : isPaleLightTheme ? "text-[var(--muted-foreground)]" : "text-white/70")}>
|
||
{positionName}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<ChevronDown
|
||
className={cn(
|
||
"size-4 shrink-0",
|
||
isCustomTheme ? "text-[var(--navigation-foreground)]/80" : isDark ? "text-slate-500" : isPaleLightTheme ? "text-[var(--muted-foreground)]" : "text-white/80",
|
||
)}
|
||
/>
|
||
</Button>
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<NotificationDialog
|
||
open={notificationOpen}
|
||
onOpenChange={setNotificationOpen}
|
||
/>
|
||
<header className="strategy-app-header theme-transition relative z-[45] flex h-16 items-center justify-between px-4">
|
||
{showTopNav ? (
|
||
<>
|
||
{/* 左分区(flex-1):菜单靠右贴近中间基座 */}
|
||
<div
|
||
className={cn(
|
||
"flex h-full min-w-0 flex-1 items-center",
|
||
isDarkLogoOnLeft ? "justify-between" : "justify-end",
|
||
)}
|
||
>
|
||
{isDarkLogoOnLeft && (
|
||
<div
|
||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-950/55"
|
||
title="系统 Logo"
|
||
>
|
||
<img
|
||
src={brandLogo}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="h-[35px] w-[35px] object-contain"
|
||
onError={handleBrandLogoError}
|
||
/>
|
||
</div>
|
||
)}
|
||
<nav
|
||
className="top-submenu-bar top-submenu-bar--group"
|
||
aria-label="左侧菜单"
|
||
>
|
||
<div className="top-submenu-list">
|
||
{leftMenuItems.map(renderTopItem)}
|
||
</div>
|
||
</nav>
|
||
</div>
|
||
|
||
{/* 中:系统 logo + 名称,落在背景图发光基座上。 */}
|
||
<div className="header-center-title">
|
||
{darkLogoPosition === "center" && (
|
||
<img
|
||
src={brandLogo}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="header-center-title__logo"
|
||
onError={handleBrandLogoError}
|
||
/>
|
||
)}
|
||
<span className="header-center-title__label">
|
||
{desensitizeText("CM业务中心")}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 右分区(flex-1):菜单靠左贴近中间基座,操作区靠最右 */}
|
||
<div className="flex h-full min-w-0 flex-1 items-center justify-between">
|
||
<nav
|
||
className="top-submenu-bar top-submenu-bar--group"
|
||
aria-label="右侧菜单"
|
||
>
|
||
<div className="top-submenu-list">
|
||
{rightMenuItems.map(renderTopItem)}
|
||
</div>
|
||
</nav>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
{actionCluster}
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : showLightTopNav ? (
|
||
<>
|
||
{/* 浅色顶栏:logo + 一级导航横排(白字落在 banner 上) */}
|
||
<div className="flex h-full min-w-0 flex-1 items-center">
|
||
<div className="flex flex-shrink-0 items-center gap-2 pr-1">
|
||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-white/20">
|
||
<img
|
||
src={brandLogo}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="h-[35px] w-[35px] object-contain"
|
||
onError={handleBrandLogoError}
|
||
/>
|
||
</div>
|
||
<span className={cn("whitespace-nowrap text-lg font-semibold", headerTextClass)}>
|
||
{desensitizeText("CM业务中心")}
|
||
</span>
|
||
</div>
|
||
<nav className="light-topnav" aria-label="一级菜单">
|
||
{visibleMenuItems.map(renderLightTopItem)}
|
||
</nav>
|
||
</div>
|
||
|
||
<div className="flex shrink-0 items-center gap-2 pl-2">
|
||
{actionCluster}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="flex h-full min-w-0 flex-1 items-center gap-3">
|
||
<div
|
||
className={cn(
|
||
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",
|
||
isCustomTheme
|
||
? "bg-white/25"
|
||
: isDark
|
||
? "bg-gradient-to-br from-blue-500 to-cyan-400"
|
||
: isPaleLightTheme
|
||
? "bg-white/55"
|
||
: "bg-white/20",
|
||
)}
|
||
>
|
||
<img
|
||
src={brandLogo}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="h-7 w-7 object-contain"
|
||
onError={handleBrandLogoError}
|
||
/>
|
||
</div>
|
||
{!collapsed && (
|
||
<span className={cn("text-sm font-semibold", headerTextClass)}>
|
||
{desensitizeText("CM业务中心")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
{actionCluster}
|
||
</div>
|
||
</>
|
||
)}
|
||
</header>
|
||
{/* 三级菜单横条仅深色用;浅色三级已改到侧边栏右侧列(见 page-sidebar.tsx) */}
|
||
{showTopNav && thirdSubNavItems.length ? (
|
||
<nav className="third-submenu-bar" aria-label="三级菜单">
|
||
<div className="third-submenu-list">
|
||
{thirdSubNavItems.map((item) => {
|
||
const isSelected = item.path || item.aliasPaths?.length
|
||
? menuItemMatchesPath(item, pathname)
|
||
: hasActiveChild(item);
|
||
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className={`third-submenu-item ${isSelected ? "third-submenu-item--active" : ""}`}
|
||
onClick={() => handleNavItemClick(item)}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</nav>
|
||
) : null}
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default Header;
|