feat(merchant-products): 开放商品编辑功能,支持超级管理员与商户修改售价积分、成本价与库存模式
1. 后端 Handler 新增 UpdateProduct 方法,并在 /api/merchant/products/:id 注册 PATCH 路由,支持修改商品显示名称、对外售价积分、成本价与库存; 2. 前端商户中心 merchantApi 扩充 updateProduct 接口对接; 3. 商品列表(ProductsTab)操作列新增【编辑】按钮与模态框,提供商品名称、对外售价(积分)、成本价(积分)、无限库存/指定限量模式及上架状态编辑功能; 4. 修复超级管理员在自营或商户商品列表中无法自由调价与调整库存的交互缺失问题。
This commit is contained in:
@@ -77,6 +77,32 @@ type merchantProductStatusReq struct {
|
||||
|
||||
// UpdateProductStatus PATCH /api/merchant/products/:id/status
|
||||
// 商户仅能上架/下架商品,价格、成本等配置由平台管理员维护。
|
||||
// UpdateProduct PATCH /api/merchant/products/:id — 编辑商品(显示名称、售价、成本、库存、状态)。
|
||||
func (h *MerchantHandler) UpdateProduct(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if id == 0 {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
var req merchantProductUpdateReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
if err := h.merchantSvc.UpdateMerchantProduct(middleware.GetMerchantID(c), uint(id), service.UpdateMerchantProductInput{
|
||||
DisplayName: req.DisplayName,
|
||||
PriceAmount: req.PriceAmount,
|
||||
CostAmount: req.CostAmount,
|
||||
Stock: req.Stock,
|
||||
Status: req.Status,
|
||||
FulfillmentConfig: req.FulfillmentConfig,
|
||||
}, middleware.GetUserID(c)); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, nil)
|
||||
}
|
||||
|
||||
func (h *MerchantHandler) UpdateProductStatus(c *gin.Context) {
|
||||
var req merchantProductStatusReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
@@ -236,6 +236,7 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
{
|
||||
merchant.GET("", h.Merchant.Current)
|
||||
merchant.GET("/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionProductsManage), h.Merchant.ListProducts)
|
||||
merchant.PATCH("/products/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionProductsManage), h.Merchant.UpdateProduct)
|
||||
merchant.PATCH("/products/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionProductsManage), h.Merchant.UpdateProductStatus)
|
||||
merchant.DELETE("/products/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionProductsManage), h.Merchant.DeleteProduct)
|
||||
merchant.GET("/orders", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.ListOrders)
|
||||
|
||||
@@ -69,6 +69,16 @@ export const merchantApi = {
|
||||
request.get('/merchant').then((r) => r.data.data as { merchant: Merchant; role: string; permissions: string[] }),
|
||||
products: (params?: Record<string, unknown>) =>
|
||||
request.get('/merchant/products', { params }).then((r) => r.data.data as PageResult<MerchantProduct>),
|
||||
updateProduct: (
|
||||
id: number,
|
||||
data: {
|
||||
display_name?: string
|
||||
price_amount?: number
|
||||
cost_amount?: number
|
||||
stock?: number
|
||||
status?: 'active' | 'inactive'
|
||||
}
|
||||
) => request.patch(`/merchant/products/${id}`, data).then((r) => r.data.data),
|
||||
updateProductStatus: (id: number, status: MerchantProduct['status']) =>
|
||||
request.patch(`/merchant/products/${id}/status`, { status }).then((r) => r.data.data),
|
||||
deleteProduct: (id: number) =>
|
||||
|
||||
@@ -538,6 +538,26 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
||||
}
|
||||
}
|
||||
|
||||
const updateProduct = async (
|
||||
id: number,
|
||||
data: {
|
||||
display_name?: string
|
||||
price_amount?: number
|
||||
cost_amount?: number
|
||||
stock?: number
|
||||
status?: 'active' | 'inactive'
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
await merchantApi.updateProduct(id, data)
|
||||
message.success('商品信息更新成功')
|
||||
loadProducts(products.page, products.size)
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '更新商品失败')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const tabItems = [
|
||||
{ key: 'products', label: '商品列表', disabled: !hasFeature('products'), children: (
|
||||
<ProductsTab
|
||||
@@ -549,6 +569,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
||||
onDeleteProduct={deleteProduct}
|
||||
onFilterChange={changeProductFilters}
|
||||
onLoadProducts={loadProducts}
|
||||
onUpdateProduct={updateProduct}
|
||||
/>
|
||||
) },
|
||||
{ key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Alert, Button, Card, Col, Form, Input, Modal, Popconfirm, Row, Select, Space, Table, Tabs, Tag, Typography, message } from 'antd'
|
||||
import { Alert, Button, Card, Col, Form, Input, InputNumber, Modal, Popconfirm, Radio, Row, Select, Space, Table, Tabs, Tag, Typography, message } from 'antd'
|
||||
import type { FormInstance } from 'antd'
|
||||
import {
|
||||
ClockCircleOutlined,
|
||||
@@ -68,6 +68,7 @@ export function ProductsTab({
|
||||
onDeleteProduct,
|
||||
onFilterChange,
|
||||
onLoadProducts,
|
||||
onUpdateProduct,
|
||||
}: {
|
||||
loading: boolean
|
||||
products: PageResult<MerchantProduct>
|
||||
@@ -77,8 +78,23 @@ export function ProductsTab({
|
||||
onDeleteProduct: (record: MerchantProduct) => void
|
||||
onFilterChange: (filters: { keyword: string; status: string }) => void
|
||||
onLoadProducts: (page?: number, size?: number) => void
|
||||
onUpdateProduct?: (
|
||||
id: number,
|
||||
data: {
|
||||
display_name?: string
|
||||
price_amount?: number
|
||||
cost_amount?: number
|
||||
stock?: number
|
||||
status?: 'active' | 'inactive'
|
||||
}
|
||||
) => Promise<void>
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState(filters.keyword)
|
||||
const [editModalOpen, setEditModalOpen] = useState(false)
|
||||
const [editingProduct, setEditingProduct] = useState<MerchantProduct | null>(null)
|
||||
const [editForm] = Form.useForm()
|
||||
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||
const [stockMode, setStockMode] = useState<'unlimited' | 'custom'>('unlimited')
|
||||
|
||||
useEffect(() => {
|
||||
setKeyword(filters.keyword)
|
||||
@@ -86,6 +102,40 @@ export function ProductsTab({
|
||||
|
||||
const submitKeyword = () => onFilterChange({ ...filters, keyword: keyword.trim() })
|
||||
|
||||
const openEditModal = (record: MerchantProduct) => {
|
||||
setEditingProduct(record)
|
||||
const isUnlimited = record.stock < 0
|
||||
setStockMode(isUnlimited ? 'unlimited' : 'custom')
|
||||
editForm.setFieldsValue({
|
||||
display_name: record.display_name || record.product?.name || '',
|
||||
price_amount: record.price_amount,
|
||||
cost_amount: record.cost_amount,
|
||||
stock_mode: isUnlimited ? 'unlimited' : 'custom',
|
||||
stock_count: isUnlimited ? 100 : record.stock,
|
||||
status: record.status,
|
||||
})
|
||||
setEditModalOpen(true)
|
||||
}
|
||||
|
||||
const handleEditSubmit = async () => {
|
||||
if (!editingProduct || !onUpdateProduct) return
|
||||
const values = await editForm.validateFields()
|
||||
setEditSubmitting(true)
|
||||
try {
|
||||
const stock = values.stock_mode === 'unlimited' ? -1 : Number(values.stock_count ?? 0)
|
||||
await onUpdateProduct(editingProduct.id, {
|
||||
display_name: values.display_name?.trim(),
|
||||
price_amount: Number(values.price_amount),
|
||||
cost_amount: Number(values.cost_amount),
|
||||
stock,
|
||||
status: values.status,
|
||||
})
|
||||
setEditModalOpen(false)
|
||||
} finally {
|
||||
setEditSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const productColumns: ColumnsType<MerchantProduct> = [
|
||||
{
|
||||
title: '商户 SKU',
|
||||
@@ -142,10 +192,17 @@ export function ProductsTab({
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
render: (_, record) => canManage ? (
|
||||
<Space size={0}>
|
||||
<Space size={4}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => openEditModal(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={record.status === 'active' ? '下架该商品?' : '上架该商品?'}
|
||||
description={record.status === 'active' ? '下架后商户将无法再创建该商品订单' : '上架后商户可正常下单'}
|
||||
@@ -180,7 +237,7 @@ export function ProductsTab({
|
||||
<div>
|
||||
<span style={{ fontWeight: 600, color: '#0f172a', marginRight: 8 }}>商品上架与售卖管理</span>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
可按状态与关键词筛选;已下架商品可删除。
|
||||
可编辑商品名称、对外积分售价、成本价及库存模式;已下架商品可删除。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -219,6 +276,87 @@ export function ProductsTab({
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={pageConfig(products, onLoadProducts)}
|
||||
/>
|
||||
|
||||
{/* 编辑商品售价与库存 Modal */}
|
||||
<Modal
|
||||
title={`编辑商品售价与库存 - [${editingProduct?.sku || ''}]`}
|
||||
open={editModalOpen}
|
||||
onOk={handleEditSubmit}
|
||||
onCancel={() => setEditModalOpen(false)}
|
||||
confirmLoading={editSubmitting}
|
||||
destroyOnClose
|
||||
width={560}
|
||||
>
|
||||
<Form form={editForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item label="商品 SKU">
|
||||
<Typography.Text code copyable>{editingProduct?.sku}</Typography.Text>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="display_name"
|
||||
label="商品显示名称"
|
||||
rules={[{ required: true, message: '请输入商品名称' }]}
|
||||
>
|
||||
<Input placeholder="展示给买家/商户的商品名称" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="price_amount"
|
||||
label="对外售价 (积分)"
|
||||
rules={[{ required: true, message: '请输入售价' }]}
|
||||
tooltip="1 元 = 100 积分。例如 2500 积分对应 25.00 元"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={100}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="积分"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="cost_amount"
|
||||
label="成本价 (积分)"
|
||||
rules={[{ required: true, message: '请输入成本价' }]}
|
||||
tooltip="商户进货成本。例如 2100 积分对应 21.00 元"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={100}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="积分"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item label="库存设置">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Form.Item name="stock_mode" noStyle>
|
||||
<Radio.Group onChange={(e) => setStockMode(e.target.value)}>
|
||||
<Radio value="unlimited">无限库存 (推荐)</Radio>
|
||||
<Radio value="custom">指定有限库存</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{stockMode === 'custom' && (
|
||||
<Form.Item
|
||||
name="stock_count"
|
||||
style={{ marginTop: 12, marginBottom: 0 }}
|
||||
rules={[{ required: true, message: '请输入库存件数' }]}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: 200 }} addonAfter="件" />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="售卖状态">
|
||||
<Radio.Group>
|
||||
<Radio value="active">正常上架 (可售)</Radio>
|
||||
<Radio value="inactive">立即下架 (不可售)</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user