商品列表优化

This commit is contained in:
liu
2026-08-05 22:02:32 +08:00
parent 258df92e6c
commit 64926e9899
5 changed files with 163 additions and 88 deletions
+2
View File
@@ -5,6 +5,8 @@ import type { IBatchPriceUpdate, IPriceMatrix } from '@/domain/iProduct.ts';
export interface PriceMatrixParams {
category_id?: number;
keyword?: string;
page?: number;
pageSize?: number;
}
/** A2 价格矩阵:行=商品,列=启用等级,值=price(缺失 null */
+1
View File
@@ -66,6 +66,7 @@ export type IPriceMatrixRow = {
export interface IPriceMatrix {
levels: { id: number; name: string }[];
rows: IPriceMatrixRow[];
total: number;
}
export interface IBatchPriceUpdate {
+152 -83
View File
@@ -1,24 +1,23 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import {
Button,
Button, Card,
Drawer,
Form, Image,
Input,
InputNumber,
message,
Select,
Space,
Table,
Tag,
Tag, Tree,
TreeSelect,
Typography,
} from 'antd';
import { MinusCircleOutlined, PlusOutlined, TableOutlined } from '@ant-design/icons';
import type { TableProps } from 'antd';
import { TableOutlined } from '@ant-design/icons';
import type { FormInstance, TableProps } from 'antd';
import XinTable from '@/components/XinTable';
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
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 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';
@@ -30,6 +29,53 @@ 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 价格矩阵与批量调价)
*/
@@ -38,15 +84,24 @@ const ProductGoodsPage: React.FC = () => {
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 [matrixSnapshot, setMatrixSnapshot] = 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 ?? []));
@@ -56,17 +111,33 @@ const ProductGoodsPage: React.FC = () => {
const loadMatrix = async (
keyword = matrixKeyword,
categoryId = matrixCategory
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 ?? [];
setMatrixRows(rows);
setMatrixSnapshot(JSON.parse(JSON.stringify(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);
@@ -75,7 +146,9 @@ const ProductGoodsPage: React.FC = () => {
const openMatrix = () => {
setMatrixOpen(true);
loadMatrix('', undefined);
setMatrixPage(1);
setMatrixPageSize(20);
loadMatrix('', undefined, 1, 20);
};
const onMatrixPriceChange = (
@@ -88,22 +161,17 @@ const ProductGoodsPage: React.FC = () => {
row.id === productId ? { ...row, [`price_${levelId}`]: value } : row
)
);
matrixDirtyRef.current[`${productId}:${levelId}`] = value;
};
/** diff 出被修改的价格行,提交批量调价 */
/** 提交所有跨页未保存的调价(null 视为清除,不提交) */
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 });
}
}
}
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;
@@ -112,6 +180,7 @@ const ProductGoodsPage: React.FC = () => {
try {
await batchPrice(updates);
message.success(`已更新 ${updates.length} 条价格,受影响门店将收到通知`);
matrixDirtyRef.current = {};
await loadMatrix();
} finally {
setSaveLoading(false);
@@ -200,6 +269,14 @@ const ProductGoodsPage: React.FC = () => {
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: '请输入商品名称' }],
},
{
@@ -240,6 +317,7 @@ const ProductGoodsPage: React.FC = () => {
treeNodeFilterProp: 'name',
placeholder: '选择分类',
},
hideInSearch: true,
render: (_, record) =>
record.category ? <Tag color="cyan">{record.category.name}</Tag> : '-',
},
@@ -322,58 +400,7 @@ const ProductGoodsPage: React.FC = () => {
hideInTable: true,
hideInSearch: true,
colProps: { span: 24 },
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>
),
fieldRender: (form) => <LevelPriceFields form={form} levels={levels} />,
},
{
title: '创建时间',
@@ -389,9 +416,11 @@ const ProductGoodsPage: React.FC = () => {
columns,
rowKey: 'id',
accessName: 'product.goods',
tableRef,
scroll: { x: 1200 },
actionBarRender: (dom) => [
dom.add,
dom.search,
<Button key="matrix" icon={<TableOutlined />} onClick={openMatrix}>
</Button>,
@@ -403,8 +432,13 @@ const ProductGoodsPage: React.FC = () => {
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]" },
},
};
@@ -417,13 +451,33 @@ const ProductGoodsPage: React.FC = () => {
</Text>
</div>
<XinTable<IProduct> {...tableProps} />
<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={800}
size={1200}
extra={
<Space>
<Button onClick={() => loadMatrix()}></Button>
@@ -439,13 +493,13 @@ const ProductGoodsPage: React.FC = () => {
placeholder="按分类筛选"
allowClear
treeDefaultExpandAll
treeNodeFilterProp="name"
fieldNames={{ label: 'name', value: 'id', children: 'children' }}
treeData={categoryTree}
value={matrixCategory}
onChange={(v) => {
setMatrixCategory(v);
loadMatrix(matrixKeyword, v);
setMatrixPage(1);
loadMatrix(matrixKeyword, v, 1, matrixPageSize);
}}
/>
<Input.Search
@@ -454,7 +508,10 @@ const ProductGoodsPage: React.FC = () => {
allowClear
value={matrixKeyword}
onChange={(e) => setMatrixKeyword(e.target.value)}
onSearch={(v) => loadMatrix(v, matrixCategory)}
onSearch={(v) => {
setMatrixPage(1);
loadMatrix(v, matrixCategory, 1, matrixPageSize);
}}
/>
</Space>
<Table<IPriceMatrixRow>
@@ -463,7 +520,19 @@ const ProductGoodsPage: React.FC = () => {
loading={matrixLoading}
columns={matrixColumns}
dataSource={matrixRows}
pagination={{ pageSize: 20, showSizeChanger: false }}
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>