release: 1.1.0
TallyNote release / linux-x64 (push) Successful in 6m24s

This commit is contained in:
Qiufeng
2026-08-31 20:11:34 +08:00
parent cbe0d65320
commit 12495fb6a4
66 changed files with 4315 additions and 77 deletions
+104
View File
@@ -0,0 +1,104 @@
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>
);
}