Files
labelmain/components/layout/AppLayout.tsx
2026-03-09 10:01:31 +08:00

352 lines
11 KiB
TypeScript

"use client"
import {
AppShell,
Box,
Burger,
Divider,
Flex,
Menu,
NavLink,
ScrollArea,
Space,
Stack,
Tooltip,
UnstyledButton,
useComputedColorScheme,
useMantineColorScheme,
} from "@mantine/core"
import { useDisclosure, useMediaQuery } from "@mantine/hooks"
import {
IconLogout,
IconMessageCircle,
IconMoon,
IconSettings,
IconSun,
IconUser,
} from "@tabler/icons-react"
import cx from "clsx"
import { Suspense, useEffect, useMemo, useState } from "react"
import actionToggleClasses from "./ActionToggle.module.css"
import headerLinkClasses from "./HeaderLink.module.css"
import { useUserStore } from "@/app/store/user"
import { usePathname, useRouter } from "next/navigation"
import { deleteCookie } from "../login/api"
import { MenuItem, withoutLayoutRoutes } from "./common"
import { ClientIcon } from "./components/ClientIcon"
import { LinksGroup } from "./components/NavbarLinks"
import { useAppLayoutStore } from "./store"
export default function AppLayout({
menu,
children,
}: {
menu: MenuItem[]
children: React.ReactNode
}) {
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure()
const { updateIsOpen } = useAppLayoutStore.getState()
const { isOpen } = useAppLayoutStore()
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
const { setColorScheme } = useMantineColorScheme()
const computedColorScheme = useComputedColorScheme("light", {
getInitialValueInEffect: true,
})
const pathname = usePathname()
const withoutLayout = useMemo(
() => withoutLayoutRoutes.includes(pathname),
[pathname]
)
const router = useRouter()
const activeHeaderMenu = useMemo(() => {
return menu?.find((child) => pathname.startsWith(`/${child.url}`))
}, [pathname, menu])
const { menuDefaultRouter } = useAppLayoutStore.getState()
const headItems = menu.map((item) => (
<a
key={item.url}
href={`#`}
className={headerLinkClasses.link}
data-active={activeHeaderMenu?.url === item.url || undefined}
onClick={(e) => {
e.preventDefault()
if (activeHeaderMenu?.url === item.url) return
let toPathname = menuDefaultRouter[`/${item.url}`] || `/${item.url}`
router.push(toPathname)
}}>
<ClientIcon icon={item.icon} style={{ width: "1rem", height: "1rem" }} />
{item.title}
</a>
))
/**
* 递归渲染 MenuItem
*/
const renderMenuItem = (
item: MenuItem,
routerUrl?: string
): React.ReactNode => {
if (item.items && item.items.length > 0) {
// 有子菜单,递归渲染
return (
<Menu.Sub key={item.url}>
<Menu.Sub.Target>
<Menu.Sub.Item>{item.title}</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
{item.items.map((sub) =>
renderMenuItem(sub, `${routerUrl}/${item.url}`)
)}
</Menu.Sub.Dropdown>
</Menu.Sub>
)
} else {
// 没有子菜单,普通 Menu.Item
return (
<Menu.Item key={item.url} p={0}>
<NavLink
noWrap={true}
onClick={() => {
router.push(`/${routerUrl}/${item.url}`)
}}
active={pathname === `/${routerUrl}/${item.url}`}
leftSection={
item.icon && (
<ClientIcon
icon={item.icon}
style={{ width: "1rem", height: "1rem" }}
/>
)
}
label={item.title}></NavLink>
</Menu.Item>
)
}
}
const handleLogout = async () => {
useUserStore.getState().removeUserInfo()
process.env.NEXT_PUBLIC_OUTPUT_TYPE === "standalone" &&
(await deleteCookie())
router.push("/login")
}
const matches = useMediaQuery("(min-width: 48em)")
return isClient ? (
withoutLayout ? (
<Suspense fallback={<></>}>{children}</Suspense>
) : (
<AppShell
header={{ height: 55 }}
navbar={{
width: isOpen ? 200 : 40,
breakpoint: "sm",
collapsed: { mobile: !mobileOpened },
}}>
<AppShell.Header>
<Flex h="100%" px="md" align="center">
<Burger
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
<Flex flex={1} gap={0} h="100%" align="center">
<Box w={184} visibleFrom="sm">
</Box>
<Space w={"1rem"} hiddenFrom="sm" />
{headItems}
</Flex>
<Flex justify="center" align="center" w={"max-content"}>
<UnstyledButton
onClick={() =>
setColorScheme(
computedColorScheme === "light" ? "dark" : "light"
)
}
aria-label="Toggle color scheme">
<IconSun
className={cx(
actionToggleClasses.icon,
actionToggleClasses.light
)}
stroke={1.5}
/>
<IconMoon
className={cx(
actionToggleClasses.icon,
actionToggleClasses.dark
)}
stroke={1.5}
/>
</UnstyledButton>
<Divider orientation="vertical" mx={"0.5rem"} />
<Menu shadow="md" width={200}>
<Menu.Target>
<UnstyledButton>
<IconUser
style={{ display: "block" }}
size={22}
stroke={1.5}
/>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label></Menu.Label>
<Menu.Item leftSection={<IconSettings size={14} />}>
</Menu.Item>
<Menu.Item leftSection={<IconMessageCircle size={14} />}>
</Menu.Item>
<Menu.Divider />
<Menu.Label></Menu.Label>
<Menu.Item
leftSection={<IconLogout size={14} />}
onClick={handleLogout}>
退
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Flex>
</Flex>
</AppShell.Header>
<AppShell.Navbar style={{ transition: "width 0.2s ease" }}>
<Stack justify="start" gap={4} flex={1}>
<ScrollArea h="calc(100vh - 95px)">
{activeHeaderMenu?.items?.map((item) => {
if (item.items?.length) {
if (matches) {
if (isOpen) {
return (
<LinksGroup
key={item.url}
item={item}
toggleMobile={toggleMobile}
routerUrl={activeHeaderMenu.url}
initiallyOpened={true}
/>
)
} else {
return (
<Menu
width={"auto"}
key={item.url + "desktop"}
position="right-start"
trigger="hover"
openDelay={100}
closeDelay={200}>
<Menu.Target>
<NavLink
noWrap={true}
leftSection={
item.icon && (
<ClientIcon
icon={item.icon}
style={{ width: "1rem", height: "1rem" }}
/>
)
}
label={item.title}
/>
</Menu.Target>
<Menu.Dropdown>
{item.items?.map((sub) =>
renderMenuItem(
sub,
`${activeHeaderMenu.url}/${item.url}`
)
)}
</Menu.Dropdown>
</Menu>
)
}
} else {
return (
<Box key={item.url + "mobile"} hiddenFrom="sm">
<LinksGroup
item={item}
toggleMobile={toggleMobile}
routerUrl={activeHeaderMenu.url}
initiallyOpened={true}
/>
</Box>
)
}
} else {
return (
<Tooltip
key={item.url}
events={{
hover: !isOpen && !mobileOpened,
focus: false,
touch: false,
}}
label={item.title || ""}
position="right"
openDelay={100}
closeDelay={200}
transitionProps={{ duration: 100 }}>
<NavLink
// href={`/${activeHeaderMenu.url}/${item.url}`}
onClick={() => {
toggleMobile()
router.push(`/${activeHeaderMenu.url}/${item.url}`)
}}
noWrap={true}
active={pathname.startsWith(
`/${activeHeaderMenu.url}/${item.url}`
)}
leftSection={
<ClientIcon
icon={item.icon}
style={{ width: "1rem", height: "1rem" }}
/>
}
label={item.title}
/>
</Tooltip>
)
}
})}
</ScrollArea>
</Stack>
<Stack justify="center" gap={0}>
<Flex
justify={isOpen ? "flex-end" : "center"}
align="center"
mb={"sm"}>
<Burger
opened={false}
onClick={() => updateIsOpen(!isOpen)}
size="sm"
visibleFrom="sm"
mr={isOpen ? "sm" : undefined}
/>
</Flex>
</Stack>
</AppShell.Navbar>
<AppShell.Main
bg={computedColorScheme === "light" ? "gray.0" : "gray.8"}>
<Suspense fallback={<></>}>{children}</Suspense>
</AppShell.Main>
</AppShell>
)
) : null
}