拆分 service 与前端大文件,修复 CORS 配置与格式问题

- 后端 internal/service 按职责拆分:
  fulfillment.go(1397→527)拆出 wallet/timeout/data/order/query/dashboard/shipnotify
  delivery.go(1124→801)拆出 upstream/link/state/helpers
  merchant.go(855→251)拆出 member/product/api_client/catalog/helpers
- 前端 MerchantCenter.tsx(1327→606)拆出 merchantCenterTabs/merchantCenterUtils
- docker-compose backend 透传 CORS_ALLOWED_ORIGINS
- CORS 白名单实现(config/router/README/.env.example 配套)
- 修复 gofmt 与文件尾部多余空行
This commit is contained in:
yml2213
2026-08-05 13:32:11 +08:00
parent 569109cd92
commit 2264851d5d
29 changed files with 2990 additions and 2606 deletions
+66 -787
View File
@@ -2,38 +2,23 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import {
Button,
Card,
Col,
Descriptions,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd'
import {
ClockCircleOutlined,
CodeOutlined,
CopyOutlined,
DollarOutlined,
FileTextOutlined,
KeyOutlined,
LinkOutlined,
PlusOutlined,
ReloadOutlined,
SafetyCertificateOutlined,
SendOutlined,
WalletOutlined,
} from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import { useAuth } from '../store/auth'
import { merchantApi } from '../api'
import { formatDateTime } from '../utils/time'
@@ -52,50 +37,29 @@ import type {
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
}
import {
apiClientMax,
defaultCallbackFormValues,
eventsToValue,
featuresToList,
memberRoleOptions,
productOptionLabel,
resolveEnabledTab,
roleText,
scopeOptions,
tabFromSearch,
testOrderStatusOptions,
} from './merchantCenterUtils'
import type { MerchantCenterProps, MerchantCenterTab } from './merchantCenterUtils'
import {
ApiKeysTab,
CallbacksTab,
MembersTab,
OrdersTab,
ProductsTab,
SecretBlock,
WalletTab,
} from './merchantCenterTabs'
export default function MerchantCenter({ fixedTab, title = '商户中心' }: MerchantCenterProps = {}) {
const { isAdmin } = useAuth()
@@ -159,23 +123,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
} : 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(() => {
@@ -198,6 +145,23 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
.catch((e) => message.error(e instanceof Error ? e.message : '恢复失败'))
}, [patchDeliveryLinkState])
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,
@@ -314,6 +278,14 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
loadWallet(1, ledger.size, merchantRole, {})
}
const handleLoadLedger = useCallback((page: number, size: number) => {
const values = walletFilterForm.getFieldsValue()
loadWallet(page, size, merchantRole, {
reference_no: values.reference_no || undefined,
type: values.type || undefined,
})
}, [loadWallet, merchantRole, walletFilterForm])
const openTestOrderCreate = () => {
testOrderForm.resetFields()
testOrderForm.setFieldsValue({
@@ -448,538 +420,25 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
}
}
const productColumns: ColumnsType<MerchantProduct> = [
{
title: '商户 SKU',
dataIndex: 'sku',
width: 180,
ellipsis: true,
render: (v) => <Typography.Text code copyable={{ tooltips: false }} style={{ maxWidth: '100%' }}>{v}</Typography.Text>,
},
{
title: '商品名称',
dataIndex: 'display_name',
width: 220,
ellipsis: true,
render: (_, r) => (
<div>
<div style={{ fontWeight: 600, color: '#0f172a' }}>{r.display_name || r.product?.name || '-'}</div>
{r.product?.category && (
<span style={{ fontSize: 11.5, color: '#64748b' }}>{r.product.category}</span>
)}
</div>
),
},
{
title: '目录编码',
dataIndex: ['product', 'code'],
width: 150,
ellipsis: true,
render: (_, r) => r.product?.code ? <Typography.Text code>{r.product.code}</Typography.Text> : '-',
},
{
title: '对外售价',
dataIndex: 'price_amount',
width: 120,
render: (v) => <span style={{ fontWeight: 700, color: '#2563eb' }}>{money(v)}</span>,
},
{
title: '成本价',
dataIndex: 'cost_amount',
width: 120,
render: (v) => <span style={{ color: '#64748b' }}>{money(v)}</span>,
},
{
title: '当前库存',
dataIndex: 'stock',
width: 110,
render: (v) => (v < 0 ? <Tag color="blue"></Tag> : v === 0 ? <Tag color="red"></Tag> : `${v}`),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: productStatusTag,
},
{
title: '操作',
key: 'action',
width: 90,
fixed: 'right',
render: (_, record) => canManage ? (
<Popconfirm
title={record.status === 'active' ? '下架该商品?' : '上架该商品?'}
description={record.status === 'active' ? '下架后商户将无法再创建该商品订单' : '上架后商户可正常下单'}
okText="确认"
onConfirm={() => openProductToggle(record)}
>
<Button type="link" size="small">
{record.status === 'active' ? '下架' : '上架'}
</Button>
</Popconfirm>
) : '-',
},
]
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: 260, render: (v) => <Typography.Text code copyable={{ tooltips: false }}>{v}</Typography.Text> },
{ title: '类型', dataIndex: 'type', width: 90, render: ledgerTypeTag },
{ title: '变动积分', dataIndex: 'amount', width: 130, render: moneyWithSign },
{ title: '结余积分', dataIndex: 'balance_after', width: 130, render: (v) => <span style={{ fontWeight: 600, color: '#0f172a' }}>{money(v)}</span> },
{ 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: 220, 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">
<Card size="small" style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<span style={{ fontWeight: 600, color: '#0f172a', marginRight: 8 }}></span>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
/
</Typography.Text>
</div>
</div>
</Card>
<Table
rowKey="id"
loading={loading}
columns={productColumns}
dataSource={products.list}
scroll={{ x: 1100 }}
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">
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<div className="metric-card-box">
<div className="metric-card-header">
<span className="metric-card-title"></span>
<div className="metric-card-icon metric-card-icon--emerald"><WalletOutlined /></div>
</div>
<div className="metric-card-value" style={{ color: '#16a34a' }}>
{(wallet?.available_balance ?? 0).toLocaleString('zh-CN')}
<span style={{ fontSize: 13, fontWeight: 500, marginLeft: 4, color: '#64748b' }}></span>
</div>
<div className="metric-card-footer">
<span>/</span>
<span style={{ fontWeight: 600, color: '#64748b' }}></span>
</div>
</div>
</Col>
<Col xs={24} sm={8}>
<div className="metric-card-box">
<div className="metric-card-header">
<span className="metric-card-title"></span>
<div className="metric-card-icon"><ClockCircleOutlined /></div>
</div>
<div className="metric-card-value">
{(wallet?.frozen_balance ?? 0).toLocaleString('zh-CN')}
<span style={{ fontSize: 13, fontWeight: 500, marginLeft: 4, color: '#64748b' }}></span>
</div>
<div className="metric-card-footer">
<span>/</span>
<span style={{ fontWeight: 600, color: '#0f172a' }}>0 </span>
</div>
</div>
</Col>
<Col xs={24} sm={8}>
<div className="metric-card-box">
<div className="metric-card-header">
<span className="metric-card-title"></span>
<div className="metric-card-icon metric-card-icon--purple"><DollarOutlined /></div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
<Tag color="blue" style={{ fontSize: 13, padding: '2px 8px', borderRadius: 4, margin: 0, fontWeight: 600 }}>
{wallet?.currency || 'POINT'}
</Tag>
<span className="status-tag status-tag--green">
<span className="status-dot"></span>
</span>
</div>
<div className="metric-card-footer">
<span></span>
<span style={{ fontWeight: 600, color: '#0f172a' }}></span>
</div>
</div>
</Col>
</Row>
{canFinance && (
<Card size="small" style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 8 }}>
<Form form={walletFilterForm} layout="inline" onFinish={handleWalletFilter}>
<Form.Item name="reference_no" label="关联单号">
<Input placeholder="店铺单号 / 充值单号" allowClear style={{ width: 220, borderRadius: 6 }} />
</Form.Item>
<Form.Item name="type" label="收支类型">
<Select placeholder="全部类型" allowClear style={{ width: 140 }}>
<Select.Option value="credit"> (Credit)</Select.Option>
<Select.Option value="debit"> (Debit)</Select.Option>
<Select.Option value="refund">退 (Refund)</Select.Option>
<Select.Option value="adjust"> (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="large">
<Card
size="small"
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
title={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<Space size={8}>
<KeyOutlined style={{ color: '#2563eb', fontSize: 16 }} />
<span style={{ fontWeight: 700, color: '#0f172a' }}>API </span>
<Tag color="blue" style={{ borderRadius: 10, padding: '0 8px', fontWeight: 600 }}>
{apiClients.length} / {apiClientMax}
</Tag>
</Space>
<Space size={8}>
<Button icon={<FileTextOutlined />} onClick={() => navigate('/open-api')}>
</Button>
{isAdmin && (
<Button icon={<CodeOutlined />} onClick={() => navigate('/api-debug')}>
</Button>
)}
{canManage && (
<Button
type="primary"
icon={<PlusOutlined />}
disabled={apiClients.length >= apiClientMax}
onClick={() => {
apiClientForm.resetFields()
apiClientForm.setFieldsValue({
signature_version: 'v1',
scopes: ['products:read', 'orders:read', 'orders:write'],
})
setApiClientOpen(true)
}}
>
API
</Button>
)}
</Space>
</div>
}
>
<div style={{ background: '#f8fafc', padding: '10px 14px', borderRadius: 8, marginBottom: 16, border: '1px solid #e2e8f0' }}>
<Typography.Text type="secondary" style={{ fontSize: 12.5 }}>
API () HMAC-SHA256 App Secret
</Typography.Text>
</div>
<Table
rowKey="id"
loading={loading}
columns={apiClientColumns}
dataSource={apiClients}
scroll={{ x: 1150 }}
pagination={false}
/>
</Card>
</Space>
)
const callbackContent = (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Card
size="small"
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
title={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<Space size={8}>
<SendOutlined style={{ color: '#2563eb', fontSize: 16 }} />
<span style={{ fontWeight: 700, color: '#0f172a' }}>Webhook </span>
{callback?.status === 'active' ? (
<span className="status-tag status-tag--green">
<span className="status-dot"></span>
</span>
) : (
<span className="status-tag status-tag--gray">
<span className="status-dot"></span>
</span>
)}
</Space>
<Tag color="cyan" style={{ borderRadius: 6, fontWeight: 500 }}>
<SafetyCertificateOutlined style={{ marginRight: 4 }} /> HMAC-SHA256
</Tag>
</div>
}
>
<div style={{ background: '#f8fafc', padding: '12px 16px', borderRadius: 8, marginBottom: 20, border: '1px solid #e2e8f0' }}>
<Row gutter={[16, 8]}>
<Col xs={24} sm={8}>
<div style={{ fontSize: 12, color: '#64748b' }}></div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', marginTop: 2 }}>
Outbox + 退 ( 16 )
</div>
</Col>
<Col xs={24} sm={8}>
<div style={{ fontSize: 12, color: '#64748b' }}> Header</div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', marginTop: 2 }}>
X-Signature (HMAC-SHA256)
</div>
</Col>
<Col xs={24} sm={8}>
<div style={{ fontSize: 12, color: '#64748b' }}></div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', marginTop: 2 }}>
5000 ms
</div>
</Col>
</Row>
</div>
<Form
form={callbackForm}
layout="vertical"
disabled={!canManage}
onFinish={submitCallback}
initialValues={defaultCallbackFormValues()}
>
<Form.Item
name="url"
label="回调接收端 URL"
rules={[
{ required: true, message: '请填写回调 URL' },
{ type: 'url', message: '请填写正确的 HTTP(S) 地址' },
]}
>
<Input
prefix={<LinkOutlined style={{ color: '#94a3b8' }} />}
placeholder="https://your-domain.com/api/v1/webhook"
allowClear
/>
</Form.Item>
<Form.Item name="events" label="订阅事件类型" rules={[{ required: true, message: '请选择至少一个回调事件' }]}>
<Select
mode="multiple"
placeholder="请选择订阅的事件列表"
options={eventOptions}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item name="status" label="回调状态" rules={[{ required: true }]}>
<Select
options={[
{ value: 'active', label: '启用回调推送' },
{ value: 'disabled', label: '禁用回调推送' },
]}
style={{ width: 200 }}
/>
</Form.Item>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: 16,
borderTop: '1px solid #f1f5f9',
marginTop: 20,
flexWrap: 'wrap',
gap: 12,
}}
>
<Typography.Text type="secondary" style={{ fontSize: 12.5 }}>
{callback?.updated_at ? `上次保存:${formatDateTime(callback.updated_at)}` : '尚未保存回调配置'}
</Typography.Text>
<Space size={12}>
{canManage && (
<Popconfirm
title="确认重置回调 Secret"
description="重置后旧 Secret 立即失效,平台将使用新 Secret 进行 HMAC 签名推送,新 Secret 仅展示一次。"
okText="确认重置"
okButtonProps={{ danger: true }}
onConfirm={rotateCallbackSecret}
>
<Button danger> Secret</Button>
</Popconfirm>
)}
{canManage && (
<Button type="primary" htmlType="submit">
</Button>
)}
</Space>
</div>
</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 },
{ key: 'products', label: '商品列表', disabled: !hasFeature('products'), children: (
<ProductsTab loading={loading} products={products} canManage={canManage} onToggleProduct={openProductToggle} onLoadProducts={loadProducts} />
) },
{ key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: (
<OrdersTab loading={loading} orders={orders} canManage={canManage} onCreateTestOrder={openTestOrderCreate} onLoadOrders={loadOrders} onCopyLink={copyDeliveryLink} onOpenLink={openDeliveryLink} onRevokeLink={revokeDeliveryLink} onRestoreLink={restoreDeliveryLink} />
) },
{ key: 'wallet', label: '钱包', disabled: !hasFeature('wallet'), children: (
<WalletTab loading={loading} wallet={wallet} canFinance={canFinance} ledger={ledger} form={walletFilterForm} onFilter={handleWalletFilter} onResetFilter={handleWalletFilterReset} onLoadLedger={handleLoadLedger} />
) },
{ key: 'api', label: 'API 密钥', disabled: !hasFeature('api'), children: (
<ApiKeysTab loading={loading} clients={apiClients} clientMax={apiClientMax} isAdmin={isAdmin} canManage={canManage} form={apiClientForm} onOpenCreate={() => setApiClientOpen(true)} onNavigateDocs={() => navigate('/open-api')} onNavigateDebug={() => navigate('/api-debug')} onToggleClient={toggleAPIClient} onDeleteClient={deleteAPIClient} />
) },
{ key: 'callbacks', label: '回调', disabled: !hasFeature('callbacks'), children: (
<CallbacksTab canManage={canManage} callback={callback} form={callbackForm} onSubmit={submitCallback} onRotateSecret={rotateCallbackSecret} />
) },
{ key: 'members', label: '成员', children: (
<MembersTab loading={loading} members={members} isOwner={merchantRole === 'owner'} form={memberForm} onOpenAdd={() => setMemberOpen(true)} />
) },
]
const fixedTabContent = tabItems.find((item) => item.key === fixedTab)?.children
@@ -1145,183 +604,3 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
</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)
if (amount > 0) {
return <span style={{ fontWeight: 700, color: '#16a34a' }}>+{money(amount)}</span>
}
if (amount < 0) {
return <span style={{ fontWeight: 700, color: '#dc2626' }}>{money(amount)}</span>
}
return <span style={{ fontWeight: 600, color: '#64748b' }}>{money(amount)}</span>
}
function productStatusTag(value: string) {
return value === 'active' ? (
<span className="status-tag status-tag--green">
<span className="status-dot"></span>
</span>
) : (
<span className="status-tag status-tag--gray">
<span className="status-dot"></span>
</span>
)
}
function activeStatusTag(value: string) {
return value === 'active' ? (
<span className="status-tag status-tag--green">
<span className="status-dot"></span>
</span>
) : (
<span className="status-tag status-tag--gray">
<span className="status-dot"></span>
</span>
)
}
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 }
const tagClassMap: Record<string, string> = {
paid: 'status-tag--blue',
delivering: 'status-tag--cyan',
delivered: 'status-tag--green',
ship_failed: 'status-tag--red',
cancelled: 'status-tag--gray',
}
const tagClass = tagClassMap[value] || 'status-tag--gray'
return (
<span className={`status-tag ${tagClass}`}>
<span className="status-dot"></span>
{item.text}
</span>
)
}
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
}
+769
View File
@@ -0,0 +1,769 @@
import { Button, Card, Col, Form, Input, Popconfirm, Row, Select, Space, Table, Tag, Typography, message } from 'antd'
import type { FormInstance } from 'antd'
import {
ClockCircleOutlined,
CodeOutlined,
CopyOutlined,
DollarOutlined,
FileTextOutlined,
KeyOutlined,
LinkOutlined,
PlusOutlined,
SafetyCertificateOutlined,
SendOutlined,
WalletOutlined,
} from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import { formatDateTime } from '../utils/time'
import type {
ApiClient,
CallbackSubscription,
FulfillmentOrder,
MerchantMember,
MerchantProduct,
PageResult,
WalletAccount,
WalletLedgerEntry,
} from '../types'
import {
defaultCallbackFormValues,
eventOptions,
money,
orderStatusMap,
pageConfig,
roleText,
scopeLabel,
} from './merchantCenterUtils'
export 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>
)
}
export function ProductsTab({
loading,
products,
canManage,
onToggleProduct,
onLoadProducts,
}: {
loading: boolean
products: PageResult<MerchantProduct>
canManage: boolean
onToggleProduct: (record: MerchantProduct) => void
onLoadProducts: (page?: number, size?: number) => void
}) {
const productColumns: ColumnsType<MerchantProduct> = [
{
title: '商户 SKU',
dataIndex: 'sku',
width: 180,
ellipsis: true,
render: (v) => <Typography.Text code copyable={{ tooltips: false }} style={{ maxWidth: '100%' }}>{v}</Typography.Text>,
},
{
title: '商品名称',
dataIndex: 'display_name',
width: 220,
ellipsis: true,
render: (_, r) => (
<div>
<div style={{ fontWeight: 600, color: '#0f172a' }}>{r.display_name || r.product?.name || '-'}</div>
{r.product?.category && (
<span style={{ fontSize: 11.5, color: '#64748b' }}>{r.product.category}</span>
)}
</div>
),
},
{
title: '目录编码',
dataIndex: ['product', 'code'],
width: 150,
ellipsis: true,
render: (_, r) => r.product?.code ? <Typography.Text code>{r.product.code}</Typography.Text> : '-',
},
{
title: '对外售价',
dataIndex: 'price_amount',
width: 120,
render: (v) => <span style={{ fontWeight: 700, color: '#2563eb' }}>{money(v)}</span>,
},
{
title: '成本价',
dataIndex: 'cost_amount',
width: 120,
render: (v) => <span style={{ color: '#64748b' }}>{money(v)}</span>,
},
{
title: '当前库存',
dataIndex: 'stock',
width: 110,
render: (v) => (v < 0 ? <Tag color="blue"></Tag> : v === 0 ? <Tag color="red"></Tag> : `${v}`),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: productStatusTag,
},
{
title: '操作',
key: 'action',
width: 90,
fixed: 'right',
render: (_, record) => canManage ? (
<Popconfirm
title={record.status === 'active' ? '下架该商品?' : '上架该商品?'}
description={record.status === 'active' ? '下架后商户将无法再创建该商品订单' : '上架后商户可正常下单'}
okText="确认"
onConfirm={() => onToggleProduct(record)}
>
<Button type="link" size="small">
{record.status === 'active' ? '下架' : '上架'}
</Button>
</Popconfirm>
) : '-',
},
]
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Card size="small" style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<span style={{ fontWeight: 600, color: '#0f172a', marginRight: 8 }}></span>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
/
</Typography.Text>
</div>
</div>
</Card>
<Table
rowKey="id"
loading={loading}
columns={productColumns}
dataSource={products.list}
scroll={{ x: 1100 }}
pagination={pageConfig(products, onLoadProducts)}
/>
</Space>
)
}
export function OrdersTab({
loading,
orders,
canManage,
onCreateTestOrder,
onLoadOrders,
onCopyLink,
onOpenLink,
onRevokeLink,
onRestoreLink,
}: {
loading: boolean
orders: PageResult<FulfillmentOrder>
canManage: boolean
onCreateTestOrder: () => void
onLoadOrders: (page?: number, size?: number) => void
onCopyLink: (orderNo: string) => void
onOpenLink: (orderNo: string) => void
onRevokeLink: (orderNo: string) => void
onRestoreLink: (orderNo: string) => void
}) {
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={() => onRestoreLink(record.order_no)}></Button>
) : (
<Space size={4} wrap>
<Button type="link" size="small" onClick={() => onCopyLink(record.order_no)}></Button>
<Button type="link" size="small" onClick={() => onOpenLink(record.order_no)}></Button>
<Button type="link" danger size="small" onClick={() => onRevokeLink(record.order_no)}></Button>
</Space>
),
},
]
return (
<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={onCreateTestOrder}>
</Button>
</Space>
)}
</Space>
<Table
rowKey="id"
loading={loading}
columns={orderColumns}
dataSource={orders.list}
tableLayout="fixed"
scroll={{ x: 1660 }}
pagination={pageConfig(orders, onLoadOrders)}
/>
</Space>
)
}
export function WalletTab({
loading,
wallet,
canFinance,
ledger,
form,
onFilter,
onResetFilter,
onLoadLedger,
}: {
loading: boolean
wallet: WalletAccount | null
canFinance: boolean
ledger: PageResult<WalletLedgerEntry>
form: FormInstance
onFilter: () => void
onResetFilter: () => void
onLoadLedger: (page: number, size: number) => void
}) {
const ledgerColumns: ColumnsType<WalletLedgerEntry> = [
{ title: '流水号', dataIndex: 'entry_no', width: 260, render: (v) => <Typography.Text code copyable={{ tooltips: false }}>{v}</Typography.Text> },
{ title: '类型', dataIndex: 'type', width: 90, render: ledgerTypeTag },
{ title: '变动积分', dataIndex: 'amount', width: 130, render: moneyWithSign },
{ title: '结余积分', dataIndex: 'balance_after', width: 130, render: (v) => <span style={{ fontWeight: 600, color: '#0f172a' }}>{money(v)}</span> },
{ 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: 220, ellipsis: { showTitle: false }, render: (v) => <Typography.Text ellipsis title={v}>{v || '-'}</Typography.Text> },
{ title: '变动时间', dataIndex: 'created_at', width: 170, render: formatDateTime },
]
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<div className="metric-card-box">
<div className="metric-card-header">
<span className="metric-card-title"></span>
<div className="metric-card-icon metric-card-icon--emerald"><WalletOutlined /></div>
</div>
<div className="metric-card-value" style={{ color: '#16a34a' }}>
{(wallet?.available_balance ?? 0).toLocaleString('zh-CN')}
<span style={{ fontSize: 13, fontWeight: 500, marginLeft: 4, color: '#64748b' }}></span>
</div>
<div className="metric-card-footer">
<span>/</span>
<span style={{ fontWeight: 600, color: '#64748b' }}></span>
</div>
</div>
</Col>
<Col xs={24} sm={8}>
<div className="metric-card-box">
<div className="metric-card-header">
<span className="metric-card-title"></span>
<div className="metric-card-icon"><ClockCircleOutlined /></div>
</div>
<div className="metric-card-value">
{(wallet?.frozen_balance ?? 0).toLocaleString('zh-CN')}
<span style={{ fontSize: 13, fontWeight: 500, marginLeft: 4, color: '#64748b' }}></span>
</div>
<div className="metric-card-footer">
<span>/</span>
<span style={{ fontWeight: 600, color: '#0f172a' }}>0 </span>
</div>
</div>
</Col>
<Col xs={24} sm={8}>
<div className="metric-card-box">
<div className="metric-card-header">
<span className="metric-card-title"></span>
<div className="metric-card-icon metric-card-icon--purple"><DollarOutlined /></div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
<Tag color="blue" style={{ fontSize: 13, padding: '2px 8px', borderRadius: 4, margin: 0, fontWeight: 600 }}>
{wallet?.currency || 'POINT'}
</Tag>
<span className="status-tag status-tag--green">
<span className="status-dot"></span>
</span>
</div>
<div className="metric-card-footer">
<span></span>
<span style={{ fontWeight: 600, color: '#0f172a' }}></span>
</div>
</div>
</Col>
</Row>
{canFinance && (
<Card size="small" style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 8 }}>
<Form form={form} layout="inline" onFinish={onFilter}>
<Form.Item name="reference_no" label="关联单号">
<Input placeholder="店铺单号 / 充值单号" allowClear style={{ width: 220, borderRadius: 6 }} />
</Form.Item>
<Form.Item name="type" label="收支类型">
<Select placeholder="全部类型" allowClear style={{ width: 140 }}>
<Select.Option value="credit"> (Credit)</Select.Option>
<Select.Option value="debit"> (Debit)</Select.Option>
<Select.Option value="refund">退 (Refund)</Select.Option>
<Select.Option value="adjust"> (Adjust)</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button onClick={onResetFilter}></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, onLoadLedger)}
/>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Space>
)
}
export function ApiKeysTab({
loading,
clients,
clientMax,
isAdmin,
canManage,
form,
onOpenCreate,
onNavigateDocs,
onNavigateDebug,
onToggleClient,
onDeleteClient,
}: {
loading: boolean
clients: ApiClient[]
clientMax: number
isAdmin: boolean
canManage: boolean
form: FormInstance
onOpenCreate: () => void
onNavigateDocs: () => void
onNavigateDebug: () => void
onToggleClient: (record: ApiClient) => void
onDeleteClient: (record: ApiClient) => void
}) {
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={() => onToggleClient(record)}>
{record.status === 'active' ? '禁用' : '启用'}
</Button>
<Popconfirm
title="删除该密钥?"
description={record.status === 'active' ? '请先停用该密钥再删除' : '删除后不可恢复,使用该密钥的对接方将无法调用开放接口。'}
okText="删除"
okButtonProps={{ danger: true }}
onConfirm={() => onDeleteClient(record)}
>
<Button type="link" size="small" danger disabled={record.status === 'active'}>
</Button>
</Popconfirm>
</Space>
) : '-',
},
]
return (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Card
size="small"
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
title={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<Space size={8}>
<KeyOutlined style={{ color: '#2563eb', fontSize: 16 }} />
<span style={{ fontWeight: 700, color: '#0f172a' }}>API </span>
<Tag color="blue" style={{ borderRadius: 10, padding: '0 8px', fontWeight: 600 }}>
{clients.length} / {clientMax}
</Tag>
</Space>
<Space size={8}>
<Button icon={<FileTextOutlined />} onClick={onNavigateDocs}>
</Button>
{isAdmin && (
<Button icon={<CodeOutlined />} onClick={onNavigateDebug}>
</Button>
)}
{canManage && (
<Button
type="primary"
icon={<PlusOutlined />}
disabled={clients.length >= clientMax}
onClick={() => {
form.resetFields()
form.setFieldsValue({
signature_version: 'v1',
scopes: ['products:read', 'orders:read', 'orders:write'],
})
onOpenCreate()
}}
>
API
</Button>
)}
</Space>
</div>
}
>
<div style={{ background: '#f8fafc', padding: '10px 14px', borderRadius: 8, marginBottom: 16, border: '1px solid #e2e8f0' }}>
<Typography.Text type="secondary" style={{ fontSize: 12.5 }}>
API () HMAC-SHA256 App Secret
</Typography.Text>
</div>
<Table
rowKey="id"
loading={loading}
columns={apiClientColumns}
dataSource={clients}
scroll={{ x: 1150 }}
pagination={false}
/>
</Card>
</Space>
)
}
export function CallbacksTab({
canManage,
callback,
form,
onSubmit,
onRotateSecret,
}: {
canManage: boolean
callback: CallbackSubscription | null
form: FormInstance
onSubmit: () => void
onRotateSecret: () => void
}) {
return (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Card
size="small"
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
title={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<Space size={8}>
<SendOutlined style={{ color: '#2563eb', fontSize: 16 }} />
<span style={{ fontWeight: 700, color: '#0f172a' }}>Webhook </span>
{callback?.status === 'active' ? (
<span className="status-tag status-tag--green">
<span className="status-dot"></span>
</span>
) : (
<span className="status-tag status-tag--gray">
<span className="status-dot"></span>
</span>
)}
</Space>
<Tag color="cyan" style={{ borderRadius: 6, fontWeight: 500 }}>
<SafetyCertificateOutlined style={{ marginRight: 4 }} /> HMAC-SHA256
</Tag>
</div>
}
>
<div style={{ background: '#f8fafc', padding: '12px 16px', borderRadius: 8, marginBottom: 20, border: '1px solid #e2e8f0' }}>
<Row gutter={[16, 8]}>
<Col xs={24} sm={8}>
<div style={{ fontSize: 12, color: '#64748b' }}></div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', marginTop: 2 }}>
Outbox + 退 ( 16 )
</div>
</Col>
<Col xs={24} sm={8}>
<div style={{ fontSize: 12, color: '#64748b' }}> Header</div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', marginTop: 2 }}>
X-Signature (HMAC-SHA256)
</div>
</Col>
<Col xs={24} sm={8}>
<div style={{ fontSize: 12, color: '#64748b' }}></div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', marginTop: 2 }}>
5000 ms
</div>
</Col>
</Row>
</div>
<Form
form={form}
layout="vertical"
disabled={!canManage}
onFinish={onSubmit}
initialValues={defaultCallbackFormValues()}
>
<Form.Item
name="url"
label="回调接收端 URL"
rules={[
{ required: true, message: '请填写回调 URL' },
{ type: 'url', message: '请填写正确的 HTTP(S) 地址' },
]}
>
<Input
prefix={<LinkOutlined style={{ color: '#94a3b8' }} />}
placeholder="https://your-domain.com/api/v1/webhook"
allowClear
/>
</Form.Item>
<Form.Item name="events" label="订阅事件类型" rules={[{ required: true, message: '请选择至少一个回调事件' }]}>
<Select
mode="multiple"
placeholder="请选择订阅的事件列表"
options={eventOptions}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item name="status" label="回调状态" rules={[{ required: true }]}>
<Select
options={[
{ value: 'active', label: '启用回调推送' },
{ value: 'disabled', label: '禁用回调推送' },
]}
style={{ width: 200 }}
/>
</Form.Item>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: 16,
borderTop: '1px solid #f1f5f9',
marginTop: 20,
flexWrap: 'wrap',
gap: 12,
}}
>
<Typography.Text type="secondary" style={{ fontSize: 12.5 }}>
{callback?.updated_at ? `上次保存:${formatDateTime(callback.updated_at)}` : '尚未保存回调配置'}
</Typography.Text>
<Space size={12}>
{canManage && (
<Popconfirm
title="确认重置回调 Secret"
description="重置后旧 Secret 立即失效,平台将使用新 Secret 进行 HMAC 签名推送,新 Secret 仅展示一次。"
okText="确认重置"
okButtonProps={{ danger: true }}
onConfirm={onRotateSecret}
>
<Button danger> Secret</Button>
</Popconfirm>
)}
{canManage && (
<Button type="primary" htmlType="submit">
</Button>
)}
</Space>
</div>
</Form>
</Card>
</Space>
)
}
export function MembersTab({
loading,
members,
isOwner,
form,
onOpenAdd,
}: {
loading: boolean
members: MerchantMember[]
isOwner: boolean
form: FormInstance
onOpenAdd: () => void
}) {
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 (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text type="secondary"></Typography.Text>
{isOwner && <Button type="primary" icon={<PlusOutlined />} onClick={() => {
form.resetFields()
form.setFieldsValue({ role: 'operator' })
onOpenAdd()
}}></Button>}
</Space>
<Table rowKey="id" loading={loading} columns={memberColumns} dataSource={members} tableLayout="fixed" />
</Space>
)
}
function moneyWithSign(value?: number | null) {
const amount = Number(value || 0)
if (amount > 0) {
return <span style={{ fontWeight: 700, color: '#16a34a' }}>+{money(amount)}</span>
}
if (amount < 0) {
return <span style={{ fontWeight: 700, color: '#dc2626' }}>{money(amount)}</span>
}
return <span style={{ fontWeight: 600, color: '#64748b' }}>{money(amount)}</span>
}
function productStatusTag(value: string) {
return value === 'active' ? (
<span className="status-tag status-tag--green">
<span className="status-dot"></span>
</span>
) : (
<span className="status-tag status-tag--gray">
<span className="status-dot"></span>
</span>
)
}
function activeStatusTag(value: string) {
return value === 'active' ? (
<span className="status-tag status-tag--green">
<span className="status-dot"></span>
</span>
) : (
<span className="status-tag status-tag--gray">
<span className="status-dot"></span>
</span>
)
}
function orderStatusTag(value: string) {
const item = orderStatusMap[value] || { color: 'default', text: value }
const tagClassMap: Record<string, string> = {
paid: 'status-tag--blue',
delivering: 'status-tag--cyan',
delivered: 'status-tag--green',
ship_failed: 'status-tag--red',
cancelled: 'status-tag--gray',
}
const tagClass = tagClassMap[value] || 'status-tag--gray'
return (
<span className={`status-tag ${tagClass}`}>
<span className="status-dot"></span>
{item.text}
</span>
)
}
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>
)
}
+119
View File
@@ -0,0 +1,119 @@
import type { MerchantMember, MerchantProduct } from '../types'
export 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: '已取消' },
}
export const memberRoleOptions = [
{ value: 'owner', label: '负责人' },
{ value: 'operator', label: '运营' },
{ value: 'finance', label: '财务' },
{ value: 'viewer', label: '只读' },
]
export const apiClientMax = 5
export const scopeOptions = [
{ value: 'products:read', label: '商品读取' },
{ value: 'orders:read', label: '订单读取' },
{ value: 'orders:write', label: '订单写入' },
{ value: 'shipping:read', label: '发货读取' },
{ value: 'wallet:read', label: '钱包读取' },
]
export const eventOptions = [
{ value: 'order.created', label: '订单创建' },
{ value: 'order.shipping.updated', label: '发货更新' },
{ value: 'order.cancelled', label: '订单取消' },
]
export const testOrderStatusOptions = [
{ value: 'paid', label: '已支付,可发货' },
{ value: 'ship_failed', label: '发货失败,可重试' },
{ value: 'cancelled', label: '已取消,不可发货' },
]
export type MerchantCenterTab = 'products' | 'orders' | 'wallet' | 'api' | 'callbacks' | 'members'
export interface MerchantCenterProps {
fixedTab?: MerchantCenterTab
title?: string
}
export 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,
}
}
export 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']
}
export function defaultCallbackFormValues() {
return {
events: ['order.shipping.updated'],
status: 'active',
}
}
export function eventsToValue(events?: string) {
return (events || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
export 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
}
export 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'
}
export function money(value?: number | null) {
return `${Number(value || 0)} 积分`
}
export function productOptionLabel(item: MerchantProduct) {
const name = item.display_name || item.product?.name || item.sku
return name === item.sku ? item.sku : `${name} / ${item.sku}`
}
export function scopeLabel(scope: string) {
return scopeOptions.find((item) => item.value === scope)?.label || scope
}
export function roleText(value: MerchantMember['role']) {
return memberRoleOptions.find((item) => item.value === value)?.label || value
}