在保持 DLC 默认零破坏的前提下,支持 priority/仅乐玩/仅 DLC 履约策略与后台 GUI 配置;同步补齐 Prepare 渠道落库、乐玩订单取消、Webhook 验签与失败重试。 Co-authored-by: Cursor <cursoragent@cursor.com>
499 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|