feat: support product batch inventory UI

This commit is contained in:
kae 2026-07-04 18:38:50 +09:00
parent 0eef618bc1
commit 6e3ee00a6a
7 changed files with 358 additions and 53 deletions

View File

@ -5,6 +5,55 @@ import { Upload, Card, message, Button, Alert, Space } from 'antd';
const { Dragger } = Upload; 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const downloadTemplate = () => {
const rows = [templateHeaders, templateSample]
.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join('')}</tr>`)
.join('');
const html = `<!doctype html><html><head><meta charset="utf-8" /></head><body><table>${rows}</table></body></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 ImportPage: React.FC = () => {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
@ -14,15 +63,15 @@ const ImportPage: React.FC = () => {
<Space direction="vertical" size={20} style={{ display: 'flex' }}> <Space direction="vertical" size={20} style={{ display: 'flex' }}>
<Alert <Alert
message="导入说明" message="导入说明"
description="请按照模板格式准备 Excel 文件,必填字段:产品名称、库存数量。支持 .xlsx 和 .xls 格式。" description="请按照模板格式准备 Excel 文件,必填字段:产品名称。可选字段:规格型号、颜色、色号、产品码、纱线配比、经纬克重、生产工艺。支持 .xlsx 和 .xls 格式。"
type="info" type="info"
showIcon showIcon
/> />
<Button icon={<DownloadOutlined />}></Button> <Button icon={<DownloadOutlined />} onClick={downloadTemplate}></Button>
<Dragger <Dragger
name="file" name="file"
action="/api/v1/inventory/products/import" action="/api/v1/inventory/products/import"
accept=".xlsx,.xls,.csv" accept=".xlsx,.xls"
headers={{ Authorization: `Bearer ${token}` }} headers={{ Authorization: `Bearer ${token}` }}
onChange={(info) => { onChange={(info) => {
if (info.file.status === 'done') { if (info.file.status === 'done') {
@ -39,7 +88,7 @@ const ImportPage: React.FC = () => {
> >
<p className="ant-upload-drag-icon"><InboxOutlined /></p> <p className="ant-upload-drag-icon"><InboxOutlined /></p>
<p className="ant-upload-text"></p> <p className="ant-upload-text"></p>
<p className="ant-upload-hint"> .xlsx.xls.csv </p> <p className="ant-upload-hint"> .xlsx.xls </p>
</Dragger> </Dragger>
</Space> </Space>
</Card> </Card>

View File

@ -9,6 +9,11 @@ import { smokeTest } from '../../../../test/smoke';
import Page from './index'; import Page from './index';
import { createProduct, getProductSummary } from '@/services/api'; import { createProduct, getProductSummary } from '@/services/api';
const clickDialogSubmit = async (user: ReturnType<typeof userEvent.setup>, dialog: HTMLElement) => {
const submitBtn = within(dialog).getByRole('button', { name: /确\s*定|确\s*认|提交|OK/i });
await user.click(submitBtn);
};
describe('Inventory / Products page', () => { describe('Inventory / Products page', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -56,13 +61,7 @@ describe('Inventory / Products page', () => {
const nameInput = within(dialog).getByRole('textbox', { name: /产品名称/ }); const nameInput = within(dialog).getByRole('textbox', { name: /产品名称/ });
await user.type(nameInput, '测试产品'); await user.type(nameInput, '测试产品');
// Click the submit/OK button find the non-close button in the dialog footer await clickDialogSubmit(user, dialog);
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 waitFor(() => { await waitFor(() => {
expect(vi.mocked(createProduct)).toHaveBeenCalled(); expect(vi.mocked(createProduct)).toHaveBeenCalled();
@ -133,12 +132,7 @@ describe('Inventory / Products page', () => {
const nameInput = within(dialog).getByRole('textbox', { name: /产品名称/ }); const nameInput = within(dialog).getByRole('textbox', { name: /产品名称/ });
await user.type(nameInput, '新布料'); await user.type(nameInput, '新布料');
const buttons = within(dialog).getAllByRole('button'); await clickDialogSubmit(user, dialog);
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 waitFor(() => { await waitFor(() => {
expect(vi.mocked(createProduct)).toHaveBeenCalled(); expect(vi.mocked(createProduct)).toHaveBeenCalled();

View File

@ -1,6 +1,6 @@
import { PlusOutlined, UploadOutlined, DownloadOutlined, ArrowLeftOutlined } from '@ant-design/icons'; import { PlusOutlined, UploadOutlined, DownloadOutlined, ArrowLeftOutlined } from '@ant-design/icons';
import { ActionType, ModalForm, ProColumns, ProFormText, ProFormTextArea, ProTable } from '@ant-design/pro-components'; import { ActionType, ModalForm, ProColumns, ProFormSelect, ProFormText, ProFormTextArea, ProTable } from '@ant-design/pro-components';
import { Button, Card, Descriptions, Drawer, Input, InputNumber, List, message, Popconfirm, Space, Tag } from 'antd'; import { Button, Card, Descriptions, Drawer, Input, InputNumber, List, message, Popconfirm, Segmented, Select, Space, Tag } from 'antd';
import { useRef, useState, useCallback } from 'react'; import { useRef, useState, useCallback } from 'react';
import { import {
getProductSummary, getProductSummary,
@ -9,9 +9,17 @@ import {
createProduct, createProduct,
updateProduct, updateProduct,
deleteProduct, deleteProduct,
exportProducts,
savePans, savePans,
listYarns,
createYarn,
listSuppliers,
listProductBatches,
createProductBatch,
updateProductBatch,
deleteProductBatch,
} from '@/services/api'; } 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'; type ViewLevel = 'summary' | 'colors' | 'tree';
@ -26,10 +34,39 @@ const ProductsPage: React.FC = () => {
const [editTreeDrawer, setEditTreeDrawer] = useState(false); const [editTreeDrawer, setEditTreeDrawer] = useState(false);
const [currentColor, setCurrentColor] = useState<ColorDetailItem | null>(null); const [currentColor, setCurrentColor] = useState<ColorDetailItem | null>(null);
const [panInputs, setPanInputs] = useState<PanInput[]>([]); const [panInputs, setPanInputs] = useState<PanInput[]>([]);
const [batches, setBatches] = useState<ProductBatchInfo[]>([]);
const [batchOpen, setBatchOpen] = useState(false);
const [currentBatch, setCurrentBatch] = useState<ProductBatchInfo | null>(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 loadTree = useCallback(async (productId: string) => {
const res = await getProductTree(productId); const res = await getProductTree(productId);
if (res?.code === 0) { if (res?.code === 0) {
const batchRes = await listProductBatches(productId);
setBatches(batchRes?.data?.list || []);
setTreeData(res.data); setTreeData(res.data);
setSelectedProductId(productId); setSelectedProductId(productId);
setViewLevel('tree'); setViewLevel('tree');
@ -39,7 +76,7 @@ const ProductsPage: React.FC = () => {
// ── Panel 1: Product Summary ── // ── Panel 1: Product Summary ──
const summaryColumns: ProColumns<ProductSummaryItem>[] = [ const summaryColumns: ProColumns<ProductSummaryItem>[] = [
{ title: '产品名称', dataIndex: 'productName', ellipsis: true }, { 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: 'colorCount', search: false },
{ title: '总盘数', dataIndex: 'totalPanCount', search: false }, { title: '总盘数', dataIndex: 'totalPanCount', search: false },
{ title: '总匹数', dataIndex: 'totalBoltCount', search: false }, { title: '总匹数', dataIndex: 'totalBoltCount', search: false },
@ -57,7 +94,10 @@ const ProductsPage: React.FC = () => {
// ── Panel 2: Color Detail ── // ── Panel 2: Color Detail ──
const colorColumns: ProColumns<ColorDetailItem>[] = [ const colorColumns: ProColumns<ColorDetailItem>[] = [
{ title: '颜色', dataIndex: 'color' }, { 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: 'panCount', search: false },
{ title: '匹数', dataIndex: 'boltCount', search: false }, { title: '匹数', dataIndex: 'boltCount', search: false },
{ title: '库存(m)', dataIndex: 'totalLengthM', search: false }, { title: '库存(m)', dataIndex: 'totalLengthM', search: false },
@ -83,14 +123,48 @@ const ProductsPage: React.FC = () => {
return ( return (
<div> <div>
<Descriptions title={`${product.productName} - ${product.color}`} bordered size="small" column={4} style={{ marginBottom: 16 }}> <Descriptions title={`${product.productName} - ${product.color}`} bordered size="small" column={4} style={{ marginBottom: 16 }}>
<Descriptions.Item label="规格">{product.spec}</Descriptions.Item> <Descriptions.Item label="产品码">{product.productCode || '-'}</Descriptions.Item>
<Descriptions.Item label="色号">{product.colorNo || '-'}</Descriptions.Item>
<Descriptions.Item label="规格">{formatCmSpec(product.spec)}</Descriptions.Item>
<Descriptions.Item label="总盘数">{totalPanCount}</Descriptions.Item> <Descriptions.Item label="总盘数">{totalPanCount}</Descriptions.Item>
<Descriptions.Item label="总匹数">{totalBoltCount}</Descriptions.Item> <Descriptions.Item label="总匹数">{totalBoltCount}</Descriptions.Item>
<Descriptions.Item label="总米数">{totalLengthM}m</Descriptions.Item> <Descriptions.Item label="总米数">{totalLengthM}m</Descriptions.Item>
</Descriptions> </Descriptions>
<Card size="small" title="批次" style={{ marginBottom: 16 }} extra={<Button size="small" type="primary" onClick={() => { setCurrentBatch(null); setBatchOpen(true); }}></Button>}>
<List
size="small"
dataSource={batches}
renderItem={(batch) => (
<List.Item
actions={[
<a key="edit" onClick={() => { setCurrentBatch(batch); setBatchOpen(true); }}></a>,
<Popconfirm
key="del"
title="确认删除?"
disabled={batch.batchNo.endsWith('-B001')}
onConfirm={async () => {
const res = await deleteProductBatch(selectedProductId, batch.batchId);
if (res?.code === 0) {
message.success('删除成功');
loadTree(selectedProductId);
}
}}
>
<a style={{ color: batch.batchNo.endsWith('-B001') ? 'var(--ant-color-text-disabled)' : 'var(--ant-color-error)' }}></a>
</Popconfirm>,
]}
>
<List.Item.Meta
title={<Space><span>{batch.batchNo}</span><Tag>{Number(batch.warpWeightGM || 0) + Number(batch.weftWeightGM || 0)}g/m</Tag></Space>}
description={batch.productionProcess || '无'}
/>
</List.Item>
)}
/>
</Card>
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: '100%' }}>
{pans.map((pan) => ( {pans.map((pan) => (
<Card key={pan.panId} size="small" title={`${pan.name}`} extra={<Tag color="blue">{pan.position || '未设置位置'}</Tag>}> <Card key={pan.panId} size="small" title={`${pan.name}`} extra={<Space><Tag>{pan.batchNo || '未归属批次'}</Tag><Tag color="blue">{pan.position || '未设置位置'}</Tag></Space>}>
<List <List
size="small" size="small"
dataSource={pan.bolts} dataSource={pan.bolts}
@ -106,6 +180,7 @@ const ProductsPage: React.FC = () => {
setPanInputs(pans.map((p) => ({ setPanInputs(pans.map((p) => ({
name: p.name, name: p.name,
position: p.position, position: p.position,
batchId: p.batchId,
boltLengths: p.bolts.map((b) => b.lengthM), boltLengths: p.bolts.map((b) => b.lengthM),
}))); })));
setEditTreeDrawer(true); 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 }; return { data: res?.data?.list || [], success: res?.code === 0, total: res?.data?.list?.length || 0 };
}} }}
toolBarRender={() => [ toolBarRender={() => [
<Segmented key="unit" size="small" value={unit} options={[{ label: 'cm', value: 'cm' }, { label: 'in', value: 'in' }]} onChange={(value) => setUnit(value as 'cm' | 'in')} />,
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>, <Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>,
<Button key="import" icon={<UploadOutlined />} onClick={() => window.location.assign('/inventory/import')}>Excel导入</Button>, <Button key="import" icon={<UploadOutlined />} onClick={() => window.location.assign('/inventory/import')}>Excel导入</Button>,
<Button key="export" icon={<DownloadOutlined />}>Excel导出</Button>, <Button key="export" icon={<DownloadOutlined />} onClick={handleExport}>Excel导出</Button>,
]} ]}
/> />
)} )}
@ -172,7 +248,19 @@ const ProductsPage: React.FC = () => {
open={createOpen} open={createOpen}
onOpenChange={setCreateOpen} onOpenChange={setCreateOpen}
onFinish={async (values) => { 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; } if (res?.code === 0) { message.success('创建成功'); summaryRef.current?.reload(); return true; }
message.error(res?.msg || '创建失败'); message.error(res?.msg || '创建失败');
return false; return false;
@ -180,8 +268,23 @@ const ProductsPage: React.FC = () => {
> >
<ProFormText name="productName" label="产品名称" rules={[{ required: true }]} /> <ProFormText name="productName" label="产品名称" rules={[{ required: true }]} />
<ProFormText name="spec" label="规格型号" /> <ProFormText name="spec" label="规格型号" />
<ProFormText name="color" label="颜色" /> <ProFormText name="color" label="颜色" initialValue="本色" />
<ProFormText name="colorNo" label="色号" />
<ProFormText name="salesPrice" label="销售价" /> <ProFormText name="salesPrice" label="销售价" />
<ProFormSelect
name="selectedYarnIds"
label="纱线"
request={async ({ keyWords }) => {
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' }}
/>
<Button type="dashed" size="small" onClick={() => setYarnOpen(true)} style={{ marginBottom: 12 }}>线</Button>
<ProFormTextArea name={['initialBatch', 'yarnRatio']} label="纱线配比" />
<ProFormText name={['initialBatch', 'warpWeightGM']} label="经线克重(g/m)" />
<ProFormText name={['initialBatch', 'weftWeightGM']} label="纬线克重(g/m)" />
<ProFormTextArea name={['initialBatch', 'productionProcess']} label="生产工艺" initialValue="无" />
<ProFormTextArea name="remark" label="备注" /> <ProFormTextArea name="remark" label="备注" />
</ModalForm> </ModalForm>
@ -194,6 +297,8 @@ const ProductsPage: React.FC = () => {
productName: currentColor.productName, productName: currentColor.productName,
spec: currentColor.spec, spec: currentColor.spec,
color: currentColor.color, color: currentColor.color,
colorNo: currentColor.colorNo,
productCode: currentColor.productCode,
salesPrice: currentColor.salesPrice, salesPrice: currentColor.salesPrice,
} : undefined} } : undefined}
onFinish={async (values) => { onFinish={async (values) => {
@ -205,12 +310,71 @@ const ProductsPage: React.FC = () => {
}} }}
> >
<ProFormText name="productName" label="产品名称" rules={[{ required: true }]} /> <ProFormText name="productName" label="产品名称" rules={[{ required: true }]} />
<ProFormText name="productCode" label="产品码" disabled />
<ProFormText name="spec" label="规格型号" /> <ProFormText name="spec" label="规格型号" />
<ProFormText name="color" label="颜色" /> <ProFormText name="color" label="颜色" />
<ProFormText name="colorNo" label="色号" />
<ProFormText name="salesPrice" label="销售价" /> <ProFormText name="salesPrice" label="销售价" />
<ProFormTextArea name="remark" label="备注" /> <ProFormTextArea name="remark" label="备注" />
</ModalForm> </ModalForm>
<ModalForm
title={currentBatch ? '编辑批次' : '新增批次'}
open={batchOpen}
onOpenChange={setBatchOpen}
initialValues={currentBatch || { productionProcess: '无' }}
onFinish={async (values) => {
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 && <ProFormText name="batchNo" label="批次号" disabled />}
<ProFormTextArea name="yarnRatio" label="纱线配比" />
<ProFormText name="warpWeightGM" label="经线克重(g/m)" />
<ProFormText name="weftWeightGM" label="纬线克重(g/m)" />
<ProFormTextArea name="productionProcess" label="生产工艺" />
<ProFormTextArea name="remark" label="备注" />
</ModalForm>
<ModalForm
title="新增纱线"
open={yarnOpen}
onOpenChange={setYarnOpen}
onFinish={async (values) => {
const res = await createYarn(values);
if (res?.code === 0) {
message.success('创建成功');
return true;
}
message.error(res?.msg || '创建失败');
return false;
}}
>
<ProFormText name="yarnName" label="纱线名称" rules={[{ required: true }]} />
<ProFormText name="color" label="颜色" initialValue="原纱本色" />
<ProFormText name="weightGM" label="纱线克重(g/m)" rules={[{ required: true }]} />
<ProFormSelect
name="supplierId"
label="供应商"
rules={[{ required: true }]}
request={async ({ keyWords }) => {
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 }}
/>
<ProFormText name="dyeFactory" label="染纱厂" initialValue="无" />
<ProFormTextArea name="remark" label="备注" />
</ModalForm>
{/* Edit Pans/Bolts Drawer */} {/* Edit Pans/Bolts Drawer */}
<Drawer <Drawer
title="编辑盘/匹" title="编辑盘/匹"
@ -259,6 +423,17 @@ const ProductsPage: React.FC = () => {
}} }}
style={{ width: 120 }} style={{ width: 120 }}
/> />
<Select
placeholder="批次"
value={pan.batchId}
style={{ width: 180 }}
options={batches.map((batch) => ({ label: batch.batchNo, value: batch.batchId }))}
onChange={(value) => {
const next = [...panInputs];
next[pi] = { ...next[pi], batchId: value };
setPanInputs(next);
}}
/>
</Space> </Space>
{pan.boltLengths?.map((len, bi) => ( {pan.boltLengths?.map((len, bi) => (
<Space key={bi} style={{ display: 'flex', marginBottom: 4 }}> <Space key={bi} style={{ display: 'flex', marginBottom: 4 }}>

View File

@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { history, useModel, request } from '@umijs/max'; import { history, useModel, request } from '@umijs/max';
import { Button, Form, Input, message, Select } from 'antd'; import { Button, Form, Input, message, Select } from 'antd';
import { LockOutlined, UserOutlined, BankOutlined } from '@ant-design/icons'; import { LockOutlined, UserOutlined, BankOutlined } from '@ant-design/icons';
@ -6,6 +6,7 @@ import { getUserInfo, login } from '@/services/api';
import type { LoginParams } from '@/types/api'; import type { LoginParams } from '@/types/api';
const USER_CACHE_KEY = 'muyu_user_state'; const USER_CACHE_KEY = 'muyu_user_state';
const DEFAULT_TENANT_OPTION = { label: '默认租户t_default_001', value: 't_default_001' };
const LoginPage: React.FC = () => { const LoginPage: React.FC = () => {
const { setInitialState } = useModel('@@initialState'); const { setInitialState } = useModel('@@initialState');
@ -14,9 +15,7 @@ const LoginPage: React.FC = () => {
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(); const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const handleTenantSearch = useCallback((keyword: string) => { const loadTenantOptions = useCallback(async (keyword = '') => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
setSearching(true); setSearching(true);
try { try {
const res = await request('/api/v1/public/tenants/search', { const res = await request('/api/v1/public/tenants/search', {
@ -24,23 +23,34 @@ const LoginPage: React.FC = () => {
params: { keyword, limit: 20 }, params: { keyword, limit: 20 },
}); });
if (res?.code === 0 && res.data?.list) { if (res?.code === 0 && res.data?.list) {
setTenantOptions( const options = res.data.list.map((t: { id: string; name: string }) => ({
res.data.list.map((t: { id: string; name: string }) => ({
label: `${t.name}${t.id}`, label: `${t.name}${t.id}`,
value: t.id, value: t.id,
})), }));
); setTenantOptions(options.length ? options : [DEFAULT_TENANT_OPTION]);
} else { } else {
setTenantOptions([DEFAULT_TENANT_OPTION]);
message.error('获取公司列表失败,请稍后重试'); message.error('获取公司列表失败,请稍后重试');
} }
} catch { } catch {
setTenantOptions([DEFAULT_TENANT_OPTION]);
message.error('获取公司列表失败,请检查网络连接'); message.error('获取公司列表失败,请检查网络连接');
} finally { } finally {
setSearching(false); setSearching(false);
} }
}, 300);
}, []); }, []);
useEffect(() => {
loadTenantOptions();
}, [loadTenantOptions]);
const handleTenantSearch = useCallback((keyword: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
loadTenantOptions(keyword);
}, 300);
}, [loadTenantOptions]);
const handleSubmit = async (values: LoginParams) => { const handleSubmit = async (values: LoginParams) => {
setLoading(true); setLoading(true);
try { try {

View File

@ -60,6 +60,14 @@ export const exportProducts = vi.fn(ok);
export const getProductSummary = vi.fn(ok); export const getProductSummary = vi.fn(ok);
export const getColorDetail = vi.fn(ok); export const getColorDetail = vi.fn(ok);
export const getProductTree = vi.fn(ok); export const getProductTree = vi.fn(ok);
export const listYarns = vi.fn(ok);
export const createYarn = vi.fn(ok);
export const updateYarn = vi.fn(ok);
export const deleteYarn = vi.fn(ok);
export const listProductBatches = vi.fn(ok);
export const createProductBatch = vi.fn(ok);
export const updateProductBatch = vi.fn(ok);
export const deleteProductBatch = vi.fn(ok);
export const listPans = vi.fn(ok); export const listPans = vi.fn(ok);
export const savePans = vi.fn(ok); export const savePans = vi.fn(ok);
export const listBolts = vi.fn(ok); export const listBolts = vi.fn(ok);

View File

@ -14,6 +14,8 @@ import type {
PanInfo, PanInfo,
PanInput, PanInput,
Product, Product,
ProductBatchInfo,
ProductBatchInput,
ProductSummaryItem, ProductSummaryItem,
ProductTree, ProductTree,
PurchaseOrder, PurchaseOrder,
@ -32,6 +34,7 @@ import type {
Supplier, Supplier,
SystemConfig, SystemConfig,
User, User,
YarnInfo,
} from '@/types/api'; } from '@/types/api';
// ── Auth ────────────────────────────────────────── // ── Auth ──────────────────────────────────────────
@ -114,13 +117,13 @@ export const updateConfig = (key: string, value: string) =>
// ── Products ────────────────────────────────────── // ── Products ──────────────────────────────────────
export const listProducts = (params: PaginationParams & { productName?: string; spec?: string; color?: string }) => export const listProducts = (params: PaginationParams & { productName?: string; spec?: string; color?: string; colorNo?: string; productCode?: string }) =>
request<ApiResponse<PaginatedData<Product>>>('/api/v1/inventory/products', { method: 'GET', params }); request<ApiResponse<PaginatedData<Product>>>('/api/v1/inventory/products', { method: 'GET', params });
export const getProduct = (id: string) => export const getProduct = (id: string) =>
request<ApiResponse<Product>>(`/api/v1/inventory/products/${id}`, { method: 'GET' }); request<ApiResponse<Product>>(`/api/v1/inventory/products/${id}`, { method: 'GET' });
export const createProduct = (data: Partial<Product> & { pans?: PanInput[] }) => export const createProduct = (data: Partial<Product> & { pans?: PanInput[]; initialBatch?: ProductBatchInput }) =>
request<ApiResponse>('/api/v1/inventory/products', { method: 'POST', data }); request<ApiResponse>('/api/v1/inventory/products', { method: 'POST', data });
export const updateProduct = (id: string, data: Partial<Product>) => export const updateProduct = (id: string, data: Partial<Product>) =>
@ -143,6 +146,30 @@ export const getColorDetail = (productName: string) =>
export const getProductTree = (id: string) => export const getProductTree = (id: string) =>
request<ApiResponse<ProductTree>>(`/api/v1/inventory/products/${id}/tree`, { method: 'GET' }); request<ApiResponse<ProductTree>>(`/api/v1/inventory/products/${id}/tree`, { method: 'GET' });
export const listYarns = (params?: PaginationParams & { yarnName?: string; supplierId?: string; status?: number }) =>
request<ApiResponse<PaginatedData<YarnInfo>>>('/api/v1/inventory/yarns', { method: 'GET', params });
export const createYarn = (data: Partial<YarnInfo>) =>
request<ApiResponse<{ id: string }>>('/api/v1/inventory/yarns', { method: 'POST', data });
export const updateYarn = (id: string, data: Partial<YarnInfo>) =>
request<ApiResponse>(`/api/v1/inventory/yarns/${id}`, { method: 'PUT', data });
export const deleteYarn = (id: string) =>
request<ApiResponse>(`/api/v1/inventory/yarns/${id}`, { method: 'DELETE' });
export const listProductBatches = (productId: string) =>
request<ApiResponse<{ list: ProductBatchInfo[] }>>(`/api/v1/inventory/products/${productId}/batches`, { method: 'GET' });
export const createProductBatch = (productId: string, batch: ProductBatchInput) =>
request<ApiResponse<{ id: string }>>(`/api/v1/inventory/products/${productId}/batches`, { method: 'POST', data: { batch } });
export const updateProductBatch = (productId: string, batchId: string, batch: ProductBatchInput & { status?: number }) =>
request<ApiResponse>(`/api/v1/inventory/products/${productId}/batches/${batchId}`, { method: 'PUT', data: { batch, status: batch.status ?? 1 } });
export const deleteProductBatch = (productId: string, batchId: string) =>
request<ApiResponse>(`/api/v1/inventory/products/${productId}/batches/${batchId}`, { method: 'DELETE' });
// ── Pan / Bolt CRUD ─────────────────────────────── // ── Pan / Bolt CRUD ───────────────────────────────
export const listPans = (productId: string) => export const listPans = (productId: string) =>

View File

@ -40,6 +40,8 @@ export interface Product {
imageUrl?: string; imageUrl?: string;
spec?: string; spec?: string;
color?: string; color?: string;
colorNo?: string;
productCode?: string;
salesPrice: string; salesPrice: string;
remark?: string; remark?: string;
status?: number; status?: number;
@ -57,6 +59,8 @@ export interface BoltInfo {
export interface PanInfo { export interface PanInfo {
panId: string; panId: string;
productId: string; productId: string;
batchId?: string;
batchNo?: string;
name: string; name: string;
position: string; position: string;
sortOrder: number; sortOrder: number;
@ -69,6 +73,7 @@ export interface PanInput {
name: string; name: string;
position?: string; position?: string;
boltLengths?: string[]; boltLengths?: string[];
batchId?: string;
} }
export interface ProductTree { export interface ProductTree {
@ -94,6 +99,8 @@ export interface ColorDetailItem {
productId: string; productId: string;
productName: string; productName: string;
color: string; color: string;
colorNo?: string;
productCode?: string;
spec: string; spec: string;
imageUrl: string; imageUrl: string;
panCount: number; panCount: number;
@ -101,6 +108,39 @@ export interface ColorDetailItem {
totalLengthM: string; totalLengthM: string;
locations: string; locations: string;
salesPrice: string; salesPrice: string;
batchCount?: number;
}
export interface YarnInfo {
yarnId: string;
yarnName: string;
color: string;
weightGM: string;
supplierId: string;
supplierName?: string;
dyeFactory?: string;
imageUrl?: string;
remark?: string;
status?: number;
createdAt?: string;
updatedAt?: string;
}
export interface ProductBatchInput {
yarnRatio?: string;
warpWeightGM?: string;
weftWeightGM?: string;
productionProcess?: string;
remark?: string;
}
export interface ProductBatchInfo extends ProductBatchInput {
batchId: string;
productId: string;
batchNo: string;
status?: number;
createdAt?: string;
updatedAt?: string;
} }
// ── Pan / Bolt dimension view ───────────────────── // ── Pan / Bolt dimension view ─────────────────────
@ -108,6 +148,8 @@ export interface ColorDetailItem {
export interface PanListItem { export interface PanListItem {
panId: string; panId: string;
productId: string; productId: string;
batchId?: string;
batchNo?: string;
name: string; name: string;
position: string; position: string;
productName: string; productName: string;