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

728 lines
24 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, useRef, useState } from 'react';
import {
Button, Card,
Drawer,
Form, Image,
Input,
InputNumber,
message,
Select,
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;
/** 计价类型:0 固定价 / 1 成本百分比(与后端 ProductPriceModel::PRICE_TYPE_* 一致) */
const PRICE_TYPE_FIXED = 0;
const PRICE_TYPE_PERCENT = 1;
const PRICE_TYPE_OPTIONS = [
{ value: PRICE_TYPE_FIXED, label: '固定价' },
{ value: PRICE_TYPE_PERCENT, label: '成本百分比' },
];
/** 四舍五入保留两位 */
const round2 = (v: number) => Math.round(v * 100) / 100;
/**
* 等级价格表单:每个等级 = 计价类型(固定价/成本百分比)+ 对应输入框;
* 切换计价类型时按成本价自动换算(固定→百分比:percent=(price/cost-1)*100;百分比→固定:price=cost*(1+percent/100)
*/
const LevelPriceFields: React.FC<{
form: FormInstance;
levels: ICustomerLevel[];
}> = ({ form, levels }) => {
const prices = Form.useWatch<IProductPrice[]>('prices', form) ?? [];
const costPrice = Number(Form.useWatch<string | number | undefined>('cost_price', form) ?? 0);
if (levels.length === 0) {
return (
<Text type="secondary">
暂无可配置的客户等级,请先到「客户等级」中新增
</Text>
);
}
const updateRow = (levelId: number, patch: Partial<IProductPrice>) => {
const next = prices.filter((p) => p.level_id !== levelId);
next.push({ ...patch, level_id: levelId });
form.setFieldValue('prices', next);
};
const removeRow = (levelId: number) => {
form.setFieldValue('prices', prices.filter((p) => p.level_id !== levelId));
};
/** 切换计价类型:按成本价自动换算(成本未设置时百分比计价不可用) */
const onTypeChange = (levelId: number, row: IProductPrice | undefined, type: number) => {
if (type === PRICE_TYPE_PERCENT) {
if (!(costPrice > 0)) {
message.warning('请先在商品信息中设置成本价,才能按成本百分比计价');
return; // Select 为受控组件,未更新表单值即回弹
}
const price = Number(row?.price ?? 0);
updateRow(levelId, {
price_type: type,
percent: price > 0 ? round2((price / costPrice - 1) * 100) : null,
});
} else {
const percent = Number(row?.percent ?? 0);
updateRow(levelId, {
price_type: type,
price: costPrice > 0 && percent > 0 ? round2(costPrice * (1 + percent / 100)) : (row?.price ?? null),
});
}
};
return (
<Space wrap>
{levels.map((level) => {
if (level.id == null) return null;
const row = prices.find((p) => p.level_id === level.id);
const type = row?.price_type ?? PRICE_TYPE_FIXED;
return (
<Space key={level.id} wrap={false}>
<Text style={{ width: 60, display: 'inline-block', textAlign: 'right' }}>{level.name}</Text>
<Select
size="middle"
style={{ width: 110 }}
value={type}
options={PRICE_TYPE_OPTIONS}
onChange={(v) => onTypeChange(level.id as number, row, v as number)}
/>
{type === PRICE_TYPE_PERCENT ? (
<InputNumber
min={0}
precision={0}
suffix={'%'}
placeholder="上浮百分点"
value={(row?.percent as number | null) ?? null}
onChange={(v) => {
if (v === null) {
removeRow(level.id as number);
return;
}
// 同步维护等价固定价 price,保证提交给后端的 price 与 percent 一致
updateRow(level.id as number, {
price_type: PRICE_TYPE_PERCENT,
percent: v,
price: costPrice > 0 ? round2(costPrice * (1 + v / 100)) : row?.price,
});
}}
style={{ width: 160 }}
/>
) : (
<InputNumber
min={0}
precision={2}
suffix={'¥'}
placeholder="未设定"
value={(row?.price as number | null) ?? null}
onChange={(v) => {
if (v === null) {
removeRow(level.id as number);
return;
}
updateRow(level.id as number, { price_type: PRICE_TYPE_FIXED, price: v });
}}
style={{ width: 160 }}
/>
)}
</Space>
);
})}
</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, price_type }(用 ref 避免异步闭包读到旧值) */
const matrixDirtyRef = useRef<Record<string, { price: number | null; price_type: number }>>({});
/** 跨页未保存的成本价:productId → costnull 视为未修改,不提交) */
const costDirtyRef = useRef<Record<number, number | null>>({});
/** 服务端原始成本价:productId → costloadMatrix 填充;跨页百分比行保存时反算 percent 用) */
const serverCostRef = useRef<Record<number, number>>({});
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 ?? [];
// 服务端原始成本价快照(供跨页百分比行保存时反算 percent)
rows.forEach((row) => {
serverCostRef.current[row.id] = Number(row.cost_price ?? 0);
});
// 叠加跨页未保存的修改,保证翻页后输入值不回退
const merged = rows.map((row) => {
const next = { ...row };
// 成本价修改
if (costDirtyRef.current[row.id] !== undefined) {
next.cost_price = costDirtyRef.current[row.id];
}
// 等级格修改(快照含 price_type
Object.entries(matrixDirtyRef.current).forEach(([key, snap]) => {
const [pid, lid] = key.split(':');
if (String(row.id) === pid) {
next[`price_${lid}`] = snap.price;
}
});
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) => {
if (row.id !== productId) return row;
// 快照该格的计价类型(读当前行数据,避免闭包旧 state)
const type = Number(row[`price_type_${levelId}`] ?? PRICE_TYPE_FIXED);
matrixDirtyRef.current[`${productId}:${levelId}`] = { price: value, price_type: type };
return { ...row, [`price_${levelId}`]: value };
})
);
};
/** 修改成本价:联动重算「未被手工修改过」的百分比格显示值(percent 取服务端快照) */
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);
Object.keys(next).forEach((key) => {
if (!key.startsWith('percent_')) return;
const lid = key.replace('percent_', '');
if (matrixDirtyRef.current[`${productId}:${lid}`]) return; // 已手工改过,不覆盖
if (Number(next[`price_type_${lid}`]) !== PRICE_TYPE_PERCENT) return;
const percent = Number(next[key] ?? 0);
next[`price_${lid}`] = cost > 0 ? round2(cost * (1 + percent / 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 });
});
// 等级价格行(按快照计价类型组装:百分比格按「当前成本价」反算上浮百分点)
for (const [key, snap] of Object.entries(matrixDirtyRef.current)) {
if (snap.price === null || snap.price === undefined) continue;
const [pid, lid] = key.split(':');
const productId = Number(pid);
if (snap.price_type === PRICE_TYPE_PERCENT) {
const cost = costDirtyRef.current[productId] ?? serverCostRef.current[productId] ?? 0;
if (!(cost > 0)) {
message.error(`商品 #${productId} 未设置成本价,无法保存百分比价格`);
return;
}
updates.push({
product_id: productId,
level_id: Number(lid),
price_type: PRICE_TYPE_PERCENT,
percent: round2((snap.price / cost - 1) * 100),
price: snap.price, // 等价固定价
});
} else {
updates.push({
product_id: productId,
level_id: Number(lid),
price_type: PRICE_TYPE_FIXED,
price: snap.price,
});
}
}
if (updates.length === 0) {
message.info('没有需要保存的价格调整');
return;
}
setSaveLoading(true);
try {
await batchPrice(updates);
message.success(`已更新 ${updates.length} 条价格,受影响门店将收到通知`);
matrixDirtyRef.current = {};
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 isPercent = Number(row[`price_type_${level.id}`] ?? PRICE_TYPE_FIXED) === PRICE_TYPE_PERCENT;
const percent = Number(row[`percent_${level.id}`] ?? 0);
return (
<div>
<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"
/>
{isPercent && (
<div className="text-xs text-gray-400">成本上浮 {percent}%</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: '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: 'cost_price',
valueType: 'digit',
hideInSearch: true,
align: 'center',
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: 'prices',
hideInForm: true,
hideInSearch: true,
width: 420,
align: 'center',
render: (_, record) => (
<Space wrap>
{record.prices?.length
? record.prices.map((p) => (
<Tag
key={p.id}
color="geekblue"
title={Number(p.price_type) === PRICE_TYPE_PERCENT ? `按成本价上浮 ${p.percent}%` : undefined}
>
{p.level?.name ?? `等级${p.level_id}`}
<span style={{color: 'red', marginLeft: 5 }}>
¥{p.actual_price ?? 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 + 290 }}
/>
</Drawer>
</>
);
};
export default ProductGoodsPage;