From 502b719f4830771eb5367483c38875834fab2415 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 30 Jul 2026 15:20:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B9=B3=E5=8F=B0=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E5=8F=AF=E5=9C=A8=E5=89=8D=E7=AB=AF=E5=88=86=E9=85=8D?= =?UTF-8?q?=E5=95=86=E5=93=81=E7=BB=99=E5=95=86=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 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个默认商品,管理员可随时增减调整 --- backend/internal/handler/merchant.go | 43 ++++++ backend/internal/router/router.go | 13 +- backend/internal/service/merchant.go | 177 +++++++++++++++++++++++ frontend/src/api/index.ts | 7 + frontend/src/pages/PlatformMerchants.tsx | 132 ++++++++++++++++- frontend/src/types/index.ts | 13 ++ 6 files changed, 376 insertions(+), 9 deletions(-) diff --git a/backend/internal/handler/merchant.go b/backend/internal/handler/merchant.go index cdcef7e..fbc183f 100644 --- a/backend/internal/handler/merchant.go +++ b/backend/internal/handler/merchant.go @@ -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"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index a12973d..d991870 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -111,13 +111,16 @@ func Setup(h *Handlers) *gin.Engine { admin := auth.Group("") admin.Use(middleware.RequireRole(model.RoleAdmin)) { - admin.GET("/users", h.User.List) - admin.POST("/users", h.User.Create) + admin.GET("/users", h.User.List) + admin.POST("/users", h.User.Create) admin.PATCH("/users/:id/status", h.User.UpdateStatus) admin.GET("/platform/merchants", h.Merchant.ListPlatformMerchants) - 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.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) } } } diff --git a/backend/internal/service/merchant.go b/backend/internal/service/merchant.go index b0f3885..3801f12 100644 --- a/backend/internal/service/merchant.go +++ b/backend/internal/service/merchant.go @@ -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 保证幂等:即使重复调用也不会报唯一索引冲突。 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 3b9f071..0d59f63 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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 }), } diff --git a/frontend/src/pages/PlatformMerchants.tsx b/frontend/src/pages/PlatformMerchants.tsx index 5aea4a6..99873c3 100644 --- a/frontend/src/pages/PlatformMerchants.tsx +++ b/frontend/src/pages/PlatformMerchants.tsx @@ -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([]) + const [merchantProducts, setMerchantProducts] = useState([]) + const [selectedCatalogIds, setSelectedCatalogIds] = useState([]) const [selectedMerchant, setSelectedMerchant] = useState(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 = [ { title: 'ID', dataIndex: 'id', width: 80 }, { title: '编码', dataIndex: 'code', width: 180, render: (v) => {v} }, @@ -151,7 +199,7 @@ export default function PlatformMerchants() { { title: '操作', key: 'action', - width: 190, + width: 260, render: (_, record) => ( + ), @@ -363,6 +419,74 @@ export default function PlatformMerchants() { + + setAssignOpen(false)} + destroyOnClose + width={760} + confirmLoading={assignSubmitting} + okText="保存分配" + > + + + + + 从平台商品目录勾选要分配给该商户的商品,保存后将以勾选结果同步(未勾选的已有商品会被移除)。 + + + + + + + + setSelectedCatalogIds(values as number[])} + style={{ width: '100%' }} + > + , + }, + { title: 'SKU', dataIndex: 'sku', width: 200, ellipsis: true, render: (v) => {v} }, + { 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' ? 上架 : 下架), + }, + ]} + /> + + + 已选 {selectedCatalogIds.length} / 目录共 {catalog.length} 个商品 · 该商户当前已分配 {merchantProducts.length} 个 + + + + ) } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 84c64ee..7464b3e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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