Files
affiliate_dash/frontend/src/layouts/MainLayout.tsx
T
2026-08-03 16:21:53 +08:00

304 lines
9.7 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import {
Layout,
Dropdown,
Space,
Typography,
Avatar,
} from 'antd'
import {
ApiOutlined,
AppstoreOutlined,
DashboardOutlined,
DownOutlined,
LogoutOutlined,
MenuOutlined,
OrderedListOutlined,
ShopOutlined,
TeamOutlined,
UserOutlined,
WalletOutlined,
} from '@ant-design/icons'
import { useAuth } from '../store/auth'
import type { MenuProps } from 'antd'
const { Header, Sider, Content } = Layout
interface SidebarChild {
key: string
label: string
path?: string
disabled?: boolean
}
interface SidebarSection {
key: string
label: string
icon: ReactNode
path?: string
disabled?: boolean
children?: SidebarChild[]
}
const adminSections: SidebarSection[] = [
{
key: 'dashboard',
label: '工作台',
icon: <DashboardOutlined />,
path: '/',
},
{
key: 'products',
label: '商品管理',
icon: <AppstoreOutlined />,
children: [{ key: 'merchant-products', label: '商品', path: '/merchant-products' }],
},
{
key: 'shops',
label: '商户管理',
icon: <ShopOutlined />,
children: [{ key: 'shop-list', label: '商户列表', path: '/platform-merchants' }],
},
{
key: 'orders',
label: '订单管理',
icon: <OrderedListOutlined />,
children: [{ key: 'fulfillment-orders', label: '发货订单', path: '/merchant-orders' }],
},
{
key: 'staff',
label: '员工管理',
icon: <TeamOutlined />,
children: [{ key: 'merchant-members', label: '成员', path: '/merchant-members' }],
},
{
key: 'funds',
label: '积分',
icon: <WalletOutlined />,
children: [{ key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' }],
},
{
key: 'open-api',
label: '开放 API',
icon: <ApiOutlined />,
children: [
{ key: 'api-keys', label: 'API 密钥', path: '/merchant-api-keys' },
{ key: 'api-callbacks', label: '回调', path: '/merchant-callbacks' },
{ key: 'api-docs', label: '对接文档', path: '/open-api' },
{ key: 'api-debug', label: '调用调试', path: '/api-debug' },
],
},
]
const merchantSections: SidebarSection[] = [
{
key: 'dashboard',
label: '工作台',
icon: <DashboardOutlined />,
path: '/',
},
{
key: 'products',
label: '商品管理',
icon: <AppstoreOutlined />,
children: [{ key: 'merchant-products', label: '商品', path: '/merchant-products' }],
},
{
key: 'orders',
label: '订单管理',
icon: <OrderedListOutlined />,
children: [{ key: 'fulfillment-orders', label: '发货订单', path: '/merchant-orders' }],
},
{
key: 'funds',
label: '积分',
icon: <WalletOutlined />,
children: [{ key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' }],
},
{
key: 'staff',
label: '员工管理',
icon: <TeamOutlined />,
children: [{ key: 'merchant-members', label: '成员', path: '/merchant-members' }],
},
{
key: 'open-api',
label: '开放 API',
icon: <ApiOutlined />,
children: [
{ key: 'api-keys', label: 'API 密钥', path: '/merchant-api-keys' },
{ key: 'api-callbacks', label: '回调', path: '/merchant-callbacks' },
],
},
]
function getSelectedKey(pathname: string, search: string) {
if (pathname === '/') return 'dashboard'
if (pathname.startsWith('/merchant-products')) return 'merchant-products'
if (pathname.startsWith('/merchant-orders')) return 'fulfillment-orders'
if (pathname.startsWith('/merchant-wallet')) return 'merchant-wallet'
if (pathname.startsWith('/merchant-members')) return 'merchant-members'
if (pathname.startsWith('/merchant-callbacks')) return 'api-callbacks'
if (pathname.startsWith('/merchant-api-keys')) return 'api-keys'
if (pathname.startsWith('/merchant-center')) {
const tab = new URLSearchParams(search).get('tab')
const tabKeys: Record<string, string> = {
products: 'merchant-products',
orders: 'fulfillment-orders',
wallet: 'merchant-wallet',
members: 'merchant-members',
callbacks: 'api-callbacks',
}
return tab ? tabKeys[tab] || 'merchant-products' : 'merchant-products'
}
if (pathname.startsWith('/open-api')) return 'api-docs'
if (pathname.startsWith('/api-debug')) return 'api-debug'
if (pathname.startsWith('/platform-merchants')) return 'shop-list'
return 'dashboard'
}
function getInitialOpenKeys(sections: SidebarSection[], selectedKey: string) {
return sections
.filter((section) => section.children?.some((child) => child.key === selectedKey))
.map((section) => section.key)
}
export default function MainLayout() {
const [collapsed, setCollapsed] = useState(false)
const { user, logout, isAdmin } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const isDocsPage = location.pathname.startsWith('/open-api')
const sections = useMemo(() => (isAdmin ? adminSections : merchantSections), [isAdmin])
const selectedKey = getSelectedKey(location.pathname, location.search)
const [openKeys, setOpenKeys] = useState<string[]>(() => getInitialOpenKeys(sections, selectedKey))
useEffect(() => {
const nextOpenKeys = getInitialOpenKeys(sections, selectedKey)
setOpenKeys((current) => Array.from(new Set([...current, ...nextOpenKeys])))
}, [sections, selectedKey])
const toggleSection = (section: SidebarSection) => {
if (section.disabled) return
if (section.path) {
navigate(section.path)
return
}
if (collapsed) {
const firstEnabledChild = section.children?.find((child) => child.path && !child.disabled)
if (firstEnabledChild?.path) navigate(firstEnabledChild.path)
return
}
setOpenKeys((current) =>
current.includes(section.key)
? current.filter((key) => key !== section.key)
: [...current, section.key],
)
}
const handleChildClick = (child: SidebarChild) => {
if (!child.path || child.disabled) return
navigate(child.path)
}
const userMenu: MenuProps['items'] = [
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: () => {
logout()
navigate('/login')
},
},
]
return (
<Layout className="app-shell">
<Sider
className={`app-sidebar${collapsed ? ' app-sidebar--collapsed' : ''}`}
trigger={null}
collapsible
collapsed={collapsed}
width={230}
collapsedWidth={74}
>
<div className="app-sidebar__brand">
<span className="app-sidebar__brand-mark">S</span>
{!collapsed ? <span className="app-sidebar__brand-text">Skin Hub</span> : null}
</div>
<nav className="app-sidebar__nav">
{sections.map((section) => {
const isOpen = openKeys.includes(section.key)
const isSelected = selectedKey === section.key
const hasSelectedChild = section.children?.some((child) => child.key === selectedKey)
return (
<div className="app-sidebar__group" key={section.key}>
<button
className={`app-sidebar__item${isSelected || hasSelectedChild ? ' app-sidebar__item--active' : ''}${section.disabled ? ' app-sidebar__item--disabled' : ''}`}
type="button"
onClick={() => toggleSection(section)}
title={collapsed ? section.label : undefined}
>
<span className="app-sidebar__icon">{section.icon}</span>
{!collapsed ? <span className="app-sidebar__label">{section.label}</span> : null}
{!collapsed && section.children?.length ? (
<DownOutlined className={`app-sidebar__arrow${isOpen ? ' app-sidebar__arrow--open' : ''}`} />
) : null}
</button>
{!collapsed && section.children?.length && isOpen ? (
<div className="app-sidebar__children">
{section.children.map((child) => (
<button
className={`app-sidebar__child${selectedKey === child.key ? ' app-sidebar__child--active' : ''}${child.disabled ? ' app-sidebar__child--disabled' : ''}`}
key={child.key}
type="button"
onClick={() => handleChildClick(child)}
disabled={child.disabled}
>
{child.label}
</button>
))}
</div>
) : null}
</div>
)
})}
</nav>
</Sider>
<Layout className={`app-main${isDocsPage ? ' app-main--flush' : ''}`}>
<Header
className="app-header"
>
<button
className="app-header__collapse"
type="button"
onClick={() => setCollapsed(!collapsed)}
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
title={collapsed ? '展开侧边栏' : '收起侧边栏'}
>
<MenuOutlined />
</button>
<Dropdown menu={{ items: userMenu }}>
<Space style={{ cursor: 'pointer' }}>
<Avatar size="small" icon={<UserOutlined />} />
<Typography.Text>
{user?.nickname || user?.username}
{isAdmin ? '(平台管理员)' : '(商户账号)'}
</Typography.Text>
</Space>
</Dropdown>
</Header>
<Content className="app-content">
<div className="app-content__body">
<Outlet />
</div>
</Content>
</Layout>
</Layout>
)
}