Files
labelmain-demo/components/layout/AppLayout.tsx
2026-05-25 13:42:38 +08:00

686 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import {
Button,
AppShell,
Box,
Burger,
Divider,
Flex,
Group,
Modal,
Menu,
NavLink,
PasswordInput,
ScrollArea,
Space,
Stack,
Text,
Tooltip,
UnstyledButton,
useComputedColorScheme,
useMantineColorScheme,
} from "@mantine/core"
import Avvvatars from "avvvatars-react"
import { useDisclosure, useMediaQuery } from "@mantine/hooks"
import { useForm } from "@mantine/form"
import { notifications } from "@mantine/notifications"
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconLock,
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 { userModifyPassword } from "../label/api/user"
import { deleteCookie } from "../login/api"
import {
settingModalActionsClassName,
settingModalClassNames,
settingModalFormClassName,
settingModalMetaTextClassName,
} from "../setting/PageSurface"
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 [
passwordModalOpened,
{ open: openPasswordModal, close: closePasswordModal },
] = useDisclosure(false)
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 passwordForm = useForm({
initialValues: {
old_password: "",
new_password: "",
confirm_new_password: "",
},
validate: {
old_password: (value) => (value ? null : "请输入当前密码"),
new_password: (value) => (value ? null : "请输入新密码"),
confirm_new_password: (value) => (value ? null : "请再次确认新密码"),
},
validateInputOnChange: true,
validateInputOnBlur: true,
clearInputErrorOnChange: true,
})
const [passwordSubmitting, setPasswordSubmitting] = useState(false)
const handleOpenPasswordModal = () => {
passwordForm.reset()
openPasswordModal()
}
const handleClosePasswordModal = () => {
passwordForm.reset()
closePasswordModal()
}
const handlePasswordSubmit = passwordForm.onSubmit(async (values) => {
if (values.new_password !== values.confirm_new_password) {
passwordForm.setFieldError(
"confirm_new_password",
"两次输入的新密码不一致"
)
return
}
setPasswordSubmitting(true)
try {
await userModifyPassword({
old_password: values.old_password,
new_password: values.new_password,
})
handleClosePasswordModal()
notifications.show({
color: "green",
title: "修改成功",
message: "密码已更新",
})
} catch (error) {
notifications.show({
color: "red",
title: "修改失败",
message:
error instanceof Error ? error.message : "密码修改失败,请重试",
})
} finally {
setPasswordSubmitting(false)
}
})
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={<IconLock size={14} />}
onClick={handleOpenPasswordModal}>
</Menu.Item>
<Menu.Item
leftSection={<IconLogout size={14} />}
onClick={handleLogout}>
退
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Modal
opened={passwordModalOpened}
onClose={handleClosePasswordModal}
title="修改密码"
centered
size={440}
classNames={settingModalClassNames}>
<form onSubmit={handlePasswordSubmit}>
<Stack gap={12} className={settingModalFormClassName}>
<Text className={settingModalMetaTextClassName}>
</Text>
<PasswordInput
withAsterisk
data-autofocus
label="当前密码"
placeholder="请输入当前密码"
{...passwordForm.getInputProps("old_password")}
/>
<PasswordInput
withAsterisk
label="新密码"
placeholder="请输入新密码"
{...passwordForm.getInputProps("new_password")}
/>
<PasswordInput
withAsterisk
label="确认新密码"
placeholder="请再次输入新密码"
{...passwordForm.getInputProps("confirm_new_password")}
/>
<Group
justify="flex-end"
gap={12}
className={settingModalActionsClassName}>
<Button
variant="default"
onClick={handleClosePasswordModal}
type="button">
</Button>
<Button type="submit" loading={passwordSubmitting}>
</Button>
</Group>
</Stack>
</form>
</Modal>
</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
}