实现多商户履约平台基础
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
|
||||
}
|
||||
Reference in New Issue
Block a user