544 lines
16 KiB
TypeScript
544 lines
16 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import {
|
|
Button, Card,
|
|
Drawer,
|
|
Form, Image,
|
|
Input,
|
|
InputNumber,
|
|
message,
|
|
Space,
|
|
Table,
|
|
Tag, Tree,
|
|
TreeSelect,
|
|
Typography,
|
|
} from 'antd';
|
|
import { TableOutlined } from '@ant-design/icons';
|
|
import type { FormInstance, TableProps } from 'antd';
|
|
import XinTable from '@/components/XinTable';
|
|
import type { XinTableColumn, XinTableInstance, XinTableProps } from '@/components/XinTable/typings.ts';
|
|
import type IProduct from '@/domain/iProduct.ts';
|
|
import type { IBatchPriceUpdate, IPriceMatrixRow, IProductPrice } 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 { 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;
|
|
|
|
/**
|
|
* 等级价格表单
|
|
*/
|
|
const LevelPriceFields: React.FC<{
|
|
form: FormInstance;
|
|
levels: ICustomerLevel[];
|
|
}> = ({ form, levels }) => {
|
|
const prices = Form.useWatch<IProductPrice[]>('prices', form) ?? [];
|
|
|
|
if (levels.length === 0) {
|
|
return (
|
|
<Text type="secondary">
|
|
暂无可配置的客户等级,请先到「客户等级」中新增
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
const setLevelPrice = (levelId: number, price: number | null) => {
|
|
const next = prices.filter((p) => p.level_id !== levelId);
|
|
if (price !== null) {
|
|
next.push({ level_id: levelId, price });
|
|
}
|
|
form.setFieldValue('prices', next);
|
|
};
|
|
|
|
return (
|
|
<Space wrap>
|
|
{levels.map((level) => {
|
|
if (level.id == null) return null;
|
|
const row = prices.find((p) => p.level_id === level.id);
|
|
return (
|
|
<InputNumber
|
|
min={0}
|
|
precision={2}
|
|
prefix={<span style={{ color: '#666' }}>{level.name}</span>}
|
|
suffix={'¥'}
|
|
placeholder="未设定"
|
|
value={(row?.price as number | null) ?? null}
|
|
onChange={(v) => setLevelPrice(level.id as number, v)}
|
|
style={{ width: 240 }}
|
|
/>
|
|
);
|
|
})}
|
|
</Space>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 商品档案管理(A1 商品列表 / A2 价格矩阵与批量调价)
|
|
*/
|
|
const ProductGoodsPage: React.FC = () => {
|
|
const [levels, setLevels] = useState<ICustomerLevel[]>([]);
|
|
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
|
const [categoryTree, setCategoryTree] = useState<IProductCategoryTree[]>([]);
|
|
|
|
// ===== 分类侧栏 =====
|
|
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}:${levelId}` → price(用 ref 避免异步闭包读到旧值) */
|
|
const matrixDirtyRef = useRef<Record<string, number | null>>({});
|
|
|
|
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,
|
|
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 dirty = matrixDirtyRef.current;
|
|
const merged = rows.map((row) => {
|
|
const next = { ...row };
|
|
Object.keys(dirty).forEach((key) => {
|
|
const [pid, lid] = key.split(':');
|
|
if (String(row.id) === pid) {
|
|
next[`price_${lid}`] = dirty[key];
|
|
}
|
|
});
|
|
return next;
|
|
});
|
|
setMatrixRows(merged);
|
|
setMatrixTotal(res.data.data?.total ?? 0);
|
|
setMatrixLevels(res.data.data?.levels ?? []);
|
|
} finally {
|
|
setMatrixLoading(false);
|
|
}
|
|
};
|
|
|
|
const openMatrix = () => {
|
|
setMatrixOpen(true);
|
|
setMatrixPage(1);
|
|
setMatrixPageSize(20);
|
|
loadMatrix('', undefined, 1, 20);
|
|
};
|
|
|
|
const onMatrixPriceChange = (
|
|
productId: number,
|
|
levelId: number,
|
|
value: number | null
|
|
) => {
|
|
setMatrixRows((prev) =>
|
|
prev.map((row) =>
|
|
row.id === productId ? { ...row, [`price_${levelId}`]: value } : row
|
|
)
|
|
);
|
|
matrixDirtyRef.current[`${productId}:${levelId}`] = value;
|
|
};
|
|
|
|
/** 提交所有跨页未保存的调价(null 视为清除,不提交) */
|
|
const saveMatrix = async () => {
|
|
const updates: IBatchPriceUpdate[] = [];
|
|
Object.entries(matrixDirtyRef.current).forEach(([key, price]) => {
|
|
if (price === null || price === undefined) return;
|
|
const [pid, lid] = key.split(':');
|
|
updates.push({ product_id: Number(pid), level_id: Number(lid), price });
|
|
});
|
|
if (updates.length === 0) {
|
|
message.info('没有需要保存的价格调整');
|
|
return;
|
|
}
|
|
setSaveLoading(true);
|
|
try {
|
|
await batchPrice(updates);
|
|
message.success(`已更新 ${updates.length} 条价格,受影响门店将收到通知`);
|
|
matrixDirtyRef.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>
|
|
),
|
|
},
|
|
...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: '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: 'spec',
|
|
valueType: 'text',
|
|
hideInSearch: true,
|
|
align: "center",
|
|
},
|
|
{
|
|
title: '单位',
|
|
dataIndex: 'unit',
|
|
valueType: 'text',
|
|
hideInSearch: true,
|
|
initialValue: '斤',
|
|
align: "center",
|
|
},
|
|
{
|
|
title: '分类',
|
|
dataIndex: 'category_id',
|
|
valueType: 'treeSelect',
|
|
required: true,
|
|
align: "center",
|
|
rules: [{ required: true, message: '请选择分类' }],
|
|
fieldProps: {
|
|
treeData: categoryTree,
|
|
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: 'content',
|
|
valueType: 'richText',
|
|
hideInSearch: true,
|
|
hideInTable: true,
|
|
colProps: { span: 24 },
|
|
fieldProps: {
|
|
height: 400,
|
|
groupId: 11,
|
|
placeholder: '输入商品图文详情,支持插入图片',
|
|
},
|
|
},
|
|
{
|
|
title: '等级价格',
|
|
dataIndex: 'prices',
|
|
hideInForm: true,
|
|
hideInSearch: true,
|
|
width: 370,
|
|
align: 'center',
|
|
render: (_, record) => (
|
|
<Space wrap>
|
|
{record.prices?.length
|
|
? record.prices.map((p) => (
|
|
<Tag key={p.id} color="geekblue">
|
|
{p.level?.name ?? `等级${p.level_id}`}
|
|
<span style={{color: 'red', marginLeft: 5 }}>¥{p.price ?? '未设定'}</span>
|
|
</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: 'prices',
|
|
hideInTable: true,
|
|
hideInSearch: true,
|
|
colProps: { span: 24 },
|
|
fieldRender: (form) => <LevelPriceFields form={form} levels={levels} />,
|
|
},
|
|
{
|
|
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>,
|
|
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">
|
|
商品档案与多等级价格体系;「价格矩阵」支持按等级批量调价,调价后自动通知受影响门店。
|
|
</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={categoryTree}
|
|
selectedKeys={activeCategory ? [activeCategory] : undefined}
|
|
fieldNames={{title: 'name', key: 'id', children: 'children'}}
|
|
/>
|
|
</Card>
|
|
<div className="min-w-0 flex-1">
|
|
<XinTable<IProduct> {...tableProps} />
|
|
</div>
|
|
</div>
|
|
|
|
<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);
|
|
}}
|
|
/>
|
|
</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 + 160 }}
|
|
/>
|
|
</Drawer>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default ProductGoodsPage;
|