959 lines
34 KiB
TypeScript
959 lines
34 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import { Alert, Button, Card, Col, Form, Input, Modal, Popconfirm, Row, Select, Space, Table, Tabs, 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,
|
||
MerchantRole,
|
||
MerchantProduct,
|
||
PageResult,
|
||
WalletAccount,
|
||
WalletLedgerEntry,
|
||
} from '../types'
|
||
import {
|
||
defaultCallbackFormValues,
|
||
eventOptions,
|
||
money,
|
||
orderStatusOptions,
|
||
orderStatusMap,
|
||
pageConfig,
|
||
rolePermissionText,
|
||
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,
|
||
orderSource,
|
||
filters,
|
||
canManage,
|
||
onCreateManualOrder,
|
||
onOrderSourceChange,
|
||
onFilterChange,
|
||
onLoadOrders,
|
||
onCopyLink,
|
||
onOpenLink,
|
||
onRevokeLink,
|
||
onRestoreLink,
|
||
onCancelOrder,
|
||
}: {
|
||
loading: boolean
|
||
orders: PageResult<FulfillmentOrder>
|
||
orderSource: 'api' | 'manual'
|
||
filters: { keyword: string; order_status: string }
|
||
canManage: boolean
|
||
onCreateManualOrder: () => void
|
||
onOrderSourceChange: (source: 'api' | 'manual') => void
|
||
onFilterChange: (filters: { keyword: string; order_status: string }) => void
|
||
onLoadOrders: (page?: number, size?: number) => void
|
||
onCopyLink: (orderNo: string) => void
|
||
onOpenLink: (orderNo: string) => void
|
||
onRevokeLink: (orderNo: string) => void
|
||
onRestoreLink: (orderNo: string) => void
|
||
onCancelOrder: (orderNo: string, reason: string) => Promise<void> | void
|
||
}) {
|
||
const [keyword, setKeyword] = useState(filters.keyword)
|
||
const [cancelling, setCancelling] = useState<FulfillmentOrder | null>(null)
|
||
const [cancelReason, setCancelReason] = useState('')
|
||
const [cancelSubmitting, setCancelSubmitting] = useState(false)
|
||
|
||
useEffect(() => {
|
||
setKeyword(filters.keyword)
|
||
}, [filters.keyword])
|
||
|
||
const submitKeyword = () => onFilterChange({ ...filters, keyword: keyword.trim() })
|
||
|
||
const submitCancel = async () => {
|
||
if (!cancelling) return
|
||
if (!cancelReason.trim()) {
|
||
message.warning('请填写取消原因')
|
||
return
|
||
}
|
||
setCancelSubmitting(true)
|
||
try {
|
||
await onCancelOrder(cancelling.order_no, cancelReason.trim())
|
||
setCancelling(null)
|
||
setCancelReason('')
|
||
} finally {
|
||
setCancelSubmitting(false)
|
||
}
|
||
}
|
||
|
||
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: 160,
|
||
render: (_, record) => {
|
||
if (record.order_status === 'cancelled') {
|
||
return <Typography.Text type="secondary">订单已取消</Typography.Text>
|
||
}
|
||
if (record.delivery_link_revoked_at) {
|
||
return <Button type="link" size="small" onClick={() => onRestoreLink(record.order_no)}>恢复链接</Button>
|
||
}
|
||
return (
|
||
<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"
|
||
title="仅作废发货链接,不影响订单状态与积分"
|
||
onClick={() => onRevokeLink(record.order_no)}
|
||
>
|
||
链接作废
|
||
</Button>
|
||
</Space>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
width: 110,
|
||
fixed: 'right',
|
||
render: (_, record) => {
|
||
if (!canManage) return '-'
|
||
if (record.order_status === 'paid' || record.order_status === 'ship_failed') {
|
||
return (
|
||
<Button
|
||
type="link"
|
||
danger
|
||
size="small"
|
||
onClick={() => {
|
||
setCancelling(record)
|
||
setCancelReason('')
|
||
}}
|
||
>
|
||
取消订单
|
||
</Button>
|
||
)
|
||
}
|
||
return '-'
|
||
},
|
||
},
|
||
]
|
||
|
||
return (
|
||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||
<Space wrap style={{ width: '100%', justifyContent: 'space-between' }}>
|
||
<Tabs
|
||
activeKey={orderSource}
|
||
onChange={(key) => onOrderSourceChange(key as 'api' | 'manual')}
|
||
items={[{ key: 'api', label: 'API 订单' }, { key: 'manual', label: '手动订单' }]}
|
||
style={{ minWidth: 220 }}
|
||
/>
|
||
{canManage && (
|
||
<Space>
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateManualOrder}>
|
||
创建手动订单
|
||
</Button>
|
||
</Space>
|
||
)}
|
||
</Space>
|
||
<Card size="small" style={{ borderRadius: 8 }}>
|
||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||
<Space wrap>
|
||
<Input.Search
|
||
value={keyword}
|
||
onChange={(event) => setKeyword(event.target.value)}
|
||
onSearch={submitKeyword}
|
||
placeholder="搜索平台订单号或商户单号"
|
||
allowClear
|
||
onClear={() => onFilterChange({ ...filters, keyword: '' })}
|
||
style={{ width: 300, maxWidth: '100%' }}
|
||
/>
|
||
</Space>
|
||
<Space wrap size={[8, 8]}>
|
||
{orderStatusOptions.map((option) => (
|
||
<Button
|
||
key={option.value || 'all'}
|
||
type={filters.order_status === option.value ? 'primary' : 'text'}
|
||
onClick={() => onFilterChange({ ...filters, order_status: option.value })}
|
||
style={{ minWidth: 76 }}
|
||
>
|
||
{option.label}
|
||
</Button>
|
||
))}
|
||
</Space>
|
||
</Space>
|
||
</Card>
|
||
<Table
|
||
rowKey="id"
|
||
loading={loading}
|
||
columns={orderColumns}
|
||
dataSource={orders.list}
|
||
tableLayout="fixed"
|
||
scroll={{ x: 1780 }}
|
||
pagination={pageConfig(orders, onLoadOrders)}
|
||
/>
|
||
<Modal
|
||
title={`取消订单:${cancelling?.order_no || ''}`}
|
||
open={!!cancelling}
|
||
confirmLoading={cancelSubmitting}
|
||
onOk={submitCancel}
|
||
onCancel={() => {
|
||
setCancelling(null)
|
||
setCancelReason('')
|
||
}}
|
||
okText="确认取消并退款"
|
||
okButtonProps={{ danger: true }}
|
||
destroyOnClose
|
||
>
|
||
<Alert
|
||
type="warning"
|
||
showIcon
|
||
message="取消后订单将不可再发货"
|
||
description="已扣积分将全额退还商户钱包,商品库存自动回补;已提交上游发货的失败订单不可取消。"
|
||
style={{ marginBottom: 16 }}
|
||
/>
|
||
<Input.TextArea
|
||
rows={3}
|
||
maxLength={512}
|
||
showCount
|
||
value={cancelReason}
|
||
onChange={(e) => setCancelReason(e.target.value)}
|
||
placeholder="请填写取消原因(必填)"
|
||
/>
|
||
</Modal>
|
||
</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,
|
||
roles,
|
||
canManage,
|
||
form,
|
||
onOpenAdd,
|
||
onOpenCreateRole,
|
||
onEditRole,
|
||
onEditMember,
|
||
onToggleMemberStatus,
|
||
onRemoveMember,
|
||
currentUserID,
|
||
}: {
|
||
loading: boolean
|
||
members: MerchantMember[]
|
||
roles: MerchantRole[]
|
||
canManage: boolean
|
||
form: FormInstance
|
||
onOpenAdd: () => void
|
||
onOpenCreateRole: () => void
|
||
onEditRole: (role: MerchantRole) => void
|
||
onEditMember: (member: MerchantMember) => void
|
||
onToggleMemberStatus: (member: MerchantMember) => void
|
||
onRemoveMember: (member: MerchantMember) => void
|
||
currentUserID?: number
|
||
}) {
|
||
const roleNames = new Map(roles.map((item) => [item.code, item.name]))
|
||
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: 140, render: (value) => <Tag color="blue">{roleNames.get(value) || roleText(value)}</Tag> },
|
||
{ 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>) },
|
||
{
|
||
title: '操作',
|
||
width: 210,
|
||
render: (_, member) => {
|
||
const protectedMember = member.role === 'owner' || member.user_id === currentUserID
|
||
if (!canManage) return '-'
|
||
if (protectedMember) return <Typography.Text type="secondary">受保护</Typography.Text>
|
||
const action = member.status === 1 ? '停用' : '启用'
|
||
return <Space size={0}>
|
||
<Button type="link" size="small" onClick={() => onEditMember(member)}>编辑</Button>
|
||
<Popconfirm title={`确认${action}该员工?`} onConfirm={() => onToggleMemberStatus(member)}>
|
||
<Button type="link" size="small">{action}</Button>
|
||
</Popconfirm>
|
||
<Popconfirm title="确认移除该员工?" description="仅移除当前商户成员关系,账号不会被删除。" onConfirm={() => onRemoveMember(member)} okButtonProps={{ danger: true }}>
|
||
<Button type="link" danger size="small">移除</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
},
|
||
},
|
||
]
|
||
|
||
return (
|
||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||
<Typography.Text type="secondary">成员仅属于当前商户;角色和权限也在当前商户内独立配置。</Typography.Text>
|
||
{canManage && <Space>
|
||
<Button onClick={() => {
|
||
form.resetFields()
|
||
onOpenCreateRole()
|
||
}}>新建角色</Button>
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||
form.resetFields()
|
||
onOpenAdd()
|
||
}}>添加员工</Button>
|
||
</Space>}
|
||
</Space>
|
||
<Table rowKey="id" loading={loading} columns={memberColumns} dataSource={members} tableLayout="fixed" />
|
||
<Card size="small" title="团队角色">
|
||
<Table
|
||
rowKey={(role) => role.id || role.code}
|
||
size="small"
|
||
pagination={false}
|
||
dataSource={roles}
|
||
columns={[
|
||
{ title: '角色', dataIndex: 'name', width: 160, render: (name, role) => <Space><Tag color={role.code === 'owner' ? 'gold' : 'blue'}>{name}</Tag>{role.code === 'owner' && <Typography.Text type="secondary">固定</Typography.Text>}</Space> },
|
||
{ title: '权限', dataIndex: 'permissions', render: (permissions) => permissions ? permissions.split(',').map((item: string) => <Tag key={item}>{rolePermissionText(item)}</Tag>) : '-' },
|
||
{ title: '操作', width: 90, render: (_, role) => role.code === 'owner' ? '-' : <Button type="link" size="small" disabled={!canManage} onClick={() => onEditRole(role)}>编辑</Button> },
|
||
]}
|
||
/>
|
||
</Card>
|
||
</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 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>
|
||
)
|
||
}
|