Files
affiliate_dash/frontend/src/pages/MerchantCenter.tsx
T
yml2213 8da9dcec43 发货订单列表移除上游单号列,文档弱化内部发货单号
- 前端订单表格删除「上游单号」列(商户无需关注)
- 对接文档 provider_order_no 标注为平台内部发货单号
- 后端 provider_order_no 保留(防重复发货标记)
2026-08-03 16:31:50 +08:00

1150 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useLocation } from 'react-router-dom'
import {
Button,
Card,
Descriptions,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd'
import {
ApiOutlined,
CopyOutlined,
LinkOutlined,
PlusOutlined,
ReloadOutlined,
WalletOutlined,
} from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import { merchantApi } from '../api'
import { formatDateTime } from '../utils/time'
import type {
ApiClient,
ApiCredential,
CallbackCredential,
CallbackSubscription,
CreateTestOrderResult,
FulfillmentOrder,
Merchant,
MerchantMember,
MerchantProduct,
PageResult,
WalletAccount,
WalletLedgerEntry,
} from '../types'
const orderStatusMap: Record<string, { color: string; text: string }> = {
paid: { color: 'blue', text: '待发货' },
delivering: { color: 'cyan', text: '发货中' },
delivered: { color: 'green', text: '已交付' },
ship_failed: { color: 'red', text: '发货失败' },
cancelled: { color: 'default', text: '已取消' },
}
const memberRoleOptions = [
{ value: 'owner', label: '负责人' },
{ value: 'operator', label: '运营' },
{ value: 'finance', label: '财务' },
{ value: 'viewer', label: '只读' },
]
const apiClientMax = 5
const scopeOptions = [
{ value: 'products:read', label: '商品读取' },
{ value: 'orders:read', label: '订单读取' },
{ value: 'orders:write', label: '订单写入' },
{ value: 'shipping:read', label: '发货读取' },
{ value: 'wallet:read', label: '钱包读取' },
]
const eventOptions = [
{ value: 'order.created', label: '订单创建' },
{ value: 'order.shipping.updated', label: '发货更新' },
{ value: 'order.cancelled', label: '订单取消' },
]
const testOrderStatusOptions = [
{ value: 'paid', label: '已支付,可发货' },
{ value: 'ship_failed', label: '发货失败,可重试' },
{ value: 'cancelled', label: '已取消,不可发货' },
]
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<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 })
const [wallet, setWallet] = useState<WalletAccount | null>(null)
const [apiClients, setApiClients] = useState<ApiClient[]>([])
const [callback, setCallback] = useState<CallbackSubscription | null>(null)
const [members, setMembers] = useState<MerchantMember[]>([])
const [loading, setLoading] = useState(false)
const [productOpen, setProductOpen] = useState(false)
const [editingProduct, setEditingProduct] = useState<MerchantProduct | null>(null)
const [walletOpen, setWalletOpen] = 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 [testOrderOpen, setTestOrderOpen] = useState(false)
const [testOrderProducts, setTestOrderProducts] = useState<MerchantProduct[]>([])
const [testOrderProductsLoading, setTestOrderProductsLoading] = useState(false)
const [testOrderResult, setTestOrderResult] = useState<CreateTestOrderResult | null>(null)
const [productForm] = Form.useForm()
const [walletForm] = Form.useForm()
const [walletFilterForm] = Form.useForm()
const [apiClientForm] = Form.useForm()
const [callbackForm] = Form.useForm()
const [memberForm] = Form.useForm()
const [testOrderForm] = Form.useForm()
const canManage = merchantRole === 'owner' || merchantRole === 'operator'
const canFinance = merchantRole === 'owner' || merchantRole === 'finance'
const enabledFeatures = useMemo(() => new Set(featuresToList(merchant?.features)), [merchant?.features])
const hasFeature = useCallback((feature: string) => !merchant || enabledFeatures.has(feature), [enabledFeatures, merchant])
const testOrderProductOptions = useMemo(() => testOrderProducts.map((item) => ({
value: item.sku,
label: productOptionLabel(item),
})), [testOrderProducts])
const copyDeliveryLink = useCallback((orderNo: string) => {
merchantApi.getDeliveryLink(orderNo)
.then((data) => navigator.clipboard.writeText(data.delivery_url).then(() => message.success('发货链接已复制')))
.catch((e) => message.error(e instanceof Error ? e.message : '复制失败'))
}, [])
const openDeliveryLink = useCallback((orderNo: string) => {
merchantApi.getDeliveryLink(orderNo)
.then((data) => window.open(data.delivery_url, '_blank', 'noopener,noreferrer'))
.catch((e) => message.error(e instanceof Error ? e.message : '打开失败'))
}, [])
const patchDeliveryLinkState = useCallback((orderNo: string, patch: Partial<FulfillmentOrder>) => {
setOrders((prev) => ({
...prev,
list: prev.list.map((item) => item.order_no === orderNo ? { ...item, ...patch } : item),
}))
setTestOrderResult((prev) => prev?.order.order_no === orderNo ? {
...prev,
order: { ...prev.order, ...patch },
} : prev)
}, [])
const loadCurrent = useCallback(async () => {
const data = await merchantApi.current()
setMerchant(data.merchant)
setMerchantRole(data.role)
return data
}, [])
const loadProducts = useCallback(async (page = products.page, size = products.size) => {
const data = await merchantApi.products({ page, size })
setProducts(data)
}, [products.page, products.size])
const loadOrders = useCallback(async (page = orders.page, size = orders.size) => {
const data = await merchantApi.orders({ page, size })
setOrders(data)
}, [orders.page, orders.size])
const revokeDeliveryLink = useCallback((orderNo: string) => {
merchantApi.revokeDeliveryLink(orderNo)
.then(() => {
const revokedAt = new Date().toISOString()
message.success('发货链接已作废')
patchDeliveryLinkState(orderNo, { delivery_link_revoked_at: revokedAt })
})
.catch((e) => message.error(e instanceof Error ? e.message : '作废失败'))
}, [patchDeliveryLinkState])
const restoreDeliveryLink = useCallback((orderNo: string) => {
merchantApi.restoreDeliveryLink(orderNo)
.then((data) => {
message.success('发货链接已恢复')
patchDeliveryLinkState(orderNo, {
delivery_link_expires_at: data.expires_at,
delivery_link_revoked_at: null,
})
})
.catch((e) => message.error(e instanceof Error ? e.message : '恢复失败'))
}, [patchDeliveryLinkState])
const loadWallet = useCallback(async (
page = ledger.page,
size = ledger.size,
role = merchantRole,
filters?: { reference_no?: string; type?: string },
) => {
const walletData = await merchantApi.wallet()
setWallet(walletData)
if (role === 'owner' || role === 'finance') {
const ledgerData = await merchantApi.ledger({ page, size, ...filters })
setLedger(ledgerData)
} else {
setLedger({ list: [], total: 0, page, size })
}
}, [ledger.page, ledger.size, merchantRole])
const loadAPIClients = useCallback(async () => {
const clientData = await merchantApi.apiClients()
setApiClients(clientData || [])
}, [])
const loadCallbacks = useCallback(async (role = merchantRole) => {
if (role !== 'owner' && role !== 'operator') {
setCallback(null)
callbackForm.resetFields()
return
}
const callbackData = await merchantApi.callbacks()
setCallback(callbackData || null)
callbackForm.setFieldsValue(callbackData ? {
url: callbackData.url,
events: eventsToValue(callbackData.events),
status: callbackData.status,
} : defaultCallbackFormValues())
}, [callbackForm, merchantRole])
const loadMembers = useCallback(async (role = merchantRole) => {
if (role !== 'owner' && role !== 'operator') {
setMembers([])
return
}
const memberData = await merchantApi.members()
setMembers(memberData || [])
}, [merchantRole])
const loadActiveTab = useCallback(async (role = merchantRole, tab = activeTab) => {
switch (tab) {
case 'products':
await loadProducts()
return
case 'orders':
await loadOrders()
return
case 'wallet':
await loadWallet(undefined, undefined, role)
return
case 'api':
await loadAPIClients()
return
case 'callbacks':
await loadCallbacks(role)
return
case 'members':
await loadMembers(role)
return
}
}, [activeTab, loadAPIClients, loadCallbacks, loadMembers, loadOrders, loadProducts, loadWallet, merchantRole])
const loadAll = useCallback(async () => {
setLoading(true)
try {
const current = await loadCurrent()
const nextTab = fixedTab ?? resolveEnabledTab(routeTab ?? activeTab, current.merchant.features)
if (!fixedTab && nextTab !== activeTab) {
setActiveTab(nextTab)
}
await loadActiveTab(current.role, nextTab)
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [activeTab, fixedTab, loadActiveTab, loadCurrent, routeTab])
useEffect(() => {
loadAll()
}, [loadAll])
useEffect(() => {
if (routeTab && activeTab !== routeTab) {
setActiveTab(routeTab)
}
}, [activeTab, routeTab])
const openProductCreate = () => {
setEditingProduct(null)
productForm.resetFields()
productForm.setFieldsValue({
currency: 'POINT',
stock: -1,
status: 'active',
price_amount: 0,
cost_amount: 0,
})
setProductOpen(true)
}
const openProductEdit = (record: MerchantProduct) => {
setEditingProduct(record)
productForm.setFieldsValue({
...record,
})
setProductOpen(true)
}
const submitProduct = async () => {
const values = await productForm.validateFields()
try {
const payload = {
...values,
}
if (editingProduct) {
await merchantApi.updateProduct(editingProduct.id, payload)
message.success('商品已更新')
} else {
await merchantApi.createProduct(payload)
message.success('商品已创建')
}
setProductOpen(false)
loadProducts()
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
}
}
const submitWalletAdjust = async () => {
const values = await walletForm.validateFields()
try {
const walletData = await merchantApi.adjustWallet({
amount: values.amount,
idempotency_key: values.idempotency_key,
note: values.note,
})
setWallet(walletData)
setWalletOpen(false)
message.success('钱包已调整')
loadWallet()
} catch (e) {
message.error(e instanceof Error ? e.message : '调整失败')
}
}
const handleWalletFilter = async () => {
const values = await walletFilterForm.validateFields()
loadWallet(1, ledger.size, merchantRole, {
reference_no: values.reference_no || undefined,
type: values.type || undefined,
})
}
const handleWalletFilterReset = () => {
walletFilterForm.resetFields()
loadWallet(1, ledger.size, merchantRole, {})
}
const openTestOrderCreate = () => {
testOrderForm.resetFields()
testOrderForm.setFieldsValue({
buyer_reference: '测试买家',
note: '联调测试订单',
order_status: 'paid',
})
setTestOrderOpen(true)
setTestOrderProductsLoading(true)
merchantApi.products({ page: 1, size: 100 })
.then((data) => {
const activeProducts = (data.list || []).filter((item) => item.status === 'active')
setTestOrderProducts(activeProducts)
if (activeProducts.length > 0) {
testOrderForm.setFieldsValue({ sku: activeProducts[0].sku })
} else {
message.warning('没有可用商品')
}
})
.catch((e) => {
message.error(e instanceof Error ? e.message : '商品加载失败')
})
.finally(() => setTestOrderProductsLoading(false))
}
const submitTestOrder = async () => {
const values = await testOrderForm.validateFields()
try {
const result = await merchantApi.createTestOrder({
sku: values.sku,
buyer_reference: values.buyer_reference,
note: values.note,
order_status: values.order_status,
})
setTestOrderOpen(false)
setTestOrderResult(result)
message.success('测试订单已创建')
loadOrders()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
}
}
const submitAPIClient = async () => {
const values = await apiClientForm.validateFields()
try {
const credential = await merchantApi.createApiClient({
name: values.name,
scopes: (values.scopes || []).join(','),
signature_version: values.signature_version,
expires_at: values.expires_at,
})
setApiCredential(credential)
setApiClientOpen(false)
message.success('API 密钥已创建')
loadAPIClients()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
}
}
const submitCallback = async () => {
const values = await callbackForm.validateFields()
try {
const credential = await merchantApi.saveCallback({
url: values.url,
events: (values.events || []).join(','),
status: values.status,
})
setCallback(credential.subscription)
if (credential.secret) {
setCallbackCredential(credential)
}
message.success('回调配置已保存')
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
}
}
const rotateCallbackSecret = async () => {
const values = await callbackForm.validateFields()
try {
const credential = await merchantApi.saveCallback({
url: values.url,
events: (values.events || []).join(','),
status: values.status,
rotate_secret: true,
})
setCallback(credential.subscription)
if (credential.secret) {
setCallbackCredential(credential)
}
message.success('回调密钥已重置,旧密钥立即失效')
} catch (e) {
message.error(e instanceof Error ? e.message : '重置失败')
}
}
const submitMember = async () => {
const values = await memberForm.validateFields()
try {
await merchantApi.addMember({
user_id: values.user_id,
role: values.role,
is_default: values.is_default,
})
setMemberOpen(false)
message.success('成员已添加')
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')
message.success('状态已更新')
loadAPIClients()
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败')
}
}
const deleteAPIClient = async (record: ApiClient) => {
try {
await merchantApi.deleteApiClient(record.id)
message.success('密钥已删除')
loadAPIClients()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
}
}
const productColumns: ColumnsType<MerchantProduct> = [
{ title: 'SKU', dataIndex: 'sku', width: 200, ellipsis: true, render: (v) => <Typography.Text code copyable={{ tooltips: false }} style={{ maxWidth: '100%' }} ellipsis>{v}</Typography.Text> },
{ title: '名称', dataIndex: 'display_name', width: 200, ellipsis: true, render: (_, r) => r.display_name || r.product?.name || '-' },
{ title: '目录编码', dataIndex: ['product', 'code'], width: 140, ellipsis: true, render: (_, r) => r.product?.code || '-' },
{ title: '售价', dataIndex: 'price_amount', width: 100, render: money },
{ title: '成本', dataIndex: 'cost_amount', width: 100, render: money },
{ title: '库存', dataIndex: 'stock', width: 80, render: (v) => (v < 0 ? '无限' : v) },
{ title: '状态', dataIndex: 'status', width: 80, render: productStatusTag },
{
title: '操作',
key: 'action',
width: 80,
render: (_, record) => canManage ? (
<Button type="link" size="small" onClick={() => openProductEdit(record)}>编辑</Button>
) : '-',
},
]
const orderColumns: ColumnsType<FulfillmentOrder> = [
{
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: 'order_status', width: 92, render: orderStatusTag },
{
title: '链接有效期',
dataIndex: 'delivery_link_expires_at',
width: 140,
render: (_, record) => record.delivery_link_revoked_at ? <Tag color="red">已作废</Tag> : formatDateTime(record.delivery_link_expires_at),
},
{ title: '时间', dataIndex: 'created_at', width: 150, render: formatDateTime },
{
title: '发货链接',
key: 'delivery_link',
width: 150,
render: (_, record) => record.delivery_link_revoked_at ? (
<Button type="link" size="small" onClick={() => restoreDeliveryLink(record.order_no)}>取消作废</Button>
) : (
<Space size={4} wrap>
<Button type="link" size="small" onClick={() => copyDeliveryLink(record.order_no)}>复制</Button>
<Button type="link" size="small" onClick={() => openDeliveryLink(record.order_no)}>打开</Button>
<Button type="link" danger size="small" onClick={() => revokeDeliveryLink(record.order_no)}>作废</Button>
</Space>
),
},
]
const ledgerColumns: ColumnsType<WalletLedgerEntry> = [
{ title: '流水号', dataIndex: 'entry_no', width: 300, render: (v) => <Typography.Text code copyable={{ tooltips: false }}>{v}</Typography.Text> },
{ title: '类型', dataIndex: 'type', width: 80, render: ledgerTypeTag },
{ title: '金额', dataIndex: 'amount', width: 120, render: moneyWithSign },
{ title: '余额', dataIndex: 'balance_after', width: 120, render: money },
{ title: '关联单号', dataIndex: 'reference_no', width: 200, ellipsis: true, render: (v) => v ? <Typography.Text code ellipsis style={{ maxWidth: '100%' }}>{v}</Typography.Text> : '-' },
{ title: '备注', dataIndex: 'note', width: 150, ellipsis: { showTitle: false }, render: (v) => <Typography.Text ellipsis title={v}>{v || '-'}</Typography.Text> },
{ title: '时间', dataIndex: 'created_at', width: 170, render: formatDateTime },
]
const apiClientColumns: ColumnsType<ApiClient> = [
{ title: '名称', dataIndex: 'name', width: 140, ellipsis: true },
{ title: 'App Key', dataIndex: 'app_key', width: 230, render: (v) => <Typography.Text code copyable>{v}</Typography.Text> },
{ title: '签名', dataIndex: 'signature_version', width: 80, render: (v) => <Tag>{v}</Tag> },
{ title: '权限', dataIndex: 'scopes', width: 250, render: scopesTag },
{ title: '状态', dataIndex: 'status', width: 80, render: activeStatusTag },
{ title: '最后使用', dataIndex: 'last_used_at', width: 170, render: formatDateTime },
{
title: '操作',
key: 'action',
width: 150,
render: (_, record) => canManage ? (
<Space size={0}>
<Button type="link" size="small" onClick={() => toggleAPIClient(record)}>
{record.status === 'active' ? '禁用' : '启用'}
</Button>
<Popconfirm
title="删除该密钥?"
description={record.status === 'active' ? '请先停用该密钥再删除' : '删除后不可恢复,使用该密钥的对接方将无法调用开放接口。'}
okText="删除"
okButtonProps={{ danger: true }}
onConfirm={() => deleteAPIClient(record)}
>
<Button type="link" size="small" danger disabled={record.status === 'active'}>
删除
</Button>
</Popconfirm>
</Space>
) : '-',
},
]
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: '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>) },
]
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' }} size="large">
<Descriptions size="small" bordered column={3}>
<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
type="primary"
icon={<WalletOutlined />}
onClick={() => {
walletForm.resetFields()
walletForm.setFieldsValue({ idempotency_key: `manual-${Date.now()}` })
setWalletOpen(true)
}}
>
调整
</Button>
)}
</Space>
{canFinance && (
<Card size="small" style={{ background: '#fafafa' }}>
<Form form={walletFilterForm} layout="inline" onFinish={handleWalletFilter}>
<Form.Item name="reference_no" label="关联单号">
<Input placeholder="店铺单号 / 充值单号" allowClear style={{ width: 220 }} />
</Form.Item>
<Form.Item name="type" label="收支">
<Select placeholder="请选择" allowClear style={{ width: 120 }}>
<Select.Option value="credit">入账</Select.Option>
<Select.Option value="debit">扣款</Select.Option>
<Select.Option value="refund">退款</Select.Option>
<Select.Option value="adjust">调整</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button onClick={handleWalletFilterReset}>重置</Button>
<Button type="primary" htmlType="submit">查询</Button>
</Space>
</Form.Item>
</Form>
</Card>
)}
{canFinance ? (
<Table
rowKey="id"
loading={loading}
columns={ledgerColumns}
dataSource={ledger.list}
tableLayout="fixed"
scroll={{ x: 1300 }}
pagination={pageConfig(ledger, (page, size) => {
const values = walletFilterForm.getFieldsValue()
loadWallet(page, size, merchantRole, {
reference_no: values.reference_no || undefined,
type: values.type || undefined,
})
})}
/>
) : (
<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 只在创建后显示一次,请及时保存。每个商户最多 {apiClientMax} 个。</Typography.Text>
{canManage && <Button type="primary" icon={<ApiOutlined />} disabled={apiClients.length >= apiClientMax} onClick={() => {
apiClientForm.resetFields()
apiClientForm.setFieldsValue({ signature_version: 'v1', scopes: ['products:read', 'orders:read', 'orders:write'] })
setApiClientOpen(true)
}}>新增密钥({apiClients.length}/{apiClientMax}</Button>}
</Space>
<Table rowKey="id" loading={loading} columns={apiClientColumns} dataSource={apiClients} tableLayout="fixed" scroll={{ x: 1050 }} />
</Space>
)
const callbackContent = (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Typography.Text type="secondary">
回调事件通过 outbox 持久化推送,失败按固定间隔退避重试(最多 16 次)后标记失败。每个商户只保留一份回调配置。
</Typography.Text>
<Card size="small" loading={loading}>
<Form
form={callbackForm}
layout="vertical"
disabled={!canManage}
onFinish={submitCallback}
initialValues={defaultCallbackFormValues()}
>
<Form.Item name="url" label="回调 URL" rules={[{ required: true, message: '请填写回调 URL' }]}>
<Input placeholder="https://example.com/callback" />
</Form.Item>
<Form.Item name="events" label="事件" rules={[{ required: true, message: '请选择回调事件' }]}>
<Select mode="multiple" options={eventOptions} />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }]} />
</Form.Item>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text type="secondary">
{callback?.updated_at ? `上次保存:${formatDateTime(callback.updated_at)}` : '尚未保存回调配置'}
</Typography.Text>
<Space>
{canManage && (
<Popconfirm
title="重置回调密钥?"
description="重置后旧密钥立即失效,平台将用新密钥签名推送,新密钥仅展示一次。"
okText="重置"
okButtonProps={{ danger: true }}
onConfirm={rotateCallbackSecret}
>
<Button>重置密钥</Button>
</Popconfirm>
)}
{canManage && <Button type="primary" htmlType="submit">保存配置</Button>}
</Space>
</Space>
</Form>
</Card>
</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>
{!fixedTab && (
<Typography.Text type="secondary">
{merchant ? `${merchant.name} / ${merchant.code}` : '加载中'} · {merchantRole ? roleText(merchantRole) : '-'}
</Typography.Text>
)}
</div>
<Button icon={<ReloadOutlined />} loading={loading} onClick={loadAll}>
刷新
</Button>
</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 }}>
{!editingProduct && (
<>
<Form.Item name="product_code" label="目录编码">
<Input placeholder="已有目录编码可选填" />
</Form.Item>
<Form.Item name="product_name" label="商品名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
</>
)}
<Space size="middle" style={{ width: '100%' }}>
<Form.Item name="sku" label="商户 SKU" rules={[{ required: true }]} style={{ width: 280 }}>
<Input disabled={!!editingProduct} />
</Form.Item>
<Form.Item name="display_name" label="展示名" style={{ width: 280 }}>
<Input />
</Form.Item>
</Space>
<Space size="middle" style={{ width: '100%' }}>
<Form.Item name="price_amount" label="售价(积分)" rules={[{ required: true }]} style={{ width: 180 }}>
<InputNumber min={0} precision={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="cost_amount" label="成本(积分)" style={{ width: 180 }}>
<InputNumber min={0} precision={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="currency" label="币种" style={{ width: 180 }}>
<Select options={[{ value: 'POINT', label: 'POINT(积分)' }]} />
</Form.Item>
</Space>
<Space size="middle" style={{ width: '100%' }}>
<Form.Item name="stock" label="库存(-1 无限)" style={{ width: 180 }}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态" style={{ width: 180 }}>
<Select options={[{ value: 'active', label: '上架' }, { value: 'inactive', label: '下架' }]} />
</Form.Item>
{!editingProduct && (
<Form.Item name="category" label="品类" style={{ width: 180 }}>
<Input />
</Form.Item>
)}
</Space>
<Form.Item name="fulfillment_config" label="发货配置">
<Input.TextArea rows={3} placeholder='{"provider":"manual"}' />
</Form.Item>
</Form>
</Modal>
<Modal title="创建测试订单" open={testOrderOpen} onOk={submitTestOrder} onCancel={() => setTestOrderOpen(false)} destroyOnClose width={560}>
<Form form={testOrderForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="sku" label="商品皮肤" rules={[{ required: true, message: '请选择商品' }]}>
<Select
showSearch
loading={testOrderProductsLoading}
optionFilterProp="label"
options={testOrderProductOptions}
/>
</Form.Item>
<Form.Item name="buyer_reference" label="买家名称" rules={[{ max: 128 }]}>
<Input />
</Form.Item>
<Form.Item name="order_status" label="订单状态" rules={[{ required: true }]}>
<Select options={testOrderStatusOptions} />
</Form.Item>
<Form.Item name="note" label="备注" rules={[{ max: 512 }]}>
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
<Modal
title="测试订单"
open={!!testOrderResult}
onCancel={() => setTestOrderResult(null)}
footer={(
<Space>
<Button onClick={() => setTestOrderResult(null)}>关闭</Button>
{testOrderResult?.order.delivery_link_revoked_at ? (
<Button onClick={() => restoreDeliveryLink(testOrderResult?.order.order_no || '')}>取消作废</Button>
) : (
<>
<Button
icon={<CopyOutlined />}
onClick={() => copyDeliveryLink(testOrderResult?.order.order_no || '')}
>
复制发货链接
</Button>
<Button type="primary" icon={<LinkOutlined />} onClick={() => openDeliveryLink(testOrderResult?.order.order_no || '')}>
打开发货页
</Button>
<Button danger onClick={() => revokeDeliveryLink(testOrderResult?.order.order_no || '')}>作废链接</Button>
</>
)}
</Space>
)}
>
{testOrderResult && (
<Descriptions size="small" bordered column={1} style={{ marginTop: 16 }}>
<Descriptions.Item label="订单号">
<Typography.Text copyable>{testOrderResult.order.order_no}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="SKU">
<Typography.Text code>{testOrderResult.order.product_sku}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="可发货">
<Tag color={testOrderResult.can_ship ? 'green' : 'red'}>
{testOrderResult.can_ship ? 'can_ship=true' : 'can_ship=false'}
</Tag>
</Descriptions.Item>
{!testOrderResult.can_ship && (
<Descriptions.Item label="不可发货原因">
<Typography.Text type="secondary">{testOrderResult.cannot_ship_reason || '-'}</Typography.Text>
</Descriptions.Item>
)}
<Descriptions.Item label="链接有效期">
{testOrderResult.order.delivery_link_revoked_at ? <Tag color="red">已作废</Tag> : formatDateTime(testOrderResult.order.delivery_link_expires_at)}
</Descriptions.Item>
<Descriptions.Item label="链接状态">
<Typography.Text type="secondary">由后端签名生成,可复制、打开或作废。</Typography.Text>
</Descriptions.Item>
</Descriptions>
)}
</Modal>
<Modal title="钱包调整" open={walletOpen} onOk={submitWalletAdjust} onCancel={() => setWalletOpen(false)} destroyOnClose>
<Form form={walletForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="amount" label="调整积分" rules={[{ required: true }]}>
<InputNumber precision={0} style={{ width: '100%' }} placeholder="正数充值,负数扣减" />
</Form.Item>
<Form.Item name="idempotency_key" label="幂等键" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="note" label="备注">
<Input />
</Form.Item>
</Form>
</Modal>
<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 }, { max: 64, message: '名称最长 64 个字符' }]}
>
<Input placeholder="按用途命名,如:下单系统 / 对账脚本" maxLength={64} />
</Form.Item>
<Form.Item name="scopes" label="权限" rules={[{ required: true }]}>
<Select mode="multiple" options={scopeOptions} />
</Form.Item>
<Form.Item name="signature_version" label="签名版本">
<Select options={[{ value: 'v1', label: 'v1' }]} />
</Form.Item>
<Typography.Text type="secondary">
建议不同用途(下单、查询、对账)分别创建密钥,便于独立禁用与审计。每个商户最多 {apiClientMax} 个。
</Typography.Text>
</Form>
</Modal>
<Modal title="API 密钥" open={!!apiCredential} onCancel={() => setApiCredential(null)} footer={<Button type="primary" onClick={() => setApiCredential(null)}>我已保存</Button>}>
{apiCredential && <SecretBlock appKey={apiCredential.client.app_key} secret={apiCredential.secret} />}
</Modal>
<Modal title="回调密钥" open={!!callbackCredential} onCancel={() => setCallbackCredential(null)} footer={<Button type="primary" onClick={() => setCallbackCredential(null)}>我已保存</Button>}>
{callbackCredential && <SecretBlock secret={callbackCredential.secret} />}
</Modal>
<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>
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
<Select options={memberRoleOptions} />
</Form.Item>
<Form.Item name="is_default" label="默认商户">
<Select options={[{ value: true, label: '是' }, { value: false, label: '否' }]} />
</Form.Item>
</Form>
</Modal>
</div>
)
}
function pageConfig<T extends { page: number; size: number; total: number }>(
data: T,
load: (page: number, size: number) => void,
) {
return {
current: data.page,
pageSize: data.size,
total: data.total,
showSizeChanger: true,
showTotal: (total: number) => `共 ${total} 条`,
onChange: load,
}
}
function SecretBlock({ appKey, secret }: { appKey?: string; secret: string }) {
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{appKey && (
<div>
<Typography.Text type="secondary">App Key</Typography.Text>
<Typography.Paragraph copyable={{ text: appKey }} code style={{ marginTop: 8 }}>{appKey}</Typography.Paragraph>
</div>
)}
<div>
<Typography.Text type="secondary">Secret</Typography.Text>
<Typography.Paragraph copyable={{ text: secret }} code style={{ marginTop: 8 }}>{secret}</Typography.Paragraph>
</div>
<Button icon={<CopyOutlined />} onClick={() => navigator.clipboard.writeText(secret).then(() => message.success('已复制'))}>
复制 Secret
</Button>
</Space>
)
}
function featuresToList(features?: string) {
const list = (features || '')
.split(/[,\s]+/)
.map((item) => item.trim())
.filter(Boolean)
return list.length > 0 ? list : ['products', 'orders', 'wallet', 'api', 'callbacks']
}
function defaultCallbackFormValues() {
return {
events: ['order.shipping.updated'],
status: 'active',
}
}
function eventsToValue(events?: string) {
return (events || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
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<MerchantCenterTab, string | null> = {
products: 'products',
orders: 'orders',
wallet: 'wallet',
api: 'api',
callbacks: 'callbacks',
members: null,
}
const feature = tabFeatures[current]
if (feature === null || enabled.has(feature)) {
return current
}
return (['products', 'orders', 'wallet', 'api', 'callbacks'] as MerchantCenterTab[]).find((key) => enabled.has(key)) || 'members'
}
function money(value?: number | null) {
return `${Number(value || 0)} 积分`
}
function moneyWithSign(value?: number | null) {
const amount = Number(value || 0)
const prefix = amount > 0 ? '+' : ''
return `${prefix}${money(amount)}`
}
function productStatusTag(value: string) {
return value === 'active' ? <Tag color="green">上架</Tag> : <Tag>下架</Tag>
}
function activeStatusTag(value: string) {
return value === 'active' ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>
}
function productOptionLabel(item: MerchantProduct) {
const name = item.display_name || item.product?.name || item.sku
return name === item.sku ? item.sku : `${name} / ${item.sku}`
}
function orderStatusTag(value: string) {
const item = orderStatusMap[value] || { color: 'default', text: value }
return <Tag color={item.color}>{item.text}</Tag>
}
function ledgerTypeTag(value: string) {
const map: Record<string, { color: string; text: string }> = {
credit: { color: 'green', text: '入账' },
debit: { color: 'red', text: '扣款' },
refund: { color: 'purple', text: '退款' },
adjust: { color: 'blue', text: '调整' },
}
const item = map[value] || { color: 'default', text: value }
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) {
return '-'
}
return (
<Space size={4} wrap>
{scopes.map((s) => <Tag key={s}>{scopeLabel(s)}</Tag>)}
</Space>
)
}
function scopeLabel(scope: string) {
return scopeOptions.find((item) => item.value === scope)?.label || scope
}
function roleText(value: MerchantMember['role']) {
return memberRoleOptions.find((item) => item.value === value)?.label || value
}