554 lines
17 KiB
TypeScript
554 lines
17 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
AppShell,
|
|
Box,
|
|
Burger,
|
|
Divider,
|
|
Flex,
|
|
Menu,
|
|
NavLink,
|
|
ScrollArea,
|
|
Space,
|
|
Stack,
|
|
Text,
|
|
Tooltip,
|
|
UnstyledButton,
|
|
useComputedColorScheme,
|
|
useMantineColorScheme,
|
|
} from "@mantine/core"
|
|
import Avvvatars from "avvvatars-react"
|
|
|
|
import { useDisclosure, useMediaQuery } from "@mantine/hooks"
|
|
import {
|
|
IconChevronDown,
|
|
IconChevronLeft,
|
|
IconChevronRight,
|
|
IconLogout,
|
|
IconMoon,
|
|
IconSun,
|
|
} 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 classes from "./AppLayout.module.css"
|
|
import { MenuItem, withoutLayoutRoutes } from "./common"
|
|
import { ClientIcon } from "./components/ClientIcon"
|
|
import { LinksGroup } from "./components/NavbarLinks"
|
|
import { getShellNavLinkStyles } from "./navStyles"
|
|
import { useAppLayoutStore } from "./store"
|
|
|
|
const COMPOUND_SURNAMES = new Set([
|
|
"欧阳",
|
|
"太史",
|
|
"端木",
|
|
"上官",
|
|
"司马",
|
|
"东方",
|
|
"独孤",
|
|
"南宫",
|
|
"万俟",
|
|
"闻人",
|
|
"夏侯",
|
|
"诸葛",
|
|
"尉迟",
|
|
"公羊",
|
|
"赫连",
|
|
"澹台",
|
|
"皇甫",
|
|
"宗政",
|
|
"濮阳",
|
|
"公冶",
|
|
"太叔",
|
|
"申屠",
|
|
"公孙",
|
|
"慕容",
|
|
"仲孙",
|
|
"钟离",
|
|
"长孙",
|
|
"宇文",
|
|
"司徒",
|
|
"鲜于",
|
|
"司空",
|
|
"闾丘",
|
|
"子车",
|
|
"亓官",
|
|
"司寇",
|
|
"巫马",
|
|
"公西",
|
|
"颛孙",
|
|
"壤驷",
|
|
"公良",
|
|
"漆雕",
|
|
"乐正",
|
|
"宰父",
|
|
"谷梁",
|
|
"拓跋",
|
|
"轩辕",
|
|
"令狐",
|
|
"段干",
|
|
"百里",
|
|
"呼延",
|
|
"东郭",
|
|
"南门",
|
|
"羊舌",
|
|
"微生",
|
|
"梁丘",
|
|
"左丘",
|
|
"东门",
|
|
"西门",
|
|
"南荣",
|
|
"第五",
|
|
])
|
|
|
|
function isChineseName(value: string) {
|
|
return /[\u4e00-\u9fff]/.test(value)
|
|
}
|
|
|
|
function getAvatarDisplayValue(rawValue?: string | null) {
|
|
const value = rawValue?.trim() ?? ""
|
|
|
|
if (!value) return "-"
|
|
|
|
if (isChineseName(value)) {
|
|
const normalized = value.replace(/[\s·•・・]/g, "")
|
|
if (normalized.length <= 1) return normalized
|
|
|
|
const surnameLength =
|
|
normalized.length >= 3 && COMPOUND_SURNAMES.has(normalized.slice(0, 2))
|
|
? 2
|
|
: 1
|
|
const givenName = normalized.slice(surnameLength)
|
|
|
|
if (!givenName) return normalized.slice(-1)
|
|
if (givenName.length <= 2) return givenName
|
|
return givenName.slice(-2)
|
|
}
|
|
|
|
const tokens = value.split(/[\s-_]+/).filter(Boolean)
|
|
if (tokens.length >= 2) {
|
|
return `${tokens[0][0] ?? ""}${tokens[tokens.length - 1][0] ?? ""}`
|
|
.trim()
|
|
.toUpperCase()
|
|
}
|
|
|
|
return value.slice(0, 2).toUpperCase()
|
|
}
|
|
|
|
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}`
|
|
window.location.href = toPathname
|
|
// router.replace(toPathname)
|
|
}}>
|
|
<ClientIcon icon={item.icon} style={{ width: 14, height: 14 }} />
|
|
{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")
|
|
window.location.reload()
|
|
}
|
|
const matches = useMediaQuery("(min-width: 48em)")
|
|
const userName = usePermissionStore((s) => s.user_name)
|
|
const detailInfo = usePermissionStore((s) => s.detailInfo)
|
|
const avatarDisplayValue = useMemo(
|
|
() => getAvatarDisplayValue(userName),
|
|
[userName]
|
|
)
|
|
|
|
return isClient ? (
|
|
withoutLayout ? (
|
|
<Suspense fallback={<></>}>{children}</Suspense>
|
|
) : (
|
|
<AppShell
|
|
header={{ height: 60 }}
|
|
navbar={{
|
|
width: isOpen ? 196 : 48,
|
|
breakpoint: "sm",
|
|
collapsed: { mobile: !mobileOpened },
|
|
}}>
|
|
<AppShell.Header className={classes.header}>
|
|
<Flex className={classes.headerInner} align="center">
|
|
<Burger
|
|
opened={mobileOpened}
|
|
onClick={toggleMobile}
|
|
hiddenFrom="sm"
|
|
size="sm"
|
|
/>
|
|
<Flex className={classes.headerNav}>
|
|
<Flex
|
|
align="center"
|
|
gap={10}
|
|
className={classes.brand}
|
|
visibleFrom="sm">
|
|
<Box
|
|
component="img"
|
|
src={`${process.env.NEXT_PUBLIC_BASE_PATH}/header.svg`}
|
|
alt="酷哇标注平台"
|
|
w={32}
|
|
h={32}
|
|
style={{
|
|
flexShrink: 0,
|
|
display: "block",
|
|
}}
|
|
/>
|
|
<Box
|
|
component="span"
|
|
style={{
|
|
color: "#0B1E43",
|
|
fontSize: "20px",
|
|
lineHeight: "28px",
|
|
letterSpacing: 0,
|
|
fontWeight: 600,
|
|
fontStyle: "normal",
|
|
whiteSpace: "nowrap",
|
|
fontFamily: '"PingFang SC", "Microsoft YaHei", sans-serif',
|
|
}}>
|
|
酷哇标注平台
|
|
</Box>
|
|
</Flex>
|
|
<Space w={"1rem"} hiddenFrom="sm" />
|
|
{headItems}
|
|
</Flex>
|
|
<Flex
|
|
className={classes.headerUser}
|
|
justify="center"
|
|
w={"max-content"}>
|
|
<UnstyledButton
|
|
display="none"
|
|
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"} display="none" />
|
|
<Menu shadow="md" width={220}>
|
|
<Menu.Target>
|
|
<Flex
|
|
align="center"
|
|
gap={12}
|
|
style={{
|
|
cursor: "pointer",
|
|
}}>
|
|
<Avvvatars
|
|
value={userName || "-"}
|
|
displayValue={avatarDisplayValue}
|
|
size={32}
|
|
/>
|
|
<Stack gap={1}>
|
|
<Text className={classes.userName}>{userName}</Text>
|
|
<Text className={classes.userBaseCity}>
|
|
{detailInfo?.base_city || "-"}
|
|
</Text>
|
|
</Stack>
|
|
<IconChevronDown
|
|
className={classes.userBaseCity}
|
|
size={12}
|
|
/>
|
|
</Flex>
|
|
</Menu.Target>
|
|
<Menu.Dropdown>
|
|
<Menu.Label>用户信息</Menu.Label>
|
|
<Box px="sm" py={4}>
|
|
<Text size="sm" fw={500} style={{ wordBreak: "break-all" }}>
|
|
{userName || "-"}
|
|
</Text>
|
|
</Box>
|
|
<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 className={classes.navbar}>
|
|
<Stack justify="start" gap={4} flex={1}>
|
|
<ScrollArea className={classes.navbarScroll}>
|
|
{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 {
|
|
const isGroupActive = pathname.startsWith(
|
|
`/${activeHeaderMenu.url}/${item.url}`
|
|
)
|
|
|
|
return (
|
|
<Menu
|
|
width={"auto"}
|
|
key={item.url + "desktop"}
|
|
position="right-start"
|
|
trigger="hover"
|
|
openDelay={100}
|
|
closeDelay={200}>
|
|
<Menu.Target>
|
|
<Tooltip
|
|
label={item.title || ""}
|
|
position="right"
|
|
openDelay={100}
|
|
closeDelay={200}
|
|
transitionProps={{ duration: 100 }}>
|
|
<UnstyledButton
|
|
className={cx(classes.navbarIconLink, {
|
|
[classes.navbarIconLinkActive]: isGroupActive,
|
|
})}
|
|
aria-label={item.title}>
|
|
{item.icon ? (
|
|
<ClientIcon
|
|
icon={item.icon}
|
|
style={{ width: 14, height: 14 }}
|
|
/>
|
|
) : null}
|
|
</UnstyledButton>
|
|
</Tooltip>
|
|
</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 {
|
|
const isActive = pathname.startsWith(
|
|
`/${activeHeaderMenu.url}/${item.url}`
|
|
)
|
|
|
|
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 }}>
|
|
{isOpen ? (
|
|
<NavLink
|
|
// href={`/${activeHeaderMenu.url}/${item.url}`}
|
|
onClick={() => {
|
|
toggleMobile()
|
|
router.push(`/${activeHeaderMenu.url}/${item.url}`)
|
|
}}
|
|
noWrap={true}
|
|
active={isActive}
|
|
leftSection={
|
|
<ClientIcon
|
|
icon={item.icon}
|
|
style={{ width: 14, height: 14 }}
|
|
/>
|
|
}
|
|
label={item.title}
|
|
styles={getShellNavLinkStyles(isActive)}
|
|
/>
|
|
) : (
|
|
<UnstyledButton
|
|
className={cx(classes.navbarIconLink, {
|
|
[classes.navbarIconLinkActive]: isActive,
|
|
})}
|
|
aria-label={item.title}
|
|
onClick={() => {
|
|
toggleMobile()
|
|
router.push(`/${activeHeaderMenu.url}/${item.url}`)
|
|
}}>
|
|
<ClientIcon
|
|
icon={item.icon}
|
|
style={{ width: 14, height: 14 }}
|
|
/>
|
|
</UnstyledButton>
|
|
)}
|
|
</Tooltip>
|
|
)
|
|
}
|
|
})}
|
|
</ScrollArea>
|
|
</Stack>
|
|
<Stack justify="center" gap={0} className={classes.navbarFooter}>
|
|
<Flex justify={isOpen ? "flex-end" : "center"} align="center">
|
|
<Box visibleFrom="sm">
|
|
<Tooltip
|
|
events={{
|
|
hover: !isOpen,
|
|
focus: false,
|
|
touch: false,
|
|
}}
|
|
label={isOpen ? "收起菜单" : "展开菜单"}
|
|
position="right"
|
|
openDelay={100}
|
|
closeDelay={200}
|
|
transitionProps={{ duration: 100 }}>
|
|
<UnstyledButton
|
|
className={classes.navbarToggleIcon}
|
|
aria-label={isOpen ? "收起菜单" : "展开菜单"}
|
|
onClick={() => updateIsOpen(!isOpen)}>
|
|
{isOpen ? (
|
|
<IconChevronLeft size={18} />
|
|
) : (
|
|
<IconChevronRight size={18} />
|
|
)}
|
|
</UnstyledButton>
|
|
</Tooltip>
|
|
</Box>
|
|
</Flex>
|
|
</Stack>
|
|
</AppShell.Navbar>
|
|
<AppShell.Main className={classes.main}>
|
|
<Suspense fallback={<></>}>{children}</Suspense>
|
|
</AppShell.Main>
|
|
</AppShell>
|
|
)
|
|
) : null
|
|
}
|