296 lines
9.5 KiB
TypeScript
296 lines
9.5 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|
import { Layout, Menu, Typography, Button, Space } from 'antd';
|
|
import type { MenuProps } from 'antd';
|
|
import {
|
|
DashboardOutlined,
|
|
UserOutlined,
|
|
ShoppingOutlined,
|
|
ShopOutlined,
|
|
TeamOutlined,
|
|
GiftOutlined,
|
|
CarOutlined,
|
|
SafetyOutlined,
|
|
LogoutOutlined,
|
|
CloudUploadOutlined,
|
|
FileTextOutlined,
|
|
LockOutlined,
|
|
SettingOutlined,
|
|
AccountBookOutlined,
|
|
} from '@ant-design/icons';
|
|
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
|
|
import { clearAuth, request, type HqProfile } from '../lib/api';
|
|
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
|
|
|
const { Header, Sider, Content } = Layout;
|
|
|
|
type MenuItem = NonNullable<MenuProps['items']>[number];
|
|
|
|
const MENU_ITEMS: MenuProps['items'] = [
|
|
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
|
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
|
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
|
|
{
|
|
key: 'products-group',
|
|
icon: <ShoppingOutlined />,
|
|
label: '商品',
|
|
children: [
|
|
{ key: '/products', label: '商品列表' },
|
|
{ key: '/product-detail-templates', label: '详情模板' },
|
|
],
|
|
},
|
|
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
|
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
|
|
{ key: '/wecom-bots', icon: <TeamOutlined />, label: '企微机器人' },
|
|
{
|
|
key: 'stores-group',
|
|
icon: <ShopOutlined />,
|
|
label: '门店',
|
|
children: [
|
|
{ key: '/stores', label: '门店列表' },
|
|
{ key: '/store-categories', label: '门店分类' },
|
|
{ key: '/store-accounts', label: '门店账户' },
|
|
{ key: '/store-media', label: '门店资源' },
|
|
],
|
|
},
|
|
{
|
|
key: 'partners-group',
|
|
icon: <TeamOutlined />,
|
|
label: '开城',
|
|
children: [
|
|
{ key: '/cities', label: '城市' },
|
|
{ key: '/city-partners', label: '城市合伙人' },
|
|
{ key: '/city-warehouses', label: '仓库' },
|
|
{ key: '/fulfillment-providers', label: '仓配管理' },
|
|
],
|
|
},
|
|
{
|
|
key: 'finance-group',
|
|
icon: <AccountBookOutlined />,
|
|
label: '财务',
|
|
children: [
|
|
{ key: '/finance/store-bills', label: '门店账单' },
|
|
{ key: '/finance/partner-bills', label: '合伙人账单' },
|
|
{ key: '/finance/winery-bills', label: '酒厂账单' },
|
|
],
|
|
},
|
|
{
|
|
key: 'benefit-group',
|
|
icon: <GiftOutlined />,
|
|
label: '好客权益',
|
|
children: [
|
|
{ key: '/benefit/coupons', label: '权益券' },
|
|
{ key: '/benefit/ledgers', label: '流水' },
|
|
{ key: '/redeem-records', label: '核销记录' },
|
|
{ key: '/redeem-pending', label: '待处理核销' },
|
|
{ key: '/redeem/debug', label: '核销调试' },
|
|
],
|
|
},
|
|
{
|
|
key: 'deliveries-group',
|
|
icon: <CarOutlined />,
|
|
label: '配送单',
|
|
children: [
|
|
{ key: '/deliveries', label: '配送单列表' },
|
|
{ key: '/deliveries/xiaofeixia', label: '小飞侠联调' },
|
|
],
|
|
},
|
|
{
|
|
key: 'tickets-group',
|
|
icon: <CarOutlined />,
|
|
label: '工单',
|
|
children: [
|
|
{ key: '/tickets', label: '工单中心' },
|
|
{ key: '/tickets/support', label: '技术支持' },
|
|
],
|
|
},
|
|
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
|
|
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
|
{
|
|
key: 'logs-group',
|
|
icon: <FileTextOutlined />,
|
|
label: '日志',
|
|
children: [
|
|
{ key: '/logs/users', label: '用户日志' },
|
|
{ key: '/logs/stores', label: '商户日志' },
|
|
{ key: '/logs/partners', label: '合伙人日志' },
|
|
{ key: '/logs/hq', label: 'HQ 操作日志' },
|
|
{ key: '/logs/third-party', label: '第三方日志' },
|
|
],
|
|
},
|
|
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
|
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
|
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
|
];
|
|
|
|
function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
|
if (key === 'tickets-group') {
|
|
return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support');
|
|
}
|
|
const map: Record<string, string | 'system_settings_any'> = {
|
|
'/': 'dashboard',
|
|
'/users': 'users',
|
|
'/wechat-bindings': 'wechat_bindings',
|
|
'products-group': 'products',
|
|
'/products': 'products',
|
|
'/product-detail-templates': 'products',
|
|
'/orders': 'orders',
|
|
'/promo-codes': 'promo_codes',
|
|
'/wecom-bots': 'wecom_bots',
|
|
'stores-group': 'stores',
|
|
'/stores': 'stores',
|
|
'/store-categories': 'stores',
|
|
'/store-accounts': 'stores',
|
|
'/store-media': 'stores',
|
|
'partners-group': 'partners',
|
|
'/cities': 'partners',
|
|
'/city-partners': 'partners',
|
|
'/city-warehouses': 'partners',
|
|
'/fulfillment-providers': 'partners',
|
|
'finance-group': 'finance',
|
|
'/finance/store-bills': 'finance',
|
|
'/finance/partner-bills': 'finance',
|
|
'/finance/winery-bills': 'finance',
|
|
'benefit-group': 'benefit',
|
|
'/benefit/coupons': 'benefit',
|
|
'/benefit/ledgers': 'benefit',
|
|
'/redeem-records': 'benefit',
|
|
'/redeem-pending': 'benefit',
|
|
'/redeem/debug': 'benefit',
|
|
'deliveries-group': 'deliveries',
|
|
'/deliveries': 'deliveries',
|
|
'/deliveries/xiaofeixia': 'deliveries',
|
|
'/tickets': 'tickets',
|
|
'/tickets/support': 'tech_support',
|
|
'/invoices': 'invoices',
|
|
'/resources': 'resources',
|
|
'logs-group': 'logs',
|
|
'/logs/users': 'logs',
|
|
'/logs/stores': 'logs',
|
|
'/logs/partners': 'logs',
|
|
'/logs/hq': 'logs',
|
|
'/logs/third-party': 'logs',
|
|
'/hq-permissions': 'hq_permissions',
|
|
'/system-settings': 'system_settings_any',
|
|
'/hq-accounts': 'hq_accounts',
|
|
};
|
|
const need = map[key];
|
|
if (!need) return true;
|
|
if (need === 'system_settings_any') return hasAnySystemSettingsPermission(permissionKeys);
|
|
return permissionKeys.includes(need);
|
|
}
|
|
|
|
function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): MenuProps['items'] {
|
|
if (!items) return items;
|
|
return items
|
|
.map((item) => {
|
|
if (!item || typeof item !== 'object' || !('key' in item)) return item;
|
|
const key = String(item.key);
|
|
if ('children' in item && Array.isArray(item.children)) {
|
|
if (!menuAllowed(key, permissionKeys)) return null;
|
|
const children = filterMenuItems(item.children as MenuProps['items'], permissionKeys);
|
|
if (!children?.length) return null;
|
|
return { ...item, children } as MenuItem;
|
|
}
|
|
return menuAllowed(key, permissionKeys) ? item : null;
|
|
})
|
|
.filter(Boolean) as MenuProps['items'];
|
|
}
|
|
|
|
export default function AdminLayout() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
|
|
|
useEffect(() => {
|
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
contentRef.current?.scrollTo({ top: 0, left: 0 });
|
|
}, [location.pathname]);
|
|
|
|
useEffect(() => {
|
|
return bindAdminEllipsisTitle();
|
|
}, []);
|
|
|
|
function logout() {
|
|
clearAuth();
|
|
navigate('/login');
|
|
}
|
|
|
|
const selectedKey = location.pathname.startsWith('/promo-codes')
|
|
? '/promo-codes'
|
|
: location.pathname;
|
|
|
|
const menuItems = useMemo(() => {
|
|
if (!profile) return MENU_ITEMS;
|
|
if (profile.adminRole === 'SUPER_ADMIN') return MENU_ITEMS;
|
|
return filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
|
|
}, [profile]);
|
|
|
|
return (
|
|
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
|
<Sider
|
|
breakpoint="lg"
|
|
collapsedWidth={64}
|
|
theme="dark"
|
|
width={220}
|
|
style={{ height: '100vh', overflow: 'hidden' }}
|
|
>
|
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
|
<div style={{ flexShrink: 0, padding: '16px', color: '#fff', fontWeight: 600 }}>
|
|
杜康 HQ
|
|
</div>
|
|
<div className="admin-sider-menu-scroll" style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
|
<Menu
|
|
theme="dark"
|
|
mode="inline"
|
|
selectedKeys={[selectedKey]}
|
|
defaultOpenKeys={[]}
|
|
items={menuItems}
|
|
onClick={({ key }) => {
|
|
if (key.startsWith('/')) navigate(key);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Sider>
|
|
<Layout style={{ height: '100vh', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
|
<Header
|
|
style={{
|
|
flexShrink: 0,
|
|
background: '#fff',
|
|
padding: '0 24px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
borderBottom: '1px solid #f0f0f0',
|
|
}}
|
|
>
|
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
|
管理后台
|
|
</Typography.Title>
|
|
<Space>
|
|
<span>{profile?.name || '—'}</span>
|
|
<span style={{ color: '#999' }}>{profile?.adminRole}</span>
|
|
<Button type="text" icon={<LogoutOutlined />} onClick={logout}>
|
|
退出
|
|
</Button>
|
|
</Space>
|
|
</Header>
|
|
<div
|
|
ref={contentRef}
|
|
className="admin-layout"
|
|
style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
|
|
>
|
|
<Content style={{ margin: 24 }}>
|
|
<Outlet />
|
|
</Content>
|
|
</div>
|
|
</Layout>
|
|
</Layout>
|
|
);
|
|
}
|