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个默认商品,管理员可随时增减调整
This commit is contained in:
yml2213
2026-07-30 15:20:02 +08:00
parent 7cff2dffed
commit 502b719f48
6 changed files with 376 additions and 9 deletions
+7
View File
@@ -11,6 +11,7 @@ import type {
MerchantMember,
MerchantProduct,
PageResult,
ProductCatalogItem,
User,
WalletAccount,
WalletLedgerEntry,
@@ -112,4 +113,10 @@ export const platformApi = {
merchantId: number,
data: { user_id: number; role: MerchantMember['role']; is_default?: boolean },
) => request.post(`/platform/merchants/${merchantId}/members`, data).then((r) => r.data.data as MerchantMember),
productCatalog: () =>
request.get('/platform/product-catalog').then((r) => r.data.data as ProductCatalogItem[]),
merchantProducts: (merchantId: number) =>
request.get(`/platform/merchants/${merchantId}/products`).then((r) => r.data.data as ProductCatalogItem[]),
assignProducts: (merchantId: number, catalogIds: number[]) =>
request.post(`/platform/merchants/${merchantId}/products/assign`, { catalog_ids: catalogIds }).then((r) => r.data.data as { assigned: number }),
}
+128 -4
View File
@@ -1,23 +1,25 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button,
Checkbox,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Spin,
Table,
Tag,
Typography,
message,
} from 'antd'
import { PlusOutlined, ReloadOutlined, TeamOutlined } from '@ant-design/icons'
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 } from '../types'
import type { Merchant, MerchantMember, PageResult, ProductCatalogItem } from '../types'
const memberRoleOptions = [
{ value: 'owner', label: '负责人' },
@@ -41,6 +43,12 @@ export default function PlatformMerchants() {
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()
@@ -119,6 +127,46 @@ export default function PlatformMerchants() {
}
}
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> },
@@ -151,7 +199,7 @@ export default function PlatformMerchants() {
{
title: '操作',
key: 'action',
width: 190,
width: 260,
render: (_, record) => (
<Space size={0}>
<Button
@@ -165,6 +213,14 @@ export default function PlatformMerchants() {
>
</Button>
<Button
type="link"
size="small"
icon={<GiftOutlined />}
onClick={() => openAssign(record)}
>
</Button>
<Button
type="link"
size="small"
@@ -192,7 +248,7 @@ export default function PlatformMerchants() {
setMemberOpen(true)
}}
>
</Button>
</Space>
),
@@ -363,6 +419,74 @@ export default function PlatformMerchants() {
</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>
)
}
+13
View File
@@ -59,6 +59,19 @@ export interface MerchantProduct {
created_at: string
}
export interface ProductCatalogItem {
id: number
product_id: number
sku: string
display_name: string
category: string
price_amount: number
cost_amount: number
currency: string
stock: number
status: 'active' | 'inactive'
}
export interface WalletAccount {
id: number
merchant_id: number