后端: - 模型 currency 默认值 CNY→POINT (MerchantProduct/WalletAccount/FulfillmentOrder) - DashboardStats TotalSales/TotalFees 从 float64 改为 int64,去掉 /100.0 转换 - 上游 OpenOrderQuery.Amount 从 float64 改为 int64,直接返回积分整数 - 钱包/商品创建时 currency 默认 POINT 前端: - 去掉 centsToYuan/yuanToCents 转换函数,金额直接用整数积分 - money() 显示从 ¥X.XX 改为 X 积分 - 商品售价/成本表单字段名改回 price_amount/cost_amount,precision 改 0 - 钱包调整表单 amount_yuan→amount,precision 改 0 - 手续费固定金额表单 precision 改 0,label 改积分 - 币种选项 CNY→POINT - Dashboard 成交金额/手续费 suffix 元→积分,去掉 precision 数据库: - 新增迁移 004: currency 默认值 CNY→POINT,存量数据更新
806 lines
31 KiB
TypeScript
806 lines
31 KiB
TypeScript
import { useCallback, useEffect, useMemo, 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 enabledFeatures = useMemo(() => new Set(featuresToList(merchant?.features)), [merchant?.features])
|
|
const hasFeature = useCallback((feature: string) => !merchant || enabledFeatures.has(feature), [enabledFeatures, merchant])
|
|
|
|
const loadCurrent = useCallback(async () => {
|
|
const data = await merchantApi.current()
|
|
setMerchant(data.merchant)
|
|
setMerchantRole(data.role)
|
|
return data
|
|
}, [])
|
|
|
|
const loadProducts = useCallback(async (page = products.page, size = products.size) => {
|
|
const data = await merchantApi.products({ page, size })
|
|
setProducts(data)
|
|
}, [products.page, products.size])
|
|
|
|
const loadOrders = useCallback(async (page = orders.page, size = orders.size) => {
|
|
const data = await merchantApi.orders({ page, size })
|
|
setOrders(data)
|
|
}, [orders.page, orders.size])
|
|
|
|
const loadWallet = useCallback(async (
|
|
page = ledger.page,
|
|
size = ledger.size,
|
|
role = merchantRole,
|
|
) => {
|
|
const walletData = await merchantApi.wallet()
|
|
setWallet(walletData)
|
|
if (role === 'owner' || role === 'finance') {
|
|
const ledgerData = await merchantApi.ledger({ page, size })
|
|
setLedger(ledgerData)
|
|
} else {
|
|
setLedger({ list: [], total: 0, page, size })
|
|
}
|
|
}, [ledger.page, ledger.size, merchantRole])
|
|
|
|
const loadAPIClients = useCallback(async (role = merchantRole) => {
|
|
if (role !== 'owner' && role !== 'operator') {
|
|
setApiClients([])
|
|
return
|
|
}
|
|
const clientData = await merchantApi.apiClients()
|
|
setApiClients(clientData || [])
|
|
}, [merchantRole])
|
|
|
|
const loadCallbacks = useCallback(async (role = merchantRole) => {
|
|
if (role !== 'owner' && role !== 'operator') {
|
|
setCallbacks([])
|
|
return
|
|
}
|
|
const callbackData = await merchantApi.callbacks()
|
|
setCallbacks(callbackData || [])
|
|
}, [merchantRole])
|
|
|
|
const loadMembers = useCallback(async (role = merchantRole) => {
|
|
if (role !== 'owner' && role !== 'operator') {
|
|
setMembers([])
|
|
return
|
|
}
|
|
const memberData = await merchantApi.members()
|
|
setMembers(memberData || [])
|
|
}, [merchantRole])
|
|
|
|
const loadActiveTab = useCallback(async (role = merchantRole, tab = activeTab) => {
|
|
switch (tab) {
|
|
case 'products':
|
|
await loadProducts()
|
|
return
|
|
case 'orders':
|
|
await loadOrders()
|
|
return
|
|
case 'wallet':
|
|
await loadWallet(undefined, undefined, role)
|
|
return
|
|
case 'api':
|
|
await loadAPIClients(role)
|
|
return
|
|
case 'callbacks':
|
|
await loadCallbacks(role)
|
|
return
|
|
case 'members':
|
|
await loadMembers(role)
|
|
return
|
|
}
|
|
}, [activeTab, loadAPIClients, loadCallbacks, loadMembers, loadOrders, loadProducts, loadWallet, merchantRole])
|
|
|
|
const loadAll = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const current = await loadCurrent()
|
|
const nextTab = resolveEnabledTab(activeTab, current.merchant.features)
|
|
if (nextTab !== activeTab) {
|
|
setActiveTab(nextTab)
|
|
}
|
|
await loadActiveTab(current.role, nextTab)
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '加载失败')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [activeTab, loadActiveTab, loadCurrent])
|
|
|
|
useEffect(() => {
|
|
loadAll()
|
|
}, [loadAll])
|
|
|
|
const openProductCreate = () => {
|
|
setEditingProduct(null)
|
|
productForm.resetFields()
|
|
productForm.setFieldsValue({
|
|
currency: 'POINT',
|
|
stock: -1,
|
|
status: 'active',
|
|
price_amount: 0,
|
|
cost_amount: 0,
|
|
})
|
|
setProductOpen(true)
|
|
}
|
|
|
|
const openProductEdit = (record: MerchantProduct) => {
|
|
setEditingProduct(record)
|
|
productForm.setFieldsValue({
|
|
...record,
|
|
})
|
|
setProductOpen(true)
|
|
}
|
|
|
|
const submitProduct = async () => {
|
|
const values = await productForm.validateFields()
|
|
try {
|
|
const payload = {
|
|
...values,
|
|
}
|
|
if (editingProduct) {
|
|
await merchantApi.updateProduct(editingProduct.id, payload)
|
|
message.success('商品已更新')
|
|
} else {
|
|
await merchantApi.createProduct(payload)
|
|
message.success('商品已创建')
|
|
}
|
|
setProductOpen(false)
|
|
loadProducts()
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '保存失败')
|
|
}
|
|
}
|
|
|
|
const submitWalletAdjust = async () => {
|
|
const values = await walletForm.validateFields()
|
|
try {
|
|
const walletData = await merchantApi.adjustWallet({
|
|
amount: values.amount,
|
|
idempotency_key: values.idempotency_key,
|
|
note: values.note,
|
|
})
|
|
setWallet(walletData)
|
|
setWalletOpen(false)
|
|
message.success('钱包已调整')
|
|
loadWallet()
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '调整失败')
|
|
}
|
|
}
|
|
|
|
const submitAPIClient = async () => {
|
|
const values = await apiClientForm.validateFields()
|
|
try {
|
|
const credential = await merchantApi.createApiClient({
|
|
name: values.name,
|
|
scopes: (values.scopes || []).join(','),
|
|
signature_version: values.signature_version,
|
|
expires_at: values.expires_at,
|
|
})
|
|
setApiCredential(credential)
|
|
setApiClientOpen(false)
|
|
message.success('API 客户端已创建')
|
|
loadAPIClients()
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '创建失败')
|
|
}
|
|
}
|
|
|
|
const submitCallback = async () => {
|
|
const values = await callbackForm.validateFields()
|
|
try {
|
|
const credential = await merchantApi.createCallback({
|
|
name: values.name,
|
|
url: values.url,
|
|
events: (values.events || []).join(','),
|
|
})
|
|
setCallbackCredential(credential)
|
|
setCallbackOpen(false)
|
|
message.success('回调已创建')
|
|
loadCallbacks()
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '创建失败')
|
|
}
|
|
}
|
|
|
|
const submitMember = async () => {
|
|
const values = await memberForm.validateFields()
|
|
try {
|
|
await merchantApi.addMember({
|
|
user_id: values.user_id,
|
|
role: values.role,
|
|
is_default: values.is_default,
|
|
})
|
|
setMemberOpen(false)
|
|
message.success('成员已添加')
|
|
loadMembers()
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '添加失败')
|
|
}
|
|
}
|
|
|
|
const toggleAPIClient = async (record: ApiClient) => {
|
|
try {
|
|
await merchantApi.updateApiClientStatus(record.id, record.status === 'active' ? 'disabled' : 'active')
|
|
message.success('状态已更新')
|
|
loadAPIClients()
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '更新失败')
|
|
}
|
|
}
|
|
|
|
const toggleCallback = async (record: CallbackSubscription) => {
|
|
try {
|
|
await merchantApi.updateCallbackStatus(record.id, record.status === 'active' ? 'disabled' : 'active')
|
|
message.success('状态已更新')
|
|
loadCallbacks()
|
|
} 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: 'base_amount', width: 100, render: money },
|
|
{ title: '手续费', dataIndex: 'service_fee_amount', width: 100, render: money },
|
|
{ title: '扣款合计', dataIndex: 'amount', width: 100, render: money },
|
|
{ title: '支付', dataIndex: 'payment_status', width: 90, render: paymentStatusTag },
|
|
{ title: '履约', dataIndex: 'fulfillment_status', width: 100, render: fulfillmentStatusTag },
|
|
{ title: '上游单号', dataIndex: 'provider_order_no', width: 140, ellipsis: true, render: (v) => v || '-' },
|
|
{ title: '时间', dataIndex: '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: '商品',
|
|
disabled: !hasFeature('products'),
|
|
children: (
|
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
|
<Typography.Text type="secondary">通用商品以商户 SKU 对外销售。</Typography.Text>
|
|
{canManage && <Button type="primary" icon={<PlusOutlined />} onClick={openProductCreate}>新增商品</Button>}
|
|
</Space>
|
|
<Table
|
|
rowKey="id"
|
|
loading={loading}
|
|
columns={productColumns}
|
|
dataSource={products.list}
|
|
tableLayout="fixed"
|
|
pagination={pageConfig(products, loadProducts)}
|
|
/>
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
key: 'orders',
|
|
label: '履约订单',
|
|
disabled: !hasFeature('orders'),
|
|
children: (
|
|
<Table
|
|
rowKey="id"
|
|
loading={loading}
|
|
columns={orderColumns}
|
|
dataSource={orders.list}
|
|
tableLayout="fixed"
|
|
scroll={{ x: 1300 }}
|
|
pagination={pageConfig(orders, loadOrders)}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'wallet',
|
|
label: '钱包',
|
|
disabled: !hasFeature('wallet'),
|
|
children: (
|
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
|
<Descriptions size="small" bordered column={3} style={{ flex: 1 }}>
|
|
<Descriptions.Item label="可用余额">{money(wallet?.available_balance ?? 0)}</Descriptions.Item>
|
|
<Descriptions.Item label="冻结余额">{money(wallet?.frozen_balance ?? 0)}</Descriptions.Item>
|
|
<Descriptions.Item label="币种">{wallet?.currency || 'POINT'}</Descriptions.Item>
|
|
</Descriptions>
|
|
{canFinance && (
|
|
<Button
|
|
icon={<WalletOutlined />}
|
|
onClick={() => {
|
|
walletForm.resetFields()
|
|
walletForm.setFieldsValue({ idempotency_key: `manual-${Date.now()}` })
|
|
setWalletOpen(true)
|
|
}}
|
|
>
|
|
调整
|
|
</Button>
|
|
)}
|
|
</Space>
|
|
{canFinance ? (
|
|
<Table
|
|
rowKey="id"
|
|
loading={loading}
|
|
columns={ledgerColumns}
|
|
dataSource={ledger.list}
|
|
tableLayout="fixed"
|
|
pagination={pageConfig(ledger, loadWallet)}
|
|
/>
|
|
) : (
|
|
<Typography.Text type="secondary">当前角色可查看余额,钱包流水仅财务或负责人可见。</Typography.Text>
|
|
)}
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
key: 'api',
|
|
label: 'API 客户端',
|
|
disabled: !hasFeature('api'),
|
|
children: (
|
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
|
<Typography.Text type="secondary">新密钥只在创建后显示一次。</Typography.Text>
|
|
{canManage && <Button type="primary" icon={<ApiOutlined />} onClick={() => {
|
|
apiClientForm.resetFields()
|
|
apiClientForm.setFieldsValue({ signature_version: 'v1', scopes: ['products:read', 'orders:read', 'orders:write'] })
|
|
setApiClientOpen(true)
|
|
}}>新增客户端</Button>}
|
|
</Space>
|
|
<Table rowKey="id" loading={loading} columns={apiClientColumns} dataSource={apiClients} tableLayout="fixed" />
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
key: 'callbacks',
|
|
label: '回调',
|
|
disabled: !hasFeature('callbacks'),
|
|
children: (
|
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
|
<Typography.Text type="secondary">回调事件通过 outbox 持久化发送。</Typography.Text>
|
|
{canManage && <Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
|
callbackForm.resetFields()
|
|
callbackForm.setFieldsValue({ events: ['order.fulfillment.updated'] })
|
|
setCallbackOpen(true)
|
|
}}>新增回调</Button>}
|
|
</Space>
|
|
<Table rowKey="id" loading={loading} columns={callbackColumns} dataSource={callbacks} tableLayout="fixed" />
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
key: 'members',
|
|
label: '成员',
|
|
children: (
|
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
|
<Typography.Text type="secondary">成员角色独立于平台账号角色。</Typography.Text>
|
|
{merchantRole === 'owner' && <Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
|
memberForm.resetFields()
|
|
memberForm.setFieldsValue({ role: 'operator' })
|
|
setMemberOpen(true)
|
|
}}>添加成员</Button>}
|
|
</Space>
|
|
<Table rowKey="id" loading={loading} columns={memberColumns} dataSource={members} tableLayout="fixed" />
|
|
</Space>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<Modal title={editingProduct ? '编辑商品' : '新增商品'} open={productOpen} onOk={submitProduct} onCancel={() => setProductOpen(false)} destroyOnClose width={620}>
|
|
<Form form={productForm} layout="vertical" style={{ marginTop: 16 }}>
|
|
{!editingProduct && (
|
|
<>
|
|
<Form.Item name="product_code" label="目录编码">
|
|
<Input placeholder="已有目录编码可选填" />
|
|
</Form.Item>
|
|
<Form.Item name="product_name" label="商品名称" rules={[{ required: true }]}>
|
|
<Input />
|
|
</Form.Item>
|
|
</>
|
|
)}
|
|
<Space size="middle" style={{ width: '100%' }}>
|
|
<Form.Item name="sku" label="商户 SKU" rules={[{ required: true }]} style={{ width: 280 }}>
|
|
<Input disabled={!!editingProduct} />
|
|
</Form.Item>
|
|
<Form.Item name="display_name" label="展示名" style={{ width: 280 }}>
|
|
<Input />
|
|
</Form.Item>
|
|
</Space>
|
|
<Space size="middle" style={{ width: '100%' }}>
|
|
<Form.Item name="price_amount" label="售价(积分)" rules={[{ required: true }]} style={{ width: 180 }}>
|
|
<InputNumber min={0} precision={0} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item name="cost_amount" label="成本(积分)" style={{ width: 180 }}>
|
|
<InputNumber min={0} precision={0} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item name="currency" label="币种" style={{ width: 180 }}>
|
|
<Select options={[{ value: 'POINT', label: 'POINT(积分)' }]} />
|
|
</Form.Item>
|
|
</Space>
|
|
<Space size="middle" style={{ width: '100%' }}>
|
|
<Form.Item name="stock" label="库存(-1 无限)" style={{ width: 180 }}>
|
|
<InputNumber style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item name="status" label="状态" style={{ width: 180 }}>
|
|
<Select options={[{ value: 'active', label: '上架' }, { value: 'inactive', label: '下架' }]} />
|
|
</Form.Item>
|
|
{!editingProduct && (
|
|
<Form.Item name="category" label="品类" style={{ width: 180 }}>
|
|
<Input />
|
|
</Form.Item>
|
|
)}
|
|
</Space>
|
|
<Form.Item name="fulfillment_config" label="履约配置">
|
|
<Input.TextArea rows={3} placeholder='{"provider":"manual"}' />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
<Modal title="钱包调整" open={walletOpen} onOk={submitWalletAdjust} onCancel={() => setWalletOpen(false)} destroyOnClose>
|
|
<Form form={walletForm} layout="vertical" style={{ marginTop: 16 }}>
|
|
<Form.Item name="amount" label="调整积分" rules={[{ required: true }]}>
|
|
<InputNumber precision={0} style={{ width: '100%' }} placeholder="正数充值,负数扣减" />
|
|
</Form.Item>
|
|
<Form.Item name="idempotency_key" label="幂等键" rules={[{ required: true }]}>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="note" label="备注">
|
|
<Input />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
<Modal title="新增 API 客户端" open={apiClientOpen} onOk={submitAPIClient} onCancel={() => setApiClientOpen(false)} destroyOnClose>
|
|
<Form form={apiClientForm} layout="vertical" style={{ marginTop: 16 }}>
|
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
|
<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 featuresToList(features?: string) {
|
|
const list = (features || '')
|
|
.split(/[,\s]+/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean)
|
|
return list.length > 0 ? list : ['products', 'orders', 'wallet', 'api', 'callbacks']
|
|
}
|
|
|
|
function resolveEnabledTab(current: string, features?: string) {
|
|
const enabled = new Set(featuresToList(features))
|
|
const tabFeatures: Record<string, string | null> = {
|
|
products: 'products',
|
|
orders: 'orders',
|
|
wallet: 'wallet',
|
|
api: 'api',
|
|
callbacks: 'callbacks',
|
|
members: null,
|
|
}
|
|
const feature = tabFeatures[current]
|
|
if (feature === null || enabled.has(feature)) {
|
|
return current
|
|
}
|
|
return ['products', 'orders', 'wallet', 'api', 'callbacks'].find((key) => enabled.has(key)) || 'members'
|
|
}
|
|
|
|
function money(value?: number | null) {
|
|
return `${Number(value || 0)} 积分`
|
|
}
|
|
|
|
function moneyWithSign(value?: number | null) {
|
|
const amount = Number(value || 0)
|
|
const prefix = amount > 0 ? '+' : ''
|
|
return `${prefix}${money(amount)}`
|
|
}
|
|
|
|
function 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
|
|
}
|