diff --git a/src/pages/Inventory/Import/index.tsx b/src/pages/Inventory/Import/index.tsx
index d05981a..a3d9b11 100644
--- a/src/pages/Inventory/Import/index.tsx
+++ b/src/pages/Inventory/Import/index.tsx
@@ -5,6 +5,55 @@ import { Upload, Card, message, Button, Alert, Space } from 'antd';
const { Dragger } = Upload;
+const templateHeaders = [
+ '产品名称',
+ '规格型号',
+ '颜色',
+ '色号',
+ '产品码',
+ '销售价',
+ '批次号',
+ '纱线配比',
+ '经线克重(g/m)',
+ '纬线克重(g/m)',
+ '生产工艺',
+ '备注',
+];
+
+const templateSample = [
+ '示例布料',
+ '120cm',
+ '本色',
+ '01',
+ '',
+ '0',
+ '',
+ '{"warp":[],"weft":[]}',
+ '0',
+ '0',
+ '无',
+ '',
+];
+
+const escapeHtml = (value: string) =>
+ value.replace(/&/g, '&').replace(//g, '>');
+
+const downloadTemplate = () => {
+ const rows = [templateHeaders, templateSample]
+ .map((row) => `
${row.map((cell) => `| ${escapeHtml(cell)} | `).join('')}
`)
+ .join('');
+ const html = ``;
+ const blob = new Blob([html], { type: 'application/vnd.ms-excel;charset=utf-8' });
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = '产品导入模板.xls';
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ window.URL.revokeObjectURL(url);
+};
+
const ImportPage: React.FC = () => {
const token = localStorage.getItem('token');
@@ -14,15 +63,15 @@ const ImportPage: React.FC = () => {
- }>下载导入模板
+ } onClick={downloadTemplate}>下载导入模板
{
if (info.file.status === 'done') {
@@ -39,7 +88,7 @@ const ImportPage: React.FC = () => {
>
点击或拖拽文件到此处上传
- 支持 .xlsx、.xls、.csv 格式
+ 支持 .xlsx、.xls 格式
diff --git a/src/pages/Inventory/Products/index.test.tsx b/src/pages/Inventory/Products/index.test.tsx
index 7faf4c3..f6a80e5 100644
--- a/src/pages/Inventory/Products/index.test.tsx
+++ b/src/pages/Inventory/Products/index.test.tsx
@@ -9,6 +9,11 @@ import { smokeTest } from '../../../../test/smoke';
import Page from './index';
import { createProduct, getProductSummary } from '@/services/api';
+const clickDialogSubmit = async (user: ReturnType, dialog: HTMLElement) => {
+ const submitBtn = within(dialog).getByRole('button', { name: /确\s*定|确\s*认|提交|OK/i });
+ await user.click(submitBtn);
+};
+
describe('Inventory / Products page', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -56,13 +61,7 @@ describe('Inventory / Products page', () => {
const nameInput = within(dialog).getByRole('textbox', { name: /产品名称/ });
await user.type(nameInput, '测试产品');
- // Click the submit/OK button – find the non-close button in the dialog footer
- const buttons = within(dialog).getAllByRole('button');
- const submitBtn = buttons.find((b) => {
- const text = b.textContent?.trim() ?? '';
- return text && !text.includes('×') && !text.includes('✕') && !/取/.test(text) && !/重/.test(text) && !/Cancel/i.test(text);
- });
- if (submitBtn) await user.click(submitBtn);
+ await clickDialogSubmit(user, dialog);
await waitFor(() => {
expect(vi.mocked(createProduct)).toHaveBeenCalled();
@@ -133,12 +132,7 @@ describe('Inventory / Products page', () => {
const nameInput = within(dialog).getByRole('textbox', { name: /产品名称/ });
await user.type(nameInput, '新布料');
- const buttons = within(dialog).getAllByRole('button');
- const submitBtn = buttons.find((b) => {
- const text = b.textContent?.trim() ?? '';
- return text && !text.includes('×') && !text.includes('✕') && !/取/.test(text) && !/重/.test(text) && !/Cancel/i.test(text);
- });
- if (submitBtn) await user.click(submitBtn);
+ await clickDialogSubmit(user, dialog);
await waitFor(() => {
expect(vi.mocked(createProduct)).toHaveBeenCalled();
diff --git a/src/pages/Inventory/Products/index.tsx b/src/pages/Inventory/Products/index.tsx
index 41a5eea..0592014 100644
--- a/src/pages/Inventory/Products/index.tsx
+++ b/src/pages/Inventory/Products/index.tsx
@@ -1,6 +1,6 @@
import { PlusOutlined, UploadOutlined, DownloadOutlined, ArrowLeftOutlined } from '@ant-design/icons';
-import { ActionType, ModalForm, ProColumns, ProFormText, ProFormTextArea, ProTable } from '@ant-design/pro-components';
-import { Button, Card, Descriptions, Drawer, Input, InputNumber, List, message, Popconfirm, Space, Tag } from 'antd';
+import { ActionType, ModalForm, ProColumns, ProFormSelect, ProFormText, ProFormTextArea, ProTable } from '@ant-design/pro-components';
+import { Button, Card, Descriptions, Drawer, Input, InputNumber, List, message, Popconfirm, Segmented, Select, Space, Tag } from 'antd';
import { useRef, useState, useCallback } from 'react';
import {
getProductSummary,
@@ -9,9 +9,17 @@ import {
createProduct,
updateProduct,
deleteProduct,
+ exportProducts,
savePans,
+ listYarns,
+ createYarn,
+ listSuppliers,
+ listProductBatches,
+ createProductBatch,
+ updateProductBatch,
+ deleteProductBatch,
} from '@/services/api';
-import type { ProductSummaryItem, ColorDetailItem, ProductTree, PanInput } from '@/types/api';
+import type { ProductSummaryItem, ColorDetailItem, ProductTree, PanInput, ProductBatchInfo } from '@/types/api';
type ViewLevel = 'summary' | 'colors' | 'tree';
@@ -26,10 +34,39 @@ const ProductsPage: React.FC = () => {
const [editTreeDrawer, setEditTreeDrawer] = useState(false);
const [currentColor, setCurrentColor] = useState(null);
const [panInputs, setPanInputs] = useState([]);
+ const [batches, setBatches] = useState([]);
+ const [batchOpen, setBatchOpen] = useState(false);
+ const [currentBatch, setCurrentBatch] = useState(null);
+ const [yarnOpen, setYarnOpen] = useState(false);
+ const [unit, setUnit] = useState<'cm' | 'in'>('cm');
+
+ const formatCmSpec = (spec?: string) => {
+ if (!spec || unit === 'cm') return spec;
+ return spec.replace(/(\d+(?:\.\d+)?)\s*cm/gi, (_, n) => `${(Number(n) / 2.54).toFixed(2)}in`);
+ };
+
+ const handleExport = async () => {
+ try {
+ const data = await exportProducts();
+ const blob = data instanceof Blob ? data : new Blob([data as BlobPart]);
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `products-${Date.now()}.xlsx`;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ window.URL.revokeObjectURL(url);
+ } catch {
+ message.error('导出失败');
+ }
+ };
const loadTree = useCallback(async (productId: string) => {
const res = await getProductTree(productId);
if (res?.code === 0) {
+ const batchRes = await listProductBatches(productId);
+ setBatches(batchRes?.data?.list || []);
setTreeData(res.data);
setSelectedProductId(productId);
setViewLevel('tree');
@@ -39,7 +76,7 @@ const ProductsPage: React.FC = () => {
// ── Panel 1: Product Summary ──
const summaryColumns: ProColumns[] = [
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
- { title: '规格型号', dataIndex: 'spec', search: false },
+ { title: '规格型号', dataIndex: 'spec', search: false, render: (_, r) => formatCmSpec(r.spec) },
{ title: '颜色数', dataIndex: 'colorCount', search: false },
{ title: '总盘数', dataIndex: 'totalPanCount', search: false },
{ title: '总匹数', dataIndex: 'totalBoltCount', search: false },
@@ -57,7 +94,10 @@ const ProductsPage: React.FC = () => {
// ── Panel 2: Color Detail ──
const colorColumns: ProColumns[] = [
{ title: '颜色', dataIndex: 'color' },
- { title: '规格', dataIndex: 'spec', search: false },
+ { title: '色号', dataIndex: 'colorNo', search: false },
+ { title: '产品码', dataIndex: 'productCode', search: false, copyable: true },
+ { title: '规格', dataIndex: 'spec', search: false, render: (_, r) => formatCmSpec(r.spec) },
+ { title: '批次数', dataIndex: 'batchCount', search: false },
{ title: '盘数', dataIndex: 'panCount', search: false },
{ title: '匹数', dataIndex: 'boltCount', search: false },
{ title: '库存(m)', dataIndex: 'totalLengthM', search: false },
@@ -83,14 +123,48 @@ const ProductsPage: React.FC = () => {
return (
- {product.spec}
+ {product.productCode || '-'}
+ {product.colorNo || '-'}
+ {formatCmSpec(product.spec)}
{totalPanCount}
{totalBoltCount}
{totalLengthM}m
+
{ setCurrentBatch(null); setBatchOpen(true); }}>新增批次}>
+ (
+ { setCurrentBatch(batch); setBatchOpen(true); }}>编辑,
+ {
+ const res = await deleteProductBatch(selectedProductId, batch.batchId);
+ if (res?.code === 0) {
+ message.success('删除成功');
+ loadTree(selectedProductId);
+ }
+ }}
+ >
+ 删除
+ ,
+ ]}
+ >
+ {batch.batchNo}{Number(batch.warpWeightGM || 0) + Number(batch.weftWeightGM || 0)}g/m}
+ description={batch.productionProcess || '无'}
+ />
+
+ )}
+ />
+
{pans.map((pan) => (
- {pan.position || '未设置位置'}}>
+ {pan.batchNo || '未归属批次'}{pan.position || '未设置位置'}}>
{
setPanInputs(pans.map((p) => ({
name: p.name,
position: p.position,
+ batchId: p.batchId,
boltLengths: p.bolts.map((b) => b.lengthM),
})));
setEditTreeDrawer(true);
@@ -144,9 +219,10 @@ const ProductsPage: React.FC = () => {
return { data: res?.data?.list || [], success: res?.code === 0, total: res?.data?.list?.length || 0 };
}}
toolBarRender={() => [
+ setUnit(value as 'cm' | 'in')} />,
} onClick={() => setCreateOpen(true)}>新增产品,
} onClick={() => window.location.assign('/inventory/import')}>Excel导入,
- }>Excel导出,
+ } onClick={handleExport}>Excel导出,
]}
/>
)}
@@ -172,7 +248,19 @@ const ProductsPage: React.FC = () => {
open={createOpen}
onOpenChange={setCreateOpen}
onFinish={async (values) => {
- const res = await createProduct({ ...values, salesPrice: String(values.salesPrice || '0') });
+ const selectedYarnIds = values.selectedYarnIds || [];
+ const initialBatch = values.initialBatch || {};
+ const yarnRatio = initialBatch.yarnRatio || (selectedYarnIds.length ? JSON.stringify({
+ warp: selectedYarnIds.map((yarnId: string) => ({ yarn_id: yarnId, ratio: 1 })),
+ weft: [],
+ }) : '');
+ const productValues = { ...values };
+ delete productValues.selectedYarnIds;
+ const res = await createProduct({
+ ...productValues,
+ salesPrice: String(values.salesPrice || '0'),
+ initialBatch: { ...initialBatch, yarnRatio },
+ });
if (res?.code === 0) { message.success('创建成功'); summaryRef.current?.reload(); return true; }
message.error(res?.msg || '创建失败');
return false;
@@ -180,8 +268,23 @@ const ProductsPage: React.FC = () => {
>
-
+
+
+ {
+ const res = await listYarns({ page: 1, pageSize: 50, yarnName: keyWords });
+ return (res?.data?.list || []).map((y) => ({ label: `${y.yarnName} / ${y.color}`, value: y.yarnId }));
+ }}
+ fieldProps={{ showSearch: true, mode: 'multiple' }}
+ />
+
+
+
+
+
@@ -194,6 +297,8 @@ const ProductsPage: React.FC = () => {
productName: currentColor.productName,
spec: currentColor.spec,
color: currentColor.color,
+ colorNo: currentColor.colorNo,
+ productCode: currentColor.productCode,
salesPrice: currentColor.salesPrice,
} : undefined}
onFinish={async (values) => {
@@ -205,12 +310,71 @@ const ProductsPage: React.FC = () => {
}}
>
+
+
+ {
+ const res = currentBatch
+ ? await updateProductBatch(selectedProductId, currentBatch.batchId, values)
+ : await createProductBatch(selectedProductId, values);
+ if (res?.code === 0) {
+ message.success('保存成功');
+ loadTree(selectedProductId);
+ return true;
+ }
+ message.error(res?.msg || '保存失败');
+ return false;
+ }}
+ >
+ {currentBatch && }
+
+
+
+
+
+
+
+ {
+ const res = await createYarn(values);
+ if (res?.code === 0) {
+ message.success('创建成功');
+ return true;
+ }
+ message.error(res?.msg || '创建失败');
+ return false;
+ }}
+ >
+
+
+
+ {
+ const res = await listSuppliers({ page: 1, pageSize: 50, supplierName: keyWords, status: 1 });
+ return (res?.data?.list || []).map((supplier) => ({ label: supplier.supplierName, value: supplier.supplierId }));
+ }}
+ fieldProps={{ showSearch: true }}
+ />
+
+
+
+
{/* Edit Pans/Bolts Drawer */}
{
}}
style={{ width: 120 }}
/>
+