545 lines
20 KiB
TypeScript
545 lines
20 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
||
import {
|
||
Button,
|
||
Checkbox,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Modal,
|
||
Select,
|
||
Space,
|
||
Spin,
|
||
Table,
|
||
Tag,
|
||
Typography,
|
||
message,
|
||
} from 'antd'
|
||
import { GiftOutlined, PlusOutlined, ReloadOutlined, WalletOutlined } from '@ant-design/icons'
|
||
import type { ColumnsType } from 'antd/es/table'
|
||
import { platformApi } from '../api'
|
||
import type { Merchant, PageResult, ProductCatalogItem, WalletAccount } from '../types'
|
||
import { formatDateTime } from '../utils/time'
|
||
import { PageHeader } from '../components/PageHeader'
|
||
|
||
const featureOptions = [
|
||
{ value: 'products', label: '商品' },
|
||
{ value: 'orders', label: '订单' },
|
||
{ value: 'wallet', label: '钱包' },
|
||
{ value: 'api', label: 'API' },
|
||
{ value: 'callbacks', label: '回调' },
|
||
]
|
||
|
||
export default function PlatformMerchants() {
|
||
const [data, setData] = useState<PageResult<Merchant>>({ list: [], total: 0, page: 1, size: 10 })
|
||
const [loading, setLoading] = useState(false)
|
||
const [createOpen, setCreateOpen] = useState(false)
|
||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||
const [assignOpen, setAssignOpen] = useState(false)
|
||
const [assignLoading, setAssignLoading] = useState(false)
|
||
const [assignSubmitting, setAssignSubmitting] = useState(false)
|
||
const [catalog, setCatalog] = useState<ProductCatalogItem[]>([])
|
||
const [merchantProducts, setMerchantProducts] = useState<ProductCatalogItem[]>([])
|
||
const [selectedCatalogIds, setSelectedCatalogIds] = useState<number[]>([])
|
||
const [selectedMerchant, setSelectedMerchant] = useState<Merchant | null>(null)
|
||
const [adjustOpen, setAdjustOpen] = useState(false)
|
||
const [adjustLoading, setAdjustLoading] = useState(false)
|
||
const [adjustSubmitting, setAdjustSubmitting] = useState(false)
|
||
const [adjustWallet, setAdjustWallet] = useState<WalletAccount | null>(null)
|
||
const [createForm] = Form.useForm()
|
||
const [settingsForm] = Form.useForm()
|
||
const [adjustForm] = Form.useForm()
|
||
|
||
const load = useCallback(async (page = data.page, size = data.size) => {
|
||
setLoading(true)
|
||
try {
|
||
const result = await platformApi.merchants({ page, size })
|
||
setData(result)
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '加载失败')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [data.page, data.size])
|
||
|
||
useEffect(() => {
|
||
load()
|
||
}, [load])
|
||
|
||
const submitCreate = async () => {
|
||
const values = await createForm.validateFields()
|
||
try {
|
||
await platformApi.createMerchant({
|
||
...values,
|
||
features: featureListToText(values.features),
|
||
fee_type: values.fee_type,
|
||
fee_rate_bp: Number(values.fee_rate_bp || 0),
|
||
fee_fixed_amount: Number(values.fee_fixed_amount || 0),
|
||
})
|
||
message.success('商户已创建')
|
||
setCreateOpen(false)
|
||
createForm.resetFields()
|
||
load()
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '创建失败')
|
||
}
|
||
}
|
||
|
||
const submitSettings = async () => {
|
||
if (!selectedMerchant) return
|
||
const values = await settingsForm.validateFields()
|
||
try {
|
||
await platformApi.updateMerchant(selectedMerchant.id, {
|
||
name: values.name,
|
||
status: values.status,
|
||
contact_name: values.contact_name,
|
||
contact_info: values.contact_info,
|
||
features: featureListToText(values.features),
|
||
fee_type: values.fee_type,
|
||
fee_rate_bp: Number(values.fee_rate_bp || 0),
|
||
fee_fixed_amount: Number(values.fee_fixed_amount || 0),
|
||
})
|
||
message.success('商户设置已更新')
|
||
setSettingsOpen(false)
|
||
load()
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '更新失败')
|
||
}
|
||
}
|
||
|
||
const openAdjust = async (record: Merchant) => {
|
||
setSelectedMerchant(record)
|
||
setAdjustOpen(true)
|
||
setAdjustLoading(true)
|
||
setAdjustWallet(null)
|
||
adjustForm.resetFields()
|
||
try {
|
||
const walletData = await platformApi.wallet(record.id)
|
||
setAdjustWallet(walletData)
|
||
adjustForm.setFieldsValue({ idempotency_key: `manual-${Date.now()}` })
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '加载钱包失败')
|
||
} finally {
|
||
setAdjustLoading(false)
|
||
}
|
||
}
|
||
|
||
const submitAdjust = async () => {
|
||
if (!selectedMerchant) return
|
||
const values = await adjustForm.validateFields()
|
||
setAdjustSubmitting(true)
|
||
try {
|
||
const walletData = await platformApi.adjustWallet(selectedMerchant.id, {
|
||
amount: Number(values.amount),
|
||
idempotency_key: values.idempotency_key,
|
||
note: values.note,
|
||
})
|
||
setAdjustWallet(walletData)
|
||
message.success('积分已调整')
|
||
setAdjustOpen(false)
|
||
load()
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '调整失败')
|
||
} finally {
|
||
setAdjustSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const openAssign = async (record: Merchant) => {
|
||
setSelectedMerchant(record)
|
||
setAssignOpen(true)
|
||
setAssignLoading(true)
|
||
try {
|
||
const [catalogData, productsData] = await Promise.all([
|
||
platformApi.productCatalog(),
|
||
platformApi.merchantProducts(record.id),
|
||
])
|
||
setCatalog(catalogData || [])
|
||
setMerchantProducts(productsData || [])
|
||
// 用目录 ID 预选:商户已有商品中 SKU 与目录匹配的,视为已分配
|
||
const assignedSKUs = new Set((productsData || []).map((p) => p.sku))
|
||
const presetIds = (catalogData || []).filter((c) => assignedSKUs.has(c.sku)).map((c) => c.id)
|
||
setSelectedCatalogIds(presetIds)
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '加载商品失败')
|
||
setCatalog([])
|
||
setMerchantProducts([])
|
||
setSelectedCatalogIds([])
|
||
} finally {
|
||
setAssignLoading(false)
|
||
}
|
||
}
|
||
|
||
const submitAssign = async () => {
|
||
if (!selectedMerchant) return
|
||
setAssignSubmitting(true)
|
||
try {
|
||
const result = await platformApi.assignProducts(selectedMerchant.id, selectedCatalogIds)
|
||
message.success(`已分配 ${result.assigned} 个商品`)
|
||
setAssignOpen(false)
|
||
load()
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '分配失败')
|
||
} finally {
|
||
setAssignSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const columns: ColumnsType<Merchant> = [
|
||
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' },
|
||
{ title: '编码', dataIndex: 'code', width: 160, render: (v) => <Typography.Text code copyable={{ text: v }}>{v}</Typography.Text> },
|
||
{ title: '商户名称', dataIndex: 'name', width: 200, ellipsis: true, render: (v) => <span style={{ fontWeight: 600, color: '#0f172a' }}>{v}</span> },
|
||
{ title: '联系人', dataIndex: 'contact_name', width: 120, render: (v) => v || '-' },
|
||
{ title: '联系方式', dataIndex: 'contact_info', width: 150, render: (v) => v || '-' },
|
||
{
|
||
title: '手续费率',
|
||
key: 'fee',
|
||
width: 140,
|
||
render: (_, r) =>
|
||
r.fee_type === 'fixed'
|
||
? `固定 ${r.fee_fixed_amount} 积分/单`
|
||
: `${(r.fee_rate_bp / 100).toFixed(2)}%`,
|
||
},
|
||
{
|
||
title: '开通功能',
|
||
dataIndex: 'features',
|
||
width: 220,
|
||
render: (v) => (
|
||
<Space size={4} wrap>
|
||
{featuresToList(v).map((feature) => (
|
||
<Tag key={feature}>{featureText(feature)}</Tag>
|
||
))}
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 100,
|
||
render: (v) =>
|
||
v === '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>
|
||
),
|
||
},
|
||
{ title: '创建时间', dataIndex: 'created_at', width: 170, render: formatDateTime },
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
width: 180,
|
||
fixed: 'right',
|
||
render: (_, record) => (
|
||
<Space size={0}>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
icon={<GiftOutlined />}
|
||
onClick={() => openAssign(record)}
|
||
>
|
||
分配商品
|
||
</Button>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => {
|
||
setSelectedMerchant(record)
|
||
settingsForm.setFieldsValue({
|
||
...record,
|
||
features: featuresToList(record.features),
|
||
fee_type: record.fee_type || 'rate',
|
||
fee_fixed_amount: record.fee_fixed_amount,
|
||
})
|
||
setSettingsOpen(true)
|
||
}}
|
||
>
|
||
设置
|
||
</Button>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
icon={<WalletOutlined />}
|
||
onClick={() => openAdjust(record)}
|
||
>
|
||
调整积分
|
||
</Button>
|
||
</Space>
|
||
),
|
||
},
|
||
]
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="平台商户管理"
|
||
subtitle="平台管理员维护各分销商户租户、分配商品库、配置手续费费率与积分调整"
|
||
breadcrumbs={[{ title: '商户管理' }]}
|
||
extra={
|
||
<Space>
|
||
<Button icon={<ReloadOutlined />} onClick={() => load()}>
|
||
刷新
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
icon={<PlusOutlined />}
|
||
onClick={() => {
|
||
createForm.resetFields()
|
||
createForm.setFieldsValue({
|
||
features: featureOptions.map((item) => item.value),
|
||
fee_type: 'rate',
|
||
fee_rate_bp: 0,
|
||
fee_fixed_amount: 0,
|
||
})
|
||
setCreateOpen(true)
|
||
}}
|
||
>
|
||
新增商户
|
||
</Button>
|
||
</Space>
|
||
}
|
||
/>
|
||
|
||
<Table
|
||
rowKey="id"
|
||
loading={loading}
|
||
columns={columns}
|
||
dataSource={data.list}
|
||
scroll={{ x: 1400 }}
|
||
pagination={{
|
||
current: data.page,
|
||
pageSize: data.size,
|
||
total: data.total,
|
||
showSizeChanger: true,
|
||
showTotal: (total) => `共 ${total} 条`,
|
||
onChange: load,
|
||
}}
|
||
/>
|
||
|
||
<Modal title="新增商户" open={createOpen} onOk={submitCreate} onCancel={() => setCreateOpen(false)} destroyOnClose width={680}>
|
||
<Form form={createForm} layout="vertical" style={{ marginTop: 16 }}>
|
||
<Form.Item name="code" label="商户编码" rules={[{ required: true }]}>
|
||
<Input placeholder="lower-case-code" />
|
||
</Form.Item>
|
||
<Form.Item name="name" label="商户名称" rules={[{ required: true }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Space size="middle" style={{ width: '100%' }}>
|
||
<Form.Item name="owner_username" label="负责人用户名" rules={[{ required: true }]} style={{ width: 300 }}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="owner_password" label="负责人密码" rules={[{ required: true, min: 6 }]} style={{ width: 300 }}>
|
||
<Input.Password />
|
||
</Form.Item>
|
||
</Space>
|
||
<Space size="middle" style={{ width: '100%' }}>
|
||
<Form.Item name="owner_nickname" label="负责人昵称" style={{ width: 300 }}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="contact_name" label="联系人" style={{ width: 300 }}>
|
||
<Input />
|
||
</Form.Item>
|
||
</Space>
|
||
<Form.Item name="contact_info" label="联系方式">
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="features" label="开通功能" rules={[{ required: true }]}>
|
||
<Select mode="multiple" options={featureOptions} />
|
||
</Form.Item>
|
||
<Form.Item name="fee_type" label="手续费类型" rules={[{ required: true }]}>
|
||
<Select
|
||
options={[
|
||
{ value: 'rate', label: '按百分比(每单按订单金额比例收取)' },
|
||
{ value: 'fixed', label: '按固定金额(每单固定积分)' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.fee_type !== cur.fee_type}>
|
||
{({ getFieldValue }) =>
|
||
getFieldValue('fee_type') === 'fixed' ? (
|
||
<Form.Item name="fee_fixed_amount" label="每单固定手续费(积分)" rules={[{ required: true }]}>
|
||
<InputNumber min={0} precision={0} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
) : (
|
||
<Form.Item name="fee_rate_bp" label="手续费比例 BP(1BP=0.01%,如 250=2.5%)" rules={[{ required: true }]}>
|
||
<InputNumber min={0} max={10000} precision={0} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
)
|
||
}
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal title={selectedMerchant ? `商户设置:${selectedMerchant.name}` : '商户设置'} open={settingsOpen} onOk={submitSettings} onCancel={() => setSettingsOpen(false)} destroyOnClose width={680}>
|
||
<Form form={settingsForm} layout="vertical" style={{ marginTop: 16 }}>
|
||
<Form.Item name="name" label="商户名称" rules={[{ required: true }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Space size="middle" style={{ width: '100%' }}>
|
||
<Form.Item name="status" label="状态" style={{ width: 300 }}>
|
||
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }]} />
|
||
</Form.Item>
|
||
<Form.Item name="contact_name" label="联系人" style={{ width: 300 }}>
|
||
<Input />
|
||
</Form.Item>
|
||
</Space>
|
||
<Form.Item name="contact_info" label="联系方式">
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="features" label="开通功能" rules={[{ required: true }]}>
|
||
<Select mode="multiple" options={featureOptions} />
|
||
</Form.Item>
|
||
<Form.Item name="fee_type" label="手续费类型" rules={[{ required: true }]}>
|
||
<Select
|
||
options={[
|
||
{ value: 'rate', label: '按百分比(每单按订单金额比例收取)' },
|
||
{ value: 'fixed', label: '按固定金额(每单固定积分)' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.fee_type !== cur.fee_type}>
|
||
{({ getFieldValue }) =>
|
||
getFieldValue('fee_type') === 'fixed' ? (
|
||
<Form.Item name="fee_fixed_amount" label="每单固定手续费(积分)" rules={[{ required: true }]}>
|
||
<InputNumber min={0} precision={0} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
) : (
|
||
<Form.Item name="fee_rate_bp" label="手续费比例 BP(1BP=0.01%,如 250=2.5%)" rules={[{ required: true }]}>
|
||
<InputNumber min={0} max={10000} precision={0} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
)
|
||
}
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={selectedMerchant ? `调整积分:${selectedMerchant.name}` : '调整积分'}
|
||
open={adjustOpen}
|
||
onOk={submitAdjust}
|
||
onCancel={() => setAdjustOpen(false)}
|
||
destroyOnClose
|
||
width={520}
|
||
confirmLoading={adjustSubmitting}
|
||
okText="确认调整"
|
||
>
|
||
<Spin spinning={adjustLoading}>
|
||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||
<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' }}>
|
||
{(adjustWallet?.available_balance ?? 0).toLocaleString('zh-CN')}
|
||
<span style={{ fontSize: 13, fontWeight: 500, marginLeft: 4, color: '#64748b' }}>积分</span>
|
||
</div>
|
||
</div>
|
||
<Form form={adjustForm} layout="vertical">
|
||
<Form.Item name="amount" label="调整积分" rules={[{ required: true, message: '请填写调整积分' }]}>
|
||
<InputNumber precision={0} style={{ width: '100%' }} placeholder="正数充值,负数扣减" />
|
||
</Form.Item>
|
||
<Form.Item name="idempotency_key" label="幂等键" rules={[{ required: true }]}>
|
||
<Input disabled />
|
||
</Form.Item>
|
||
<Form.Item name="note" label="备注" rules={[{ max: 512 }]}>
|
||
<Input.TextArea rows={3} placeholder="调整原因,会写入积分流水备注" maxLength={512} />
|
||
</Form.Item>
|
||
</Form>
|
||
<Typography.Text type="secondary">
|
||
仅平台管理员可手动调整商户积分,商户侧无调账入口。调整记录会写入该商户的积分流水(类型:调整)。
|
||
</Typography.Text>
|
||
</Space>
|
||
</Spin>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={selectedMerchant ? `分配商品:${selectedMerchant.name}` : '分配商品'}
|
||
open={assignOpen}
|
||
onOk={submitAssign}
|
||
onCancel={() => setAssignOpen(false)}
|
||
destroyOnClose
|
||
width={760}
|
||
confirmLoading={assignSubmitting}
|
||
okText="保存分配"
|
||
>
|
||
<Spin spinning={assignLoading}>
|
||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||
<Typography.Text type="secondary">
|
||
从平台商品目录勾选要分配给该商户的商品,保存后将以勾选结果同步(未勾选的已有商品会被移除)。
|
||
</Typography.Text>
|
||
<Space>
|
||
<Button size="small" onClick={() => setSelectedCatalogIds(catalog.map((c) => c.id))}>全选</Button>
|
||
<Button size="small" onClick={() => setSelectedCatalogIds([])}>清空</Button>
|
||
<Button
|
||
size="small"
|
||
onClick={() => {
|
||
const assignedSKUs = new Set(merchantProducts.map((p) => p.sku))
|
||
setSelectedCatalogIds(catalog.filter((c) => assignedSKUs.has(c.sku)).map((c) => c.id))
|
||
}}
|
||
>
|
||
重置
|
||
</Button>
|
||
</Space>
|
||
</Space>
|
||
<Checkbox.Group
|
||
value={selectedCatalogIds}
|
||
onChange={(values) => setSelectedCatalogIds(values as number[])}
|
||
style={{ width: '100%' }}
|
||
>
|
||
<Table
|
||
rowKey="id"
|
||
size="small"
|
||
pagination={false}
|
||
scroll={{ y: 420 }}
|
||
dataSource={catalog}
|
||
columns={[
|
||
{
|
||
title: '',
|
||
dataIndex: 'id',
|
||
width: 50,
|
||
render: (id) => <Checkbox value={id} />,
|
||
},
|
||
{ title: 'SKU', dataIndex: 'sku', width: 200, ellipsis: true, render: (v) => <Typography.Text code>{v}</Typography.Text> },
|
||
{ title: '名称', dataIndex: 'display_name', ellipsis: true },
|
||
{ title: '品类', dataIndex: 'category', width: 100, render: (v) => v || '-' },
|
||
{ title: '售价', dataIndex: 'price_amount', width: 90, render: (v) => `${v} 积分` },
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 80,
|
||
render: (v) => (v === 'active' ? <Tag color="green">上架</Tag> : <Tag>下架</Tag>),
|
||
},
|
||
]}
|
||
/>
|
||
</Checkbox.Group>
|
||
<Typography.Text type="secondary">
|
||
已选 {selectedCatalogIds.length} / 目录共 {catalog.length} 个商品 · 该商户当前已分配 {merchantProducts.length} 个
|
||
</Typography.Text>
|
||
</Space>
|
||
</Spin>
|
||
</Modal>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function featuresToList(features?: string) {
|
||
return (features || '')
|
||
.split(/[,\s]+/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
}
|
||
|
||
function featureListToText(features?: string[]) {
|
||
return (features || []).join(',')
|
||
}
|
||
|
||
function featureText(feature: string) {
|
||
return featureOptions.find((item) => item.value === feature)?.label || feature
|
||
}
|