363 lines
12 KiB
TypeScript
363 lines
12 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,
|
|
Tag,
|
|
Button,
|
|
Tooltip,
|
|
} from 'antd'
|
|
import {
|
|
ApiOutlined,
|
|
AppstoreOutlined,
|
|
CodeOutlined,
|
|
DashboardOutlined,
|
|
DownOutlined,
|
|
FileTextOutlined,
|
|
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: 'user-info',
|
|
disabled: true,
|
|
label: (
|
|
<div style={{ padding: '4px 0' }}>
|
|
<div style={{ fontWeight: 600, color: '#0f172a' }}>{user?.nickname || user?.username}</div>
|
|
<div style={{ fontSize: 12, color: '#64748b' }}>角色:{isAdmin ? '平台超级管理员' : '商户成员'}</div>
|
|
</div>
|
|
),
|
|
},
|
|
{ type: 'divider' },
|
|
{
|
|
key: 'logout',
|
|
icon: <LogoutOutlined style={{ color: '#dc2626' }} />,
|
|
label: <span style={{ color: '#dc2626' }}>退出登录</span>,
|
|
onClick: () => {
|
|
logout()
|
|
navigate('/login')
|
|
},
|
|
},
|
|
]
|
|
|
|
return (
|
|
<Layout className="app-shell">
|
|
<Sider
|
|
className={`app-sidebar${collapsed ? ' app-sidebar--collapsed' : ''}`}
|
|
trigger={null}
|
|
collapsible
|
|
collapsed={collapsed}
|
|
width={240}
|
|
collapsedWidth={74}
|
|
style={{
|
|
position: 'sticky',
|
|
top: 0,
|
|
height: '100vh',
|
|
left: 0,
|
|
zIndex: 100,
|
|
}}
|
|
>
|
|
<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)
|
|
const isParentActive = hasSelectedChild
|
|
const isSingleActive = isSelected && !section.children?.length
|
|
return (
|
|
<div className="app-sidebar__group" key={section.key}>
|
|
<button
|
|
className={`app-sidebar__item${isSingleActive ? ' app-sidebar__item--active' : ''}${isParentActive ? ' app-sidebar__item--parent-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">
|
|
<div className="app-header__left">
|
|
<button
|
|
className="app-header__collapse"
|
|
type="button"
|
|
onClick={() => setCollapsed(!collapsed)}
|
|
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
|
title={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
|
>
|
|
<MenuOutlined />
|
|
</button>
|
|
</div>
|
|
<div className="app-header__right">
|
|
{isAdmin && (
|
|
<Space size="small">
|
|
<Tooltip title="开放 API 接口文档">
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
icon={<FileTextOutlined />}
|
|
onClick={() => navigate('/open-api')}
|
|
>
|
|
API文档
|
|
</Button>
|
|
</Tooltip>
|
|
<Tooltip title="API 签名与请求在线调试器">
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
icon={<CodeOutlined />}
|
|
onClick={() => navigate('/api-debug')}
|
|
>
|
|
接口调试
|
|
</Button>
|
|
</Tooltip>
|
|
</Space>
|
|
)}
|
|
<Dropdown menu={{ items: userMenu }} placement="bottomRight">
|
|
<div className="app-header__user">
|
|
<Avatar className="app-header__user-avatar" size="small" icon={<UserOutlined />} />
|
|
<Typography.Text style={{ fontWeight: 600, fontSize: 13.5, color: '#1e293b' }}>
|
|
{user?.nickname || user?.username}
|
|
</Typography.Text>
|
|
{isAdmin ? (
|
|
<Tag color="blue" bordered={false} style={{ margin: 0, fontSize: 11, fontWeight: 600 }}>
|
|
管理员
|
|
</Tag>
|
|
) : (
|
|
<Tag color="cyan" bordered={false} style={{ margin: 0, fontSize: 11, fontWeight: 600 }}>
|
|
商户
|
|
</Tag>
|
|
)}
|
|
</div>
|
|
</Dropdown>
|
|
</div>
|
|
</Header>
|
|
<Content className="app-content">
|
|
<div className="app-content__body">
|
|
<Outlet />
|
|
</div>
|
|
</Content>
|
|
</Layout>
|
|
</Layout>
|
|
)
|
|
}
|