优化商户功能导航与表格
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
@@ -81,10 +82,19 @@ const testOrderStatusOptions = [
|
||||
{ value: 'failed', label: '发货失败,可重试' },
|
||||
]
|
||||
|
||||
export default function MerchantCenter() {
|
||||
type MerchantCenterTab = 'products' | 'orders' | 'wallet' | 'api' | 'callbacks' | 'members'
|
||||
|
||||
interface MerchantCenterProps {
|
||||
fixedTab?: MerchantCenterTab
|
||||
title?: string
|
||||
}
|
||||
|
||||
export default function MerchantCenter({ fixedTab, title = '商户中心' }: MerchantCenterProps = {}) {
|
||||
const location = useLocation()
|
||||
const routeTab = useMemo(() => fixedTab ?? tabFromSearch(location.search), [fixedTab, location.search])
|
||||
const [merchant, setMerchant] = useState<Merchant | null>(null)
|
||||
const [merchantRole, setMerchantRole] = useState<MerchantMember['role']>()
|
||||
const [activeTab, setActiveTab] = useState('products')
|
||||
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 })
|
||||
const [ledger, setLedger] = useState<PageResult<WalletLedgerEntry>>({ list: [], total: 0, page: 1, size: 10 })
|
||||
@@ -252,8 +262,8 @@ export default function MerchantCenter() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const current = await loadCurrent()
|
||||
const nextTab = resolveEnabledTab(activeTab, current.merchant.features)
|
||||
if (nextTab !== activeTab) {
|
||||
const nextTab = fixedTab ?? resolveEnabledTab(routeTab ?? activeTab, current.merchant.features)
|
||||
if (!fixedTab && nextTab !== activeTab) {
|
||||
setActiveTab(nextTab)
|
||||
}
|
||||
await loadActiveTab(current.role, nextTab)
|
||||
@@ -262,12 +272,18 @@ export default function MerchantCenter() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [activeTab, loadActiveTab, loadCurrent])
|
||||
}, [activeTab, fixedTab, loadActiveTab, loadCurrent, routeTab])
|
||||
|
||||
useEffect(() => {
|
||||
loadAll()
|
||||
}, [loadAll])
|
||||
|
||||
useEffect(() => {
|
||||
if (routeTab && activeTab !== routeTab) {
|
||||
setActiveTab(routeTab)
|
||||
}
|
||||
}, [activeTab, routeTab])
|
||||
|
||||
const openProductCreate = () => {
|
||||
setEditingProduct(null)
|
||||
productForm.resetFields()
|
||||
@@ -380,7 +396,7 @@ export default function MerchantCenter() {
|
||||
})
|
||||
setApiCredential(credential)
|
||||
setApiClientOpen(false)
|
||||
message.success('API 客户端已创建')
|
||||
message.success('API 密钥已创建')
|
||||
loadAPIClients()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败')
|
||||
@@ -459,27 +475,39 @@ export default function MerchantCenter() {
|
||||
]
|
||||
|
||||
const orderColumns: ColumnsType<FulfillmentOrder> = [
|
||||
{ title: '平台订单号', dataIndex: 'order_no', width: 210, render: (v) => <Typography.Text copyable>{v}</Typography.Text> },
|
||||
{ title: '商户单号', dataIndex: 'client_order_no', width: 160, ellipsis: true },
|
||||
{ title: 'SKU', dataIndex: 'product_sku', width: 150, render: (v) => <Typography.Text code>{v}</Typography.Text> },
|
||||
{ title: '商品', dataIndex: 'product_name', ellipsis: true },
|
||||
{ title: '基础金额', dataIndex: 'base_amount', width: 100, render: money },
|
||||
{ title: '手续费', dataIndex: 'service_fee_amount', width: 100, render: money },
|
||||
{ title: '扣款合计', dataIndex: 'amount', width: 100, render: money },
|
||||
{ title: '支付', dataIndex: 'payment_status', width: 90, render: paymentStatusTag },
|
||||
{ title: '履约', dataIndex: 'fulfillment_status', width: 100, render: fulfillmentStatusTag },
|
||||
{ title: '上游单号', dataIndex: 'provider_order_no', width: 140, ellipsis: true, render: (v) => v || '-' },
|
||||
{
|
||||
title: '平台订单号',
|
||||
dataIndex: 'order_no',
|
||||
width: 190,
|
||||
ellipsis: true,
|
||||
render: (v) => <Typography.Text copyable ellipsis style={{ maxWidth: '100%' }}>{v}</Typography.Text>,
|
||||
},
|
||||
{ title: '商户单号', dataIndex: 'client_order_no', width: 140, ellipsis: true },
|
||||
{
|
||||
title: 'SKU',
|
||||
dataIndex: 'product_sku',
|
||||
width: 130,
|
||||
ellipsis: true,
|
||||
render: (v) => <Typography.Text code ellipsis style={{ maxWidth: '100%' }}>{v}</Typography.Text>,
|
||||
},
|
||||
{ title: '商品', dataIndex: 'product_name', width: 220, ellipsis: true },
|
||||
{ title: '基础金额', dataIndex: 'base_amount', width: 88, render: money },
|
||||
{ title: '手续费', dataIndex: 'service_fee_amount', width: 82, render: money },
|
||||
{ title: '扣款合计', dataIndex: 'amount', width: 88, render: money },
|
||||
{ title: '支付', dataIndex: 'payment_status', width: 76, render: paymentStatusTag },
|
||||
{ title: '履约', dataIndex: 'fulfillment_status', width: 86, render: fulfillmentStatusTag },
|
||||
{ title: '上游单号', dataIndex: 'provider_order_no', width: 120, ellipsis: true, render: (v) => v || '-' },
|
||||
{
|
||||
title: '链接有效期',
|
||||
dataIndex: 'delivery_link_expires_at',
|
||||
width: 160,
|
||||
width: 140,
|
||||
render: (_, record) => record.delivery_link_revoked_at ? <Tag color="red">已作废</Tag> : formatDateTime(record.delivery_link_expires_at),
|
||||
},
|
||||
{ title: '时间', dataIndex: 'created_at', width: 180, render: formatDateTime },
|
||||
{ title: '时间', dataIndex: 'created_at', width: 150, render: formatDateTime },
|
||||
{
|
||||
title: '发货链接',
|
||||
key: 'delivery_link',
|
||||
width: 220,
|
||||
width: 150,
|
||||
render: (_, record) => record.delivery_link_revoked_at ? (
|
||||
<Button type="link" size="small" onClick={() => restoreDeliveryLink(record.order_no)}>取消作废</Button>
|
||||
) : (
|
||||
@@ -547,12 +575,141 @@ export default function MerchantCenter() {
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (v) => (v === 1 ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>) },
|
||||
]
|
||||
|
||||
const productContent = (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Text type="secondary">通用商品以商户 SKU 对外销售。</Typography.Text>
|
||||
{canManage && <Button type="primary" icon={<PlusOutlined />} onClick={openProductCreate}>新增商品</Button>}
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={productColumns}
|
||||
dataSource={products.list}
|
||||
tableLayout="fixed"
|
||||
pagination={pageConfig(products, loadProducts)}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
|
||||
const orderContent = (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Text type="secondary">发货链接由后端生成并带签名,用户只填写游戏 UID。</Typography.Text>
|
||||
{canManage && (
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openTestOrderCreate}>
|
||||
创建测试订单
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={orderColumns}
|
||||
dataSource={orders.list}
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 1660 }}
|
||||
pagination={pageConfig(orders, loadOrders)}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
|
||||
const walletContent = (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Descriptions size="small" bordered column={3} style={{ flex: 1 }}>
|
||||
<Descriptions.Item label="可用余额">{money(wallet?.available_balance ?? 0)}</Descriptions.Item>
|
||||
<Descriptions.Item label="冻结余额">{money(wallet?.frozen_balance ?? 0)}</Descriptions.Item>
|
||||
<Descriptions.Item label="币种">{wallet?.currency || 'POINT'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{canFinance && (
|
||||
<Button
|
||||
icon={<WalletOutlined />}
|
||||
onClick={() => {
|
||||
walletForm.resetFields()
|
||||
walletForm.setFieldsValue({ idempotency_key: `manual-${Date.now()}` })
|
||||
setWalletOpen(true)
|
||||
}}
|
||||
>
|
||||
调整
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
{canFinance ? (
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={ledgerColumns}
|
||||
dataSource={ledger.list}
|
||||
tableLayout="fixed"
|
||||
pagination={pageConfig(ledger, loadWallet)}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text type="secondary">当前角色可查看余额,钱包流水仅财务或负责人可见。</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
|
||||
const apiKeyContent = (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Text type="secondary">用于开放 API 调用鉴权。新 Secret 只在创建后显示一次,请及时保存。</Typography.Text>
|
||||
{canManage && <Button type="primary" icon={<ApiOutlined />} onClick={() => {
|
||||
apiClientForm.resetFields()
|
||||
apiClientForm.setFieldsValue({ signature_version: 'v1', scopes: ['products:read', 'orders:read', 'orders:write'] })
|
||||
setApiClientOpen(true)
|
||||
}}>新增密钥</Button>}
|
||||
</Space>
|
||||
<Table rowKey="id" loading={loading} columns={apiClientColumns} dataSource={apiClients} tableLayout="fixed" />
|
||||
</Space>
|
||||
)
|
||||
|
||||
const callbackContent = (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Text type="secondary">回调事件通过 outbox 持久化发送。</Typography.Text>
|
||||
{canManage && <Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
callbackForm.resetFields()
|
||||
callbackForm.setFieldsValue({ events: ['order.fulfillment.updated'] })
|
||||
setCallbackOpen(true)
|
||||
}}>新增回调</Button>}
|
||||
</Space>
|
||||
<Table rowKey="id" loading={loading} columns={callbackColumns} dataSource={callbacks} tableLayout="fixed" />
|
||||
</Space>
|
||||
)
|
||||
|
||||
const memberContent = (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Text type="secondary">成员角色独立于平台账号角色。</Typography.Text>
|
||||
{merchantRole === 'owner' && <Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
memberForm.resetFields()
|
||||
memberForm.setFieldsValue({ role: 'operator' })
|
||||
setMemberOpen(true)
|
||||
}}>添加成员</Button>}
|
||||
</Space>
|
||||
<Table rowKey="id" loading={loading} columns={memberColumns} dataSource={members} tableLayout="fixed" />
|
||||
</Space>
|
||||
)
|
||||
|
||||
const tabItems = [
|
||||
{ key: 'products', label: '商品', disabled: !hasFeature('products'), children: productContent },
|
||||
{ key: 'orders', label: '履约订单', disabled: !hasFeature('orders'), children: orderContent },
|
||||
{ key: 'wallet', label: '钱包', disabled: !hasFeature('wallet'), children: walletContent },
|
||||
{ key: 'api', label: 'API 密钥', disabled: !hasFeature('api'), children: apiKeyContent },
|
||||
{ key: 'callbacks', label: '回调', disabled: !hasFeature('callbacks'), children: callbackContent },
|
||||
{ key: 'members', label: '成员', children: memberContent },
|
||||
]
|
||||
const fixedTabContent = tabItems.find((item) => item.key === fixedTab)?.children
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
商户中心
|
||||
{title}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
{merchant ? `${merchant.name} / ${merchant.code}` : '加载中'} · {merchantRole ? roleText(merchantRole) : '-'}
|
||||
@@ -563,154 +720,13 @@ export default function MerchantCenter() {
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{
|
||||
key: 'products',
|
||||
label: '商品',
|
||||
disabled: !hasFeature('products'),
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Text type="secondary">通用商品以商户 SKU 对外销售。</Typography.Text>
|
||||
{canManage && <Button type="primary" icon={<PlusOutlined />} onClick={openProductCreate}>新增商品</Button>}
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={productColumns}
|
||||
dataSource={products.list}
|
||||
tableLayout="fixed"
|
||||
pagination={pageConfig(products, loadProducts)}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'orders',
|
||||
label: '履约订单',
|
||||
disabled: !hasFeature('orders'),
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Text type="secondary">发货链接由后端生成并带签名,用户只填写游戏 UID。</Typography.Text>
|
||||
{canManage && (
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openTestOrderCreate}>
|
||||
创建测试订单
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={orderColumns}
|
||||
dataSource={orders.list}
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 1500 }}
|
||||
pagination={pageConfig(orders, loadOrders)}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'wallet',
|
||||
label: '钱包',
|
||||
disabled: !hasFeature('wallet'),
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Descriptions size="small" bordered column={3} style={{ flex: 1 }}>
|
||||
<Descriptions.Item label="可用余额">{money(wallet?.available_balance ?? 0)}</Descriptions.Item>
|
||||
<Descriptions.Item label="冻结余额">{money(wallet?.frozen_balance ?? 0)}</Descriptions.Item>
|
||||
<Descriptions.Item label="币种">{wallet?.currency || 'POINT'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{canFinance && (
|
||||
<Button
|
||||
icon={<WalletOutlined />}
|
||||
onClick={() => {
|
||||
walletForm.resetFields()
|
||||
walletForm.setFieldsValue({ idempotency_key: `manual-${Date.now()}` })
|
||||
setWalletOpen(true)
|
||||
}}
|
||||
>
|
||||
调整
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
{canFinance ? (
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={ledgerColumns}
|
||||
dataSource={ledger.list}
|
||||
tableLayout="fixed"
|
||||
pagination={pageConfig(ledger, loadWallet)}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text type="secondary">当前角色可查看余额,钱包流水仅财务或负责人可见。</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'api',
|
||||
label: 'API 客户端',
|
||||
disabled: !hasFeature('api'),
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Text type="secondary">新密钥只在创建后显示一次。</Typography.Text>
|
||||
{canManage && <Button type="primary" icon={<ApiOutlined />} onClick={() => {
|
||||
apiClientForm.resetFields()
|
||||
apiClientForm.setFieldsValue({ signature_version: 'v1', scopes: ['products:read', 'orders:read', 'orders:write'] })
|
||||
setApiClientOpen(true)
|
||||
}}>新增客户端</Button>}
|
||||
</Space>
|
||||
<Table rowKey="id" loading={loading} columns={apiClientColumns} dataSource={apiClients} tableLayout="fixed" />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'callbacks',
|
||||
label: '回调',
|
||||
disabled: !hasFeature('callbacks'),
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Text type="secondary">回调事件通过 outbox 持久化发送。</Typography.Text>
|
||||
{canManage && <Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
callbackForm.resetFields()
|
||||
callbackForm.setFieldsValue({ events: ['order.fulfillment.updated'] })
|
||||
setCallbackOpen(true)
|
||||
}}>新增回调</Button>}
|
||||
</Space>
|
||||
<Table rowKey="id" loading={loading} columns={callbackColumns} dataSource={callbacks} tableLayout="fixed" />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
label: '成员',
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Text type="secondary">成员角色独立于平台账号角色。</Typography.Text>
|
||||
{merchantRole === 'owner' && <Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
memberForm.resetFields()
|
||||
memberForm.setFieldsValue({ role: 'operator' })
|
||||
setMemberOpen(true)
|
||||
}}>添加成员</Button>}
|
||||
</Space>
|
||||
<Table rowKey="id" loading={loading} columns={memberColumns} dataSource={members} tableLayout="fixed" />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{fixedTab ? fixedTabContent : (
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as MerchantCenterTab)}
|
||||
items={tabItems}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal title={editingProduct ? '编辑商品' : '新增商品'} open={productOpen} onOk={submitProduct} onCancel={() => setProductOpen(false)} destroyOnClose width={620}>
|
||||
<Form form={productForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
@@ -847,7 +863,7 @@ export default function MerchantCenter() {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="新增 API 客户端" open={apiClientOpen} onOk={submitAPIClient} onCancel={() => setApiClientOpen(false)} destroyOnClose>
|
||||
<Modal title="新增 API 密钥" open={apiClientOpen} onOk={submitAPIClient} onCancel={() => setApiClientOpen(false)} destroyOnClose>
|
||||
<Form form={apiClientForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
@@ -942,9 +958,15 @@ function featuresToList(features?: string) {
|
||||
return list.length > 0 ? list : ['products', 'orders', 'wallet', 'api', 'callbacks']
|
||||
}
|
||||
|
||||
function resolveEnabledTab(current: string, features?: string) {
|
||||
function tabFromSearch(search: string): MerchantCenterTab | null {
|
||||
const tab = new URLSearchParams(search).get('tab')
|
||||
const allowedTabs: MerchantCenterTab[] = ['products', 'orders', 'wallet', 'api', 'callbacks', 'members']
|
||||
return allowedTabs.includes(tab as MerchantCenterTab) ? tab as MerchantCenterTab : null
|
||||
}
|
||||
|
||||
function resolveEnabledTab(current: MerchantCenterTab, features?: string): MerchantCenterTab {
|
||||
const enabled = new Set(featuresToList(features))
|
||||
const tabFeatures: Record<string, string | null> = {
|
||||
const tabFeatures: Record<MerchantCenterTab, string | null> = {
|
||||
products: 'products',
|
||||
orders: 'orders',
|
||||
wallet: 'wallet',
|
||||
@@ -956,7 +978,7 @@ function resolveEnabledTab(current: string, features?: string) {
|
||||
if (feature === null || enabled.has(feature)) {
|
||||
return current
|
||||
}
|
||||
return ['products', 'orders', 'wallet', 'api', 'callbacks'].find((key) => enabled.has(key)) || 'members'
|
||||
return (['products', 'orders', 'wallet', 'api', 'callbacks'] as MerchantCenterTab[]).find((key) => enabled.has(key)) || 'members'
|
||||
}
|
||||
|
||||
function money(value?: number | null) {
|
||||
|
||||
Reference in New Issue
Block a user