import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Alert, Button, Card, Drawer, Image, Input, InputNumber, message, Modal, Space, Table, Tag, Tree, TreeSelect, Typography, Upload, } from 'antd'; import { DeleteOutlined, DownloadOutlined, InboxOutlined, TableOutlined, UploadOutlined } from '@ant-design/icons'; import type { TableProps } from 'antd'; import XinTable from '@/components/XinTable'; import AuthButton from '@/components/AuthButton'; import type { XinTableColumn, XinTableInstance, XinTableProps } from '@/components/XinTable/typings.ts'; import type IProduct from '@/domain/iProduct.ts'; import type { IBatchPriceUpdate, IPriceMatrixRow } from '@/domain/iProduct.ts'; import { PRODUCT_STATUS_MAP } from '@/domain/iProduct.ts'; import type ICustomerLevel from '@/domain/iCustomerLevel.ts'; import type {IProductCategoryTree} from '@/domain/iProductCategory.ts'; import { getSupplierOptions } from '@/api/customer/supplier.ts'; import type ISupplier from '@/domain/iSupplier.ts'; import { getCategoryTree } from '@/api/product/category.ts'; import { batchDeleteProducts, batchPrice, getPriceMatrix } from '@/api/product/goods.ts'; import type { IImportError } from '@/api/product/goods.ts'; import { downloadProductTemplate, exportProducts, importProducts } from '@/api/product/goods.ts'; const { Title, Text } = Typography; /** 四舍五入保留两位 */ const round2 = (v: number) => Math.round(v * 100) / 100; /** 商品表单用分类树:有子分类的节点禁选(商品只能挂在末级分类上) */ const markParentDisabled = (nodes: IProductCategoryTree[]): IProductCategoryTree[] => nodes.map((node) => ({ ...node, disabled: !!node.children?.length, children: node.children?.length ? markParentDisabled(node.children) : node.children, })); /** 侧栏用分类树:有子分类的节点不可选中(仅供展开,筛选按末级分类) */ const markParentUnselectable = (nodes: IProductCategoryTree[]): IProductCategoryTree[] => nodes.map((node) => ({ ...node, selectable: !node.children?.length, children: node.children?.length ? markParentUnselectable(node.children) : node.children, })); /** * 商品档案管理(A1 商品列表 / A2 价格矩阵与批量调价) * * 价格体系:售价 = 成本价 × (100 + 客户等级上浮比例) / 100; * 等级上浮比例在「客户等级」中维护,本页价格矩阵仅批量调整成本价。 */ const ProductGoodsPage: React.FC = () => { const [suppliers, setSuppliers] = useState([]); const [categoryTree, setCategoryTree] = useState([]); // 商品表单专用分类树:父分类禁选,仅末级可选(侧栏筛选/价格矩阵仍用原始树) const formCategoryTree = useMemo(() => markParentDisabled(categoryTree), [categoryTree]); // 侧栏分类树:父分类不可选中,仅作展开归组 const sidebarCategoryTree = useMemo(() => markParentUnselectable(categoryTree), [categoryTree]); // ===== 分类侧栏 ===== const [activeCategory, setActiveCategory] = useState(undefined); const tableRef = useRef | null>(null); useEffect(() => { setSelectedRowKeys([]); tableRef.current?.reset(); }, [activeCategory]); // ===== 批量删除 ===== const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [batchDeleting, setBatchDeleting] = useState(false); /** 批量删除(软删除,成功后清空勾选并刷新列表) */ const handleBatchDelete = () => { window.$modal?.confirm({ title: `确定要删除选中的 ${selectedRowKeys.length} 件商品吗?`, okText: '删除', okButtonProps: { danger: true }, cancelText: '取消', onOk: async () => { setBatchDeleting(true); try { await batchDeleteProducts(selectedRowKeys); message.success('批量删除成功'); setSelectedRowKeys([]); await tableRef.current?.reload(); } finally { setBatchDeleting(false); } }, }); }; // ===== 价格矩阵抽屉 ===== const [matrixOpen, setMatrixOpen] = useState(false); const [matrixLoading, setMatrixLoading] = useState(false); const [saveLoading, setSaveLoading] = useState(false); const [matrixRows, setMatrixRows] = useState([]); const [matrixPage, setMatrixPage] = useState(1); const [matrixPageSize, setMatrixPageSize] = useState(20); const [matrixTotal, setMatrixTotal] = useState(0); const [matrixLevels, setMatrixLevels] = useState([]); const [matrixKeyword, setMatrixKeyword] = useState(''); const [matrixCategory, setMatrixCategory] = useState(undefined); /** 跨页未保存的成本价:productId → cost(null 视为未修改,不提交) */ const costDirtyRef = useRef>({}); // ===== Excel 导入/导出 ===== const [exportLoading, setExportLoading] = useState(false); const [importOpen, setImportOpen] = useState(false); const [importFile, setImportFile] = useState(null); const [importing, setImporting] = useState(false); const [importErrors, setImportErrors] = useState([]); /** 导出商品列表(跟随侧栏分类筛选;列格式与导入模板一致) */ const handleExport = async () => { setExportLoading(true); try { await exportProducts(activeCategory ? { category_id: activeCategory } : {}); } finally { setExportLoading(false); } }; /** 提交导入:成功刷新列表;校验失败展示错误行明细(错误提示由拦截器统一弹出) */ const handleImport = async () => { if (!importFile) { return; } setImporting(true); setImportErrors([]); try { await importProducts(importFile); setImportOpen(false); setImportFile(null); tableRef.current?.reset(); } catch (err: any) { const errors = err?.data?.data?.errors; if (Array.isArray(errors)) { setImportErrors(errors); } } finally { setImporting(false); } }; useEffect(() => { getSupplierOptions().then((res) => setSuppliers(res.data.data ?? [])); getCategoryTree().then((res) => setCategoryTree(res.data.data ?? [])); }, []); const loadMatrix = async ( keyword = matrixKeyword, categoryId = matrixCategory, page = matrixPage, pageSize = matrixPageSize ) => { setMatrixLoading(true); try { const res = await getPriceMatrix({ keyword: keyword || undefined, category_id: categoryId, page, pageSize, }); const rows = res.data.data?.rows ?? []; const levels = res.data.data?.levels ?? []; // 叠加跨页未保存的成本价修改,并重算各等级展示价,保证翻页后输入值不回退 const merged = rows.map((row) => { const dirty = costDirtyRef.current[row.id]; if (dirty === undefined) return row; const next: IPriceMatrixRow = { ...row, cost_price: dirty }; const cost = Number(dirty ?? 0); levels.forEach((level) => { if (level.id == null) return; next[`price_${level.id}`] = cost > 0 ? round2(cost * (1 + Number(level.percent ?? 0) / 100)) : null; }); return next; }); setMatrixRows(merged); setMatrixTotal(res.data.data?.total ?? 0); setMatrixLevels(levels); } finally { setMatrixLoading(false); } }; const openMatrix = () => { setMatrixOpen(true); setMatrixPage(1); setMatrixPageSize(20); loadMatrix('', undefined, 1, 20); }; /** 修改成本价:联动重算各等级展示价(上浮比例取 levels 配置) */ const onMatrixCostChange = (productId: number, value: number | null) => { costDirtyRef.current[productId] = value; setMatrixRows((prev) => prev.map((row) => { if (row.id !== productId) return row; const next: IPriceMatrixRow = { ...row, cost_price: value }; const cost = Number(value ?? 0); matrixLevels.forEach((level) => { if (level.id == null) return; next[`price_${level.id}`] = cost > 0 ? round2(cost * (1 + Number(level.percent ?? 0) / 100)) : null; }); return next; }) ); }; /** 提交所有跨页未保存的成本价调整(null 视为清除,不提交) */ const saveMatrix = async () => { const updates: IBatchPriceUpdate[] = []; Object.entries(costDirtyRef.current).forEach(([pid, cost]) => { if (cost === null || cost === undefined) return; updates.push({ product_id: Number(pid), cost_price: cost }); }); if (updates.length === 0) { message.info('没有需要保存的价格调整'); return; } setSaveLoading(true); try { await batchPrice(updates); message.success(`已更新 ${updates.length} 件商品成本价,各等级售价已按上浮比例联动,受影响门店将收到通知`); costDirtyRef.current = {}; await loadMatrix(); } finally { setSaveLoading(false); } }; const matrixColumns: TableProps['columns'] = [ { title: '商品', dataIndex: 'name', fixed: 'left', width: 160, render: (name: string, row) => (
{name}
{row.spec} {row.unit ? ` / ${row.unit}` : ''}
), }, { title: '成本价', key: 'cost_price', fixed: 'left', width: 130, render: (_: unknown, row: IPriceMatrixRow) => ( onMatrixCostChange(row.id, v)} className="w-32" /> ), }, ...matrixLevels.map((level) => ({ title: level.name, key: `price_${level.id}`, width: 150, render: (_: unknown, row: IPriceMatrixRow) => { const price = row[`price_${level.id}`]; return (
{price != null ? `¥${price}` : 未设成本价}
成本上浮 {Number(level.percent ?? 0)}%
); }, })), ]; const columns: XinTableColumn[] = [ // { // title: 'ID', // dataIndex: 'id', // hideInForm: true, // hideInSearch: true, // width: 70, // align: 'center', // }, { title: '商品名称', align: 'center', dataIndex: 'name', valueType: 'text', colProps: { span: 24 }, required: true, render: (_, record) => { return (
{ record.name }
{ record.remark }
) }, rules: [{ required: true, message: '请输入商品名称' }], }, { title: '商品图片', dataIndex: 'image_ids', valueType: 'image', fieldProps: { action: '/product/goods/upload', maxWidth: 3000, maxHeight: 3000, maxSize: 10, mode: 'multiple', maxCount: 5, changeType: "id" }, render: (_, record) => { if (!record.images_arr || record.images_arr?.length <= 0) return '-'; return ( {record.images_arr.map(i => ( ))} ); }, align: 'center', hideInSearch: true, colProps: { span: 24 }, }, { title: '商品描述', dataIndex: 'remark', valueType: 'text', hideInSearch: true, hideInTable: true, colProps: { span: 24 }, }, { title: '成本价', dataIndex: 'cost_price', valueType: 'digit', hideInSearch: true, align: 'center', tooltip: '各等级售价 = 成本价 × (100 + 等级上浮比例) / 100', fieldProps: { min: 0, precision: 2, prefix: '¥' }, render: (_, record) => { const cost = Number(record.cost_price ?? 0); return cost > 0 ? `¥${record.cost_price}` : 未设置; }, }, { title: '规格/包规', dataIndex: 'spec', valueType: 'text', hideInSearch: true, align: "center", }, { title: '单位', dataIndex: 'unit', valueType: 'text', hideInSearch: true, initialValue: '斤', align: "center", }, { title: '单价单位', dataIndex: 'price_unit', valueType: 'text', hideInTable: true, hideInSearch: true, initialValue: '元/斤', fieldProps: { placeholder: '如:元/斤、元/箱', maxLength: 20 }, }, { title: '排序', dataIndex: 'sort', valueType: 'digit', hideInSearch: true, fieldProps: { min: 0 }, align: 'center', }, { title: '分类', dataIndex: 'category_id', valueType: 'treeSelect', required: true, align: "center", rules: [{ required: true, message: '请选择分类' }], fieldProps: { treeData: formCategoryTree, fieldNames: { label: 'name', value: 'id', children: 'children' }, treeDefaultExpandAll: true, showSearch: true, treeNodeFilterProp: 'name', placeholder: '选择末级分类', }, hideInSearch: true, render: (_, record) => record.category ? {record.category.name} : '-', }, { title: '供应商', dataIndex: 'supplier_id', valueType: 'select', hideInSearch: true, align: "center", fieldProps: { options: suppliers.map((s) => ({ label: s.name, value: s.id })), showSearch: true, optionFilterProp: 'label', allowClear: true, placeholder: '默认供应商(可选)', }, render: (_, record) => record.supplier?.name ?? '-', }, { title: '市场', dataIndex: 'market', valueType: 'text', align: 'center', fieldProps: { placeholder: '市场名称,如:新发地', maxLength: 50, allowClear: true, }, render: (_, record) => record.market || '-', }, { title: '状态', dataIndex: 'status', valueType: 'radioButton', initialValue: 1, fieldProps: { options: [ { value: 1, label: '上架' }, { value: 0, label: '下架' }, ], }, render: (_, record) => { const item = PRODUCT_STATUS_MAP[record.status ?? 1]; return {item?.text}; }, align: 'center', }, { title: '图文详情', dataIndex: 'content', valueType: 'richText', hideInSearch: true, hideInTable: true, colProps: { span: 24 }, fieldProps: { height: 400, groupId: 11, placeholder: '输入商品图文详情,支持插入图片', }, }, { title: '等级价格', dataIndex: 'prices', hideInForm: true, hideInSearch: true, width: 420, align: 'center', render: (_, record) => ( {record.prices?.length ? record.prices.map((p) => ( {p.level?.name ?? `等级${p.level_id}`} ¥{p.price ?? '未设定'} )) : '-'} ), }, { title: '创建时间', dataIndex: 'created_at', hideInForm: true, hideInSearch: true, align: 'center', }, ]; const tableProps: XinTableProps = { api: '/product/goods', columns, rowKey: 'id', accessName: 'product.goods', tableRef, scroll: { x: 1200 }, actionBarRender: (dom) => [ dom.add, dom.search, , , , , dom.keywordSearch, ], formProps: { grid: true, colProps: { span: 12 }, rowProps: { gutter: 20 }, layout: 'vertical', }, requestParams: (params) => ({ ...params, category_id: activeCategory }), rowSelection: { selectedRowKeys, onChange: (keys) => setSelectedRowKeys(keys.map(Number)), }, modalProps: { width: 800, centered: true, classNames: { body: "overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden h-[80vh]" }, }, }; return ( <>
商品列表 商品售价按客户等级上浮比例自动换算(成本价 × (100 + 上浮比例) / 100);「价格矩阵」支持批量调整成本价,调价后自动通知受影响门店。
{ setActiveCategory(undefined) }} > 全部商品
showLine blockNode onSelect={(selectedKeys) => setActiveCategory(Number(selectedKeys[0]))} treeData={sidebarCategoryTree} selectedKeys={activeCategory ? [activeCategory] : undefined} fieldNames={{title: 'name', key: 'id', children: 'children'}} />
{...tableProps} />
setImportOpen(false)} onOk={handleImport} okText="开始导入" okButtonProps={{ disabled: !importFile }} confirmLoading={importing} destroyOnHidden width={560} >
列格式:品名*、分类*(末级分类,支持「父分类/子分类」路径)、供应商(不存在自动创建)、市场、 规格/包规、单位(默认斤)、成本价、排序、库存、保质期、状态(上架/下架)、备注。 导入一律新增商品;整表校验,任何一行有错则全部不导入。
{ setImportFile(file); setImportErrors([]); return false; }} onRemove={() => { setImportFile(null); setImportErrors([]); }} >

点击或拖拽 Excel 文件到此区域

仅支持 .xlsx / .xls,单次最多 1000 行

{importErrors.length > 0 && ( {importErrors.map((item, index) => (
  • 第 {item.row} 行:{item.message}
  • ))} } /> )}
    setMatrixOpen(false)} size={1200} extra={ } > { setMatrixCategory(v); setMatrixPage(1); loadMatrix(matrixKeyword, v, 1, matrixPageSize); }} /> setMatrixKeyword(e.target.value)} onSearch={(v) => { setMatrixPage(1); loadMatrix(v, matrixCategory, 1, matrixPageSize); }} /> 等级列按上浮比例自动换算,仅成本价可编辑 rowKey="id" size="small" loading={matrixLoading} columns={matrixColumns} dataSource={matrixRows} pagination={{ current: matrixPage, pageSize: matrixPageSize, total: matrixTotal, showSizeChanger: true, pageSizeOptions: [20, 50, 100], showTotal: (total) => `共 ${total} 件商品`, onChange: (page, pageSize) => { setMatrixPage(page); setMatrixPageSize(pageSize); loadMatrix(matrixKeyword, matrixCategory, page, pageSize); }, }} scroll={{ x: matrixLevels.length * 150 + 290 }} /> ); }; export default ProductGoodsPage;