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
+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>