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