Files
affiliate_dash/frontend/src/layouts/MainLayout.tsx
T
yml2213andCursor f7f48d0feb 功能:接入乐玩开放平台双渠道路由,并修复渠道锁定与 Webhook 可靠性
在保持 DLC 默认零破坏的前提下,支持 priority/仅乐玩/仅 DLC 履约策略与后台 GUI 配置;同步补齐 Prepare 渠道落库、乐玩订单取消、Webhook 验签与失败重试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 15:48:03 +08:00

499 lines
18 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import {
Layout,
Dropdown,
Form,
Input,
Modal,
Space,
Typography,
Avatar,
Tag,
Button,
message,
Tooltip,
} from 'antd'
import {
ApiOutlined,
AppstoreOutlined,
CodeOutlined,
DashboardOutlined,
DownOutlined,
FileTextOutlined,
KeyOutlined,
LogoutOutlined,
MenuOutlined,
OrderedListOutlined,
ShopOutlined,
TeamOutlined,
UserOutlined,
WalletOutlined,
} from '@ant-design/icons'
import { useAuth } from '../store/auth'
import { authApi } from '../api'
import type { MenuProps } from 'antd'
const { Header, Sider, Content } = Layout
interface SidebarChild {
key: string
label: string
path?: string
disabled?: boolean
permission?: string
feature?: string
}
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: 'platform-products', label: '渠道与路由配置', path: '/platform-products' },
],
},
{
key: 'shops',
label: '商户管理',
icon: <ShopOutlined />,
children: [{ key: 'shop-list', label: '平台商户', path: '/platform-merchants' }],
},
{
key: 'accounts',
label: '账号管理',
icon: <UserOutlined />,
children: [{ key: 'platform-users', label: '平台账号', path: '/platform-users' }],
},
{
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: 'merchant-recharge', label: '积分充值', path: '/merchant-recharge' },
{ key: 'merchant-alert-settings', label: '余额告警', path: '/merchant-alert-settings' },
],
},
{
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', permission: 'products:manage', feature: 'products' }],
},
{
key: 'orders',
label: '订单管理',
icon: <OrderedListOutlined />,
children: [{ key: 'fulfillment-orders', label: '发货订单', path: '/merchant-orders', permission: 'orders:manage', feature: 'orders' }],
},
{
key: 'funds',
label: '积分管理',
icon: <WalletOutlined />,
children: [
{ key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet', permission: 'wallet:ledger', feature: 'wallet' },
{ key: 'merchant-recharge', label: '积分充值', path: '/merchant-recharge', permission: 'wallet:view', feature: 'wallet' },
{ key: 'merchant-alert-settings', label: '余额告警', path: '/merchant-alert-settings', permission: 'recharge:manage', feature: 'wallet' },
],
},
{
key: 'staff',
label: '员工管理',
icon: <TeamOutlined />,
children: [{ key: 'merchant-members', label: '成员管理', path: '/merchant-members', permission: 'members:manage' }],
},
{
key: 'open-api',
label: '开放 API',
icon: <ApiOutlined />,
children: [
{ key: 'api-keys', label: 'API 密钥', path: '/merchant-api-keys', permission: 'api:manage', feature: 'api' },
{ key: 'api-callbacks', label: '回调订阅', path: '/merchant-callbacks', permission: 'callbacks:manage', feature: 'callbacks' },
{ key: 'api-docs', label: '对接文档', path: '/open-api', permission: 'api:manage', feature: 'api' },
],
},
]
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-recharge')) return 'merchant-recharge'
if (pathname.startsWith('/merchant-alert-settings')) return 'merchant-alert-settings'
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'
if (pathname.startsWith('/platform-users')) return 'platform-users'
return 'dashboard'
}
function getInitialOpenKeys(sections: SidebarSection[], selectedKey: string) {
return sections
.filter((section) => section.children?.some((child) => child.key === selectedKey))
.map((section) => section.key)
}
const SIDEBAR_OPEN_KEYS_KEY = 'sidebar_open_keys'
const SIDEBAR_COLLAPSED_KEY = 'sidebar_collapsed'
function loadStoredOpenKeys(sections: SidebarSection[], selectedKey: string): string[] {
const initial = getInitialOpenKeys(sections, selectedKey)
try {
const raw = localStorage.getItem(SIDEBAR_OPEN_KEYS_KEY)
if (raw) {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
return Array.from(new Set([...parsed, ...initial]))
}
}
} catch (e) {
// ignore
}
return initial
}
export default function MainLayout() {
const [collapsed, setCollapsed] = useState<boolean>(() => {
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true'
})
const { user, logout, refreshProfile, isAdmin, merchant, merchantPermissions } = useAuth()
const [passwordOpen, setPasswordOpen] = useState(false)
const [passwordSaving, setPasswordSaving] = useState(false)
const [passwordForm] = Form.useForm()
const navigate = useNavigate()
const location = useLocation()
const isDocsPage = location.pathname.startsWith('/open-api')
const sections = useMemo(() => {
if (isAdmin) return adminSections
const features = new Set((merchant?.features || '').split(/[\s,]+/).filter(Boolean))
return merchantSections.flatMap((section) => {
if (!section.children) return [section]
const children = section.children.filter((child) => (
(!child.permission || merchantPermissions.includes(child.permission))
&& (!child.feature || features.has(child.feature))
))
return children.length > 0 ? [{ ...section, children }] : []
})
}, [isAdmin, merchant?.features, merchantPermissions])
const selectedKey = getSelectedKey(location.pathname, location.search)
const [openKeys, setOpenKeys] = useState<string[]>(() => loadStoredOpenKeys(sections, selectedKey))
useEffect(() => {
const nextOpenKeys = getInitialOpenKeys(sections, selectedKey)
setOpenKeys((current) => {
const merged = Array.from(new Set([...current, ...nextOpenKeys]))
localStorage.setItem(SIDEBAR_OPEN_KEYS_KEY, JSON.stringify(merged))
return merged
})
}, [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) => {
const next = current.includes(section.key)
? current.filter((key) => key !== section.key)
: [...current, section.key]
localStorage.setItem(SIDEBAR_OPEN_KEYS_KEY, JSON.stringify(next))
return next
})
}
const handleToggleCollapse = (next: boolean) => {
setCollapsed(next)
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next))
}
const handleChildClick = (child: SidebarChild) => {
if (!child.path || child.disabled) return
navigate(child.path)
}
const submitAccountUpdate = async () => {
try {
const values = await passwordForm.validateFields()
setPasswordSaving(true)
await authApi.updateCurrentAccount({
current_password: values.current_password,
username: values.username,
nickname: values.nickname || '',
new_password: values.new_password || undefined,
})
await refreshProfile()
setPasswordOpen(false)
passwordForm.resetFields()
message.success('账户设置已保存')
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
setPasswordSaving(false)
}
}
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: 'account-settings',
icon: <KeyOutlined />,
label: '账户设置',
onClick: () => {
passwordForm.setFieldsValue({ username: user?.username || '', nickname: user?.nickname || '' })
setPasswordOpen(true)
},
},
{
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={() => handleToggleCollapse(!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>
<Modal
title="账户设置"
open={passwordOpen}
confirmLoading={passwordSaving}
onOk={submitAccountUpdate}
onCancel={() => {
setPasswordOpen(false)
passwordForm.resetFields()
}}
destroyOnClose
>
<Form form={passwordForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }, { min: 3, message: '用户名至少 3 位' }, { max: 64, message: '用户名不能超过 64 位' }]}>
<Input autoComplete="username" />
</Form.Item>
<Form.Item name="nickname" label="昵称" rules={[{ max: 64, message: '昵称不能超过 64 位' }]}>
<Input autoComplete="off" />
</Form.Item>
<Form.Item name="current_password" label="当前密码" rules={[{ required: true, message: '请输入当前密码' }]}>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Form.Item name="new_password" label="新密码" rules={[{ min: 8, message: '新密码至少 8 位' }]} extra="留空表示不修改密码">
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item name="confirm_password" label="确认新密码" dependencies={['new_password']} rules={[
({ getFieldValue }) => ({ validator: (_, value) => !value || getFieldValue('new_password') === value ? Promise.resolve() : Promise.reject(new Error('两次输入的密码不一致')) }),
]}>
<Input.Password autoComplete="new-password" />
</Form.Item>
</Form>
</Modal>
</Layout>
</Layout>
)
}