WEB/src/pages/Product/List/index.tsx

808 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 {
productcontrollerBatchdeleteproduct,
productcontrollerBatchupdateproduct,
productcontrollerBindproductsiteskus,
productcontrollerDeleteproduct,
productcontrollerGetcategoriesall,
productcontrollerGetproductcomponents,
productcontrollerGetproductlist,
productcontrollerUpdatenamecn,
} from '@/servers/api/product';
import { sitecontrollerAll } from '@/servers/api/site';
import { siteapicontrollerGetproducts } from '@/servers/api/siteApi';
import {
wpproductcontrollerBatchsynctosite,
wpproductcontrollerSynctoproduct,
} from '@/servers/api/wpProduct';
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';
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<{ productId: number }> = ({ productId }) => {
const [components, setComponents] = React.useState<any[]>([]);
React.useEffect(() => {
(async () => {
const { data = [] } = await productcontrollerGetproductcomponents({
id: productId,
});
setComponents(data || []);
})();
}, [productId]);
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 SyncToSiteModal: React.FC<{
visible: boolean;
onClose: () => void;
productIds: number[];
productRows: API.Product[];
onSuccess: () => void;
}> = ({ visible, onClose, productIds, productRows, onSuccess }) => {
const { message } = App.useApp();
const [sites, setSites] = useState<any[]>([]);
const formRef = useRef<any>();
useEffect(() => {
if (visible) {
sitecontrollerAll().then((res: any) => {
setSites(res?.data || []);
});
}
}, [visible]);
return (
<ModalForm
title={`同步到站点 (${productIds.length} 项)`}
open={visible}
onOpenChange={(open) => !open && onClose()}
modalProps={{ destroyOnClose: true }}
formRef={formRef}
onValuesChange={(changedValues) => {
if ('siteId' in changedValues && changedValues.siteId) {
const siteId = changedValues.siteId;
const site = sites.find((s: any) => s.id === siteId) || {};
const prefix = site.skuPrefix || '';
const map: Record<string, any> = {};
productRows.forEach((p) => {
map[p.id] = {
code: `${prefix}${p.sku || ''}`,
quantity: undefined,
};
});
formRef.current?.setFieldsValue({ productSiteSkus: map });
}
}}
onFinish={async (values) => {
if (!values.siteId) return false;
try {
await wpproductcontrollerBatchsynctosite(
{ siteId: values.siteId },
{ productIds },
);
const map = values.productSiteSkus || {};
for (const currentProductId of productIds) {
const entry = map?.[currentProductId];
if (entry && entry.code) {
await productcontrollerBindproductsiteskus(
{ id: currentProductId },
{
siteSkus: [
{
siteId: values.siteId,
code: entry.code,
quantity: entry.quantity,
},
],
},
);
}
}
message.success('同步任务已提交');
onSuccess();
return true;
} catch (error: any) {
message.error(error.message || '同步失败');
return false;
}
}}
>
<ProFormSelect
name="siteId"
label="选择站点"
options={sites.map((site) => ({ label: site.name, value: site.id }))}
rules={[{ required: true, message: '请选择站点' }]}
/>
{productRows.map((row) => (
<div
key={row.id}
style={{ display: 'flex', gap: 12, alignItems: 'flex-end' }}
>
<div style={{ minWidth: 220 }}>SKU: {row.sku || '-'}</div>
<ProFormText
name={['productSiteSkus', row.id, 'code']}
label={`商品 ${row.id} 站点SKU`}
placeholder="请输入站点SKU"
/>
<ProFormText
name={['productSiteSkus', row.id, 'quantity']}
label="数量"
placeholder="请输入数量"
/>
</div>
))}
</ModalForm>
);
};
const WpProductInfo: React.FC<{
skus: string[];
record: API.Product;
parentTableRef: React.MutableRefObject<ActionType | undefined>;
}> = ({ skus, record, parentTableRef }) => {
const actionRef = useRef<ActionType>();
const { message } = App.useApp();
return (
<ProTable
headerTitle="站点产品信息"
actionRef={actionRef}
search={false}
options={false}
pagination={false}
toolBarRender={() => [
<Button
key="refresh"
type="primary"
onClick={() => actionRef.current?.reload()}
>
</Button>,
]}
request={async () => {
// 判断是否存在站点SKU列表
if (!skus || skus.length === 0) return { data: [] };
try {
// 获取所有站点列表用于遍历查询
const { data: siteResponse } = await sitecontrollerAll();
const siteList = siteResponse || [];
// 聚合所有站点的产品数据
const aggregatedProducts: any[] = [];
// 遍历每一个站点
for (const siteItem of siteList) {
// 遍历每一个SKU在当前站点进行搜索
for (const skuCode of skus) {
// 直接调用站点API根据搜索关键字获取产品列表
const response = await siteapicontrollerGetproducts({
siteId: Number(siteItem.id),
per_page: 100,
search: skuCode,
});
const productPage = response as any;
const siteProducts = productPage?.data?.items || [];
// 将站点信息附加到产品数据中便于展示
siteProducts.forEach((p: any) => {
aggregatedProducts.push({
...p,
siteId: siteItem.id,
siteName: siteItem.name,
});
});
}
}
return { data: aggregatedProducts, success: true };
} catch (error: any) {
// 请求失败进行错误提示
message.error(error?.message || '获取站点产品失败');
return { data: [], success: false };
}
}}
columns={[
{
title: '站点',
dataIndex: 'siteName',
},
{
title: 'SKU',
dataIndex: 'sku',
},
{
title: '价格',
dataIndex: 'regular_price',
render: (_, row) => (
<div>
<div>: {row.regular_price}</div>
<div>: {row.sale_price}</div>
</div>
),
},
{
title: '状态',
dataIndex: 'status',
},
{
title: '操作',
valueType: 'option',
render: (_, wpRow) => [
<a
key="syncToSite"
onClick={async () => {
try {
await wpproductcontrollerBatchsynctosite(
{ siteId: wpRow.siteId },
{ productIds: [record.id] },
);
message.success('同步到站点成功');
actionRef.current?.reload();
} catch (e: any) {
message.error(e.message || '同步失败');
}
}}
>
</a>,
<a
key="syncToProduct"
onClick={async () => {
try {
await wpproductcontrollerSynctoproduct({ id: wpRow.id });
message.success('同步进商品成功');
parentTableRef.current?.reload();
} catch (e: any) {
message.error(e.message || '同步失败');
}
}}
>
</a>,
<Popconfirm
key="delete"
title="删除"
description="确认删除?"
onConfirm={async () => {
try {
await request(`/wp_product/${wpRow.id}`, {
method: 'DELETE',
});
message.success('删除成功');
actionRef.current?.reload();
} catch (e: any) {
message.error(e.message || '删除失败');
}
}}
>
<a style={{ color: 'red' }}></a>
</Popconfirm>,
],
},
]}
/>
);
};
const List: React.FC = () => {
const actionRef = useRef<ActionType>();
// 状态:存储当前选中的行
const [selectedRows, setSelectedRows] = React.useState<API.Product[]>([]);
const [batchEditModalVisible, setBatchEditModalVisible] = useState(false);
const [syncModalVisible, setSyncModalVisible] = useState(false);
const [syncProductIds, setSyncProductIds] = useState<number[]>([]);
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: '商品SKU',
dataIndex: 'siteSkus',
render: (_, record) => (
<>
{record.siteSkus?.map((code, index) => (
<Tag key={index} color="cyan">
{code}
</Tag>
))}
</>
),
},
{
title: '名称',
dataIndex: 'name',
sorter: true,
},
{
title: '中文名',
dataIndex: 'nameCn',
render: (_, record) => {
return (
<NameCn value={record.nameCn} id={record.id} tableRef={actionRef} />
);
},
},
{
title: '商品类型',
dataIndex: 'category',
render: (_, record: any) => {
return record.category?.title || record.category?.name || '-';
},
},
{
title: '价格',
dataIndex: 'price',
hideInSearch: true,
sorter: true,
},
{
title: '促销价',
dataIndex: 'promotionPrice',
hideInSearch: true,
sorter: true,
},
{
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 productId={(record as any).id} />,
},
{
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={() => {
setSyncProductIds([record.id]);
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} />,
// 批量编辑按钮
<Button
disabled={selectedRows.length <= 0}
onClick={() => setBatchEditModalVisible(true)}
>
</Button>,
// 批量同步按钮
<Button
disabled={selectedRows.length <= 0}
onClick={() => {
setSyncProductIds(selectedRows.map((row) => row.id));
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>,
// 导入 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>CSV</Button>
</Upload>,
]}
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 { data, success } = await productcontrollerGetproductlist({
...params,
sortField,
sortOrder,
} as any);
return {
total: data?.total || 0,
data: data?.items || [],
success,
};
}}
columns={columns}
expandable={{
expandedRowRender: (record) => (
<WpProductInfo
skus={(record.siteSkus as string[]) || []}
record={record}
parentTableRef={actionRef}
/>
),
rowExpandable: (record) =>
!!(record.siteSkus && record.siteSkus.length > 0),
}}
editable={{
type: 'single',
onSave: async (key, record, originRow) => {
console.log('保存数据:', record);
},
}}
rowSelection={{
onChange: (_, selectedRows) => setSelectedRows(selectedRows),
}}
/>
<BatchEditModal
visible={batchEditModalVisible}
onClose={() => setBatchEditModalVisible(false)}
selectedRows={selectedRows}
tableRef={actionRef}
onSuccess={() => {
setBatchEditModalVisible(false);
setSelectedRows([]);
}}
/>
<SyncToSiteModal
visible={syncModalVisible}
onClose={() => setSyncModalVisible(false)}
productIds={syncProductIds}
productRows={selectedRows}
onSuccess={() => {
setSyncModalVisible(false);
setSelectedRows([]);
actionRef.current?.reload();
}}
/>
</PageContainer>
);
};
export default List;