Files
affiliate_dash/frontend/src/pages/PlatformMerchants.tsx
T
yml2213 502b719f48 feat: 平台管理员可在前端分配商品给商户
后端:
- service: 新增 ListProductCatalog(自营商户商品目录)、ListMerchantProductsByAdmin、AssignProducts(按目录ID批量同步商户商品,未勾选的已有商品会被移除)
- handler + router: 新增 GET /platform/product-catalog、GET /platform/merchants/:id/products、POST /platform/merchants/:id/products/assign 三个管理员路由

前端:
- types: 新增 ProductCatalogItem 类型
- api: platformApi 新增 productCatalog / merchantProducts / assignProducts
- PlatformMerchants: 商户列表操作列新增「分配商品」按钮,弹窗内以表格+复选框展示平台商品目录,支持全选/清空/重置,打开时按已有商品预选,保存后同步分配结果

实测:创建商户自动获得27个默认商品,管理员可随时增减调整
2026-07-30 15:20:02 +08:00

508 lines
19 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, TeamOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { useNavigate } from 'react-router-dom'
import { platformApi } from '../api'
import type { Merchant, MerchantMember, PageResult, ProductCatalogItem } from '../types'
const memberRoleOptions = [
{ value: 'owner', label: '负责人' },
{ value: 'operator', label: '运营' },
{ value: 'finance', label: '财务' },
{ value: 'viewer', label: '只读' },
]
const featureOptions = [
{ value: 'products', label: '商品' },
{ value: 'orders', label: '订单' },
{ value: 'wallet', label: '钱包' },
{ value: 'api', label: 'API' },
{ value: 'callbacks', label: '回调' },
]
export default function PlatformMerchants() {
const navigate = useNavigate()
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 [memberOpen, setMemberOpen] = 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 [createForm] = Form.useForm()
const [settingsForm] = Form.useForm()
const [memberForm] = 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 submitMember = async () => {
if (!selectedMerchant) return
const values = await memberForm.validateFields()
try {
await platformApi.addMember(selectedMerchant.id, {
user_id: values.user_id,
role: values.role as MerchantMember['role'],
is_default: values.is_default,
})
message.success('成员已添加')
setMemberOpen(false)
} catch (e) {
message.error(e instanceof Error ? e.message : '添加失败')
}
}
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: 80 },
{ title: '编码', dataIndex: 'code', width: 180, render: (v) => <Typography.Text code copyable>{v}</Typography.Text> },
{ title: '名称', dataIndex: 'name', ellipsis: true },
{ title: '联系人', dataIndex: 'contact_name', width: 120, render: (v) => v || '-' },
{ title: '联系方式', dataIndex: 'contact_info', width: 160, render: (v) => v || '-' },
{
title: '手续费',
key: 'fee',
width: 160,
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: 90, render: (v) => v === 'active' ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag> },
{ title: '创建时间', dataIndex: 'created_at', width: 160, render: (v) => dayjs(v).format('YYYY-MM-DD HH:mm') },
{
title: '操作',
key: 'action',
width: 260,
render: (_, record) => (
<Space size={0}>
<Button
type="link"
size="small"
onClick={() => {
localStorage.setItem('merchant_id', String(record.id))
message.success('当前商户已切换')
navigate('/merchant-center')
}}
>
进入
</Button>
<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={<TeamOutlined />}
onClick={() => {
setSelectedMerchant(record)
memberForm.resetFields()
memberForm.setFieldsValue({ role: 'operator', is_default: false })
setMemberOpen(true)
}}
>
成员
</Button>
</Space>
),
},
]
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
商户管理
</Typography.Title>
<Typography.Text type="secondary">平台管理员维护租户与授权关系。</Typography.Text>
</div>
<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>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data.list}
tableLayout="fixed"
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={memberOpen}
onOk={submitMember}
onCancel={() => setMemberOpen(false)}
destroyOnClose
>
<Form form={memberForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="user_id" label="用户 ID" rules={[{ required: true }]}>
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
<Select options={memberRoleOptions} />
</Form.Item>
<Form.Item name="is_default" label="默认商户">
<Select options={[{ value: true, label: '是' }, { value: false, label: '否' }]} />
</Form.Item>
</Form>
</Modal>
<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
}