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 { 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 };
+15
View File
@@ -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>
);
}
+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>
);
}
+46
View File
@@ -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>
);
}
+8
View File
@@ -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";