354 lines
11 KiB
TypeScript
354 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 { usePermissionStore } from "../label/store/auth"
|
|
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()
|
|
usePermissionStore.getState().logout()
|
|
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
|
|
}
|