aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src/layouts/BasicLayout.tsx
diff options
context:
space:
mode:
authorshu <nvcsadjkojf@outlook.com>2026-09-06 17:04:49 +0800
committershu <nvcsadjkojf@outlook.com>2026-09-06 17:04:49 +0800
commit2f1057a9d9d51d0e657c1203d3781173e8efc611 (patch)
tree2f73e849ca435ac071fce3a58baa411005bcf5bc /src/layouts/BasicLayout.tsx
初始化 React TypeScript 模板并实现 Mantine 响应式基础布局
- 使用 Vite、React 和 TypeScript 初始化项目,配置 pnpm、Oxlint 与 Mantine PostCSS - 接入 React Router Data Mode,将页面路由、菜单标题和图标统一维护在路由配置中 - 添加首页、关于概览与关于我页面,使用 Nested NavLinks 根据路由生成多级菜单 - 实现桌面侧栏展开和图标模式、移动端全宽抽屉以及与品牌区对齐的顶栏 - 将布局自定义样式拆分为 CSS Modules,统一 xs 间距并固定菜单图标列位置 - 添加文档流页脚,展示自动更新年份的示例版权说明 - 配置 Mantine 默认圆角、项目说明及中文提交约定 验证:pnpm build、pnpm lint、git diff --cached --check 均通过。
Diffstat (limited to 'src/layouts/BasicLayout.tsx')
-rw-r--r--src/layouts/BasicLayout.tsx225
1 files changed, 225 insertions, 0 deletions
diff --git a/src/layouts/BasicLayout.tsx b/src/layouts/BasicLayout.tsx
new file mode 100644
index 0000000..6af8564
--- /dev/null
+++ b/src/layouts/BasicLayout.tsx
@@ -0,0 +1,225 @@
+import { useEffect, useState } from 'react'
+import {
+ AppShell,
+ ActionIcon,
+ Box,
+ Burger,
+ Drawer,
+ Group,
+ NavLink,
+ Stack,
+ Text,
+ Tooltip,
+ rem,
+ useMantineTheme,
+} from '@mantine/core'
+import { useDisclosure, useMediaQuery } from '@mantine/hooks'
+import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
+import { NavLink as RouterNavLink, Outlet, useLocation, useMatches } from 'react-router'
+import { pageRoutes } from '../routes.ts'
+import type { PageRoute } from '../routes.ts'
+import classes from './BasicLayout.module.css'
+
+const collapsedNavbarWidth = rem(64)
+
+type NavigationProps = {
+ onNavigate?: () => void
+ collapsed?: boolean
+ routes?: PageRoute[]
+ parentPath?: string
+}
+
+function NavigationItem({
+ route,
+ parentPath,
+ collapsed,
+ onNavigate,
+}: {
+ route: PageRoute
+ parentPath: string
+ collapsed: boolean
+ onNavigate?: () => void
+}) {
+ const matches = useMatches()
+ const active = matches.some((match) => match.id === route.id)
+ const selected = matches.at(-1)?.id === route.id
+ const [opened, setOpened] = useState(active)
+
+ if (!route.handle) return null
+
+ const { title: label, icon: Icon } = route.handle
+ const to = !route.path
+ ? parentPath
+ : route.path.startsWith('/')
+ ? route.path
+ : `${parentPath.replace(/\/$/, '')}/${route.path}`
+ const hasChildren = route.children?.some((child) => child.handle)
+ const sharedProps = {
+ label,
+ noWrap: true,
+ p: 'xs',
+ 'aria-label': label,
+ leftSection: <Icon size={20} aria-hidden="true" />,
+ active,
+ 'data-collapsed': collapsed || undefined,
+ classNames: {
+ root: classes.menuItem,
+ section: classes.icon,
+ body: classes.label,
+ },
+ }
+
+ return (
+ <Tooltip label={label} position="right" disabled={!collapsed}
+ events={{ hover: true, focus: true, touch: false }}>
+ {hasChildren ? (
+ <NavLink
+ {...sharedProps}
+ component="button"
+ variant={selected ? 'light' : 'subtle'}
+ type="button"
+ opened={opened}
+ onChange={setOpened}
+ childrenOffset={collapsed ? 0 : 'xs'}
+ >
+ <Navigation
+ routes={route.children}
+ parentPath={to}
+ collapsed={collapsed}
+ onNavigate={onNavigate}
+ />
+ </NavLink>
+ ) : (
+ <NavLink
+ {...sharedProps}
+ component={RouterNavLink}
+ to={to}
+ end
+ onClick={onNavigate}
+ />
+ )}
+ </Tooltip>
+ )
+}
+
+function Navigation({
+ onNavigate,
+ collapsed = false,
+ routes = pageRoutes,
+ parentPath = '/',
+}: NavigationProps) {
+ const { key } = useLocation()
+
+ return (
+ <Stack gap="xs" style={{ '--collapsed-navigation-width': collapsedNavbarWidth }}>
+ {routes.map((route) => (
+ <NavigationItem
+ key={`${route.id}:${key}`}
+ route={route}
+ parentPath={parentPath}
+ collapsed={collapsed}
+ onNavigate={onNavigate}
+ />
+ ))}
+ </Stack>
+ )
+}
+
+export function BasicLayout() {
+ const theme = useMantineTheme()
+ const desktop = useMediaQuery(`(min-width: ${theme.breakpoints.sm})`)
+ const [opened, { toggle, close }] = useDisclosure(false)
+ const [sidebarOpened, { toggle: toggleSidebar }] = useDisclosure(true)
+ const { key } = useLocation()
+ const matches = useMatches()
+ const title = matches
+ .map(({ handle }) => (
+ handle && typeof handle === 'object' && 'title' in handle && typeof handle.title === 'string'
+ ? handle.title
+ : undefined
+ ))
+ .findLast((value) => value !== undefined) ?? 'Template React'
+
+ useEffect(() => {
+ close()
+ }, [key, close])
+
+ useEffect(() => {
+ if (desktop) close()
+ }, [desktop, close])
+
+ return (
+ <AppShell
+ layout="alt"
+ header={{ height: 60 }}
+ navbar={{
+ width: sidebarOpened ? 240 : collapsedNavbarWidth,
+ breakpoint: 'sm',
+ collapsed: { mobile: true },
+ }}
+ >
+ <AppShell.Navbar id="desktop-navigation" aria-label="主导航">
+ <Text fw={700} size="lg" px="xs" className={classes.brand}
+ data-collapsed={!sidebarOpened || undefined} aria-label="Template React">
+ {sidebarOpened ? 'Template React' : 'TR'}
+ </Text>
+ <Box p="xs">
+ <Navigation collapsed={!sidebarOpened} />
+ </Box>
+ </AppShell.Navbar>
+
+ <AppShell.Header>
+ <Group h="100%" px="md">
+ <ActionIcon
+ onClick={toggleSidebar}
+ visibleFrom="sm"
+ size="lg"
+ variant="subtle"
+ color="gray"
+ aria-label={sidebarOpened ? '折叠菜单' : '展开菜单'}
+ aria-expanded={sidebarOpened}
+ aria-controls="desktop-navigation"
+ >
+ {sidebarOpened
+ ? <CaretLeftIcon size={20} aria-hidden="true" />
+ : <CaretRightIcon size={20} aria-hidden="true" />}
+ </ActionIcon>
+ <Burger
+ opened={opened}
+ onClick={toggle}
+ hiddenFrom="sm"
+ size="sm"
+ aria-label={opened ? '关闭菜单' : '打开菜单'}
+ aria-expanded={opened}
+ aria-controls="mobile-navigation"
+ />
+ <Text fw={600}>{title}</Text>
+ </Group>
+ </AppShell.Header>
+
+ <AppShell.Main component="div" className={classes.main}>
+ <Box component="main" p="md" className={classes.content}>
+ <Outlet />
+ </Box>
+ <Box component="footer" px="md" py="xs" className={classes.footer}>
+ <Text size="xs" c="dimmed" ta="center">
+ © {new Date().getFullYear()} Template React. 仅供示例使用。
+ </Text>
+ </Box>
+ </AppShell.Main>
+
+ <Drawer
+ opened={opened && !desktop}
+ onClose={close}
+ title="Template React"
+ position="left"
+ size="100%"
+ closeButtonProps={{ 'aria-label': '关闭菜单' }}
+ >
+ <nav id="mobile-navigation" aria-label="移动端主导航">
+ <Navigation onNavigate={close} />
+ </nav>
+ </Drawer>
+ </AppShell>
+ )
+}