686 lines
21 KiB
TypeScript
686 lines
21 KiB
TypeScript
"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
|
||
}
|