Files
affiliate_dash/frontend/src/layouts/MainLayout.tsx
T

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>
)
}