feat: support merchant team roles and permissions

This commit is contained in:
yml2213
2026-08-13 11:41:34 +08:00
parent 6c98fa557e
commit a4791decb0
21 changed files with 969 additions and 201 deletions
+41 -12
View File
@@ -1,5 +1,5 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { ConfigProvider, App as AntdApp } from 'antd'
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
import { ConfigProvider, App as AntdApp, Spin } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import { AuthProvider, useAuth } from './store/auth'
import MainLayout from './layouts/MainLayout'
@@ -25,6 +25,35 @@ function AdminRoute({ children }: { children: ReactNode }) {
return <>{children}</>
}
const tabPermissions: Record<string, string> = {
products: 'products:manage',
orders: 'orders:manage',
wallet: 'wallet:ledger',
api: 'api:manage',
callbacks: 'callbacks:manage',
members: 'members:manage',
}
function MerchantPermissionRoute({ permission, children }: { permission?: string; children: ReactNode }) {
const { isAdmin, merchantLoading, merchantPermissions } = useAuth()
if (isAdmin || !permission) return <>{children}</>
if (merchantLoading) {
return <div style={{ textAlign: 'center', padding: '100px 0' }}><Spin size="large" /></div>
}
return merchantPermissions.includes(permission) ? <>{children}</> : <Navigate to="/" replace />
}
function MerchantCenterRoute() {
const location = useLocation()
const requestedTab = new URLSearchParams(location.search).get('tab') || 'products'
const tab = requestedTab in tabPermissions ? requestedTab : 'products'
return (
<MerchantPermissionRoute permission={tabPermissions[tab]}>
<MerchantCenter />
</MerchantPermissionRoute>
)
}
function AppRoutes() {
return (
<Routes>
@@ -39,15 +68,15 @@ function AppRoutes() {
}
>
<Route index element={<Dashboard />} />
<Route path="merchant-center" element={<MerchantCenter />} />
<Route path="merchant-products" element={<MerchantCenter fixedTab="products" title="商品列表" />} />
<Route path="merchant-orders" element={<MerchantCenter fixedTab="orders" title="发货订单" />} />
<Route path="merchant-wallet" element={<MerchantCenter fixedTab="wallet" title="积分明细" />} />
<Route path="merchant-recharge" element={<MerchantRecharge />} />
<Route path="merchant-alert-settings" element={<AlertSettings />} />
<Route path="merchant-members" element={<MerchantCenter fixedTab="members" title="成员" />} />
<Route path="merchant-callbacks" element={<MerchantCenter fixedTab="callbacks" title="回调" />} />
<Route path="merchant-api-keys" element={<MerchantCenter fixedTab="api" title="API 密钥" />} />
<Route path="merchant-center" element={<MerchantCenterRoute />} />
<Route path="merchant-products" element={<MerchantPermissionRoute permission="products:manage"><MerchantCenter fixedTab="products" title="商品列表" /></MerchantPermissionRoute>} />
<Route path="merchant-orders" element={<MerchantPermissionRoute permission="orders:manage"><MerchantCenter fixedTab="orders" title="发货订单" /></MerchantPermissionRoute>} />
<Route path="merchant-wallet" element={<MerchantPermissionRoute permission="wallet:ledger"><MerchantCenter fixedTab="wallet" title="积分明细" /></MerchantPermissionRoute>} />
<Route path="merchant-recharge" element={<MerchantPermissionRoute permission="wallet:view"><MerchantRecharge /></MerchantPermissionRoute>} />
<Route path="merchant-alert-settings" element={<MerchantPermissionRoute permission="recharge:manage"><AlertSettings /></MerchantPermissionRoute>} />
<Route path="merchant-members" element={<MerchantPermissionRoute permission="members:manage"><MerchantCenter fixedTab="members" title="成员" /></MerchantPermissionRoute>} />
<Route path="merchant-callbacks" element={<MerchantPermissionRoute permission="callbacks:manage"><MerchantCenter fixedTab="callbacks" title="回调" /></MerchantPermissionRoute>} />
<Route path="merchant-api-keys" element={<MerchantPermissionRoute permission="api:manage"><MerchantCenter fixedTab="api" title="API 密钥" /></MerchantPermissionRoute>} />
<Route
path="platform-merchants"
element={
@@ -56,7 +85,7 @@ function AppRoutes() {
</AdminRoute>
}
/>
<Route path="open-api" element={<OpenApiDocs />} />
<Route path="open-api" element={<MerchantPermissionRoute permission="api:manage"><OpenApiDocs /></MerchantPermissionRoute>} />
<Route
path="api-debug"
element={
+9 -3
View File
@@ -18,6 +18,7 @@ import type {
LowBalanceAlertConfig,
Merchant,
MerchantMember,
MerchantRole,
MerchantProduct,
PageResult,
ProductCatalogItem,
@@ -54,7 +55,7 @@ export const userApi = {
export const merchantApi = {
current: () =>
request.get('/merchant').then((r) => r.data.data as { merchant: Merchant; role: MerchantMember['role'] }),
request.get('/merchant').then((r) => r.data.data as { merchant: Merchant; role: string; permissions: string[] }),
products: (params?: Record<string, unknown>) =>
request.get('/merchant/products', { params }).then((r) => r.data.data as PageResult<MerchantProduct>),
updateProductStatus: (id: number, status: MerchantProduct['status']) =>
@@ -103,8 +104,13 @@ export const merchantApi = {
request.post('/merchant/callbacks', data).then((r) => r.data.data as CallbackCredential),
members: () =>
request.get('/merchant/members').then((r) => r.data.data as MerchantMember[]),
addMember: (data: { user_id: number; role: MerchantMember['role']; is_default?: boolean }) =>
addMember: (data: { user_id?: number; username?: string; password?: string; nickname?: string; role: string; is_default?: boolean }) =>
request.post('/merchant/members', data).then((r) => r.data.data as MerchantMember),
roles: () => request.get('/merchant/roles').then((r) => r.data.data as MerchantRole[]),
createRole: (data: { code: string; name: string; permissions: string[] }) =>
request.post('/merchant/roles', data).then((r) => r.data.data as MerchantRole),
updateRole: (id: number, data: { name: string; permissions: string[] }) =>
request.patch(`/merchant/roles/${id}`, data).then((r) => r.data.data as MerchantRole),
rechargeApplications: (params?: Record<string, unknown>) =>
request.get('/merchant/recharge/applications', { params }).then((r) => r.data.data as PageResult<RechargeApplication>),
createRechargeApplication: (data: { amount_cny: number; vouchers: string[]; note?: string }) =>
@@ -180,7 +186,7 @@ export const platformApi = {
request.patch(`/platform/merchants/${id}`, data).then((r) => r.data.data),
addMember: (
merchantId: number,
data: { user_id: number; role: MerchantMember['role']; is_default?: boolean },
data: { user_id?: number; username?: string; password?: string; nickname?: string; role: string; is_default?: boolean },
) => request.post(`/platform/merchants/${merchantId}/members`, data).then((r) => r.data.data as MerchantMember),
productCatalog: () =>
request.get('/platform/product-catalog').then((r) => r.data.data as ProductCatalogItem[]),
+24 -11
View File
@@ -36,6 +36,8 @@ interface SidebarChild {
label: string
path?: string
disabled?: boolean
permission?: string
feature?: string
}
interface SidebarSection {
@@ -112,38 +114,38 @@ const merchantSections: SidebarSection[] = [
key: 'products',
label: '商品管理',
icon: <AppstoreOutlined />,
children: [{ key: 'merchant-products', label: '商品列表', path: '/merchant-products' }],
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' }],
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' },
{ key: 'merchant-recharge', label: '积分充值', path: '/merchant-recharge' },
{ key: 'merchant-alert-settings', label: '余额告警', path: '/merchant-alert-settings' },
{ 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' }],
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' },
{ key: 'api-callbacks', label: '回调订阅', path: '/merchant-callbacks' },
{ key: 'api-docs', label: '对接文档', path: '/open-api' },
{ 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' },
],
},
]
@@ -204,12 +206,23 @@ export default function MainLayout() {
const [collapsed, setCollapsed] = useState<boolean>(() => {
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true'
})
const { user, logout, isAdmin } = useAuth()
const { user, logout, isAdmin, merchant, merchantPermissions } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const isDocsPage = location.pathname.startsWith('/open-api')
const sections = useMemo(() => (isAdmin ? adminSections : merchantSections), [isAdmin])
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))
+97 -72
View File
@@ -103,8 +103,15 @@ function CustomStatusLine({
export default function Dashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null)
const [loading, setLoading] = useState(true)
const { user, isAdmin } = useAuth()
const { user, isAdmin, merchantPermissions } = useAuth()
const navigate = useNavigate()
const can = (permission: string) => isAdmin || merchantPermissions.includes(permission)
const canOrders = can('orders:manage')
const canProducts = can('products:manage')
const canMembers = can('members:manage')
const canWallet = can('wallet:view')
const canAPI = can('api:manage')
const canCallbacks = can('callbacks:manage')
useEffect(() => {
dashboardApi
@@ -144,12 +151,16 @@ export default function Dashboard() {
Skin Hub
</p>
<div className="dash-quick-launch">
<button className="dash-quick-btn" type="button" onClick={() => navigate('/merchant-orders')}>
<PlusOutlined />
</button>
<button className="dash-quick-btn" type="button" onClick={() => navigate('/merchant-wallet')}>
<WalletOutlined />
</button>
{canOrders && (
<button className="dash-quick-btn" type="button" onClick={() => navigate('/merchant-orders')}>
<PlusOutlined />
</button>
)}
{can('wallet:ledger') && (
<button className="dash-quick-btn" type="button" onClick={() => navigate('/merchant-wallet')}>
<WalletOutlined />
</button>
)}
{isAdmin && (
<>
<button className="dash-quick-btn" type="button" onClick={() => navigate('/platform-merchants')}>
@@ -166,42 +177,49 @@ export default function Dashboard() {
</div>
</div>
{/* 第一组:核心业务指标 */}
<Typography.Title level={5} style={{ marginBottom: 14, fontWeight: 700, color: '#1e293b' }}>
</Typography.Title>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={8}>
<ModernMetricCard
title={stats?.scope === 'platform' ? '平台可售商品' : '商户上架商品'}
value={stats?.active_product_count ?? 0}
icon={<ShoppingOutlined />}
iconTheme=""
footerText="活跃商品库"
footerValue="已上线"
/>
</Col>
<Col xs={24} sm={12} lg={8}>
<ModernMetricCard
title={stats?.scope === 'platform' ? '平台启用商户' : '当前合作商户'}
value={stats?.active_merchant_count ?? 0}
icon={<ShopOutlined />}
iconTheme="metric-card-icon--purple"
footerText="商户状态"
footerValue="正常运行"
/>
</Col>
<Col xs={24} sm={12} lg={8}>
<ModernMetricCard
title="平台成员账号"
value={stats?.user_count ?? 0}
icon={<TeamOutlined />}
iconTheme="metric-card-icon--emerald"
footerText="团队成员"
footerValue="活跃"
/>
</Col>
<Col xs={24} sm={12} lg={8}>
{(canProducts || canMembers || canOrders) && (
<>
<Typography.Title level={5} style={{ marginBottom: 14, fontWeight: 700, color: '#1e293b' }}>
</Typography.Title>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
{canProducts && (
<Col xs={24} sm={12} lg={8}>
<ModernMetricCard
title={stats?.scope === 'platform' ? '平台可售商品' : '商户上架商品'}
value={stats?.active_product_count ?? 0}
icon={<ShoppingOutlined />}
footerText="活跃商品库"
footerValue="已上线"
/>
</Col>
)}
{isAdmin && (
<Col xs={24} sm={12} lg={8}>
<ModernMetricCard
title="平台启用商户"
value={stats?.active_merchant_count ?? 0}
icon={<ShopOutlined />}
iconTheme="metric-card-icon--purple"
footerText="商户状态"
footerValue="正常运行"
/>
</Col>
)}
{canMembers && (
<Col xs={24} sm={12} lg={8}>
<ModernMetricCard
title={isAdmin ? '平台成员账号' : '团队成员'}
value={stats?.user_count ?? 0}
icon={<TeamOutlined />}
iconTheme="metric-card-icon--emerald"
footerText="团队成员"
footerValue="活跃"
/>
</Col>
)}
{canOrders && <>
<Col xs={24} sm={12} lg={8}>
<ModernMetricCard
title="累计订单总数"
value={stats?.order_count ?? 0}
@@ -211,7 +229,7 @@ export default function Dashboard() {
footerValue={`${stats?.today_order_count ?? 0}`}
/>
</Col>
<Col xs={24} sm={12} lg={8}>
<Col xs={24} sm={12} lg={8}>
<ModernMetricCard
title="今日订单总量"
value={stats?.today_order_count ?? 0}
@@ -221,7 +239,7 @@ export default function Dashboard() {
footerValue={`${ratio(stats?.today_order_count ?? 0, stats?.order_count ?? 0)}%`}
/>
</Col>
<Col xs={24} sm={12} lg={8}>
<Col xs={24} sm={12} lg={8}>
<ModernMetricCard
title="待处理/待发货订单"
value={stats?.paid_order_count ?? 0}
@@ -231,14 +249,19 @@ export default function Dashboard() {
footerValue={(stats?.paid_order_count ?? 0) > 0 ? '需处理' : '无积压'}
/>
</Col>
</Row>
</>}
</Row>
</>
)}
{/* 第二组:积分与资金监控 */}
<Typography.Title level={5} style={{ marginBottom: 14, fontWeight: 700, color: '#1e293b' }}>
</Typography.Title>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={6}>
{canWallet && (
<>
<Typography.Title level={5} style={{ marginBottom: 14, fontWeight: 700, color: '#1e293b' }}>
</Typography.Title>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
{canOrders && <>
<Col xs={24} sm={12} lg={6}>
<ModernMetricCard
title="累计成交积分"
value={stats?.total_sales ?? 0}
@@ -247,7 +270,7 @@ export default function Dashboard() {
iconTheme="metric-card-icon--emerald"
/>
</Col>
<Col xs={24} sm={12} lg={6}>
<Col xs={24} sm={12} lg={6}>
<ModernMetricCard
title="今日成交积分"
value={stats?.today_sales ?? 0}
@@ -256,7 +279,7 @@ export default function Dashboard() {
iconTheme="metric-card-icon--amber"
/>
</Col>
<Col xs={24} sm={12} lg={6}>
<Col xs={24} sm={12} lg={6}>
<ModernMetricCard
title="平台扣费/手续费"
value={stats?.total_fees ?? 0}
@@ -265,7 +288,8 @@ export default function Dashboard() {
iconTheme="metric-card-icon--purple"
/>
</Col>
<Col xs={24} sm={12} lg={6}>
</>}
{canWallet && <Col xs={24} sm={12} lg={6}>
<ModernMetricCard
title="账户可用余额"
value={stats?.wallet_available_balance ?? 0}
@@ -273,12 +297,13 @@ export default function Dashboard() {
icon={<WalletOutlined />}
iconTheme=""
/>
</Col>
</Row>
</Col>}
</Row>
</>
)}
{/* 第三组:订单发货分布与 API 监控 */}
<Row gutter={[20, 20]}>
<Col xs={24} lg={14}>
{(canOrders || canAPI || canCallbacks) && <Row gutter={[20, 20]}>
{canOrders && <Col xs={24} lg={canAPI || canCallbacks ? 14 : 24}>
<Card title="发货订单状态占比分布" style={{ height: '100%' }}>
<CustomStatusLine
label="待发货 (paid)"
@@ -316,44 +341,44 @@ export default function Dashboard() {
tagClass="status-tag--gray"
/>
</Card>
</Col>
<Col xs={24} lg={10}>
</Col>}
{(canAPI || canCallbacks) && <Col xs={24} lg={canOrders ? 10 : 24}>
<Card title="接口密钥与 Webhook 回调监控" style={{ height: '100%' }}>
<Row gutter={[16, 20]}>
<Col span={12}>
{canAPI && <Col span={12}>
<Statistic
title="API 密钥 (活跃/总量)"
value={stats?.active_api_client_count ?? 0}
prefix={<ApiOutlined style={{ color: '#2563eb' }} />}
suffix={`/ ${numberText(stats?.api_client_count)}`}
/>
</Col>
<Col span={12}>
</Col>}
{canCallbacks && <Col span={12}>
<Statistic
title="Webhook 回调订阅"
value={stats?.callback_subscription_count ?? 0}
prefix={<CheckCircleOutlined style={{ color: '#16a34a' }} />}
/>
</Col>
<Col span={12}>
</Col>}
{canCallbacks && <Col span={12}>
<Statistic
title="待投递回调队列"
value={stats?.pending_callback_count ?? 0}
prefix={<ClockCircleOutlined style={{ color: '#d97706' }} />}
/>
</Col>
<Col span={12}>
</Col>}
{canCallbacks && <Col span={12}>
<Statistic
title="投递失败回调"
value={stats?.failed_callback_count ?? 0}
prefix={<CloseCircleOutlined style={{ color: '#dc2626' }} />}
valueStyle={(stats?.failed_callback_count ?? 0) > 0 ? { color: '#dc2626' } : undefined}
/>
</Col>
</Col>}
</Row>
</Card>
</Col>
</Row>
</Col>}
</Row>}
</div>
)
}
+103 -36
View File
@@ -23,6 +23,7 @@ import type {
FulfillmentOrder,
Merchant,
MerchantMember,
MerchantRole,
MerchantProduct,
PageResult,
WalletAccount,
@@ -33,9 +34,9 @@ import {
defaultCallbackFormValues,
eventsToValue,
featuresToList,
memberRoleOptions,
productOptionLabel,
resolveEnabledTab,
rolePermissionOptions,
roleText,
scopeOptions,
tabFromSearch,
@@ -58,6 +59,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const routeTab = useMemo(() => fixedTab ?? tabFromSearch(location.search), [fixedTab, location.search])
const [merchant, setMerchant] = useState<Merchant | null>(null)
const [merchantRole, setMerchantRole] = useState<MerchantMember['role']>()
const [merchantPermissions, setMerchantPermissions] = useState<string[]>([])
const [activeTab, setActiveTab] = useState<MerchantCenterTab>(routeTab ?? 'products')
const [products, setProducts] = useState<PageResult<MerchantProduct>>({ list: [], total: 0, page: 1, size: 10 })
const [orders, setOrders] = useState<PageResult<FulfillmentOrder>>({ list: [], total: 0, page: 1, size: 10 })
@@ -67,11 +69,14 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const [apiClients, setApiClients] = useState<ApiClient[]>([])
const [callback, setCallback] = useState<CallbackSubscription | null>(null)
const [members, setMembers] = useState<MerchantMember[]>([])
const [roles, setRoles] = useState<MerchantRole[]>([])
const [loading, setLoading] = useState(false)
const [apiClientOpen, setApiClientOpen] = useState(false)
const [apiCredential, setApiCredential] = useState<ApiCredential | null>(null)
const [callbackCredential, setCallbackCredential] = useState<CallbackCredential | null>(null)
const [memberOpen, setMemberOpen] = useState(false)
const [roleOpen, setRoleOpen] = useState(false)
const [editingRole, setEditingRole] = useState<MerchantRole | null>(null)
const [manualOrderOpen, setManualOrderOpen] = useState(false)
const [manualOrderSubmitting, setManualOrderSubmitting] = useState(false)
const [manualOrderProducts, setManualOrderProducts] = useState<MerchantProduct[]>([])
@@ -80,10 +85,15 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const [apiClientForm] = Form.useForm()
const [callbackForm] = Form.useForm()
const [memberForm] = Form.useForm()
const [roleForm] = Form.useForm()
const [manualOrderForm] = Form.useForm()
const canManage = merchantRole === 'owner' || merchantRole === 'operator'
const canFinance = merchantRole === 'owner' || merchantRole === 'finance'
const hasPermission = useCallback((permission: string) => merchantPermissions.includes(permission), [merchantPermissions])
const canManage = hasPermission('products:manage')
const canOrderManage = hasPermission('orders:manage')
const canAPIManage = hasPermission('api:manage')
const canCallbacksManage = hasPermission('callbacks:manage')
const canFinance = hasPermission('wallet:ledger')
const enabledFeatures = useMemo(() => new Set(featuresToList(merchant?.features)), [merchant?.features])
const hasFeature = useCallback((feature: string) => !merchant || enabledFeatures.has(feature), [enabledFeatures, merchant])
const manualOrderProductOptions = useMemo(() => manualOrderProducts.map((item) => ({
@@ -136,6 +146,12 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const data = await merchantApi.current()
setMerchant(data.merchant)
setMerchantRole(data.role)
const nextPermissions = [...new Set(data.permissions || [])].sort()
setMerchantPermissions((current) => (
current.length === nextPermissions.length && current.every((value, index) => value === nextPermissions[index])
? current
: nextPermissions
))
return data
}, [])
@@ -152,26 +168,26 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const loadWallet = useCallback(async (
page = ledger.page,
size = ledger.size,
role = merchantRole,
permissions = merchantPermissions,
filters?: { reference_no?: string; type?: string },
) => {
const walletData = await merchantApi.wallet()
setWallet(walletData)
if (role === 'owner' || role === 'finance') {
if (permissions.includes('wallet:ledger')) {
const ledgerData = await merchantApi.ledger({ page, size, ...filters })
setLedger(ledgerData)
} else {
setLedger({ list: [], total: 0, page, size })
}
}, [ledger.page, ledger.size, merchantRole])
}, [ledger.page, ledger.size, merchantPermissions])
const loadAPIClients = useCallback(async () => {
const clientData = await merchantApi.apiClients()
setApiClients(clientData || [])
}, [])
const loadCallbacks = useCallback(async (role = merchantRole) => {
if (role !== 'owner' && role !== 'operator') {
const loadCallbacks = useCallback(async (permissions = merchantPermissions) => {
if (!permissions.includes('callbacks:manage')) {
setCallback(null)
callbackForm.resetFields()
return
@@ -183,18 +199,20 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
events: eventsToValue(callbackData.events),
status: callbackData.status,
} : defaultCallbackFormValues())
}, [callbackForm, merchantRole])
}, [callbackForm, merchantPermissions])
const loadMembers = useCallback(async (role = merchantRole) => {
if (role !== 'owner' && role !== 'operator') {
const loadMembers = useCallback(async (permissions = merchantPermissions) => {
if (!permissions.includes('members:manage')) {
setMembers([])
setRoles([])
return
}
const memberData = await merchantApi.members()
const [memberData, roleData] = await Promise.all([merchantApi.members(), merchantApi.roles()])
setMembers(memberData || [])
}, [merchantRole])
setRoles(roleData || [])
}, [merchantPermissions])
const loadActiveTab = useCallback(async (role = merchantRole, tab = activeTab) => {
const loadActiveTab = useCallback(async (permissions = merchantPermissions, tab = activeTab) => {
switch (tab) {
case 'products':
await loadProducts()
@@ -203,19 +221,19 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
await loadOrders()
return
case 'wallet':
await loadWallet(undefined, undefined, role)
await loadWallet(undefined, undefined, permissions)
return
case 'api':
await loadAPIClients()
return
case 'callbacks':
await loadCallbacks(role)
await loadCallbacks(permissions)
return
case 'members':
await loadMembers(role)
await loadMembers(permissions)
return
}
}, [activeTab, loadAPIClients, loadCallbacks, loadMembers, loadOrders, loadProducts, loadWallet, merchantRole])
}, [activeTab, loadAPIClients, loadCallbacks, loadMembers, loadOrders, loadProducts, loadWallet, merchantPermissions])
const loadAll = useCallback(async () => {
setLoading(true)
@@ -225,7 +243,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
if (!fixedTab && nextTab !== activeTab) {
setActiveTab(nextTab)
}
await loadActiveTab(current.role, nextTab)
await loadActiveTab(current.permissions || [], nextTab)
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败')
} finally {
@@ -254,7 +272,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const handleWalletFilter = async () => {
const values = await walletFilterForm.validateFields()
loadWallet(1, ledger.size, merchantRole, {
loadWallet(1, ledger.size, merchantPermissions, {
reference_no: values.reference_no || undefined,
type: values.type || undefined,
})
@@ -262,16 +280,16 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const handleWalletFilterReset = () => {
walletFilterForm.resetFields()
loadWallet(1, ledger.size, merchantRole, {})
loadWallet(1, ledger.size, merchantPermissions, {})
}
const handleLoadLedger = useCallback((page: number, size: number) => {
const values = walletFilterForm.getFieldsValue()
loadWallet(page, size, merchantRole, {
loadWallet(page, size, merchantPermissions, {
reference_no: values.reference_no || undefined,
type: values.type || undefined,
})
}, [loadWallet, merchantRole, walletFilterForm])
}, [loadWallet, merchantPermissions, walletFilterForm])
const newManualClientOrderNo = () => {
const suffix = window.crypto?.randomUUID?.().replaceAll('-', '') || `${Date.now()}${Math.random().toString(36).slice(2)}`
@@ -389,9 +407,10 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const values = await memberForm.validateFields()
try {
await merchantApi.addMember({
user_id: values.user_id,
username: values.username,
password: values.password,
nickname: values.nickname,
role: values.role,
is_default: values.is_default,
})
setMemberOpen(false)
message.success('成员已添加')
@@ -401,6 +420,23 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
}
}
const submitRole = async () => {
const values = await roleForm.validateFields()
try {
if (editingRole) {
await merchantApi.updateRole(editingRole.id, { name: values.name, permissions: values.permissions || [] })
} else {
await merchantApi.createRole({ ...values, permissions: values.permissions || [] })
}
setRoleOpen(false)
setEditingRole(null)
message.success(editingRole ? '角色已更新' : '角色已创建')
loadMembers()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
}
}
const toggleAPIClient = async (record: ApiClient) => {
try {
await merchantApi.updateApiClientStatus(record.id, record.status === 'active' ? 'disabled' : 'active')
@@ -426,19 +462,39 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
<ProductsTab loading={loading} products={products} canManage={canManage} onToggleProduct={openProductToggle} onLoadProducts={loadProducts} />
) },
{ key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: (
<OrdersTab loading={loading} orders={orders} orderSource={orderSource} canManage={canManage} onCreateManualOrder={openManualOrderCreate} onOrderSourceChange={changeOrderSource} onLoadOrders={loadOrders} onCopyLink={copyDeliveryLink} onOpenLink={openDeliveryLink} onRevokeLink={revokeDeliveryLink} onRestoreLink={restoreDeliveryLink} />
<OrdersTab loading={loading} orders={orders} orderSource={orderSource} canManage={canOrderManage} onCreateManualOrder={openManualOrderCreate} onOrderSourceChange={changeOrderSource} onLoadOrders={loadOrders} onCopyLink={copyDeliveryLink} onOpenLink={openDeliveryLink} onRevokeLink={revokeDeliveryLink} onRestoreLink={restoreDeliveryLink} />
) },
{ key: 'wallet', label: '钱包', disabled: !hasFeature('wallet'), children: (
<WalletTab loading={loading} wallet={wallet} canFinance={canFinance} ledger={ledger} form={walletFilterForm} onFilter={handleWalletFilter} onResetFilter={handleWalletFilterReset} onLoadLedger={handleLoadLedger} />
) },
{ key: 'api', label: 'API 密钥', disabled: !hasFeature('api'), children: (
<ApiKeysTab loading={loading} clients={apiClients} clientMax={apiClientMax} isAdmin={isAdmin} canManage={canManage} form={apiClientForm} onOpenCreate={() => setApiClientOpen(true)} onNavigateDocs={() => navigate('/open-api')} onNavigateDebug={() => navigate('/api-debug')} onToggleClient={toggleAPIClient} onDeleteClient={deleteAPIClient} />
<ApiKeysTab loading={loading} clients={apiClients} clientMax={apiClientMax} isAdmin={isAdmin} canManage={canAPIManage} form={apiClientForm} onOpenCreate={() => setApiClientOpen(true)} onNavigateDocs={() => navigate('/open-api')} onNavigateDebug={() => navigate('/api-debug')} onToggleClient={toggleAPIClient} onDeleteClient={deleteAPIClient} />
) },
{ key: 'callbacks', label: '回调', disabled: !hasFeature('callbacks'), children: (
<CallbacksTab canManage={canManage} callback={callback} form={callbackForm} onSubmit={submitCallback} onRotateSecret={rotateCallbackSecret} />
<CallbacksTab canManage={canCallbacksManage} callback={callback} form={callbackForm} onSubmit={submitCallback} onRotateSecret={rotateCallbackSecret} />
) },
{ key: 'members', label: '成员', children: (
<MembersTab loading={loading} members={members} isOwner={merchantRole === 'owner'} form={memberForm} onOpenAdd={() => setMemberOpen(true)} />
<MembersTab
loading={loading}
members={members}
roles={roles}
canManage={hasPermission('members:manage')}
form={memberForm}
onOpenAdd={() => setMemberOpen(true)}
onOpenCreateRole={() => {
setEditingRole(null)
setRoleOpen(true)
}}
onEditRole={(role) => {
setEditingRole(role)
roleForm.setFieldsValue({
name: role.name,
code: role.code,
permissions: role.permissions.split(',').filter(Boolean),
})
setRoleOpen(true)
}}
/>
) },
]
const fixedTabContent = tabItems.find((item) => item.key === fixedTab)?.children
@@ -541,17 +597,28 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
{callbackCredential && <SecretBlock secret={callbackCredential.secret} />}
</Modal>
<Modal title="添加员" open={memberOpen} onOk={submitMember} onCancel={() => setMemberOpen(false)} destroyOnClose>
<Modal title="添加员" open={memberOpen} onOk={submitMember} onCancel={() => setMemberOpen(false)} destroyOnClose>
<Form form={memberForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="user_id" label="用户 ID" rules={[{ required: true }]}>
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
<Form.Item name="username" label="员工用户名" rules={[{ required: true, min: 3, message: '请输入至少 3 位用户名' }]}>
<Input maxLength={64} />
</Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
<Select options={memberRoleOptions} />
<Form.Item name="password" label="初始密码" rules={[{ required: true, min: 6, message: '密码至少 6 位' }]}>
<Input.Password maxLength={128} />
</Form.Item>
<Form.Item name="is_default" label="默认商户">
<Select options={[{ value: true, label: '是' }, { value: false, label: '否' }]} />
<Form.Item name="nickname" label="员工姓名">
<Input maxLength={64} />
</Form.Item>
<Form.Item name="role" label="团队角色" rules={[{ required: true }]}>
<Select options={roles.map((item) => ({ value: item.code, label: item.name }))} />
</Form.Item>
</Form>
</Modal>
<Modal title={editingRole ? `编辑角色:${editingRole.name}` : '新建团队角色'} open={roleOpen} onOk={submitRole} onCancel={() => { setRoleOpen(false); setEditingRole(null) }} destroyOnClose>
<Form form={roleForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="角色名称" rules={[{ required: true }]}><Input maxLength={64} placeholder="例如:客服" /></Form.Item>
{!editingRole && <Form.Item name="code" label="角色编码" rules={[{ required: true, pattern: /^[a-z0-9][a-z0-9-]{1,63}$/, message: '使用 2-64 位小写字母、数字或连字符' }]}><Input maxLength={64} placeholder="例如:support" /></Form.Item>}
<Form.Item name="permissions" label="权限"><Select mode="multiple" options={rolePermissionOptions} /></Form.Item>
</Form>
</Modal>
</div>
+13 -1
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Button,
Checkbox,
@@ -30,6 +31,7 @@ const featureOptions = [
]
export default function PlatformMerchants() {
const navigate = useNavigate()
const [data, setData] = useState<PageResult<Merchant>>({ list: [], total: 0, page: 1, size: 10 })
const [loading, setLoading] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
@@ -272,7 +274,7 @@ export default function PlatformMerchants() {
{
title: '操作',
key: 'action',
width: 180,
width: 240,
fixed: 'right',
render: (_, record) => (
<Space size={0}>
@@ -284,6 +286,16 @@ export default function PlatformMerchants() {
>
</Button>
<Button
type="link"
size="small"
onClick={() => {
localStorage.setItem('merchant_id', String(record.id))
navigate('/merchant-members')
}}
>
</Button>
<Button
type="link"
size="small"
+34 -11
View File
@@ -20,6 +20,7 @@ import type {
CallbackSubscription,
FulfillmentOrder,
MerchantMember,
MerchantRole,
MerchantProduct,
PageResult,
WalletAccount,
@@ -31,6 +32,7 @@ import {
money,
orderStatusMap,
pageConfig,
rolePermissionText,
roleText,
scopeLabel,
} from './merchantCenterUtils'
@@ -660,20 +662,27 @@ export function CallbacksTab({
export function MembersTab({
loading,
members,
isOwner,
roles,
canManage,
form,
onOpenAdd,
onOpenCreateRole,
onEditRole,
}: {
loading: boolean
members: MerchantMember[]
isOwner: boolean
roles: MerchantRole[]
canManage: boolean
form: FormInstance
onOpenAdd: () => void
onOpenCreateRole: () => void
onEditRole: (role: MerchantRole) => void
}) {
const roleNames = new Map(roles.map((item) => [item.code, item.name]))
const memberColumns: ColumnsType<MerchantMember> = [
{ title: '用户', dataIndex: ['user', 'username'], render: (_, r) => r.user?.username || `#${r.user_id}` },
{ title: '昵称', dataIndex: ['user', 'nickname'], render: (_, r) => r.user?.nickname || '-' },
{ title: '角色', dataIndex: 'role', width: 120, render: memberRoleTag },
{ title: '角色', dataIndex: 'role', width: 140, render: (value) => <Tag color="blue">{roleNames.get(value) || roleText(value)}</Tag> },
{ title: '默认', dataIndex: 'is_default', width: 80, render: (v) => (v ? <Tag color="blue"></Tag> : '-') },
{ title: '状态', dataIndex: 'status', width: 90, render: (v) => (v === 1 ? <Tag color="green"></Tag> : <Tag></Tag>) },
]
@@ -681,14 +690,32 @@ export function MembersTab({
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text type="secondary"></Typography.Text>
{isOwner && <Button type="primary" icon={<PlusOutlined />} onClick={() => {
<Typography.Text type="secondary"></Typography.Text>
{canManage && <Space>
<Button onClick={() => {
form.resetFields()
onOpenCreateRole()
}}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
form.resetFields()
form.setFieldsValue({ role: 'operator' })
onOpenAdd()
}}></Button>}
}}></Button>
</Space>}
</Space>
<Table rowKey="id" loading={loading} columns={memberColumns} dataSource={members} tableLayout="fixed" />
<Card size="small" title="团队角色">
<Table
rowKey={(role) => role.id || role.code}
size="small"
pagination={false}
dataSource={roles}
columns={[
{ title: '角色', dataIndex: 'name', width: 160, render: (name, role) => <Space><Tag color={role.code === 'owner' ? 'gold' : 'blue'}>{name}</Tag>{role.code === 'owner' && <Typography.Text type="secondary"></Typography.Text>}</Space> },
{ title: '权限', dataIndex: 'permissions', render: (permissions) => permissions ? permissions.split(',').map((item: string) => <Tag key={item}>{rolePermissionText(item)}</Tag>) : '-' },
{ title: '操作', width: 90, render: (_, role) => role.code === 'owner' ? '-' : <Button type="link" size="small" disabled={!canManage} onClick={() => onEditRole(role)}></Button> },
]}
/>
</Card>
</Space>
)
}
@@ -761,10 +788,6 @@ function ledgerTypeTag(value: string) {
return <Tag color={item.color}>{item.text}</Tag>
}
function memberRoleTag(value: MerchantMember['role']) {
return <Tag color={value === 'owner' ? 'gold' : 'blue'}>{roleText(value)}</Tag>
}
function scopesTag(value: string) {
const scopes = (value || '').split(',').filter(Boolean)
if (scopes.length === 0) {
+16 -1
View File
@@ -12,9 +12,24 @@ export const memberRoleOptions = [
{ value: 'owner', label: '负责人' },
{ value: 'operator', label: '运营' },
{ value: 'finance', label: '财务' },
{ value: 'viewer', label: '只读' },
{ value: 'support', label: '客服' },
]
export const rolePermissionOptions = [
{ value: 'products:manage', label: '商品管理' },
{ value: 'orders:manage', label: '订单与发货管理' },
{ value: 'wallet:view', label: '查看钱包与充值记录' },
{ value: 'wallet:ledger', label: '查看积分明细' },
{ value: 'recharge:manage', label: '申请充值与管理余额告警' },
{ value: 'api:manage', label: '管理 API 密钥' },
{ value: 'callbacks:manage', label: '管理回调' },
{ value: 'members:manage', label: '管理团队成员与角色' },
]
export function rolePermissionText(permission: string) {
return rolePermissionOptions.find((item) => item.value === permission)?.label || permission
}
export const apiClientMax = 5
export const scopeOptions = [
+57 -3
View File
@@ -2,12 +2,13 @@ import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
import type { User } from '../types'
import { authApi } from '../api'
import type { Merchant, User } from '../types'
import { authApi, merchantApi } from '../api'
interface AuthState {
token: string | null
@@ -15,6 +16,11 @@ interface AuthState {
login: (username: string, password: string) => Promise<void>
logout: () => void
refreshProfile: () => Promise<void>
merchant: Merchant | null
merchantRole: string | null
merchantPermissions: string[]
merchantLoading: boolean
refreshMerchantContext: () => Promise<void>
isAdmin: boolean
}
@@ -32,20 +38,63 @@ function loadUser(): User | null {
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(() => localStorage.getItem('token'))
const [user, setUser] = useState<User | null>(() => loadUser())
const [merchant, setMerchant] = useState<Merchant | null>(null)
const [merchantRole, setMerchantRole] = useState<string | null>(null)
const [merchantPermissions, setMerchantPermissions] = useState<string[]>([])
const [merchantLoading, setMerchantLoading] = useState(() => Boolean(localStorage.getItem('token')))
const refreshMerchantContext = useCallback(async () => {
if (!token) {
setMerchant(null)
setMerchantRole(null)
setMerchantPermissions([])
setMerchantLoading(false)
return
}
setMerchantLoading(true)
try {
const data = await merchantApi.current()
setMerchant(data.merchant)
setMerchantRole(data.role)
const nextPermissions = [...new Set(data.permissions || [])].sort()
setMerchantPermissions((current) => (
current.length === nextPermissions.length && current.every((value, index) => value === nextPermissions[index])
? current
: nextPermissions
))
} catch {
setMerchant(null)
setMerchantRole(null)
setMerchantPermissions([])
} finally {
setMerchantLoading(false)
}
}, [token])
useEffect(() => {
void refreshMerchantContext()
}, [refreshMerchantContext])
const login = useCallback(async (username: string, password: string) => {
const result = await authApi.login(username, password)
localStorage.removeItem('merchant_id')
localStorage.setItem('token', result.token)
localStorage.setItem('user', JSON.stringify(result.user))
setToken(result.token)
setUser(result.user)
setMerchantLoading(true)
}, [])
const logout = useCallback(() => {
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('merchant_id')
setToken(null)
setUser(null)
setMerchant(null)
setMerchantRole(null)
setMerchantPermissions([])
setMerchantLoading(false)
}, [])
const refreshProfile = useCallback(async () => {
@@ -61,9 +110,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
login,
logout,
refreshProfile,
merchant,
merchantRole,
merchantPermissions,
merchantLoading,
refreshMerchantContext,
isAdmin: user?.role === 'admin',
}),
[token, user, login, logout, refreshProfile],
[token, user, login, logout, refreshProfile, merchant, merchantRole, merchantPermissions, merchantLoading, refreshMerchantContext],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
+11 -1
View File
@@ -25,7 +25,7 @@ export interface MerchantMember {
id: number
merchant_id: number
user_id: number
role: 'owner' | 'operator' | 'finance' | 'viewer'
role: string
status: number
is_default: boolean
user?: User
@@ -33,6 +33,16 @@ export interface MerchantMember {
created_at: string
}
export interface MerchantRole {
id: number
merchant_id: number
code: string
name: string
permissions: string
status: number
created_at: string
}
export interface Product {
id: number
code: string