105 lines
4.6 KiB
TypeScript
105 lines
4.6 KiB
TypeScript
import { ChevronDown, KeyRound, LogOut, Menu as MenuIcon } from "lucide-react";
|
|
import { useEffect, useRef, useState, type KeyboardEvent, type Ref } from "react";
|
|
import { Avatar, Button, Dropdown, Layout, Space, Tooltip, type DropdownOption } from "tdesign-react";
|
|
|
|
export interface AppHeaderProps {
|
|
adminName?: string;
|
|
adminUsername?: string;
|
|
onToggleMenu?: () => void;
|
|
menuButtonRef?: Ref<HTMLButtonElement>;
|
|
menuOpen?: boolean;
|
|
menuControls?: string;
|
|
onLogout?: () => void;
|
|
onOpenPassword?: () => void;
|
|
}
|
|
|
|
export default function AppHeader({ adminName, adminUsername, onToggleMenu, menuButtonRef, menuOpen = false, menuControls, onLogout, onOpenPassword }: AppHeaderProps) {
|
|
const displayName = adminName?.trim() || "管理员";
|
|
const avatarText = [...displayName][0]?.toUpperCase() || "管";
|
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
|
const userMenuButtonRef = useRef<HTMLButtonElement>(null);
|
|
const menuItem = (label: string) => <button type="button" role="menuitem" className="tn-user-dropdown-button" onKeyDown={handleMenuKeyDown}>{label}</button>;
|
|
const userOptions: DropdownOption[] = [
|
|
...(onOpenPassword ? [{ value: "password", content: menuItem("修改密码"), prefixIcon: <KeyRound size={16} /> }] : []),
|
|
...(onLogout ? [{ value: "logout", content: menuItem("退出登录"), theme: "error" as const, prefixIcon: <LogOut size={16} /> }] : []),
|
|
];
|
|
|
|
useEffect(() => {
|
|
if (!userMenuOpen) return;
|
|
let disposed = false;
|
|
const focusMenu = () => {
|
|
if (disposed) return;
|
|
const menu = document.querySelector<HTMLElement>(".tn-user-dropdown");
|
|
if (!menu) return;
|
|
menu.setAttribute("role", "menu");
|
|
menu.setAttribute("aria-label", "账号操作");
|
|
menu.querySelector<HTMLElement>("[role='menuitem']")?.focus();
|
|
};
|
|
const frame = window.requestAnimationFrame(focusMenu);
|
|
const timer = window.setTimeout(focusMenu, 80);
|
|
return () => { disposed = true; window.cancelAnimationFrame(frame); window.clearTimeout(timer); };
|
|
}, [userMenuOpen]);
|
|
|
|
function handleMenuKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
|
|
const items = Array.from(document.querySelectorAll<HTMLButtonElement>(".tn-user-dropdown [role='menuitem']"));
|
|
const index = items.indexOf(event.currentTarget);
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
userMenuButtonRef.current?.click();
|
|
userMenuButtonRef.current?.focus();
|
|
return;
|
|
}
|
|
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key) || !items.length) return;
|
|
event.preventDefault();
|
|
const nextIndex = event.key === "Home" ? 0
|
|
: event.key === "End" ? items.length - 1
|
|
: event.key === "ArrowDown" ? (index + 1) % items.length
|
|
: (index - 1 + items.length) % items.length;
|
|
items[nextIndex]?.focus();
|
|
}
|
|
|
|
const handleUserAction = (item: DropdownOption) => {
|
|
if (item.value === "password") onOpenPassword?.();
|
|
if (item.value === "logout") onLogout?.();
|
|
};
|
|
|
|
return (
|
|
<Layout.Header className="tn-header">
|
|
<Space className="tn-header-leading" align="center" size="small">
|
|
<Tooltip content="导航菜单" placement="bottom">
|
|
<Button
|
|
ref={menuButtonRef}
|
|
className="tn-header-menu-toggle"
|
|
shape="square"
|
|
size="large"
|
|
variant="text"
|
|
aria-label="导航菜单"
|
|
aria-expanded={menuOpen}
|
|
aria-controls={menuControls}
|
|
onClick={onToggleMenu}
|
|
icon={<MenuIcon size={19} />}
|
|
/>
|
|
</Tooltip>
|
|
<span className="tn-header-nav-label">主导航</span>
|
|
</Space>
|
|
<Space className="tn-header-actions" align="center" size="small">
|
|
{userOptions.length > 0 && <Dropdown
|
|
trigger="click"
|
|
placement="bottom-right"
|
|
options={userOptions}
|
|
minColumnWidth={184}
|
|
maxColumnWidth={240}
|
|
onClick={handleUserAction}
|
|
panelTopContent={<div className="tn-user-dropdown-identity"><strong>{displayName}</strong>{adminUsername && <span>{adminUsername}</span>}</div>}
|
|
popupProps={{ overlayInnerClassName: "tn-user-dropdown", onVisibleChange: setUserMenuOpen }}
|
|
>
|
|
<Button ref={userMenuButtonRef} className="tn-user-menu-trigger" variant="text" size="large" aria-label={`${displayName},打开账号菜单`} aria-haspopup="menu" aria-expanded={userMenuOpen} suffix={<ChevronDown size={16} />}>
|
|
<Avatar className="tn-user-avatar" size="32px" shape="circle">{avatarText}</Avatar>
|
|
<span className="tn-header-user">{displayName}</span>
|
|
</Button>
|
|
</Dropdown>}
|
|
</Space>
|
|
</Layout.Header>
|
|
);
|
|
}
|