实现多商户履约平台基础
This commit is contained in:
@@ -0,0 +1,731 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import {
|
||||
ApiOutlined,
|
||||
CopyOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
WalletOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import dayjs from 'dayjs'
|
||||
import { merchantApi } from '../api'
|
||||
import type {
|
||||
ApiClient,
|
||||
ApiCredential,
|
||||
CallbackCredential,
|
||||
CallbackSubscription,
|
||||
FulfillmentOrder,
|
||||
Merchant,
|
||||
MerchantMember,
|
||||
MerchantProduct,
|
||||
PageResult,
|
||||
WalletAccount,
|
||||
WalletLedgerEntry,
|
||||
} from '../types'
|
||||
|
||||
const fulfillmentStatusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'orange', text: '待履约' },
|
||||
processing: { color: 'cyan', text: '履约中' },
|
||||
succeeded: { color: 'green', text: '已成功' },
|
||||
failed: { color: 'red', text: '已失败' },
|
||||
cancelled: { color: 'default', text: '已取消' },
|
||||
}
|
||||
|
||||
const paymentStatusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'orange', text: '待支付' },
|
||||
paid: { color: 'blue', text: '已支付' },
|
||||
refunded: { color: 'purple', text: '已退款' },
|
||||
cancelled: { color: 'default', text: '已取消' },
|
||||
}
|
||||
|
||||
const memberRoleOptions = [
|
||||
{ value: 'owner', label: '负责人' },
|
||||
{ value: 'operator', label: '运营' },
|
||||
{ value: 'finance', label: '财务' },
|
||||
{ value: 'viewer', label: '只读' },
|
||||
]
|
||||
|
||||
const scopeOptions = [
|
||||
{ value: 'products:read', label: '商品读取' },
|
||||
{ value: 'orders:read', label: '订单读取' },
|
||||
{ value: 'orders:write', label: '订单写入' },
|
||||
{ value: 'fulfillment:read', label: '履约读取' },
|
||||
{ value: 'fulfillment:write', label: '履约写入' },
|
||||
{ value: 'wallet:read', label: '钱包读取' },
|
||||
]
|
||||
|
||||
const eventOptions = [
|
||||
{ value: 'order.created', label: '订单创建' },
|
||||
{ value: 'order.fulfillment.updated', label: '履约更新' },
|
||||
{ value: 'order.cancelled', label: '订单取消' },
|
||||
]
|
||||
|
||||
export default function MerchantCenter() {
|
||||
const [merchant, setMerchant] = useState<Merchant | null>(null)
|
||||
const [merchantRole, setMerchantRole] = useState<MerchantMember['role']>()
|
||||
const [activeTab, setActiveTab] = useState('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 [callbacks, setCallbacks] = useState<CallbackSubscription[]>([])
|
||||
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 [callbackOpen, setCallbackOpen] = useState(false)
|
||||
const [callbackCredential, setCallbackCredential] = useState<CallbackCredential | null>(null)
|
||||
const [memberOpen, setMemberOpen] = useState(false)
|
||||
const [productForm] = Form.useForm()
|
||||
const [walletForm] = Form.useForm()
|
||||
const [apiClientForm] = Form.useForm()
|
||||
const [callbackForm] = Form.useForm()
|
||||
const [memberForm] = Form.useForm()
|
||||
|
||||
const canManage = merchantRole === 'owner' || merchantRole === 'operator'
|
||||
const canFinance = merchantRole === 'owner' || merchantRole === 'finance'
|
||||
|
||||
const loadCurrent = useCallback(async () => {
|
||||
const data = await merchantApi.current()
|
||||
setMerchant(data.merchant)
|
||||
setMerchantRole(data.role)
|
||||
}, [])
|
||||
|
||||
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 loadWallet = useCallback(async (page = ledger.page, size = ledger.size) => {
|
||||
const [walletData, ledgerData] = await Promise.all([
|
||||
merchantApi.wallet(),
|
||||
merchantApi.ledger({ page, size }),
|
||||
])
|
||||
setWallet(walletData)
|
||||
setLedger(ledgerData)
|
||||
}, [ledger.page, ledger.size])
|
||||
|
||||
const loadIntegrations = useCallback(async () => {
|
||||
const [clientData, callbackData, memberData] = await Promise.all([
|
||||
merchantApi.apiClients(),
|
||||
merchantApi.callbacks(),
|
||||
merchantApi.members(),
|
||||
])
|
||||
setApiClients(clientData || [])
|
||||
setCallbacks(callbackData || [])
|
||||
setMembers(memberData || [])
|
||||
}, [])
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await loadCurrent()
|
||||
await Promise.all([loadProducts(), loadOrders(), loadWallet(), loadIntegrations()])
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [loadCurrent, loadProducts, loadOrders, loadWallet, loadIntegrations])
|
||||
|
||||
useEffect(() => {
|
||||
loadAll()
|
||||
}, [loadAll])
|
||||
|
||||
const openProductCreate = () => {
|
||||
setEditingProduct(null)
|
||||
productForm.resetFields()
|
||||
productForm.setFieldsValue({
|
||||
currency: 'CNY',
|
||||
stock: -1,
|
||||
status: 'active',
|
||||
price_yuan: 0,
|
||||
cost_yuan: 0,
|
||||
})
|
||||
setProductOpen(true)
|
||||
}
|
||||
|
||||
const openProductEdit = (record: MerchantProduct) => {
|
||||
setEditingProduct(record)
|
||||
productForm.setFieldsValue({
|
||||
...record,
|
||||
price_yuan: centsToYuan(record.price_amount),
|
||||
cost_yuan: centsToYuan(record.cost_amount),
|
||||
})
|
||||
setProductOpen(true)
|
||||
}
|
||||
|
||||
const submitProduct = async () => {
|
||||
const values = await productForm.validateFields()
|
||||
try {
|
||||
const payload = {
|
||||
...values,
|
||||
price_amount: yuanToCents(values.price_yuan),
|
||||
cost_amount: yuanToCents(values.cost_yuan),
|
||||
}
|
||||
delete payload.price_yuan
|
||||
delete payload.cost_yuan
|
||||
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: yuanToCents(values.amount_yuan),
|
||||
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 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 客户端已创建')
|
||||
loadIntegrations()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
const submitCallback = async () => {
|
||||
const values = await callbackForm.validateFields()
|
||||
try {
|
||||
const credential = await merchantApi.createCallback({
|
||||
name: values.name,
|
||||
url: values.url,
|
||||
events: (values.events || []).join(','),
|
||||
})
|
||||
setCallbackCredential(credential)
|
||||
setCallbackOpen(false)
|
||||
message.success('回调已创建')
|
||||
loadIntegrations()
|
||||
} 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('成员已添加')
|
||||
loadIntegrations()
|
||||
} 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('状态已更新')
|
||||
loadIntegrations()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCallback = async (record: CallbackSubscription) => {
|
||||
try {
|
||||
await merchantApi.updateCallbackStatus(record.id, record.status === 'active' ? 'disabled' : 'active')
|
||||
message.success('状态已更新')
|
||||
loadIntegrations()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
const productColumns: ColumnsType<MerchantProduct> = [
|
||||
{ title: 'SKU', dataIndex: 'sku', width: 180, render: (v) => <Typography.Text code copyable>{v}</Typography.Text> },
|
||||
{ title: '名称', dataIndex: 'display_name', ellipsis: true, render: (_, r) => r.display_name || r.product?.name || '-' },
|
||||
{ title: '目录编码', dataIndex: ['product', 'code'], width: 160, ellipsis: true, render: (_, r) => r.product?.code || '-' },
|
||||
{ title: '售价', dataIndex: 'price_amount', width: 110, render: money },
|
||||
{ title: '成本', dataIndex: 'cost_amount', width: 110, render: money },
|
||||
{ title: '库存', dataIndex: 'stock', width: 90, render: (v) => (v < 0 ? '无限' : v) },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: productStatusTag },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 90,
|
||||
render: (_, record) => canManage ? (
|
||||
<Button type="link" size="small" onClick={() => openProductEdit(record)}>编辑</Button>
|
||||
) : '-',
|
||||
},
|
||||
]
|
||||
|
||||
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: '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: 'created_at', width: 160, render: formatTime },
|
||||
]
|
||||
|
||||
const ledgerColumns: ColumnsType<WalletLedgerEntry> = [
|
||||
{ title: '流水号', dataIndex: 'entry_no', width: 210, ellipsis: true },
|
||||
{ title: '类型', dataIndex: 'type', width: 90, render: ledgerTypeTag },
|
||||
{ title: '金额', dataIndex: 'amount', width: 110, render: moneyWithSign },
|
||||
{ title: '余额', dataIndex: 'balance_after', width: 110, render: money },
|
||||
{ title: '关联单号', dataIndex: 'reference_no', ellipsis: true },
|
||||
{ title: '备注', dataIndex: 'note', ellipsis: true, render: (v) => v || '-' },
|
||||
{ title: '时间', dataIndex: 'created_at', width: 160, render: formatTime },
|
||||
]
|
||||
|
||||
const apiClientColumns: ColumnsType<ApiClient> = [
|
||||
{ title: '名称', dataIndex: 'name', width: 180, ellipsis: true },
|
||||
{ title: 'App Key', dataIndex: 'app_key', width: 260, render: (v) => <Typography.Text code copyable>{v}</Typography.Text> },
|
||||
{ title: '签名', dataIndex: 'signature_version', width: 110, render: (v) => <Tag>{v}</Tag> },
|
||||
{ title: '权限', dataIndex: 'scopes', ellipsis: true },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: activeStatusTag },
|
||||
{ title: '最后使用', dataIndex: 'last_used_at', width: 160, render: formatTime },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 90,
|
||||
render: (_, record) => canManage ? (
|
||||
<Button type="link" size="small" onClick={() => toggleAPIClient(record)}>
|
||||
{record.status === 'active' ? '禁用' : '启用'}
|
||||
</Button>
|
||||
) : '-',
|
||||
},
|
||||
]
|
||||
|
||||
const callbackColumns: ColumnsType<CallbackSubscription> = [
|
||||
{ title: '名称', dataIndex: 'name', width: 180, ellipsis: true },
|
||||
{ title: 'URL', dataIndex: 'url', ellipsis: true },
|
||||
{ title: '事件', dataIndex: 'events', width: 260, ellipsis: true },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: activeStatusTag },
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 160, render: formatTime },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 90,
|
||||
render: (_, record) => canManage ? (
|
||||
<Button type="link" size="small" onClick={() => toggleCallback(record)}>
|
||||
{record.status === 'active' ? '禁用' : '启用'}
|
||||
</Button>
|
||||
) : '-',
|
||||
},
|
||||
]
|
||||
|
||||
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>) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
商户中心
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
{merchant ? `${merchant.name} / ${merchant.code}` : '加载中'} · {merchantRole ? roleText(merchantRole) : '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button icon={<ReloadOutlined />} loading={loading} onClick={loadAll}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{
|
||||
key: 'products',
|
||||
label: '商品',
|
||||
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: '履约订单',
|
||||
children: (
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={orderColumns}
|
||||
dataSource={orders.list}
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 1300 }}
|
||||
pagination={pageConfig(orders, loadOrders)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'wallet',
|
||||
label: '钱包',
|
||||
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 || 'CNY'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{canFinance && (
|
||||
<Button
|
||||
icon={<WalletOutlined />}
|
||||
onClick={() => {
|
||||
walletForm.resetFields()
|
||||
walletForm.setFieldsValue({ idempotency_key: `manual-${Date.now()}` })
|
||||
setWalletOpen(true)
|
||||
}}
|
||||
>
|
||||
调整
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={ledgerColumns}
|
||||
dataSource={ledger.list}
|
||||
tableLayout="fixed"
|
||||
pagination={pageConfig(ledger, loadWallet)}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'api',
|
||||
label: '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: '回调',
|
||||
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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<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_yuan" label="售价" rules={[{ required: true }]} style={{ width: 180 }}>
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="cost_yuan" label="成本" style={{ width: 180 }}>
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="currency" label="币种" style={{ width: 180 }}>
|
||||
<Select options={[{ value: 'CNY', label: 'CNY' }]} />
|
||||
</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={walletOpen} onOk={submitWalletAdjust} onCancel={() => setWalletOpen(false)} destroyOnClose>
|
||||
<Form form={walletForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="amount_yuan" label="调整金额" rules={[{ required: true }]}>
|
||||
<InputNumber precision={2} 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 }]}>
|
||||
<Input />
|
||||
</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>
|
||||
</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={callbackOpen} onOk={submitCallback} onCancel={() => setCallbackOpen(false)} destroyOnClose>
|
||||
<Form form={callbackForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="url" label="回调 URL" rules={[{ required: true }]}>
|
||||
<Input placeholder="https://example.com/callback" />
|
||||
</Form.Item>
|
||||
<Form.Item name="events" label="事件" rules={[{ required: true }]}>
|
||||
<Select mode="multiple" options={eventOptions} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</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 yuanToCents(value?: number | null) {
|
||||
return Math.round(Number(value || 0) * 100)
|
||||
}
|
||||
|
||||
function centsToYuan(value?: number | null) {
|
||||
return Number(((value || 0) / 100).toFixed(2))
|
||||
}
|
||||
|
||||
function money(value?: number | null) {
|
||||
return `¥${centsToYuan(value).toFixed(2)}`
|
||||
}
|
||||
|
||||
function moneyWithSign(value?: number | null) {
|
||||
const amount = Number(value || 0)
|
||||
const prefix = amount > 0 ? '+' : ''
|
||||
return `${prefix}${money(amount)}`
|
||||
}
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
|
||||
}
|
||||
|
||||
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 paymentStatusTag(value: string) {
|
||||
const item = paymentStatusMap[value] || { color: 'default', text: value }
|
||||
return <Tag color={item.color}>{item.text}</Tag>
|
||||
}
|
||||
|
||||
function fulfillmentStatusTag(value: string) {
|
||||
const item = fulfillmentStatusMap[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 roleText(value: MerchantMember['role']) {
|
||||
return memberRoleOptions.find((item) => item.value === value)?.label || value
|
||||
}
|
||||
+138
-253
@@ -1,86 +1,67 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { Alert, Card, Descriptions, Space, Table, Tabs, Tag, Typography } from 'antd'
|
||||
|
||||
const { Title, Paragraph, Text, Link } = Typography
|
||||
const { Title, Paragraph, Text } = Typography
|
||||
|
||||
const baseUrl =
|
||||
typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':8080') : 'http://localhost:8080'
|
||||
typeof window !== 'undefined'
|
||||
? window.location.origin.replace(':5173', ':8080')
|
||||
: 'http://localhost:8080'
|
||||
|
||||
const signPython = `import hmac, hashlib, time, uuid, requests
|
||||
const signPython = `import hashlib, hmac, json, time, uuid, requests
|
||||
|
||||
API_KEY = "sk_source_dev_key_change_me"
|
||||
API_SECRET = "sk_source_dev_secret_change_me"
|
||||
APP_KEY = "ak_xxx"
|
||||
APP_SECRET = "sk_xxx"
|
||||
BASE = "${baseUrl}"
|
||||
|
||||
def build_sign_string(api_key, timestamp, nonce, method, path, body=""):
|
||||
params = {
|
||||
"api_key": api_key,
|
||||
"body": body,
|
||||
"method": method.upper(),
|
||||
"nonce": nonce,
|
||||
"path": path,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
# 字典序 + & 拼接,value 不 URL encode
|
||||
return "&".join(f"{k}={params[k]}" for k in sorted(params.keys()))
|
||||
|
||||
def sign_headers(method: str, path: str, body: str = "") -> dict:
|
||||
def sign_headers(method: str, path: str, body: bytes = b"") -> dict:
|
||||
ts = str(int(time.time()))
|
||||
nonce = uuid.uuid4().hex
|
||||
raw = build_sign_string(API_KEY, ts, nonce, method, path, body)
|
||||
sign = hmac.new(API_SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||
body_hash = hashlib.sha256(body).hexdigest()
|
||||
raw = "\\n".join([ts, nonce, method.upper(), path, body_hash])
|
||||
sign = hmac.new(APP_SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||
return {
|
||||
"X-Api-Key": API_KEY,
|
||||
"X-App-Key": APP_KEY,
|
||||
"X-Timestamp": ts,
|
||||
"X-Nonce": nonce,
|
||||
"X-Sign": sign,
|
||||
}
|
||||
|
||||
# 查询
|
||||
path = "/api/open/v1/orders/O你的订单号"
|
||||
path = "/api/client/v1/products"
|
||||
print(requests.get(BASE + path, headers=sign_headers("GET", path)).json())
|
||||
|
||||
# 推送(body 必须与签名一致)
|
||||
path = "/api/open/v1/orders/ship-notify"
|
||||
body = '{"order_no":"O你的订单号","ship_status":"success","provider_order_no":"SRC001"}'
|
||||
headers = {"Content-Type": "application/json", **sign_headers("POST", path, body)}
|
||||
print(requests.post(BASE + path, headers=headers, data=body.encode()).json())`
|
||||
path = "/api/client/v1/orders"
|
||||
payload = {"client_order_no": "shop-10001", "sku": "sku-basic", "quantity": 1}
|
||||
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
headers = {"Content-Type": "application/json", "Idempotency-Key": payload["client_order_no"], **sign_headers("POST", path, body)}
|
||||
print(requests.post(BASE + path, headers=headers, data=body).json())`
|
||||
|
||||
export default function OpenApiDocs() {
|
||||
return (
|
||||
<div>
|
||||
<Title level={4} style={{ marginTop: 0 }}>
|
||||
开放接口文档(皮肤源头)
|
||||
开放接口
|
||||
</Title>
|
||||
<Paragraph type="secondary">
|
||||
给上游发货系统对接使用。详细 Markdown 文档见仓库{' '}
|
||||
<Text code>docs/开放接口-皮肤源头对接.md</Text>。
|
||||
面向商户系统、履约器和外部平台调用,凭证在「商户中心 / API 客户端」创建。原
|
||||
<Text code>/api/open/v1</Text> 保留给上游发货对接。
|
||||
</Paragraph>
|
||||
|
||||
<Alert
|
||||
type="warning"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="鉴权:X-Api-Key + HMAC 签名(必填)"
|
||||
message="v1 鉴权头:X-App-Key / X-Timestamp / X-Nonce / X-Sign"
|
||||
description={
|
||||
<div>
|
||||
请求头必须同时携带:
|
||||
<Text code>X-Api-Key</Text>、<Text code>X-Timestamp</Text>、
|
||||
<Text code>X-Nonce</Text>、<Text code>X-Sign</Text>
|
||||
<br />
|
||||
开发默认 Key:
|
||||
<Text code copyable>
|
||||
sk_source_dev_key_change_me
|
||||
<Space direction="vertical" size={4}>
|
||||
<Text>
|
||||
签名内容为 <Text code>timestamp\nnonce\nMETHOD\npath\nsha256(body)</Text>。
|
||||
</Text>
|
||||
,Secret:
|
||||
<Text code copyable>
|
||||
sk_source_dev_secret_change_me
|
||||
<Text>
|
||||
旧 <Text code>X-Api-Key</Text> 签名只给兼容客户端使用,新对接统一使用{' '}
|
||||
<Text code>X-App-Key</Text>。
|
||||
</Text>
|
||||
<br />
|
||||
生产环境变量:
|
||||
<Text code>OPEN_API_KEY</Text> / <Text code>OPEN_API_SECRET</Text> /{' '}
|
||||
<Text code>OPEN_SIGN_SKEW</Text>
|
||||
</div>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -88,231 +69,135 @@ export default function OpenApiDocs() {
|
||||
items={[
|
||||
{
|
||||
key: 'auth',
|
||||
label: '签名规则',
|
||||
label: '签名',
|
||||
children: (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="待签名字符串(字典序 + & 拼接)">
|
||||
<Paragraph type="secondary" style={{ marginBottom: 8 }}>
|
||||
参数:api_key / body / method / nonce / path / timestamp → 按 key 排序后拼成
|
||||
k=v&k=v…(value <Text strong>不</Text> URL encode)
|
||||
</Paragraph>
|
||||
<pre style={preStyle}>{`api_key=sk_xxx&body=&method=GET&nonce=a1b2c3d4e5f67890&path=/api/open/v1/orders/O123×tamp=1721450000`}</pre>
|
||||
<pre style={{ ...preStyle, marginTop: 8 }}>{`api_key=sk_xxx&body={"order_no":"O123","ship_status":"success"}&method=POST&nonce=...&path=/api/open/v1/orders/ship-notify×tamp=1721450000`}</pre>
|
||||
<Descriptions size="small" column={1} bordered style={{ marginTop: 12 }}>
|
||||
<Descriptions.Item label="method">大写 GET / POST</Descriptions.Item>
|
||||
<Descriptions.Item label="path">
|
||||
URL.Path,不含 query,如 /api/open/v1/orders/O123
|
||||
<Card size="small" title="签名规则">
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="参与字段">
|
||||
timestamp、nonce、METHOD、path、sha256(body)
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="body">
|
||||
原始请求体;GET 用空字符串。POST 必须与实际发送 body 字节一致
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="X-Sign">
|
||||
hex(HMAC-SHA256(api_secret, string_to_sign)) 小写
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="时间窗">默认 ±300 秒</Descriptions.Item>
|
||||
<Descriptions.Item label="Nonce">8~64 字符,有效期内同一 Key 不可重复</Descriptions.Item>
|
||||
<Descriptions.Item label="拼接方式">按固定顺序用换行符拼接</Descriptions.Item>
|
||||
<Descriptions.Item label="path">仅 URL.Path,不含域名和 query</Descriptions.Item>
|
||||
<Descriptions.Item label="body">GET 为空字节;POST 必须与实际发送 body 完全一致</Descriptions.Item>
|
||||
<Descriptions.Item label="X-Sign">hex(HMAC-SHA256(app_secret, raw)) 小写</Descriptions.Item>
|
||||
<Descriptions.Item label="Nonce">8~96 字符,同一客户端有效期内不可重复</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<pre style={{ ...preStyle, marginTop: 12 }}>{`timestamp
|
||||
nonce
|
||||
POST
|
||||
/api/open/v1/orders
|
||||
sha256(body)`}</pre>
|
||||
</Card>
|
||||
<Card size="small" title="Python 完整示例">
|
||||
<Card size="small" title="Python 示例">
|
||||
<pre style={preStyle}>{signPython}</pre>
|
||||
</Card>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'flow',
|
||||
label: '对接流程',
|
||||
children: (
|
||||
<Card size="small">
|
||||
<Paragraph>
|
||||
<ol>
|
||||
<li>买家在店铺下单并完成支付(订单 status = paid)</li>
|
||||
<li>上游拿到店铺订单号 <Text code>order_no</Text></li>
|
||||
<li>
|
||||
带签名调用「订单查询」确认 <Text code>product.sku</Text> 与{' '}
|
||||
<Text code>can_ship</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text code>can_ship=true</Text> 时执行发货(按 sku)
|
||||
</li>
|
||||
<li>带签名调用「发货推送」回传 success / failed / processing</li>
|
||||
<li>我方同步订单状态;重复 success 幂等成功</li>
|
||||
</ol>
|
||||
</Paragraph>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
Base URL 示例:<Text code>{baseUrl}</Text>
|
||||
</Paragraph>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'query',
|
||||
label: '订单查询',
|
||||
children: (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="请求">
|
||||
<Paragraph>
|
||||
<Tag color="blue">GET</Tag>
|
||||
<Text code>/api/open/v1/orders/{order_no}</Text>
|
||||
</Paragraph>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
Header:X-Api-Key / X-Timestamp / X-Nonce / X-Sign(见「签名规则」)
|
||||
</Paragraph>
|
||||
</Card>
|
||||
<Card size="small" title="响应字段">
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="field"
|
||||
dataSource={[
|
||||
{ field: 'order_no', desc: '店铺订单号' },
|
||||
{ field: 'status', desc: '订单状态' },
|
||||
{ field: 'can_ship', desc: '是否可发货(发货前置请以此为准)' },
|
||||
{ field: 'cannot_ship_reason', desc: '不可发货原因' },
|
||||
{ field: 'product.sku', desc: '商品英文固定标识(发货用)' },
|
||||
{ field: 'product.name', desc: '商品中文名' },
|
||||
{ field: 'product.game', desc: '游戏,如和平精英' },
|
||||
{ field: 'buyer_name', desc: '买家名' },
|
||||
{ field: 'amount', desc: '金额' },
|
||||
{ field: 'shipped_at', desc: '发货成功时间' },
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
title: '字段',
|
||||
dataIndex: 'field',
|
||||
width: 200,
|
||||
render: (v) => <Text code>{v}</Text>,
|
||||
},
|
||||
{ title: '说明', dataIndex: 'desc' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
<Card size="small" title="can_ship 规则">
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="status"
|
||||
dataSource={[
|
||||
{ status: 'pending', ship: 'false', note: '未支付' },
|
||||
{ status: 'paid', ship: 'true', note: '可发' },
|
||||
{ status: 'delivering', ship: 'false', note: '发货中' },
|
||||
{ status: 'delivered', ship: 'false', note: '已完成' },
|
||||
{ status: 'ship_failed', ship: 'true', note: '可重试' },
|
||||
{ status: 'cancelled', ship: 'false', note: '已取消' },
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
title: 'status',
|
||||
dataIndex: 'status',
|
||||
render: (v) => <Text code>{v}</Text>,
|
||||
},
|
||||
{
|
||||
title: 'can_ship',
|
||||
dataIndex: 'ship',
|
||||
render: (v) =>
|
||||
v === 'true' ? <Tag color="green">true</Tag> : <Tag>false</Tag>,
|
||||
},
|
||||
{ title: '说明', dataIndex: 'note' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'notify',
|
||||
label: '发货推送',
|
||||
children: (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="请求">
|
||||
<Paragraph>
|
||||
<Tag color="green">POST</Tag>
|
||||
<Text code>/api/open/v1/orders/ship-notify</Text>
|
||||
</Paragraph>
|
||||
<Paragraph type="secondary">
|
||||
除签名头外,Body 参与签名。Body 示例:
|
||||
</Paragraph>
|
||||
<pre style={preStyle}>{`{
|
||||
"order_no": "O202607201550038000",
|
||||
"ship_status": "success",
|
||||
"provider_order_no": "SRC20260720001",
|
||||
"shipped_at": "2026-07-20T16:00:00+08:00",
|
||||
"fail_reason": ""
|
||||
}`}</pre>
|
||||
</Card>
|
||||
<Card size="small" title="请求参数">
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="order_no">必填,店铺订单号</Descriptions.Item>
|
||||
<Descriptions.Item label="ship_status">
|
||||
必填:success / failed / processing
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="provider_order_no">可选,上游单号</Descriptions.Item>
|
||||
<Descriptions.Item label="shipped_at">
|
||||
可选,RFC3339;success 缺省用服务端时间
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="fail_reason">可选,失败原因</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
<Card size="small" title="状态映射与幂等">
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="ship"
|
||||
style={{ marginBottom: 12 }}
|
||||
dataSource={[
|
||||
{ ship: 'processing', order: 'delivering', note: '已接单/发货中' },
|
||||
{ ship: 'success', order: 'delivered', note: '发货成功' },
|
||||
{ ship: 'failed', order: 'ship_failed', note: '失败可重试' },
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
title: 'ship_status',
|
||||
dataIndex: 'ship',
|
||||
render: (v) => <Text code>{v}</Text>,
|
||||
},
|
||||
{
|
||||
title: '订单状态',
|
||||
dataIndex: 'order',
|
||||
render: (v) => <Text code>{v}</Text>,
|
||||
},
|
||||
{ title: '说明', dataIndex: 'note' },
|
||||
]}
|
||||
/>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="幂等:订单已 delivered 时再次推送 success 仍返回成功,不会重复处理。"
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'errors',
|
||||
label: '错误码',
|
||||
key: 'endpoints',
|
||||
label: '接口',
|
||||
children: (
|
||||
<Card size="small">
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="code"
|
||||
rowKey="path"
|
||||
dataSource={[
|
||||
{ code: 0, http: 200, msg: '成功' },
|
||||
{ code: 401, http: 401, msg: '鉴权失败:Key/签名/时间/Nonce' },
|
||||
{ code: 404, http: 404, msg: '订单不存在' },
|
||||
{ code: 400, http: 400, msg: '参数错误 / 状态不允许' },
|
||||
{ method: 'GET', path: '/api/client/v1/products', scope: 'products:read', desc: '查询已授权商品' },
|
||||
{ method: 'POST', path: '/api/client/v1/orders', scope: 'orders:write', desc: '幂等创建订单并扣款' },
|
||||
{ method: 'GET', path: '/api/client/v1/orders/{order_no}', scope: 'orders:read', desc: '查询订单状态' },
|
||||
{ method: 'POST', path: '/api/client/v1/orders/{order_no}/cancel', scope: 'orders:write', desc: '取消未履约订单并退款' },
|
||||
{ method: 'POST', path: '/api/client/v1/orders/{order_no}/ship-notify', scope: 'fulfillment:write', desc: '履约器回传 processing/success/failed' },
|
||||
{ method: 'GET', path: '/api/client/v1/wallet', scope: 'wallet:read', desc: '查询商户钱包' },
|
||||
]}
|
||||
columns={[
|
||||
{ title: 'code', dataIndex: 'code', width: 80 },
|
||||
{ title: 'HTTP', dataIndex: 'http', width: 80 },
|
||||
{ title: '说明', dataIndex: 'msg' },
|
||||
{ title: '方法', dataIndex: 'method', width: 90, render: (v) => <Tag color={v === 'GET' ? 'blue' : 'green'}>{v}</Tag> },
|
||||
{ title: '路径', dataIndex: 'path', render: (v) => <Text code>{v}</Text> },
|
||||
{ title: '权限', dataIndex: 'scope', width: 150, render: (v) => <Text code>{v}</Text> },
|
||||
{ title: '说明', dataIndex: 'desc' },
|
||||
]}
|
||||
/>
|
||||
<Paragraph type="secondary" style={{ marginTop: 12, marginBottom: 0 }}>
|
||||
商品 sku 对照:
|
||||
<Link href="/skins"> 皮肤商品列表</Link>
|
||||
(字段「英文名」)或仓库 docs/商品英文名对照.md
|
||||
</Paragraph>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'order',
|
||||
label: '下单',
|
||||
children: (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="请求">
|
||||
<Paragraph>
|
||||
<Tag color="green">POST</Tag>
|
||||
<Text code>/api/client/v1/orders</Text>
|
||||
</Paragraph>
|
||||
<pre style={preStyle}>{`{
|
||||
"client_order_no": "shop-10001",
|
||||
"sku": "sku-basic",
|
||||
"quantity": 1,
|
||||
"buyer_reference": "buyer-or-account",
|
||||
"data": {
|
||||
"server": "ios-wechat",
|
||||
"uid": "player-id"
|
||||
}
|
||||
}`}</pre>
|
||||
</Card>
|
||||
<Card size="small" title="响应">
|
||||
<pre style={preStyle}>{`{
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {
|
||||
"idempotent": false,
|
||||
"order": {
|
||||
"order_no": "FO202607300001...",
|
||||
"client_order_no": "shop-10001",
|
||||
"payment_status": "paid",
|
||||
"fulfillment_status": "pending",
|
||||
"can_fulfill": true
|
||||
}
|
||||
}
|
||||
}`}</pre>
|
||||
</Card>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态',
|
||||
children: (
|
||||
<Card size="small">
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="status"
|
||||
dataSource={[
|
||||
{ status: 'pending', can: 'true', desc: '待履约,可被履约器接单' },
|
||||
{ status: 'processing', can: 'false', desc: '履约中' },
|
||||
{ status: 'succeeded', can: 'false', desc: '履约成功' },
|
||||
{ status: 'failed', can: 'true', desc: '履约失败,可重试' },
|
||||
{ status: 'cancelled', can: 'false', desc: '已取消' },
|
||||
]}
|
||||
columns={[
|
||||
{ title: 'fulfillment_status', dataIndex: 'status', render: (v) => <Text code>{v}</Text> },
|
||||
{ title: 'can_fulfill', dataIndex: 'can', width: 120, render: (v) => v === 'true' ? <Tag color="green">true</Tag> : <Tag>false</Tag> },
|
||||
{ title: '说明', dataIndex: 'desc' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'callback',
|
||||
label: '回调',
|
||||
children: (
|
||||
<Card size="small">
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="事件">order.created / order.fulfillment.updated / order.cancelled</Descriptions.Item>
|
||||
<Descriptions.Item label="Header">X-Event-ID、X-Timestamp、X-Sign</Descriptions.Item>
|
||||
<Descriptions.Item label="签名">hex(HMAC-SHA256(callback_secret, timestamp + "\n" + sha256(body)))</Descriptions.Item>
|
||||
<Descriptions.Item label="投递">持久化 outbox,失败指数退避重试</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { PlusOutlined, ReloadOutlined, TeamOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import dayjs from 'dayjs'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { platformApi } from '../api'
|
||||
import type { Merchant, MerchantMember, PageResult } from '../types'
|
||||
|
||||
const memberRoleOptions = [
|
||||
{ value: 'owner', label: '负责人' },
|
||||
{ value: 'operator', label: '运营' },
|
||||
{ value: 'finance', label: '财务' },
|
||||
{ value: 'viewer', label: '只读' },
|
||||
]
|
||||
|
||||
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)
|
||||
const [memberOpen, setMemberOpen] = useState(false)
|
||||
const [selectedMerchant, setSelectedMerchant] = useState<Merchant | null>(null)
|
||||
const [createForm] = Form.useForm()
|
||||
const [memberForm] = Form.useForm()
|
||||
|
||||
const load = useCallback(async (page = data.page, size = data.size) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await platformApi.merchants({ page, size })
|
||||
setData(result)
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [data.page, data.size])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const submitCreate = async () => {
|
||||
const values = await createForm.validateFields()
|
||||
try {
|
||||
await platformApi.createMerchant(values)
|
||||
message.success('商户已创建')
|
||||
setCreateOpen(false)
|
||||
createForm.resetFields()
|
||||
load()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
const submitMember = async () => {
|
||||
if (!selectedMerchant) return
|
||||
const values = await memberForm.validateFields()
|
||||
try {
|
||||
await platformApi.addMember(selectedMerchant.id, {
|
||||
user_id: values.user_id,
|
||||
role: values.role as MerchantMember['role'],
|
||||
is_default: values.is_default,
|
||||
})
|
||||
message.success('成员已添加')
|
||||
setMemberOpen(false)
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Merchant> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{ title: '编码', dataIndex: 'code', width: 180, render: (v) => <Typography.Text code copyable>{v}</Typography.Text> },
|
||||
{ title: '名称', dataIndex: 'name', ellipsis: true },
|
||||
{ title: '联系人', dataIndex: 'contact_name', width: 120, render: (v) => v || '-' },
|
||||
{ title: '联系方式', dataIndex: 'contact_info', width: 160, render: (v) => v || '-' },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (v) => v === 'active' ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag> },
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 160, render: (v) => dayjs(v).format('YYYY-MM-DD HH:mm') },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 190,
|
||||
render: (_, record) => (
|
||||
<Space size={0}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
localStorage.setItem('merchant_id', String(record.id))
|
||||
message.success('当前商户已切换')
|
||||
navigate('/merchant-center')
|
||||
}}
|
||||
>
|
||||
进入
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<TeamOutlined />}
|
||||
onClick={() => {
|
||||
setSelectedMerchant(record)
|
||||
memberForm.resetFields()
|
||||
memberForm.setFieldsValue({ role: 'operator', is_default: false })
|
||||
setMemberOpen(true)
|
||||
}}
|
||||
>
|
||||
添加成员
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
商户管理
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">平台管理员维护租户与授权关系。</Typography.Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => load()}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
createForm.resetFields()
|
||||
setCreateOpen(true)
|
||||
}}
|
||||
>
|
||||
新增商户
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data.list}
|
||||
tableLayout="fixed"
|
||||
pagination={{
|
||||
current: data.page,
|
||||
pageSize: data.size,
|
||||
total: data.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: load,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal title="新增商户" open={createOpen} onOk={submitCreate} onCancel={() => setCreateOpen(false)} destroyOnClose>
|
||||
<Form form={createForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="code" label="商户编码" rules={[{ required: true }]}>
|
||||
<Input placeholder="lower-case-code" />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="商户名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="owner_user_id" label="负责人用户 ID" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="contact_name" label="联系人">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="contact_info" label="联系方式">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={selectedMerchant ? `添加成员:${selectedMerchant.name}` : '添加成员'}
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user