实现多商户履约平台基础

This commit is contained in:
yml2213
2026-07-30 12:02:32 +08:00
parent dde6f2c269
commit 83429456a6
41 changed files with 5487 additions and 504 deletions
+11
View File
@@ -11,6 +11,8 @@ import Orders from './pages/Orders'
import Distributors from './pages/Distributors'
import ShipLogs from './pages/ShipLogs'
import OpenApiDocs from './pages/OpenApiDocs'
import MerchantCenter from './pages/MerchantCenter'
import PlatformMerchants from './pages/PlatformMerchants'
import type { ReactNode } from 'react'
function PrivateRoute({ children }: { children: ReactNode }) {
@@ -41,6 +43,7 @@ function AppRoutes() {
<Route index element={<Dashboard />} />
<Route path="skins" element={<Skins />} />
<Route path="orders" element={<Orders />} />
<Route path="merchant-center" element={<MerchantCenter />} />
<Route
path="distributors"
element={
@@ -49,6 +52,14 @@ function AppRoutes() {
</AdminRoute>
}
/>
<Route
path="platform-merchants"
element={
<AdminRoute>
<PlatformMerchants />
</AdminRoute>
}
/>
<Route
path="ship-logs"
element={
+70
View File
@@ -1,12 +1,22 @@
import request from './request'
import type {
DashboardStats,
ApiClient,
ApiCredential,
CallbackCredential,
CallbackSubscription,
FulfillmentOrder,
LoginResult,
Merchant,
MerchantMember,
MerchantProduct,
Order,
PageResult,
ShipLog,
Skin,
User,
WalletAccount,
WalletLedgerEntry,
} from '../types'
export const authApi = {
@@ -69,3 +79,63 @@ export const shipLogApi = {
list: (params?: Record<string, unknown>) =>
request.get('/ship-logs', { params }).then((r) => r.data.data as PageResult<ShipLog>),
}
export const merchantApi = {
current: () =>
request.get('/merchant').then((r) => r.data.data as { merchant: Merchant; role: MerchantMember['role'] }),
products: (params?: Record<string, unknown>) =>
request.get('/merchant/products', { params }).then((r) => r.data.data as PageResult<MerchantProduct>),
createProduct: (data: Partial<MerchantProduct> & {
product_code?: string
product_name?: string
category?: string
description?: string
attributes?: string
}) => request.post('/merchant/products', data).then((r) => r.data.data as MerchantProduct),
updateProduct: (id: number, data: Partial<MerchantProduct>) =>
request.patch(`/merchant/products/${id}`, data).then((r) => r.data.data),
orders: (params?: Record<string, unknown>) =>
request.get('/merchant/orders', { params }).then((r) => r.data.data as PageResult<FulfillmentOrder>),
wallet: () =>
request.get('/merchant/wallet').then((r) => r.data.data as WalletAccount),
ledger: (params?: Record<string, unknown>) =>
request.get('/merchant/wallet/ledger', { params }).then((r) => r.data.data as PageResult<WalletLedgerEntry>),
adjustWallet: (data: { amount: number; idempotency_key: string; note?: string }) =>
request.post('/merchant/wallet/adjust', data).then((r) => r.data.data as WalletAccount),
apiClients: () =>
request.get('/merchant/api-clients').then((r) => r.data.data as ApiClient[]),
createApiClient: (data: {
name: string
scopes: string
signature_version?: string
expires_at?: string
}) => request.post('/merchant/api-clients', data).then((r) => r.data.data as ApiCredential),
updateApiClientStatus: (id: number, status: ApiClient['status']) =>
request.patch(`/merchant/api-clients/${id}/status`, { status }).then((r) => r.data.data),
callbacks: () =>
request.get('/merchant/callbacks').then((r) => r.data.data as CallbackSubscription[]),
createCallback: (data: { name: string; url: string; events: string }) =>
request.post('/merchant/callbacks', data).then((r) => r.data.data as CallbackCredential),
updateCallbackStatus: (id: number, status: CallbackSubscription['status']) =>
request.patch(`/merchant/callbacks/${id}/status`, { status }).then((r) => r.data.data),
members: () =>
request.get('/merchant/members').then((r) => r.data.data as MerchantMember[]),
addMember: (data: { user_id: number; role: MerchantMember['role']; is_default?: boolean }) =>
request.post('/merchant/members', data).then((r) => r.data.data as MerchantMember),
}
export const platformApi = {
merchants: (params?: Record<string, unknown>) =>
request.get('/platform/merchants', { params }).then((r) => r.data.data as PageResult<Merchant>),
createMerchant: (data: {
code: string
name: string
contact_name?: string
contact_info?: string
owner_user_id: number
}) => request.post('/platform/merchants', data).then((r) => r.data.data as Merchant),
addMember: (
merchantId: number,
data: { user_id: number; role: MerchantMember['role']; is_default?: boolean },
) => request.post(`/platform/merchants/${merchantId}/members`, data).then((r) => r.data.data as MerchantMember),
}
+4
View File
@@ -11,6 +11,10 @@ request.interceptors.request.use((config) => {
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
const merchantId = localStorage.getItem('merchant_id')
if (merchantId) {
config.headers['X-Merchant-ID'] = merchantId
}
return config
})
+3
View File
@@ -20,6 +20,7 @@ import {
MenuUnfoldOutlined,
SendOutlined,
ApiOutlined,
ShopOutlined,
} from '@ant-design/icons'
import { useAuth } from '../store/auth'
import type { MenuProps } from 'antd'
@@ -40,10 +41,12 @@ export default function MainLayout() {
{ key: '/', icon: <DashboardOutlined />, label: '数据概览' },
{ key: '/skins', icon: <SkinOutlined />, label: '皮肤商品' },
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单管理' },
{ key: '/merchant-center', icon: <ShopOutlined />, label: '商户中心' },
]
if (isAdmin) {
items.push(
{ key: '/distributors', icon: <TeamOutlined />, label: '分销商' },
{ key: '/platform-merchants', icon: <ShopOutlined />, label: '商户管理' },
{ key: '/ship-logs', icon: <SendOutlined />, label: '发货记录' },
{ key: '/open-api', icon: <ApiOutlined />, label: '开放接口' },
)
+731
View File
@@ -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
View File
@@ -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=vvalue <Text strong></Text> URL encode
</Paragraph>
<pre style={preStyle}>{`api_key=sk_xxx&body=&method=GET&nonce=a1b2c3d4e5f67890&path=/api/open/v1/orders/O123&timestamp=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&timestamp=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="参与字段">
timestampnonceMETHODpathsha256(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/&#123;order_no&#125;</Text>
</Paragraph>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
HeaderX-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">
RFC3339success
</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-IDX-TimestampX-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>
),
},
+210
View File
@@ -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>
)
}
+122
View File
@@ -9,6 +9,128 @@ export interface User {
created_at: string
}
export interface Merchant {
id: number
code: string
name: string
status: 'active' | 'disabled'
contact_name?: string
contact_info?: string
created_at: string
}
export interface MerchantMember {
id: number
merchant_id: number
user_id: number
role: 'owner' | 'operator' | 'finance' | 'viewer'
status: number
is_default: boolean
user?: User
merchant?: Merchant
created_at: string
}
export interface Product {
id: number
code: string
name: string
category: string
description: string
attributes: string
status: 'active' | 'inactive'
}
export interface MerchantProduct {
id: number
merchant_id: number
product_id: number
sku: string
display_name: string
price_amount: number
cost_amount: number
currency: string
stock: number
status: 'active' | 'inactive'
fulfillment_config: string
product?: Product
created_at: string
}
export interface WalletAccount {
id: number
merchant_id: number
currency: string
available_balance: number
frozen_balance: number
updated_at: string
}
export interface WalletLedgerEntry {
id: number
merchant_id: number
entry_no: string
type: 'credit' | 'debit' | 'refund' | 'adjust'
amount: number
balance_after: number
reference_type: string
reference_no: string
note: string
created_at: string
}
export interface FulfillmentOrder {
id: number
order_no: string
client_order_no: string
product_sku: string
product_name: string
quantity: number
amount: number
currency: string
payment_status: string
fulfillment_status: string
buyer_reference: string
provider_order_no: string
failure_reason: string
created_at: string
delivered_at?: string | null
cancelled_at?: string | null
}
export interface ApiClient {
id: number
merchant_id: number
name: string
app_key: string
signature_version: string
scopes: string
status: 'active' | 'disabled'
expires_at?: string | null
last_used_at?: string | null
created_at: string
}
export interface ApiCredential {
client: ApiClient
secret: string
}
export interface CallbackSubscription {
id: number
merchant_id: number
name: string
url: string
events: string
status: 'active' | 'disabled'
created_at: string
}
export interface CallbackCredential {
subscription: CallbackSubscription
secret: string
}
export interface Skin {
id: number
name: string