Files
affiliate_dash/frontend/src/pages/PlatformMerchants.tsx
T

545 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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="手续费比例 BP1BP=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="手续费比例 BP1BP=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
}