304 lines
9.7 KiB
TypeScript
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>
|
|
)
|
|
}
|