first version
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
TreeSelect,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { MinusCircleOutlined, PlusOutlined, TableOutlined } from '@ant-design/icons';
|
||||
import type { TableProps } from 'antd';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type { XinTableColumn, 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 IProductCategory from '@/domain/iProductCategory.ts';
|
||||
import { getLevelOptions } from '@/api/customer/level.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';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 商品档案管理(A1 商品列表 / A2 价格矩阵与批量调价)
|
||||
*/
|
||||
const ProductGoodsPage: React.FC = () => {
|
||||
const [levels, setLevels] = useState<ICustomerLevel[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
||||
const [categoryTree, setCategoryTree] = useState<IProductCategory[]>([]);
|
||||
|
||||
// ===== 价格矩阵抽屉 =====
|
||||
const [matrixOpen, setMatrixOpen] = useState(false);
|
||||
const [matrixLoading, setMatrixLoading] = useState(false);
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const [matrixRows, setMatrixRows] = useState<IPriceMatrixRow[]>([]);
|
||||
const [matrixSnapshot, setMatrixSnapshot] = useState<IPriceMatrixRow[]>([]);
|
||||
const [matrixLevels, setMatrixLevels] = useState<ICustomerLevel[]>([]);
|
||||
const [matrixKeyword, setMatrixKeyword] = useState('');
|
||||
const [matrixCategory, setMatrixCategory] = useState<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
getLevelOptions().then((res) => setLevels(res.data.data ?? []));
|
||||
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
||||
getCategoryTree().then((res) => setCategoryTree(res.data.data ?? []));
|
||||
}, []);
|
||||
|
||||
const loadMatrix = async (
|
||||
keyword = matrixKeyword,
|
||||
categoryId = matrixCategory
|
||||
) => {
|
||||
setMatrixLoading(true);
|
||||
try {
|
||||
const res = await getPriceMatrix({
|
||||
keyword: keyword || undefined,
|
||||
category_id: categoryId,
|
||||
});
|
||||
const rows = res.data.data?.rows ?? [];
|
||||
setMatrixRows(rows);
|
||||
setMatrixSnapshot(JSON.parse(JSON.stringify(rows)));
|
||||
setMatrixLevels(res.data.data?.levels ?? []);
|
||||
} finally {
|
||||
setMatrixLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openMatrix = () => {
|
||||
setMatrixOpen(true);
|
||||
loadMatrix('', undefined);
|
||||
};
|
||||
|
||||
const onMatrixPriceChange = (
|
||||
productId: number,
|
||||
levelId: number,
|
||||
value: number | null
|
||||
) => {
|
||||
setMatrixRows((prev) =>
|
||||
prev.map((row) =>
|
||||
row.id === productId ? { ...row, [`price_${levelId}`]: value } : row
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/** diff 出被修改的价格行,提交批量调价 */
|
||||
const saveMatrix = async () => {
|
||||
const updates: IBatchPriceUpdate[] = [];
|
||||
for (const row of matrixRows) {
|
||||
const old = matrixSnapshot.find((r) => r.id === row.id);
|
||||
for (const level of matrixLevels) {
|
||||
const key = `price_${level.id}`;
|
||||
const next = row[key];
|
||||
const prev = old?.[key];
|
||||
if (next !== null && next !== undefined && String(next) !== String(prev ?? '')) {
|
||||
updates.push({ product_id: row.id, level_id: level.id!, price: next as number });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (updates.length === 0) {
|
||||
message.info('没有需要保存的价格调整');
|
||||
return;
|
||||
}
|
||||
setSaveLoading(true);
|
||||
try {
|
||||
await batchPrice(updates);
|
||||
message.success(`已更新 ${updates.length} 条价格,受影响门店将收到通知`);
|
||||
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>
|
||||
),
|
||||
},
|
||||
...matrixLevels.map((level) => ({
|
||||
title: level.name,
|
||||
key: `price_${level.id}`,
|
||||
width: 150,
|
||||
render: (_: unknown, row: IPriceMatrixRow) => (
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
value={row[`price_${level.id}`] as number | null}
|
||||
onChange={(v) => onMatrixPriceChange(row.id, level.id!, v)}
|
||||
className="w-32"
|
||||
/>
|
||||
),
|
||||
})),
|
||||
];
|
||||
|
||||
const columns: XinTableColumn<IProduct>[] = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 70,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '商品名称',
|
||||
dataIndex: 'name',
|
||||
valueType: 'text',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请输入商品名称' }],
|
||||
},
|
||||
{
|
||||
title: '规格/包规',
|
||||
dataIndex: 'spec',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
title: '等级',
|
||||
dataIndex: 'grade',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
title: '单位',
|
||||
dataIndex: 'unit',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
initialValue: '斤',
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category_id',
|
||||
valueType: 'treeSelect',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请选择分类' }],
|
||||
fieldProps: {
|
||||
treeData: categoryTree,
|
||||
fieldNames: { label: 'name', value: 'id', children: 'children' },
|
||||
treeDefaultExpandAll: true,
|
||||
showSearch: true,
|
||||
treeNodeFilterProp: 'name',
|
||||
placeholder: '选择分类',
|
||||
},
|
||||
render: (_, record) =>
|
||||
record.category ? <Tag color="cyan">{record.category.name}</Tag> : '-',
|
||||
},
|
||||
{
|
||||
title: '供应商',
|
||||
dataIndex: 'supplier_id',
|
||||
valueType: 'select',
|
||||
hideInSearch: true,
|
||||
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: 'prices',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (_, record) => (
|
||||
<Space size={[0, 4]} wrap>
|
||||
{record.prices?.length
|
||||
? record.prices.map((p) => (
|
||||
<Tag key={p.level_id} color="geekblue">
|
||||
{p.level?.name ?? `等级${p.level_id}`} ¥{p.price}
|
||||
</Tag>
|
||||
))
|
||||
: '-'}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
valueType: 'digit',
|
||||
hideInSearch: true,
|
||||
fieldProps: { min: 0 },
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
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: 'remark',
|
||||
valueType: 'textarea',
|
||||
hideInSearch: true,
|
||||
hideInTable: true,
|
||||
fieldProps: { rows: 2 },
|
||||
},
|
||||
{
|
||||
title: '等级价格设置',
|
||||
dataIndex: 'prices',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
fieldRender: () => (
|
||||
<Form.List name="prices">
|
||||
{(fields, { add, remove }) => (
|
||||
<div className="space-y-2">
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Space key={key} align="baseline" className="flex">
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'level_id']}
|
||||
rules={[{ required: true, message: '请选择等级' }]}
|
||||
className="!mb-0"
|
||||
>
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
placeholder="选择客户等级"
|
||||
options={levels.map((l) => ({ label: l.name, value: l.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'price']}
|
||||
rules={[{ required: true, message: '请输入单价' }]}
|
||||
className="!mb-0"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
placeholder="单价"
|
||||
className="w-32"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<MinusCircleOutlined />}
|
||||
onClick={() => remove(name)}
|
||||
/>
|
||||
</Space>
|
||||
))}
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => add()}
|
||||
>
|
||||
添加等级价格
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<IProduct> = {
|
||||
api: '/product/goods',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'product.goods',
|
||||
scroll: { x: 1200 },
|
||||
actionBarRender: (dom) => [
|
||||
dom.add,
|
||||
<Button key="matrix" icon={<TableOutlined />} onClick={openMatrix}>
|
||||
价格矩阵
|
||||
</Button>,
|
||||
dom.keywordSearch,
|
||||
],
|
||||
formProps: {
|
||||
grid: true,
|
||||
colProps: { span: 12 },
|
||||
layout: 'vertical',
|
||||
},
|
||||
modalProps: { width: 800 },
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>商品列表</Title>
|
||||
<Text type="secondary">
|
||||
商品档案与多等级价格体系;「价格矩阵」支持按等级批量调价,调价后自动通知受影响门店。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<IProduct> {...tableProps} />
|
||||
|
||||
<Drawer
|
||||
title="价格矩阵 · 批量调价"
|
||||
open={matrixOpen}
|
||||
onClose={() => setMatrixOpen(false)}
|
||||
width={matrixLevels.length * 150 + 260}
|
||||
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
|
||||
treeNodeFilterProp="name"
|
||||
fieldNames={{ label: 'name', value: 'id', children: 'children' }}
|
||||
treeData={categoryTree}
|
||||
value={matrixCategory}
|
||||
onChange={(v) => {
|
||||
setMatrixCategory(v);
|
||||
loadMatrix(matrixKeyword, v);
|
||||
}}
|
||||
/>
|
||||
<Input.Search
|
||||
style={{ width: 240 }}
|
||||
placeholder="搜索品名/规格"
|
||||
allowClear
|
||||
value={matrixKeyword}
|
||||
onChange={(e) => setMatrixKeyword(e.target.value)}
|
||||
onSearch={(v) => loadMatrix(v, matrixCategory)}
|
||||
/>
|
||||
</Space>
|
||||
<Table<IPriceMatrixRow>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={matrixLoading}
|
||||
columns={matrixColumns}
|
||||
dataSource={matrixRows}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
scroll={{ x: matrixLevels.length * 150 + 160 }}
|
||||
/>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductGoodsPage;
|
||||
Reference in New Issue
Block a user