Files
xin-procurement/web/pages/product/goods.tsx
T
2026-08-20 14:45:06 +08:00

668 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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 { 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<ISupplier[]>([]);
const [categoryTree, setCategoryTree] = useState<IProductCategoryTree[]>([]);
// 商品表单专用分类树:父分类禁选,仅末级可选(侧栏筛选/价格矩阵仍用原始树)
const formCategoryTree = useMemo(() => markParentDisabled(categoryTree), [categoryTree]);
// 侧栏分类树:父分类不可选中,仅作展开归组
const sidebarCategoryTree = useMemo(() => markParentUnselectable(categoryTree), [categoryTree]);
// ===== 分类侧栏 =====
const [activeCategory, setActiveCategory] = useState<number | undefined>(undefined);
const tableRef = useRef<XinTableInstance<IProduct> | null>(null);
useEffect(() => { tableRef.current?.reset() }, [activeCategory]);
// ===== 价格矩阵抽屉 =====
const [matrixOpen, setMatrixOpen] = useState(false);
const [matrixLoading, setMatrixLoading] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const [matrixRows, setMatrixRows] = useState<IPriceMatrixRow[]>([]);
const [matrixPage, setMatrixPage] = useState(1);
const [matrixPageSize, setMatrixPageSize] = useState(20);
const [matrixTotal, setMatrixTotal] = useState(0);
const [matrixLevels, setMatrixLevels] = useState<ICustomerLevel[]>([]);
const [matrixKeyword, setMatrixKeyword] = useState('');
const [matrixCategory, setMatrixCategory] = useState<number | undefined>(undefined);
/** 跨页未保存的成本价:productId → costnull 视为未修改,不提交) */
const costDirtyRef = useRef<Record<number, number | null>>({});
// ===== Excel 导入/导出 =====
const [exportLoading, setExportLoading] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importFile, setImportFile] = useState<File | null>(null);
const [importing, setImporting] = useState(false);
const [importErrors, setImportErrors] = useState<IImportError[]>([]);
/** 导出商品列表(跟随侧栏分类筛选;列格式与导入模板一致) */
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<IPriceMatrixRow>['columns'] = [
{
title: '商品',
dataIndex: 'name',
fixed: 'left',
width: 160,
render: (name: string, row) => (
<div>
<div className="font-medium">{name}</div>
<Text type="secondary" className="text-xs">
{row.spec}
{row.unit ? ` / ${row.unit}` : ''}
</Text>
</div>
),
},
{
title: '成本价',
key: 'cost_price',
fixed: 'left',
width: 130,
render: (_: unknown, row: IPriceMatrixRow) => (
<InputNumber
size="small"
min={0}
precision={2}
prefix="¥"
value={row.cost_price as number | null}
onChange={(v) => 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 (
<div>
<div>{price != null ? ${price}` : <Text type="secondary">未设成本价</Text>}</div>
<div className="text-xs text-gray-400">成本上浮 {Number(level.percent ?? 0)}%</div>
</div>
);
},
})),
];
const columns: XinTableColumn<IProduct>[] = [
{
title: 'ID',
dataIndex: 'id',
hideInForm: true,
hideInSearch: true,
width: 70,
align: 'center',
},
{
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 (
<Space wrap={false}>
{record.images_arr.map(i => (
<Image
key={i.id}
src={i.preview_url}
width={60}
height={60}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
))}
</Space>
);
},
align: 'center',
hideInSearch: true,
colProps: { span: 24 },
},
{
title: '商品名称',
dataIndex: 'name',
valueType: 'text',
colProps: { span: 24 },
required: true,
render: (_, record) => {
return (
<div>
<div className={"mb-1.5"}>{ record.name }</div>
<div className={"text-[#999] text-[12px]"}>{ record.remark }</div>
</div>
)
},
rules: [{ required: true, message: '请输入商品名称' }],
},
{
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}` : <Text type="secondary">未设置</Text>;
},
},
{
title: '规格/包规',
dataIndex: 'spec',
valueType: 'text',
hideInSearch: true,
align: "center",
},
{
title: '单位',
dataIndex: 'unit',
valueType: 'text',
hideInSearch: true,
initialValue: '斤',
align: "center",
},
{
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 ? <Tag color="cyan">{record.category.name}</Tag> : '-',
},
{
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: 'status',
valueType: 'radioButton',
initialValue: 1,
fieldProps: {
options: [
{ value: 1, label: '上架' },
{ value: 0, label: '下架' },
],
},
render: (_, record) => {
const item = PRODUCT_STATUS_MAP[record.status ?? 1];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
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) => (
<Space wrap>
{record.prices?.length
? record.prices.map((p) => (
<Tag
key={p.level_id}
color="geekblue"
title={`按成本价上浮 ${p.percent ?? 0}%`}
>
{p.level?.name ?? `等级${p.level_id}`}
<span style={{color: 'red', marginLeft: 5 }}>
¥{p.price ?? '未设定'}
</span>
</Tag>
))
: '-'}
</Space>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
hideInForm: true,
hideInSearch: true,
align: 'center',
},
];
const tableProps: XinTableProps<IProduct> = {
api: '/product/goods',
columns,
rowKey: 'id',
accessName: 'product.goods',
tableRef,
scroll: { x: 1200 },
actionBarRender: (dom) => [
dom.add,
dom.search,
<Button key="matrix" icon={<TableOutlined />} onClick={openMatrix}>
价格矩阵
</Button>,
<AuthButton key="export" auth="product.goods.export">
<Button icon={<DownloadOutlined />} loading={exportLoading} onClick={handleExport}>
导出
</Button>
</AuthButton>,
<AuthButton key="import" auth="product.goods.import">
<Button icon={<UploadOutlined />} onClick={() => setImportOpen(true)}>
导入
</Button>
</AuthButton>,
dom.keywordSearch,
],
formProps: {
grid: true,
colProps: { span: 12 },
rowProps: { gutter: 20 },
layout: 'vertical',
},
requestParams: (params) => ({
...params,
category_id: activeCategory
}),
modalProps: {
width: 800,
centered: true,
classNames: { body: "overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden h-[80vh]" },
},
};
return (
<>
<div className="mb-5">
<Title level={3}>商品列表</Title>
<Text type="secondary">
商品售价按客户等级上浮比例自动换算(成本价 × (100 + 上浮比例) / 100);「价格矩阵」支持批量调整成本价,调价后自动通知受影响门店。
</Text>
</div>
<div className="flex items-start gap-4">
<Card style={{ width: 200 }} title={'商品分类'}>
<div
className={`cursor-pointer rounded-2xl mb-2 px-2 h-6 leading-6 ${activeCategory === undefined ? 'bg-blue-50' : 'hover:bg-gray-50'}`}
onClick={() => { setActiveCategory(undefined) }}
>
全部商品
</div>
<Tree<any>
showLine
blockNode
onSelect={(selectedKeys) => setActiveCategory(Number(selectedKeys[0]))}
treeData={sidebarCategoryTree}
selectedKeys={activeCategory ? [activeCategory] : undefined}
fieldNames={{title: 'name', key: 'id', children: 'children'}}
/>
</Card>
<div className="min-w-0 flex-1">
<XinTable<IProduct> {...tableProps} />
</div>
</div>
<Modal
title="导入商品"
open={importOpen}
onCancel={() => setImportOpen(false)}
onOk={handleImport}
okText="开始导入"
okButtonProps={{ disabled: !importFile }}
confirmLoading={importing}
destroyOnHidden
width={560}
>
<div className="mb-3">
<Text type="secondary">
列格式:品名*、分类*(末级分类,支持「父分类/子分类」路径)、供应商(不存在自动创建)、
规格/包规、单位(默认斤)、成本价、排序、库存、保质期、状态(上架/下架)、备注。
导入一律新增商品;整表校验,任何一行有错则全部不导入。
</Text>
<Button type="link" className="px-0" onClick={downloadProductTemplate}>
下载导入模板
</Button>
</div>
<Upload.Dragger
accept=".xlsx,.xls"
maxCount={1}
beforeUpload={(file) => {
setImportFile(file);
setImportErrors([]);
return false;
}}
onRemove={() => {
setImportFile(null);
setImportErrors([]);
}}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">点击或拖拽 Excel 文件到此区域</p>
<p className="ant-upload-hint">仅支持 .xlsx / .xls,单次最多 1000 </p>
</Upload.Dragger>
{importErrors.length > 0 && (
<Alert
className="mt-3"
type="error"
showIcon
message={`共 ${importErrors.length} 处错误,修正后请重新导入`}
description={
<ul className="max-h-48 overflow-y-auto pl-4 mb-0 list-disc">
{importErrors.map((item, index) => (
<li key={index}> {item.row} 行:{item.message}</li>
))}
</ul>
}
/>
)}
</Modal>
<Drawer
title="价格矩阵 · 批量调价"
open={matrixOpen}
onClose={() => setMatrixOpen(false)}
size={1200}
extra={
<Space>
<Button onClick={() => loadMatrix()}>刷新</Button>
<Button type="primary" loading={saveLoading} onClick={saveMatrix}>
保存调价
</Button>
</Space>
}
>
<Space className="mb-4" wrap>
<TreeSelect
style={{ width: 180 }}
placeholder="按分类筛选"
allowClear
treeDefaultExpandAll
fieldNames={{ label: 'name', value: 'id', children: 'children' }}
treeData={categoryTree}
value={matrixCategory}
onChange={(v) => {
setMatrixCategory(v);
setMatrixPage(1);
loadMatrix(matrixKeyword, v, 1, matrixPageSize);
}}
/>
<Input.Search
style={{ width: 240 }}
placeholder="搜索品名/规格"
allowClear
value={matrixKeyword}
onChange={(e) => setMatrixKeyword(e.target.value)}
onSearch={(v) => {
setMatrixPage(1);
loadMatrix(v, matrixCategory, 1, matrixPageSize);
}}
/>
<Text type="secondary">等级列按上浮比例自动换算,仅成本价可编辑</Text>
</Space>
<Table<IPriceMatrixRow>
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 }}
/>
</Drawer>
</>
);
};
export default ProductGoodsPage;