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:
@@ -329,6 +329,49 @@ func (h *MerchantHandler) ListPlatformMerchants(c *gin.Context) {
|
||||
response.Page(c, list, total, page, size)
|
||||
}
|
||||
|
||||
// ListProductCatalog 返回自营商户的全部可售商品,作为平台默认商品目录供分配。
|
||||
func (h *MerchantHandler) ListProductCatalog(c *gin.Context) {
|
||||
list, err := h.merchantSvc.ListProductCatalog()
|
||||
if err != nil {
|
||||
response.ServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, list)
|
||||
}
|
||||
|
||||
// ListMerchantProductsByAdmin 供平台管理员查看指定商户的可售商品。
|
||||
func (h *MerchantHandler) ListMerchantProductsByAdmin(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
list, err := h.merchantSvc.ListMerchantProductsByAdmin(uint(id))
|
||||
if err != nil {
|
||||
response.ServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, list)
|
||||
}
|
||||
|
||||
type assignProductsReq struct {
|
||||
CatalogIDs []uint `json:"catalog_ids"`
|
||||
}
|
||||
|
||||
// AssignProducts 按商品目录 ID 批量同步商户的可售商品。
|
||||
func (h *MerchantHandler) AssignProducts(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req assignProductsReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
count, err := h.merchantSvc.AssignProducts(uint(id), service.AssignProductsInput{
|
||||
CatalogIDs: req.CatalogIDs,
|
||||
}, middleware.GetUserID(c))
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"assigned": count})
|
||||
}
|
||||
|
||||
type createMerchantReq struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
|
||||
@@ -118,6 +118,9 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
admin.POST("/platform/merchants", h.Merchant.CreateMerchant)
|
||||
admin.PATCH("/platform/merchants/:id", h.Merchant.UpdateMerchantSettings)
|
||||
admin.POST("/platform/merchants/:id/members", h.Merchant.AddPlatformMerchantMember)
|
||||
admin.GET("/platform/product-catalog", h.Merchant.ListProductCatalog)
|
||||
admin.GET("/platform/merchants/:id/products", h.Merchant.ListMerchantProductsByAdmin)
|
||||
admin.POST("/platform/merchants/:id/products/assign", h.Merchant.AssignProducts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,6 +535,183 @@ func (s *MerchantService) UpdateAPIClientStatus(merchantID, id uint, status stri
|
||||
})
|
||||
}
|
||||
|
||||
// ProductCatalogItem 是商品目录(自营商户可售商品)的展示项,供平台管理员分配商品时勾选。
|
||||
type ProductCatalogItem struct {
|
||||
ID uint `json:"id"`
|
||||
ProductID uint `json:"product_id"`
|
||||
SKU string `json:"sku"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Category string `json:"category"`
|
||||
PriceAmount int64 `json:"price_amount"`
|
||||
CostAmount int64 `json:"cost_amount"`
|
||||
Currency string `json:"currency"`
|
||||
Stock int64 `json:"stock"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ListProductCatalog 返回自营商户的全部可售商品,作为平台默认商品目录供分配。
|
||||
func (s *MerchantService) ListProductCatalog() ([]ProductCatalogItem, error) {
|
||||
var selfMerchant model.Merchant
|
||||
if err := s.db.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("平台商品目录尚未初始化")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var products []model.MerchantProduct
|
||||
if err := s.db.Preload("Product").Where("merchant_id = ?", selfMerchant.ID).Order("id ASC").Find(&products).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]ProductCatalogItem, 0, len(products))
|
||||
for _, p := range products {
|
||||
category := ""
|
||||
if p.Product != nil {
|
||||
category = p.Product.Category
|
||||
}
|
||||
items = append(items, ProductCatalogItem{
|
||||
ID: p.ID,
|
||||
ProductID: p.ProductID,
|
||||
SKU: p.SKU,
|
||||
DisplayName: p.DisplayName,
|
||||
Category: category,
|
||||
PriceAmount: p.PriceAmount,
|
||||
CostAmount: p.CostAmount,
|
||||
Currency: p.Currency,
|
||||
Stock: p.Stock,
|
||||
Status: p.Status,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ListMerchantProductsByAdmin 供平台管理员查看指定商户的可售商品(不限功能开关)。
|
||||
func (s *MerchantService) ListMerchantProductsByAdmin(merchantID uint) ([]ProductCatalogItem, error) {
|
||||
var products []model.MerchantProduct
|
||||
if err := s.db.Preload("Product").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&products).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]ProductCatalogItem, 0, len(products))
|
||||
for _, p := range products {
|
||||
category := ""
|
||||
if p.Product != nil {
|
||||
category = p.Product.Category
|
||||
}
|
||||
items = append(items, ProductCatalogItem{
|
||||
ID: p.ID,
|
||||
ProductID: p.ProductID,
|
||||
SKU: p.SKU,
|
||||
DisplayName: p.DisplayName,
|
||||
Category: category,
|
||||
PriceAmount: p.PriceAmount,
|
||||
CostAmount: p.CostAmount,
|
||||
Currency: p.Currency,
|
||||
Stock: p.Stock,
|
||||
Status: p.Status,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// AssignProductsInput 批量分配商品给商户的入参。
|
||||
type AssignProductsInput struct {
|
||||
// CatalogIDs 为自营商户商品目录 ID 列表;为空表示清空该商户全部商品。
|
||||
CatalogIDs []uint
|
||||
}
|
||||
|
||||
// AssignProducts 按自营商户商品目录 ID 批量同步商户的可售商品:
|
||||
// 目录中勾选的商品会被复制(已存在则跳过),未勾选的已有商品会被移除。
|
||||
func (s *MerchantService) AssignProducts(merchantID uint, in AssignProductsInput, actorUserID uint) (int, error) {
|
||||
assigned := 0
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var merchant model.Merchant
|
||||
if err := tx.Where("id = ?", merchantID).First(&merchant).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("商户不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if merchant.Code == model.MerchantCodeSelfOperated {
|
||||
return errors.New("自营商户的商品目录由平台维护,不可分配")
|
||||
}
|
||||
var selfMerchant model.Merchant
|
||||
if err := tx.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil {
|
||||
return errors.New("平台商品目录尚未初始化")
|
||||
}
|
||||
|
||||
// 读取目录全量,构造 id -> 模板 的映射
|
||||
var templates []model.MerchantProduct
|
||||
if err := tx.Where("merchant_id = ?", selfMerchant.ID).Find(&templates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
tmplByID := make(map[uint]model.MerchantProduct, len(templates))
|
||||
for _, t := range templates {
|
||||
tmplByID[t.ID] = t
|
||||
}
|
||||
|
||||
// 读取商户已有商品,构造 sku -> 已有 的映射
|
||||
var existing []model.MerchantProduct
|
||||
if err := tx.Where("merchant_id = ?", merchantID).Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
existBySKU := make(map[string]model.MerchantProduct, len(existing))
|
||||
for _, e := range existing {
|
||||
existBySKU[e.SKU] = e
|
||||
}
|
||||
|
||||
// 计算需要新增的 SKU 集合
|
||||
wantSKUs := make(map[string]bool, len(in.CatalogIDs))
|
||||
toCreate := make([]model.MerchantProduct, 0, len(in.CatalogIDs))
|
||||
for _, id := range in.CatalogIDs {
|
||||
t, ok := tmplByID[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
wantSKUs[t.SKU] = true
|
||||
if _, has := existBySKU[t.SKU]; !has {
|
||||
toCreate = append(toCreate, model.MerchantProduct{
|
||||
MerchantID: merchantID,
|
||||
ProductID: t.ProductID,
|
||||
SKU: t.SKU,
|
||||
DisplayName: t.DisplayName,
|
||||
PriceAmount: t.PriceAmount,
|
||||
CostAmount: t.CostAmount,
|
||||
Currency: t.Currency,
|
||||
Stock: t.Stock,
|
||||
Status: t.Status,
|
||||
FulfillmentConfig: t.FulfillmentConfig,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 移除未勾选的已有商品
|
||||
var removeIDs []uint
|
||||
for _, e := range existing {
|
||||
if !wantSKUs[e.SKU] {
|
||||
removeIDs = append(removeIDs, e.ID)
|
||||
}
|
||||
}
|
||||
if len(removeIDs) > 0 {
|
||||
if err := tx.Where("merchant_id = ? AND id IN ?", merchantID, removeIDs).Delete(&model.MerchantProduct{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 新增勾选但尚未拥有的商品
|
||||
if len(toCreate) > 0 {
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&toCreate).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
assigned = len(wantSKUs)
|
||||
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.products.assign", "merchant", fmt.Sprint(merchantID), map[string]string{"assigned": fmt.Sprint(assigned)})
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return assigned, nil
|
||||
}
|
||||
|
||||
// copyDefaultProducts 将自营商户的全部可售商品复制给新建商户,作为默认商品目录。
|
||||
// 自营商户(self-operated)充当平台默认商品模板,新商户开箱即用。
|
||||
// 使用 OnConflict DoNothing 保证幂等:即使重复调用也不会报唯一索引冲突。
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user