Files
deerflow-code/frontend-web/src/strategy-components/Header.tsx
T
2026-09-06 20:49:35 +08:00

425 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;