From 6e3ee00a6a3de1ea3334a06ebca8be1412997cad Mon Sep 17 00:00:00 2001 From: kae Date: Sat, 4 Jul 2026 18:38:50 +0900 Subject: [PATCH 1/7] feat: support product batch inventory UI --- src/pages/Inventory/Import/index.tsx | 57 +++++- src/pages/Inventory/Products/index.test.tsx | 20 +- src/pages/Inventory/Products/index.tsx | 195 +++++++++++++++++++- src/pages/Login/index.tsx | 58 +++--- src/services/__mocks__/api.ts | 8 + src/services/api.ts | 31 +++- src/types/api.ts | 42 +++++ 7 files changed, 358 insertions(+), 53 deletions(-) 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 = `${rows}
`; + 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 = () => { - + { 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')} />, , , - , + , ]} /> )} @@ -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 }} /> + loadYarnOptions()} + onChange={(value) => handleSelectYarn(side, value)} + /> + + + rowKey="key" + size="small" + pagination={false} + dataSource={rows} + locale={{ emptyText: '尚未添加纱线' }} + columns={[ + { + title: '纱线名称/纱线颜色', + dataIndex: 'yarnName', + render: (_, row) => ( + + {row.yarnName} + / {row.yarnColor} + + ), + }, + { + title: '使用配比(合计 10)', + dataIndex: 'ratio', + width: 180, + render: (_, row) => ( + updateYarnRatio(side, row.key, value)} + /> + ), + }, + { + title: '操作', + width: 80, + render: (_, row) => ( + removeYarnRatioRow(side, row.key)}>移除 + ), + }, + ]} + footer={() => ( + + 合计 {total} / 10{rows.length === 0 ? '(未添加)' : ''} + + )} + /> + {error && ( +
+ {error} +
+ )} +
+ ); + }; + // ── Panel 1: Product Summary ── const summaryColumns: ProColumns[] = [ { title: '产品名称', dataIndex: 'productName', ellipsis: true }, @@ -220,7 +502,7 @@ const ProductsPage: React.FC = () => { }} toolBarRender={() => [ setUnit(value as 'cm' | 'in')} />, - , + , , , ]} @@ -246,16 +528,19 @@ const ProductsPage: React.FC = () => { { - const selectedYarnIds = values.selectedYarnIds || []; + const ratioErrors = getRatioErrors(); + if (ratioErrors.length > 0) { + const errorText = ratioErrors.join(';'); + setRatioSubmitError(errorText); + message.error(errorText); + return false; + } const initialBatch = values.initialBatch || {}; - const yarnRatio = initialBatch.yarnRatio || (selectedYarnIds.length ? JSON.stringify({ - warp: selectedYarnIds.map((yarnId: string) => ({ yarn_id: yarnId, ratio: 1 })), - weft: [], - }) : ''); + const yarnRatio = serializeYarnRatio(); const productValues = { ...values }; - delete productValues.selectedYarnIds; const res = await createProduct({ ...productValues, salesPrice: String(values.salesPrice || '0'), @@ -271,21 +556,25 @@ 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' }} - /> - - +
纱线配比
+ {ratioSubmitError && ( + {ratioSubmitError}} style={{ marginBottom: 12 }} /> + )} + {renderYarnRatioTable('warp')} + {renderYarnRatioTable('weft')} +
克重数据
+ + {weightCalcError && ( +
+ {weightCalcError} +
+ )} - - + +
{/* Edit product modal */} @@ -347,10 +636,34 @@ const ProductsPage: React.FC = () => { { + setYarnOpen(open); + if (!open) setYarnCreateTarget(null); + }} onFinish={async (values) => { const res = await createYarn(values); if (res?.code === 0) { + const createdYarnId = res.data?.id || ''; + const createdYarn: YarnInfo = { + yarnId: createdYarnId, + yarnName: values.yarnName, + color: values.color || '原纱本色', + weightGM: values.weightGM, + supplierId: values.supplierId, + dyeFactory: values.dyeFactory, + remark: values.remark, + }; + if (createdYarn.yarnId) { + setYarnOptions((options) => [ + createdYarn, + ...options.filter((item) => item.yarnId !== createdYarn.yarnId), + ]); + if (yarnCreateTarget) { + appendYarnRatioRow(yarnCreateTarget, createdYarn); + } + } else { + loadYarnOptions(); + } message.success('创建成功'); return true; } From 219d308d1a9e71ab9488283e181940a9ada1063b Mon Sep 17 00:00:00 2001 From: kae_mihara Date: Sun, 5 Jul 2026 07:54:19 +0900 Subject: [PATCH 3/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/pages/Inventory/Products/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/Inventory/Products/index.tsx b/src/pages/Inventory/Products/index.tsx index 555e44a..2838863 100644 --- a/src/pages/Inventory/Products/index.tsx +++ b/src/pages/Inventory/Products/index.tsx @@ -335,9 +335,9 @@ const ProductsPage: React.FC = () => { { title: '操作', width: 80, - render: (_, row) => ( - removeYarnRatioRow(side, row.key)}>移除 - ), +render: (_, row) => ( + +), }, ]} footer={() => ( From ffd6354e88972c2a2313dff3a5aac41cf853b2d8 Mon Sep 17 00:00:00 2001 From: kae_mihara Date: Sun, 5 Jul 2026 07:54:50 +0900 Subject: [PATCH 4/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/pages/Inventory/Products/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/Inventory/Products/index.tsx b/src/pages/Inventory/Products/index.tsx index 2838863..edce958 100644 --- a/src/pages/Inventory/Products/index.tsx +++ b/src/pages/Inventory/Products/index.tsx @@ -571,8 +571,8 @@ render: (_, row) => ( {weightCalcError} )} - - + + From bd42ff8a5274614805f50f349c527f44254079d4 Mon Sep 17 00:00:00 2001 From: kae_mihara Date: Sun, 5 Jul 2026 07:55:59 +0900 Subject: [PATCH 5/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/services/api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/api.ts b/src/services/api.ts index 77494c2..316d5e8 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -168,8 +168,8 @@ export const listProductBatches = (productId: string) => export const createProductBatch = (productId: string, batch: ProductBatchInput) => request>(`/api/v1/inventory/products/${productId}/batches`, { method: 'POST', data: { batch } }); -export const updateProductBatch = (productId: string, batchId: string, batch: ProductBatchInput & { status?: number }) => - request(`/api/v1/inventory/products/${productId}/batches/${batchId}`, { method: 'PUT', data: { batch, status: batch.status ?? 1 } }); +export const updateProductBatch = (productId: string, batchId: string, batch: ProductBatchInput, status?: number) => + request(`/api/v1/inventory/products/${productId}/batches/${batchId}`, { method: 'PUT', data: status === undefined ? { batch } : { batch, status } }); export const deleteProductBatch = (productId: string, batchId: string) => request(`/api/v1/inventory/products/${productId}/batches/${batchId}`, { method: 'DELETE' }); From 5d5bc0742aee211cdb8b14045192b228bd0bb24c Mon Sep 17 00:00:00 2001 From: kae_mihara Date: Sun, 5 Jul 2026 07:56:24 +0900 Subject: [PATCH 6/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/pages/Inventory/Products/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Inventory/Products/index.tsx b/src/pages/Inventory/Products/index.tsx index edce958..57d9d14 100644 --- a/src/pages/Inventory/Products/index.tsx +++ b/src/pages/Inventory/Products/index.tsx @@ -673,7 +673,7 @@ render: (_, row) => ( > - + Date: Sun, 5 Jul 2026 07:56:39 +0900 Subject: [PATCH 7/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/pages/Inventory/Products/index.tsx | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/pages/Inventory/Products/index.tsx b/src/pages/Inventory/Products/index.tsx index 57d9d14..0a61216 100644 --- a/src/pages/Inventory/Products/index.tsx +++ b/src/pages/Inventory/Products/index.tsx @@ -736,17 +736,18 @@ render: (_, row) => ( }} style={{ width: 120 }} /> - ({ label: batch.batchNo, value: batch.batchId }))} + onChange={(value) => { + const next = [...panInputs]; + next[pi] = { ...next[pi], batchId: value || undefined }; + setPanInputs(next); + }} +/> {pan.boltLengths?.map((len, bi) => (