forked from yoone/WEB
586 lines
17 KiB
TypeScript
586 lines
17 KiB
TypeScript
import {
|
||
productcontrollerBatchdeleteproduct,
|
||
productcontrollerBatchupdateproduct,
|
||
productcontrollerDeleteproduct,
|
||
productcontrollerGetcategoriesall,
|
||
productcontrollerGetproductlist,
|
||
productcontrollerUpdatenamecn,
|
||
} from '@/servers/api/product';
|
||
import {
|
||
ActionType,
|
||
ModalForm,
|
||
PageContainer,
|
||
ProColumns,
|
||
ProFormSelect,
|
||
ProFormText,
|
||
ProTable,
|
||
} from '@ant-design/pro-components';
|
||
import { request } from '@umijs/max';
|
||
import { App, Button, Modal, Popconfirm, Tag, Upload } from 'antd';
|
||
import React, { useEffect, useRef, useState } from 'react';
|
||
import CreateForm from './CreateForm';
|
||
import EditForm from './EditForm';
|
||
import SyncToSiteModal from './SyncToSiteModal';
|
||
|
||
const NameCn: React.FC<{
|
||
id: number;
|
||
value: string | undefined;
|
||
tableRef: React.MutableRefObject<ActionType | undefined>;
|
||
}> = ({ value, tableRef, id }) => {
|
||
const { message } = App.useApp();
|
||
const [editable, setEditable] = React.useState<boolean>(false);
|
||
if (!editable)
|
||
return <div onClick={() => setEditable(true)}>{value || '-'}</div>;
|
||
return (
|
||
<ProFormText
|
||
initialValue={value}
|
||
fieldProps={{
|
||
autoFocus: true,
|
||
onBlur: async (e: React.FocusEvent<HTMLInputElement>) => {
|
||
if (!e.target.value) return setEditable(false);
|
||
const { success, message: errMsg } =
|
||
await productcontrollerUpdatenamecn({
|
||
id,
|
||
nameCn: e.target.value,
|
||
});
|
||
setEditable(false);
|
||
if (!success) {
|
||
return message.error(errMsg);
|
||
}
|
||
tableRef?.current?.reloadAndRest?.();
|
||
},
|
||
}}
|
||
/>
|
||
);
|
||
};
|
||
|
||
const AttributesCell: React.FC<{ record: any }> = ({ record }) => {
|
||
return (
|
||
<div>
|
||
{(record.attributes || []).map((data: any, idx: number) => (
|
||
<Tag key={idx} color="purple" style={{ marginBottom: 4 }}>
|
||
{data?.dict?.name}: {data.name}
|
||
</Tag>
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ComponentsCell: React.FC<{ components?: any[] }> = ({ components }) => {
|
||
return (
|
||
<div>
|
||
{components && components.length ? (
|
||
components.map((component: any) => (
|
||
<Tag key={component.id} color="blue" style={{ marginBottom: 4 }}>
|
||
{component.sku || `#${component.id}`} × {component.quantity}
|
||
(库存:
|
||
{component.stock
|
||
?.map((s: any) => `${s.name}:${s.quantity}`)
|
||
.join(', ') || '-'}
|
||
)
|
||
</Tag>
|
||
))
|
||
) : (
|
||
<span>-</span>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const BatchEditModal: React.FC<{
|
||
visible: boolean;
|
||
onClose: () => void;
|
||
selectedRows: API.Product[];
|
||
tableRef: React.MutableRefObject<ActionType | undefined>;
|
||
onSuccess: () => void;
|
||
}> = ({ visible, onClose, selectedRows, tableRef, onSuccess }) => {
|
||
const { message } = App.useApp();
|
||
const [categories, setCategories] = useState<any[]>([]);
|
||
|
||
useEffect(() => {
|
||
if (visible) {
|
||
productcontrollerGetcategoriesall().then((res: any) => {
|
||
setCategories(res?.data || []);
|
||
});
|
||
}
|
||
}, [visible]);
|
||
|
||
return (
|
||
<ModalForm
|
||
title={`批量修改 (${selectedRows.length} 项)`}
|
||
open={visible}
|
||
onOpenChange={(open) => !open && onClose()}
|
||
modalProps={{ destroyOnClose: true }}
|
||
onFinish={async (values) => {
|
||
const ids = selectedRows.map((row) => row.id);
|
||
const updateData: any = { ids };
|
||
// 只有当用户输入了值才进行更新
|
||
if (values.price) updateData.price = Number(values.price);
|
||
if (values.promotionPrice)
|
||
updateData.promotionPrice = Number(values.promotionPrice);
|
||
if (values.categoryId) updateData.categoryId = values.categoryId;
|
||
|
||
if (Object.keys(updateData).length <= 1) {
|
||
message.warning('未修改任何属性');
|
||
return false;
|
||
}
|
||
|
||
const { success, message: errMsg } =
|
||
await productcontrollerBatchupdateproduct(updateData);
|
||
if (success) {
|
||
message.success('批量修改成功');
|
||
onSuccess();
|
||
tableRef.current?.reload();
|
||
return true;
|
||
} else {
|
||
message.error(errMsg);
|
||
return false;
|
||
}
|
||
}}
|
||
>
|
||
<ProFormText name="price" label="价格" placeholder="不修改请留空" />
|
||
<ProFormText
|
||
name="promotionPrice"
|
||
label="促销价格"
|
||
placeholder="不修改请留空"
|
||
/>
|
||
<ProFormSelect
|
||
name="categoryId"
|
||
label="分类"
|
||
options={categories.map((c) => ({ label: c.title, value: c.id }))}
|
||
placeholder="不修改请留空"
|
||
/>
|
||
</ModalForm>
|
||
);
|
||
};
|
||
const ProductList = ({
|
||
filter,
|
||
columns,
|
||
}: {
|
||
filter: { skus: string[] };
|
||
columns: any[];
|
||
}) => {
|
||
return (
|
||
<ProTable
|
||
request={async (pag) => {
|
||
const { data, success } = await productcontrollerGetproductlist({
|
||
where: filter,
|
||
});
|
||
if (!success) return [];
|
||
return data || [];
|
||
}}
|
||
columns={columns}
|
||
pagination={false}
|
||
rowKey="id"
|
||
bordered
|
||
size="small"
|
||
scroll={{ x: 'max-content' }}
|
||
headerTitle={null}
|
||
toolBarRender={false}
|
||
/>
|
||
);
|
||
};
|
||
|
||
const List: React.FC = () => {
|
||
const actionRef = useRef<ActionType>();
|
||
// 状态:存储当前选中的行
|
||
const [selectedRows, setSelectedRows] = React.useState<API.Product[]>([]);
|
||
const [batchEditModalVisible, setBatchEditModalVisible] = useState(false);
|
||
const [syncProducts, setSyncProducts] = useState<API.Product[]>([]);
|
||
const [syncModalVisible, setSyncModalVisible] = useState(false);
|
||
|
||
const { message } = App.useApp();
|
||
// 导出产品 CSV(带认证请求)
|
||
const handleDownloadProductsCSV = async () => {
|
||
try {
|
||
// 发起认证请求获取 CSV Blob
|
||
const blob = await request('/product/export', { responseType: 'blob' });
|
||
// 构建下载文件名
|
||
const d = new Date();
|
||
const pad = (n: number) => String(n).padStart(2, '0');
|
||
const filename = `products-${d.getFullYear()}${pad(
|
||
d.getMonth() + 1,
|
||
)}${pad(d.getDate())}.csv`;
|
||
// 创建临时链接并触发下载
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
window.URL.revokeObjectURL(url);
|
||
} catch (error) {
|
||
message.error('导出失败');
|
||
}
|
||
};
|
||
const columns: ProColumns<API.Product>[] = [
|
||
{
|
||
title: 'sku',
|
||
dataIndex: 'sku',
|
||
sorter: true,
|
||
},
|
||
{
|
||
title: '关联商品',
|
||
dataIndex: 'siteSkus',
|
||
width: 200,
|
||
render: (_, record) => (
|
||
<>
|
||
{record.siteSkus?.map((siteSku, index) => (
|
||
<Tag key={index} color="cyan">
|
||
{siteSku}
|
||
</Tag>
|
||
))}
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
title: '图片',
|
||
dataIndex: 'image',
|
||
width: 100,
|
||
valueType: 'image',
|
||
},
|
||
{
|
||
title: '名称',
|
||
dataIndex: 'name',
|
||
sorter: true,
|
||
},
|
||
{
|
||
title: '中文名',
|
||
dataIndex: 'nameCn',
|
||
render: (_, record) => {
|
||
return (
|
||
<NameCn value={record.nameCn} id={record.id} tableRef={actionRef} />
|
||
);
|
||
},
|
||
},
|
||
|
||
{
|
||
title: '价格',
|
||
dataIndex: 'price',
|
||
hideInSearch: true,
|
||
sorter: true,
|
||
},
|
||
{
|
||
title: '促销价',
|
||
dataIndex: 'promotionPrice',
|
||
hideInSearch: true,
|
||
sorter: true,
|
||
},
|
||
{
|
||
title: '商品类型',
|
||
dataIndex: 'category',
|
||
render: (_, record: any) => {
|
||
return record.category?.title || record.category?.name || '-';
|
||
},
|
||
},
|
||
{
|
||
title: '属性',
|
||
dataIndex: 'attributes',
|
||
hideInSearch: true,
|
||
render: (_, record) => <AttributesCell record={record} />,
|
||
},
|
||
{
|
||
title: '产品类型',
|
||
dataIndex: 'type',
|
||
valueType: 'select',
|
||
valueEnum: {
|
||
single: { text: '单品' },
|
||
bundle: { text: '套装' },
|
||
},
|
||
render: (_, record) => {
|
||
// 如果类型不存在,则返回-
|
||
if (!record.type) return '-';
|
||
// 判断是否为单品
|
||
const isSingle = record.type === 'single';
|
||
// 根据类型显示不同颜色的标签
|
||
return (
|
||
<Tag color={isSingle ? 'green' : 'orange'}>
|
||
{isSingle ? '单品' : '套装'}
|
||
</Tag>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '构成',
|
||
dataIndex: 'components',
|
||
hideInSearch: true,
|
||
render: (_, record) => <ComponentsCell components={record.components} />,
|
||
},
|
||
|
||
{
|
||
title: '描述',
|
||
dataIndex: 'description',
|
||
hideInSearch: true,
|
||
},
|
||
{
|
||
title: '更新时间',
|
||
dataIndex: 'updatedAt',
|
||
valueType: 'dateTime',
|
||
hideInSearch: true,
|
||
sorter: true,
|
||
},
|
||
{
|
||
title: '创建时间',
|
||
dataIndex: 'createdAt',
|
||
valueType: 'dateTime',
|
||
hideInSearch: true,
|
||
sorter: true,
|
||
},
|
||
{
|
||
title: '操作',
|
||
dataIndex: 'option',
|
||
valueType: 'option',
|
||
fixed: 'right',
|
||
render: (_, record) => (
|
||
<>
|
||
<EditForm record={record} tableRef={actionRef} />
|
||
<Button
|
||
type="link"
|
||
onClick={() => {
|
||
setSyncProducts([record]);
|
||
setSyncModalVisible(true);
|
||
}}
|
||
>
|
||
同步到站点
|
||
</Button>
|
||
<Popconfirm
|
||
title="删除"
|
||
description="确认删除?"
|
||
onConfirm={async () => {
|
||
try {
|
||
const { success, message: errMsg } =
|
||
await productcontrollerDeleteproduct({ id: record.id });
|
||
if (!success) {
|
||
throw new Error(errMsg);
|
||
}
|
||
actionRef.current?.reload();
|
||
} catch (error: any) {
|
||
message.error(error.message);
|
||
}
|
||
}}
|
||
>
|
||
<Button type="link" danger>
|
||
删除
|
||
</Button>
|
||
</Popconfirm>
|
||
</>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<PageContainer header={{ title: '产品列表' }}>
|
||
<ProTable<API.Product>
|
||
scroll={{ x: 'max-content' }}
|
||
headerTitle="查询表格"
|
||
actionRef={actionRef}
|
||
rowKey="id"
|
||
toolBarRender={() => [
|
||
// 新建按钮
|
||
<CreateForm tableRef={actionRef} />,
|
||
// 导入 CSV(使用 customRequest 以支持 request 拦截器和鉴权)
|
||
<Upload
|
||
name="file"
|
||
accept=".csv"
|
||
showUploadList={false}
|
||
maxCount={1}
|
||
customRequest={async (options) => {
|
||
const { file, onSuccess, onError } = options;
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
try {
|
||
const res = await request('/product/import', {
|
||
method: 'POST',
|
||
data: formData,
|
||
requestType: 'form',
|
||
});
|
||
|
||
const {
|
||
created = 0,
|
||
updated = 0,
|
||
errors = [],
|
||
} = res.data || {};
|
||
|
||
if (errors && errors.length > 0) {
|
||
Modal.warning({
|
||
title: '导入结果 (存在错误)',
|
||
width: 600,
|
||
content: (
|
||
<div>
|
||
<p>创建成功: {created}</p>
|
||
<p>更新成功: {updated}</p>
|
||
<p style={{ color: 'red', fontWeight: 'bold' }}>
|
||
失败数量: {errors.length}
|
||
</p>
|
||
<div
|
||
style={{
|
||
maxHeight: '300px',
|
||
overflowY: 'auto',
|
||
background: '#f5f5f5',
|
||
padding: '8px',
|
||
marginTop: '8px',
|
||
borderRadius: '4px',
|
||
border: '1px solid #d9d9d9',
|
||
}}
|
||
>
|
||
{errors.map((err: string, idx: number) => (
|
||
<div
|
||
key={idx}
|
||
style={{
|
||
fontSize: '12px',
|
||
marginBottom: '4px',
|
||
borderBottom: '1px solid #e8e8e8',
|
||
paddingBottom: '2px',
|
||
color: '#ff4d4f',
|
||
}}
|
||
>
|
||
{idx + 1}. {err}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
),
|
||
});
|
||
} else {
|
||
message.success(`导入成功: 创建 ${created}, 更新 ${updated}`);
|
||
}
|
||
|
||
onSuccess?.('ok');
|
||
actionRef.current?.reload();
|
||
} catch (error: any) {
|
||
message.error('导入失败: ' + (error.message || '未知错误'));
|
||
onError?.(error);
|
||
}
|
||
}}
|
||
>
|
||
<Button>批量导入</Button>
|
||
</Upload>,
|
||
// 批量编辑按钮
|
||
<Button
|
||
disabled={selectedRows.length <= 0}
|
||
onClick={() => setBatchEditModalVisible(true)}
|
||
>
|
||
批量修改
|
||
</Button>,
|
||
// 批量同步按钮
|
||
<Button
|
||
disabled={selectedRows.length <= 0}
|
||
onClick={() => {
|
||
setSyncProducts(selectedRows);
|
||
setSyncModalVisible(true);
|
||
}}
|
||
>
|
||
批量同步到站点
|
||
</Button>,
|
||
// 批量删除按钮
|
||
<Button
|
||
danger
|
||
disabled={selectedRows.length <= 0}
|
||
onClick={() => {
|
||
Modal.confirm({
|
||
title: '确认删除',
|
||
content: `确定要删除选中的 ${selectedRows.length} 个产品吗?此操作不可恢复。`,
|
||
onOk: async () => {
|
||
try {
|
||
const { success, message: errMsg } =
|
||
await productcontrollerBatchdeleteproduct({
|
||
ids: selectedRows.map((row) => row.id),
|
||
});
|
||
if (success) {
|
||
message.success('批量删除成功');
|
||
setSelectedRows([]);
|
||
actionRef.current?.reload();
|
||
} else {
|
||
message.error(errMsg || '删除失败');
|
||
}
|
||
} catch (error: any) {
|
||
message.error(error.message || '删除失败');
|
||
}
|
||
},
|
||
});
|
||
}}
|
||
>
|
||
批量删除
|
||
</Button>,
|
||
// 导出 CSV(后端返回 text/csv,直接新窗口下载)
|
||
<Button onClick={handleDownloadProductsCSV}>导出CSV</Button>,
|
||
]}
|
||
request={async (params, sort) => {
|
||
let sortField = undefined;
|
||
let sortOrder = undefined;
|
||
|
||
if (sort && Object.keys(sort).length > 0) {
|
||
const field = Object.keys(sort)[0];
|
||
sortField = field;
|
||
sortOrder = sort[field];
|
||
}
|
||
const { current, pageSize, ...where } = params;
|
||
console.log(`params`, params);
|
||
const { data, success } = await productcontrollerGetproductlist({
|
||
where,
|
||
page: current || 1,
|
||
per_page: pageSize || 10,
|
||
sortField,
|
||
sortOrder,
|
||
} as any);
|
||
return {
|
||
total: data?.total || 0,
|
||
data: data?.items || [],
|
||
success,
|
||
};
|
||
}}
|
||
columns={columns}
|
||
// expandable={{
|
||
// expandedRowRender: (record) => {
|
||
// return <ProductList filter={{
|
||
// skus: record.components?.map(component => component.sku) || [],
|
||
// }}
|
||
// columns={columns}
|
||
// ></ProductList>
|
||
// }
|
||
// ,
|
||
// rowExpandable: (record) =>
|
||
// !!(record.type==='bundle'),
|
||
// }}
|
||
editable={{
|
||
type: 'single',
|
||
onSave: async (key, record, originRow) => {
|
||
console.log('保存数据:', record);
|
||
},
|
||
}}
|
||
rowSelection={{
|
||
onChange: (_, selectedRows) => setSelectedRows(selectedRows),
|
||
}}
|
||
pagination={{
|
||
showSizeChanger: true,
|
||
showQuickJumper: true,
|
||
pageSizeOptions: ['10', '20', '50', '100', '1000', '2000'],
|
||
}}
|
||
/>
|
||
<BatchEditModal
|
||
visible={batchEditModalVisible}
|
||
onClose={() => setBatchEditModalVisible(false)}
|
||
selectedRows={selectedRows}
|
||
tableRef={actionRef}
|
||
onSuccess={() => {
|
||
setBatchEditModalVisible(false);
|
||
setSelectedRows([]);
|
||
}}
|
||
/>
|
||
<SyncToSiteModal
|
||
visible={syncModalVisible}
|
||
onClose={() => setSyncModalVisible(false)}
|
||
products={syncProducts}
|
||
onSuccess={() => {
|
||
setSyncModalVisible(false);
|
||
setSelectedRows([]);
|
||
actionRef.current?.reload();
|
||
}}
|
||
/>
|
||
</PageContainer>
|
||
);
|
||
};
|
||
|
||
export default List;
|