This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Button, Drawer, Layout } from "tdesign-react";
|
||||
import { X } from "lucide-react";
|
||||
import { DEFAULT_ROUTE_ID, type RouteId } from "../router";
|
||||
import AppContent from "./Content";
|
||||
import AppHeader from "./Header";
|
||||
import AppMenu from "./Menu";
|
||||
import { useDrawerFocus } from "../hooks/useDrawerFocus";
|
||||
|
||||
export interface AppLayoutProps {
|
||||
activeId?: RouteId;
|
||||
adminName?: string;
|
||||
adminUsername?: string;
|
||||
children: ReactNode;
|
||||
onNavigate: (id: RouteId) => void;
|
||||
onLogout?: () => void;
|
||||
onOpenPassword?: () => void;
|
||||
}
|
||||
|
||||
export default function AppLayout({ activeId = DEFAULT_ROUTE_ID, adminName, adminUsername, children, onNavigate, onLogout, onOpenPassword }: AppLayoutProps) {
|
||||
const [desktopVisible, setDesktopVisible] = useState(true);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [mobileVisible, setMobileVisible] = useState(false);
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
useDrawerFocus(mobileVisible, ".tn-mobile-drawer");
|
||||
|
||||
useEffect(() => {
|
||||
const media = typeof window.matchMedia === "function" ? window.matchMedia("(max-width: 900px)") : null;
|
||||
const sync = () => {
|
||||
const mobile = media?.matches ?? window.innerWidth <= 900;
|
||||
setIsMobile(mobile);
|
||||
if (!mobile) setMobileVisible(false);
|
||||
};
|
||||
sync();
|
||||
if (!media) return;
|
||||
if (typeof media.addEventListener === "function") {
|
||||
media.addEventListener("change", sync);
|
||||
return () => media.removeEventListener("change", sync);
|
||||
}
|
||||
media.addListener?.(sync);
|
||||
return () => media.removeListener?.(sync);
|
||||
}, []);
|
||||
|
||||
const restoreMenuFocus = () => {
|
||||
window.requestAnimationFrame(() => menuButtonRef.current?.focus());
|
||||
};
|
||||
|
||||
const closeMobileMenu = () => {
|
||||
setMobileVisible(false);
|
||||
restoreMenuFocus();
|
||||
};
|
||||
|
||||
const choose = (id: RouteId) => {
|
||||
onNavigate(id);
|
||||
closeMobileMenu();
|
||||
};
|
||||
|
||||
const toggleMenu = () => {
|
||||
if (isMobile) {
|
||||
setMobileVisible(value => !value);
|
||||
return;
|
||||
}
|
||||
setMobileVisible(false);
|
||||
setDesktopVisible(value => !value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout className="tn-shell tn-layout">
|
||||
<nav id="tn-desktop-navigation" className={`tn-desktop-menu ${desktopVisible ? "" : "is-hidden"}`} aria-label="主导航" aria-hidden={!desktopVisible || undefined} inert={!desktopVisible || undefined}>
|
||||
<AppMenu activeId={activeId} onChange={choose} />
|
||||
</nav>
|
||||
<Layout className="tn-layout-main">
|
||||
<AppHeader
|
||||
adminName={adminName}
|
||||
adminUsername={adminUsername}
|
||||
menuButtonRef={menuButtonRef}
|
||||
menuOpen={isMobile ? mobileVisible : desktopVisible}
|
||||
menuControls={isMobile ? "tn-mobile-navigation" : "tn-desktop-navigation"}
|
||||
onToggleMenu={toggleMenu}
|
||||
onLogout={onLogout}
|
||||
onOpenPassword={onOpenPassword}
|
||||
/>
|
||||
<AppContent>{children}</AppContent>
|
||||
</Layout>
|
||||
<Drawer
|
||||
className="tn-mobile-drawer"
|
||||
visible={mobileVisible}
|
||||
placement="left"
|
||||
size="216px"
|
||||
closeBtn={false}
|
||||
header={<div className="tn-mobile-drawer-header"><span>主导航</span><Button variant="text" shape="square" aria-label="关闭导航菜单" onClick={closeMobileMenu} icon={<X size={17} />} /></div>}
|
||||
footer={false}
|
||||
destroyOnClose
|
||||
onClose={closeMobileMenu}
|
||||
>
|
||||
<nav id="tn-mobile-navigation" className="tn-mobile-menu-shell" aria-label="主导航">
|
||||
<AppMenu activeId={activeId} onChange={choose} width={["216px", "64px"]} />
|
||||
</nav>
|
||||
</Drawer>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export { AppContent, AppHeader, AppMenu };
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Layout } from "tdesign-react";
|
||||
|
||||
export interface AppContentProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AppContent({ children, className }: AppContentProps) {
|
||||
return (
|
||||
<Layout.Content className={["tn-content", className].filter(Boolean).join(" ")}>
|
||||
<div className="tn-content-inner">{children}</div>
|
||||
</Layout.Content>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Menu as TMenu } from "tdesign-react";
|
||||
import type { MenuValue } from "tdesign-react";
|
||||
import { APP_ROUTES, routePath, type RouteId } from "../router";
|
||||
|
||||
export interface AppMenuProps {
|
||||
activeId: RouteId;
|
||||
collapsed?: boolean;
|
||||
onChange: (id: RouteId) => void;
|
||||
className?: string;
|
||||
showLogo?: boolean;
|
||||
width?: [string, string];
|
||||
}
|
||||
|
||||
const { MenuItem } = TMenu;
|
||||
|
||||
export default function AppMenu({ activeId, collapsed = false, onChange, className, showLogo = true, width = ["232px", "64px"] }: AppMenuProps) {
|
||||
const handleChange = (value: MenuValue) => {
|
||||
if (typeof value === "string") onChange(value as RouteId);
|
||||
};
|
||||
|
||||
return (
|
||||
<TMenu
|
||||
className={className}
|
||||
value={activeId}
|
||||
collapsed={collapsed}
|
||||
width={width}
|
||||
theme="light"
|
||||
logo={showLogo ? <MenuLogo collapsed={collapsed} /> : undefined}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{APP_ROUTES.map(({ id, title, icon: Icon }) => (
|
||||
<MenuItem key={id} value={id} href={routePath(id)} onClick={({ e }) => e.preventDefault()} icon={<Icon className="t-icon tn-menu-icon" size={18} strokeWidth={1.8} />}>
|
||||
{title}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuLogo({ collapsed }: { collapsed: boolean }) {
|
||||
return (
|
||||
<div className="tn-menu-logo" aria-label="TallyNote">
|
||||
<span className={collapsed ? "tn-logo-short" : "tn-logo-full"}>{collapsed ? "TN" : "TallyNote"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default as AppLayout } from "./AppLayout";
|
||||
export { default as AppContent } from "./Content";
|
||||
export { default as AppHeader } from "./Header";
|
||||
export { default as AppMenu } from "./Menu";
|
||||
export type { AppLayoutProps } from "./AppLayout";
|
||||
export type { AppContentProps } from "./Content";
|
||||
export type { AppHeaderProps } from "./Header";
|
||||
export type { AppMenuProps } from "./Menu";
|
||||
Reference in New Issue
Block a user