first init

This commit is contained in:
Chever John 2026-04-20 01:58:36 +00:00
parent 4aba6541f3
commit 8096f4a848
10 changed files with 742 additions and 146 deletions

View File

@ -57,7 +57,9 @@ export default defineConfig({
path: '/inventory',
icon: 'DatabaseOutlined',
routes: [
{ name: '产品档案', path: '/inventory/products', component: './Inventory/Products' },
{ name: '产品库存', path: '/inventory/products', component: './Inventory/Products' },
{ name: '盘库存', path: '/inventory/pans', component: './Inventory/Pans' },
{ name: '匹库存', path: '/inventory/bolts', component: './Inventory/Bolts' },
{ name: '库存查询', path: '/inventory/stocks', component: './Inventory/Stocks' },
{ name: '库存统计', path: '/inventory/stats', component: './Inventory/Stats' },
{ name: '库存盘点', path: '/inventory/checks', component: './Inventory/Checks' },

View File

@ -199,7 +199,7 @@ const DashboardPage: React.FC = () => {
</div>
<Row gutter={[16, 16]}>
{[
{ title: '产品档案', desc: '管理产品信息', path: '/inventory/products', icon: <ShoppingOutlined /> },
{ title: '产品库存', desc: '管理产品信息', path: '/inventory/products', icon: <ShoppingOutlined /> },
{ title: '库存查询', desc: '实时库存数据', path: '/inventory/stocks', icon: <DatabaseOutlined /> },
{ title: '库存统计', desc: '分类统计分析', path: '/inventory/stats', icon: <RiseOutlined /> },
{ title: 'Excel导入', desc: '批量导入产品', path: '/inventory/import', icon: <InboxOutlined /> },

View File

@ -0,0 +1,53 @@
import { ProColumns, ProTable } from '@ant-design/pro-components';
import { Tag } from 'antd';
import { listBoltView } from '@/services/api';
import type { BoltListItem } from '@/types/api';
const columns: ProColumns<BoltListItem>[] = [
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
{
title: '颜色',
dataIndex: 'color',
search: false,
render: (_, r) => r.color ? <Tag>{r.color}</Tag> : <span style={{ color: '#bbb' }}>-</span>,
},
{ title: '盘标签', dataIndex: 'panName', search: false, width: 80 },
{
title: '位置',
dataIndex: 'position',
render: (_, r) =>
r.position ? <Tag color="blue">{r.position}</Tag> : <span style={{ color: '#bbb' }}>-</span>,
},
{ title: '序号', dataIndex: 'sortOrder', search: false, width: 70 },
{
title: '长度(m)',
dataIndex: 'lengthM',
search: false,
sorter: true,
render: (_, r) => <strong>{r.lengthM} m</strong>,
},
];
const BoltsPage: React.FC = () => (
<ProTable<BoltListItem>
headerTitle="匹维度库存"
rowKey="boltId"
columns={columns}
request={async (params) => {
const res = await listBoltView({
page: params.current,
pageSize: params.pageSize,
productName: params.productName,
position: params.position,
});
return {
data: res?.data?.list || [],
total: res?.data?.total || 0,
success: res?.code === 0,
};
}}
search={{ labelWidth: 'auto' }}
/>
);
export default BoltsPage;

View File

@ -0,0 +1,62 @@
import { ProColumns, ProTable } from '@ant-design/pro-components';
import { Tag, Space } from 'antd';
import { listPanView } from '@/services/api';
import type { PanListItem } from '@/types/api';
const columns: ProColumns<PanListItem>[] = [
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
{ title: '盘标签', dataIndex: 'name', search: false, width: 80 },
{
title: '位置',
dataIndex: 'position',
render: (_, r) =>
r.position ? <Tag color="blue">{r.position}</Tag> : <span style={{ color: '#bbb' }}>-</span>,
},
{
title: '颜色汇总',
dataIndex: 'colors',
search: false,
render: (_, r) =>
r.colors ? (
<Space size={4} wrap>
{r.colors.split(',').map((c) => (
<Tag key={c.trim()}>{c.trim()}</Tag>
))}
</Space>
) : (
<span style={{ color: '#bbb' }}>-</span>
),
},
{ title: '匹数', dataIndex: 'boltCount', search: false, sorter: true, width: 80 },
{
title: '库存(m)',
dataIndex: 'totalLengthM',
search: false,
sorter: true,
render: (_, r) => `${r.totalLengthM} m`,
},
];
const PansPage: React.FC = () => (
<ProTable<PanListItem>
headerTitle="盘维度库存"
rowKey="panId"
columns={columns}
request={async (params) => {
const res = await listPanView({
page: params.current,
pageSize: params.pageSize,
productName: params.productName,
position: params.position,
});
return {
data: res?.data?.list || [],
total: res?.data?.total || 0,
success: res?.code === 0,
};
}}
search={{ labelWidth: 'auto' }}
/>
);
export default PansPage;

View File

@ -1,120 +1,301 @@
import { PlusOutlined, UploadOutlined, DownloadOutlined } from '@ant-design/icons';
import { ActionType, ModalForm, ProColumns, ProFormDigit, ProFormText, ProFormTextArea, ProTable } from '@ant-design/pro-components';
import { Button, message, Popconfirm } from 'antd';
import { useRef, useState } from 'react';
import { listProducts, createProduct, updateProduct, deleteProduct } from '@/services/api';
import type { Product } from '@/types/api';
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 { useRef, useState, useCallback } from 'react';
import {
getProductSummary,
getColorDetail,
getProductTree,
createProduct,
updateProduct,
deleteProduct,
savePans,
} from '@/services/api';
import type { ProductSummaryItem, ColorDetailItem, ProductTree, PanInput } from '@/types/api';
const productFormFields = (
<>
<ProFormText name="productName" label="产品名称" rules={[{ required: true }]} />
<ProFormText name="spec" label="规格型号" />
<ProFormText name="color" label="颜色" />
<ProFormDigit name="unitPieces" label="匹数" min={0} />
<ProFormDigit name="unitRolls" label="盘数" min={0} />
<ProFormText name="stockQuantity" label="库存数量" rules={[{ required: true }]} />
<ProFormText name="location" label="存放位置" />
<ProFormText name="costPrice" label="成本价" />
<ProFormText name="salesPrice" label="销售价" />
<ProFormTextArea name="remark" label="备注" />
</>
);
type ViewLevel = 'summary' | 'colors' | 'tree';
const ProductsPage: React.FC = () => {
const actionRef = useRef<ActionType>();
const summaryRef = useRef<ActionType>();
const [viewLevel, setViewLevel] = useState<ViewLevel>('summary');
const [selectedProductName, setSelectedProductName] = useState('');
const [selectedProductId, setSelectedProductId] = useState('');
const [treeData, setTreeData] = useState<ProductTree | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [currentRow, setCurrentRow] = useState<Product | null>(null);
const [editTreeDrawer, setEditTreeDrawer] = useState(false);
const [currentColor, setCurrentColor] = useState<ColorDetailItem | null>(null);
const [panInputs, setPanInputs] = useState<PanInput[]>([]);
const columns: ProColumns<Product>[] = [
const loadTree = useCallback(async (productId: string) => {
const res = await getProductTree(productId);
if (res?.code === 0) {
setTreeData(res.data);
setSelectedProductId(productId);
setViewLevel('tree');
}
}, []);
// ── Panel 1: Product Summary ──
const summaryColumns: ProColumns<ProductSummaryItem>[] = [
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
{ title: '规格型号', dataIndex: 'spec', ellipsis: true },
{ title: '颜色', dataIndex: 'color' },
{ title: '匹数', dataIndex: 'unitPieces', search: false },
{ title: '盘数', dataIndex: 'unitRolls', search: false },
{ title: '库存数量', dataIndex: 'stockQuantity', search: false },
{ title: '存放位置', dataIndex: 'location' },
{ title: '成本价', dataIndex: 'costPrice', search: false, valueType: 'money' },
{ title: '销售价', dataIndex: 'salesPrice', search: false, valueType: 'money' },
{ title: '更新时间', dataIndex: 'updatedAt', valueType: 'dateTime', search: false },
{ title: '规格型号', dataIndex: 'spec', search: false },
{ title: '颜色数', dataIndex: 'colorCount', search: false },
{ title: '总盘数', dataIndex: 'totalPanCount', search: false },
{ title: '总匹数', dataIndex: 'totalBoltCount', search: false },
{ title: '总米数', dataIndex: 'totalLengthM', search: false },
{ title: '颜色', dataIndex: 'colors', search: false, render: (_, r) => r.colors?.split(',').map((c) => <Tag key={c}>{c}</Tag>) },
{ title: '位置', dataIndex: 'locations', search: false, ellipsis: true },
{
title: '操作',
valueType: 'option',
render: (_, record) => [
<a key="edit" onClick={() => { setCurrentRow(record); setEditOpen(true); }}></a>,
<Popconfirm
key="delete"
title="确认删除?"
onConfirm={async () => {
const res = await deleteProduct(record.productId);
if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); }
}}
>
<a style={{ color: 'var(--ant-color-error)' }}></a>
</Popconfirm>,
title: '操作', valueType: 'option',
render: (_, r) => [
<a key="detail" onClick={() => { setSelectedProductName(r.productName); setViewLevel('colors'); }}></a>,
],
},
];
const normalizeValues = (values: Record<string, any>) => ({
...values,
stockQuantity: String(values.stockQuantity || '0'),
costPrice: String(values.costPrice || '0'),
salesPrice: String(values.salesPrice || '0'),
});
// ── Panel 2: Color Detail ──
const colorColumns: ProColumns<ColorDetailItem>[] = [
{ title: '颜色', dataIndex: 'color' },
{ title: '规格', dataIndex: 'spec', search: false },
{ title: '盘数', dataIndex: 'panCount', search: false },
{ title: '匹数', dataIndex: 'boltCount', search: false },
{ title: '库存(m)', dataIndex: 'totalLengthM', search: false },
{ title: '位置', dataIndex: 'locations', search: false, ellipsis: true },
{ title: '销售价', dataIndex: 'salesPrice', search: false, valueType: 'money' },
{
title: '操作', valueType: 'option',
render: (_, r) => [
<a key="tree" onClick={() => loadTree(r.productId)}>/</a>,
<a key="edit" onClick={() => { setCurrentColor(r); setEditOpen(true); }}></a>,
<Popconfirm key="del" title="确认删除?" onConfirm={async () => {
const res = await deleteProduct(r.productId);
if (res?.code === 0) message.success('删除成功');
}}><a style={{ color: 'var(--ant-color-error)' }}></a></Popconfirm>,
],
},
];
// ── Panel 3: Tree Detail ──
const renderTree = () => {
if (!treeData) return null;
const { product, pans, totalPanCount, totalBoltCount, totalLengthM } = treeData;
return (
<div>
<Descriptions title={`${product.productName} - ${product.color}`} bordered size="small" column={4} style={{ marginBottom: 16 }}>
<Descriptions.Item label="规格">{product.spec}</Descriptions.Item>
<Descriptions.Item label="总盘数">{totalPanCount}</Descriptions.Item>
<Descriptions.Item label="总匹数">{totalBoltCount}</Descriptions.Item>
<Descriptions.Item label="总米数">{totalLengthM}m</Descriptions.Item>
</Descriptions>
<Space direction="vertical" style={{ width: '100%' }}>
{pans.map((pan) => (
<Card key={pan.panId} size="small" title={`${pan.name}`} extra={<Tag color="blue">{pan.position || '未设置位置'}</Tag>}>
<List
size="small"
dataSource={pan.bolts}
renderItem={(bolt, idx) => (
<List.Item>{idx + 1}: {bolt.lengthM}m</List.Item>
)}
footer={<div>: {pan.boltCount}, {pan.totalLength}m</div>}
/>
</Card>
))}
</Space>
<Button type="primary" style={{ marginTop: 16 }} onClick={() => {
setPanInputs(pans.map((p) => ({
name: p.name,
position: p.position,
boltLengths: p.bolts.map((b) => b.lengthM),
})));
setEditTreeDrawer(true);
}}>/</Button>
</div>
);
};
const breadcrumb = () => (
<Space style={{ marginBottom: 16 }}>
{viewLevel !== 'summary' && (
<Button icon={<ArrowLeftOutlined />} onClick={() => {
if (viewLevel === 'tree') setViewLevel('colors');
else setViewLevel('summary');
}}></Button>
)}
<a onClick={() => setViewLevel('summary')}></a>
{viewLevel !== 'summary' && <span> / {selectedProductName}</span>}
{viewLevel === 'tree' && treeData && <span> / {treeData.product.color}</span>}
</Space>
);
return (
<>
<ProTable<Product>
headerTitle="产品档案"
actionRef={actionRef}
rowKey="productId"
columns={columns}
request={async (params) => {
const res = await listProducts({
page: params.current,
pageSize: params.pageSize,
productName: params.productName,
spec: params.spec,
color: params.color,
location: params.location,
});
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
}}
toolBarRender={() => [
<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="export" icon={<DownloadOutlined />}>Excel导出</Button>,
]}
/>
{breadcrumb()}
{viewLevel === 'summary' && (
<ProTable<ProductSummaryItem>
headerTitle="产品总览"
actionRef={summaryRef}
rowKey="productName"
columns={summaryColumns}
search={false}
request={async () => {
const res = await getProductSummary();
return { data: res?.data?.list || [], success: res?.code === 0, total: res?.data?.list?.length || 0 };
}}
toolBarRender={() => [
<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="export" icon={<DownloadOutlined />}>Excel导出</Button>,
]}
/>
)}
{viewLevel === 'colors' && (
<ProTable<ColorDetailItem>
headerTitle={`${selectedProductName} - 颜色明细`}
rowKey="productId"
columns={colorColumns}
search={false}
request={async () => {
const res = await getColorDetail(selectedProductName);
return { data: res?.data?.list || [], success: res?.code === 0, total: res?.data?.list?.length || 0 };
}}
/>
)}
{viewLevel === 'tree' && renderTree()}
{/* Create product modal */}
<ModalForm
title="新增产品"
open={createOpen}
onOpenChange={setCreateOpen}
onFinish={async (values) => {
const res = await createProduct(normalizeValues(values));
if (res?.code === 0) { message.success('创建成功'); actionRef.current?.reload(); return true; }
const res = await createProduct({ ...values, salesPrice: String(values.salesPrice || '0') });
if (res?.code === 0) { message.success('创建成功'); summaryRef.current?.reload(); return true; }
message.error(res?.msg || '创建失败');
return false;
}}
>
{productFormFields}
<ProFormText name="productName" label="产品名称" rules={[{ required: true }]} />
<ProFormText name="spec" label="规格型号" />
<ProFormText name="color" label="颜色" />
<ProFormText name="salesPrice" label="销售价" />
<ProFormTextArea name="remark" label="备注" />
</ModalForm>
{/* Edit product modal */}
<ModalForm
title="编辑产品"
open={editOpen}
onOpenChange={setEditOpen}
initialValues={currentRow ?? undefined}
initialValues={currentColor ? {
productName: currentColor.productName,
spec: currentColor.spec,
color: currentColor.color,
salesPrice: currentColor.salesPrice,
} : undefined}
onFinish={async (values) => {
if (!currentRow) return false;
const res = await updateProduct(currentRow.productId, normalizeValues(values));
if (res?.code === 0) { message.success('更新成功'); actionRef.current?.reload(); return true; }
if (!currentColor) return false;
const res = await updateProduct(currentColor.productId, { ...values, salesPrice: String(values.salesPrice || '0') });
if (res?.code === 0) { message.success('更新成功'); return true; }
message.error(res?.msg || '更新失败');
return false;
}}
>
{productFormFields}
<ProFormText name="productName" label="产品名称" rules={[{ required: true }]} />
<ProFormText name="spec" label="规格型号" />
<ProFormText name="color" label="颜色" />
<ProFormText name="salesPrice" label="销售价" />
<ProFormTextArea name="remark" label="备注" />
</ModalForm>
{/* Edit Pans/Bolts Drawer */}
<Drawer
title="编辑盘/匹"
width={640}
open={editTreeDrawer}
onClose={() => setEditTreeDrawer(false)}
extra={
<Button type="primary" onClick={async () => {
const res = await savePans(selectedProductId, panInputs);
if (res?.code === 0) {
message.success('保存成功');
setEditTreeDrawer(false);
loadTree(selectedProductId);
}
}}></Button>
}
>
{panInputs.map((pan, pi) => (
<Card
key={pi}
size="small"
title={`${pan.name}`}
style={{ marginBottom: 12 }}
extra={
<Button danger size="small" onClick={() => setPanInputs((p) => p.filter((_, i) => i !== pi))}></Button>
}
>
<Space style={{ marginBottom: 8 }}>
<Input
placeholder="盘名"
value={pan.name}
onChange={(e) => {
const next = [...panInputs];
next[pi] = { ...next[pi], name: e.target.value };
setPanInputs(next);
}}
style={{ width: 80 }}
/>
<Input
placeholder="位置"
value={pan.position}
onChange={(e) => {
const next = [...panInputs];
next[pi] = { ...next[pi], position: e.target.value };
setPanInputs(next);
}}
style={{ width: 120 }}
/>
</Space>
{pan.boltLengths?.map((len, bi) => (
<Space key={bi} style={{ display: 'flex', marginBottom: 4 }}>
<span>{bi + 1}:</span>
<InputNumber
value={Number(len)}
min={0}
step={0.01}
addonAfter="m"
onChange={(v) => {
const next = [...panInputs];
const bolts = [...(next[pi].boltLengths || [])];
bolts[bi] = String(v || 0);
next[pi] = { ...next[pi], boltLengths: bolts };
setPanInputs(next);
}}
/>
<Button danger size="small" onClick={() => {
const next = [...panInputs];
const bolts = [...(next[pi].boltLengths || [])];
bolts.splice(bi, 1);
next[pi] = { ...next[pi], boltLengths: bolts };
setPanInputs(next);
}}></Button>
</Space>
))}
<Button size="small" type="dashed" block style={{ marginTop: 8 }} onClick={() => {
const next = [...panInputs];
next[pi] = { ...next[pi], boltLengths: [...(next[pi].boltLengths || []), '0'] };
setPanInputs(next);
}}>+ </Button>
</Card>
))}
<Button type="dashed" block onClick={() => {
setPanInputs([...panInputs, { name: String.fromCharCode(65 + panInputs.length), position: '', boltLengths: [] }]);
}}>+ </Button>
</Drawer>
</>
);
};

View File

@ -6,12 +6,8 @@ const columns: ProColumns<Product>[] = [
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
{ title: '规格型号', dataIndex: 'spec' },
{ title: '颜色', dataIndex: 'color' },
{ title: '匹数', dataIndex: 'unitPieces', search: false },
{ title: '盘数', dataIndex: 'unitRolls', search: false },
{ title: '库存数量', dataIndex: 'stockQuantity', search: false, sorter: true },
{ title: '存放位置', dataIndex: 'location' },
{ title: '成本价', dataIndex: 'costPrice', search: false, valueType: 'money' },
{ title: '销售价', dataIndex: 'salesPrice', search: false, valueType: 'money' },
{ title: '更新时间', dataIndex: 'updatedAt', search: false, valueType: 'dateTime' },
];
const StocksPage: React.FC = () => (
@ -25,7 +21,6 @@ const StocksPage: React.FC = () => (
pageSize: params.pageSize,
productName: params.productName,
color: params.color,
location: params.location,
});
return {
data: res?.data?.list || [],

View File

@ -1,13 +1,39 @@
import React, { useState } from 'react';
import { history, useModel } from '@umijs/max';
import { Button, Form, Input, message } from 'antd';
import { LockOutlined, UserOutlined, HomeOutlined } from '@ant-design/icons';
import React, { useCallback, useRef, useState } from 'react';
import { history, useModel, request } from '@umijs/max';
import { Button, Form, Input, message, Select } from 'antd';
import { LockOutlined, UserOutlined, BankOutlined } from '@ant-design/icons';
import { getUserInfo, login } from '@/services/api';
import type { LoginParams } from '@/types/api';
const LoginPage: React.FC = () => {
const { setInitialState } = useModel('@@initialState');
const [loading, setLoading] = useState(false);
const [tenantOptions, setTenantOptions] = useState<{ label: string; value: string }[]>([]);
const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const handleTenantSearch = useCallback((keyword: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
setSearching(true);
try {
const res = await request('/api/v1/public/tenants/search', {
method: 'GET',
params: { keyword, limit: 20 },
});
if (res?.code === 0 && res.data?.list) {
setTenantOptions(
res.data.list.map((t: { id: string; name: string }) => ({
label: `${t.name}${t.id}`,
value: t.id,
})),
);
}
} finally {
setSearching(false);
}
}, 300);
}, []);
const handleSubmit = async (values: LoginParams) => {
setLoading(true);
@ -63,7 +89,7 @@ const LoginPage: React.FC = () => {
<ruby><rt style={{ fontSize: 11, color: '#94A3B8', fontWeight: 400, letterSpacing: 4 }}></rt></ruby>
<ruby><rt style={{ fontSize: 11, color: '#94A3B8', fontWeight: 400, letterSpacing: 2 }}></rt></ruby>
</h1>
<p style={styles.subtitle}></p>
<p style={styles.subtitle}></p>
</div>
<Form<LoginParams>
@ -88,11 +114,20 @@ const LoginPage: React.FC = () => {
/>
</Form.Item>
<Form.Item name="tenantId" rules={[{ required: true, message: '请输入租户ID' }]}>
<Input
prefix={<HomeOutlined style={{ color: '#94A3B8' }} />}
placeholder="租户ID必填"
style={styles.input}
<Form.Item name="tenantId" rules={[{ required: true, message: '请选择所属公司' }]}>
<Select
showSearch
filterOption={false}
placeholder="搜索并选择公司"
onSearch={handleTenantSearch}
onFocus={() => handleTenantSearch('')}
loading={searching}
options={tenantOptions}
notFoundContent={searching ? '搜索中...' : '无匹配公司'}
suffixIcon={<BankOutlined style={{ color: '#94A3B8' }} />}
style={{ height: 48 }}
popupMatchSelectWidth={true}
size="large"
/>
</Form.Item>

View File

@ -1,74 +1,196 @@
import { PlusOutlined } from '@ant-design/icons';
import {
PlusOutlined,
AppstoreOutlined,
ApartmentOutlined,
AuditOutlined,
BarChartOutlined,
BlockOutlined,
DashboardOutlined,
DatabaseOutlined,
DeploymentUnitOutlined,
FileTextOutlined,
ImportOutlined,
MenuOutlined,
NodeIndexOutlined,
ScissorOutlined,
SearchOutlined,
SettingOutlined,
SwapOutlined,
TeamOutlined,
ToolOutlined,
UserOutlined,
} from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { Button, Card, Table, Tag, message, Popconfirm } from 'antd';
import { useEffect, useState } from 'react';
import { Button, Card, Table, Tag, message, Popconfirm, Space } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import { listMenus, deleteMenu } from '@/services/api';
import type { Menu } from '@/types/api';
import type { ColumnsType } from 'antd/es/table';
const ICON_MAP: Record<string, React.ComponentType> = {
AppstoreOutlined, ApartmentOutlined, AuditOutlined, BarChartOutlined,
BlockOutlined, DashboardOutlined, DatabaseOutlined, DeploymentUnitOutlined,
FileTextOutlined, ImportOutlined, MenuOutlined, NodeIndexOutlined,
ScissorOutlined, SearchOutlined, SettingOutlined, SwapOutlined,
TeamOutlined, ToolOutlined, UserOutlined,
};
const MENU_TYPE_MAP: Record<number, { text: string; color: string }> = {
1: { text: '目录', color: 'blue' },
2: { text: '菜单', color: 'green' },
3: { text: '按钮', color: 'orange' },
};
function renderIcon(name: string): React.ReactNode {
if (!name) return null;
const IconComp = ICON_MAP[name];
if (!IconComp) return <span style={{ color: '#999', fontSize: 12 }}>{name}</span>;
return <IconComp />;
}
function buildMenuTree(flat: Menu[]): Menu[] {
const map = new Map<string, Menu>();
const roots: Menu[] = [];
const sorted = [...flat].sort((a, b) => a.sortOrder - b.sortOrder);
for (const item of sorted) {
map.set(item.menuId, { ...item, children: [] });
}
for (const item of sorted) {
const node = map.get(item.menuId)!;
if (!item.parentId) {
roots.push(node);
} else {
const parent = map.get(item.parentId);
if (parent) {
parent.children!.push(node);
} else {
roots.push(node);
}
}
}
const prune = (nodes: Menu[]): Menu[] =>
nodes.map((n) => ({
...n,
children: n.children?.length ? prune(n.children) : undefined,
}));
return prune(roots);
}
function collectKeys(nodes: Menu[]): string[] {
const keys: string[] = [];
for (const n of nodes) {
if (n.children?.length) {
keys.push(n.menuId);
keys.push(...collectKeys(n.children));
}
}
return keys;
}
const MenusPage: React.FC = () => {
const [data, setData] = useState<Menu[]>([]);
const [flatData, setFlatData] = useState<Menu[]>([]);
const [loading, setLoading] = useState(false);
const fetchData = async () => {
setLoading(true);
const res = await listMenus();
if (res?.code === 0) setData(res.data?.list || []);
if (res?.code === 0) setFlatData(res.data?.list || []);
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
const treeData = useMemo(() => buildMenuTree(flatData), [flatData]);
const defaultExpandedKeys = useMemo(() => collectKeys(treeData), [treeData]);
const columns: ColumnsType<Menu> = [
{ title: '菜单名称', dataIndex: 'menuName', key: 'menuName' },
{ title: '路由', dataIndex: 'path', key: 'path' },
{ title: '图标', dataIndex: 'icon', key: 'icon' },
{
title: '菜单名称',
dataIndex: 'menuName',
key: 'menuName',
width: 240,
render: (text: string, record) => (
<Space>
{renderIcon(record.icon)}
<span>{text}</span>
</Space>
),
},
{
title: '路由',
dataIndex: 'path',
key: 'path',
width: 220,
render: (v: string) => v ? <code style={{ fontSize: 13 }}>{v}</code> : '-',
},
{
title: '类型',
dataIndex: 'menuType',
key: 'menuType',
width: 80,
align: 'center',
render: (v: number) => {
const t = MENU_TYPE_MAP[v] ?? MENU_TYPE_MAP[1];
return <Tag color={t.color}>{t.text}</Tag>;
},
},
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder' },
{
title: '权限标识',
dataIndex: 'permission',
key: 'permission',
width: 200,
render: (v: string) => v ? <code style={{ fontSize: 12, color: '#666' }}>{v}</code> : '-',
},
{
title: '排序',
dataIndex: 'sortOrder',
key: 'sortOrder',
width: 70,
align: 'center',
},
{
title: '操作',
key: 'action',
render: (_, record) => [
<a key="edit" style={{ marginRight: 8 }}></a>,
<Popconfirm
key="del"
title="确认删除?"
onConfirm={async () => {
const res = await deleteMenu(record.menuId);
if (res?.code === 0) { message.success('删除成功'); fetchData(); }
}}
>
<a style={{ color: 'var(--ant-color-error)' }}></a>
</Popconfirm>,
],
width: 120,
render: (_, record) => (
<Space size="small">
<a key="edit"></a>
<Popconfirm
key="del"
title="确认删除该菜单?子菜单将一并删除。"
onConfirm={async () => {
const res = await deleteMenu(record.menuId);
if (res?.code === 0) { message.success('删除成功'); fetchData(); }
}}
>
<a style={{ color: 'var(--ant-color-error)' }}></a>
</Popconfirm>
</Space>
),
},
];
return (
<PageContainer>
<Card bordered={false}>
<Button type="primary" icon={<PlusOutlined />} style={{ marginBottom: 16 }}></Button>
<Button type="primary" icon={<PlusOutlined />} style={{ marginBottom: 16 }}>
</Button>
<Table<Menu>
columns={columns}
dataSource={data}
dataSource={treeData}
rowKey="menuId"
loading={loading}
childrenColumnName="children"
pagination={false}
defaultExpandAllRows
expandable={{ defaultExpandedRowKeys: defaultExpandedKeys }}
size="middle"
indentSize={24}
/>
</Card>
</PageContainer>

View File

@ -1,23 +1,29 @@
import { request } from '@umijs/max';
import type {
ApiResponse,
BoltInfo,
ColorDetailItem,
CurrentUser,
LoginParams,
LoginResult,
PaginatedData,
PaginationParams,
Product,
StockSummary,
StockGroup,
StockCheck,
StockAdjust,
User,
Role,
Menu,
OperationLog,
SystemConfig,
PaginatedData,
PaginationParams,
PanInfo,
PanInput,
Product,
ProductSummaryItem,
ProductTree,
Relation,
RelationHistory,
Role,
StockAdjust,
StockCheck,
StockGroup,
StockSummary,
SystemConfig,
User,
} from '@/types/api';
// ── Auth ──────────────────────────────────────────
@ -100,13 +106,13 @@ export const updateConfig = (key: string, value: string) =>
// ── Products ──────────────────────────────────────
export const listProducts = (params: PaginationParams & { productName?: string; spec?: string; color?: string; location?: string }) =>
export const listProducts = (params: PaginationParams & { productName?: string; spec?: string; color?: string }) =>
request<ApiResponse<PaginatedData<Product>>>('/api/v1/inventory/products', { method: 'GET', params });
export const getProduct = (id: string) =>
request<ApiResponse<Product>>(`/api/v1/inventory/products/${id}`, { method: 'GET' });
export const createProduct = (data: Partial<Product>) =>
export const createProduct = (data: Partial<Product> & { pans?: PanInput[] }) =>
request<ApiResponse>('/api/v1/inventory/products', { method: 'POST', data });
export const updateProduct = (id: string, data: Partial<Product>) =>
@ -118,6 +124,55 @@ export const deleteProduct = (id: string) =>
export const exportProducts = () =>
request('/api/v1/inventory/products/export', { method: 'GET', responseType: 'blob' });
// ── Product Panels ────────────────────────────────
export const getProductSummary = () =>
request<ApiResponse<{ list: ProductSummaryItem[] }>>('/api/v1/inventory/products/summary', { method: 'GET' });
export const getColorDetail = (productName: string) =>
request<ApiResponse<{ list: ColorDetailItem[] }>>('/api/v1/inventory/products/colors', { method: 'GET', params: { productName } });
export const getProductTree = (id: string) =>
request<ApiResponse<ProductTree>>(`/api/v1/inventory/products/${id}/tree`, { method: 'GET' });
// ── Pan / Bolt CRUD ───────────────────────────────
export const listPans = (productId: string) =>
request<ApiResponse<{ list: PanInfo[] }>>(`/api/v1/inventory/products/${productId}/pans`, { method: 'GET' });
export const savePans = (productId: string, pans: PanInput[]) =>
request<ApiResponse>(`/api/v1/inventory/products/${productId}/pans`, { method: 'PUT', data: { pans } });
export const listBolts = (panId: string) =>
request<ApiResponse<{ list: BoltInfo[] }>>(`/api/v1/inventory/pans/${panId}/bolts`, { method: 'GET' });
export const saveBolts = (panId: string, lengths: string[]) =>
request<ApiResponse>(`/api/v1/inventory/pans/${panId}/bolts`, { method: 'PUT', data: { lengths } });
// ── Pan / Bolt dimension panels ───────────────────
export const listPanView = (params: {
page?: number;
pageSize?: number;
productName?: string;
position?: string;
}) =>
request<ApiResponse<{ total: number; list: import('@/types/api').PanListItem[] }>>(
'/api/v1/inventory/pans',
{ method: 'GET', params },
);
export const listBoltView = (params: {
page?: number;
pageSize?: number;
productName?: string;
position?: string;
}) =>
request<ApiResponse<{ total: number; list: import('@/types/api').BoltListItem[] }>>(
'/api/v1/inventory/bolts',
{ method: 'GET', params },
);
// ── Stocks ────────────────────────────────────────
export const getStockSummary = () =>

View File

@ -32,27 +32,109 @@ export interface LoginResult {
userInfo: CurrentUser;
}
// ── Product (三层模型) ────────────────────────────
export interface Product {
productId: string;
productName: string;
imageUrl?: string;
spec?: string;
color?: string;
unitPieces: number;
unitRolls: number;
stockQuantity: string;
location?: string;
costPrice: string;
salesPrice: string;
remark?: string;
status?: number;
createdAt: string;
updatedAt: string;
}
export interface BoltInfo {
boltId: string;
panId: string;
lengthM: string;
sortOrder: number;
}
export interface PanInfo {
panId: string;
productId: string;
name: string;
position: string;
sortOrder: number;
bolts: BoltInfo[];
boltCount: number;
totalLength: string;
}
export interface PanInput {
name: string;
position?: string;
boltLengths?: string[];
}
export interface ProductTree {
product: Product;
pans: PanInfo[];
totalPanCount: number;
totalBoltCount: number;
totalLengthM: string;
}
export interface ProductSummaryItem {
productName: string;
spec: string;
colorCount: number;
totalPanCount: number;
totalBoltCount: number;
totalLengthM: string;
colors: string;
locations: string;
}
export interface ColorDetailItem {
productId: string;
productName: string;
color: string;
spec: string;
imageUrl: string;
panCount: number;
boltCount: number;
totalLengthM: string;
locations: string;
salesPrice: string;
}
// ── Pan / Bolt dimension view ─────────────────────
export interface PanListItem {
panId: string;
productId: string;
name: string;
position: string;
productName: string;
colors: string; // aggregated from bolts, e.g. "红色, 蓝色"
boltCount: number;
totalLengthM: string;
}
export interface BoltListItem {
boltId: string;
panId: string;
lengthM: string;
color: string; // bolt's own color
sortOrder: number;
panName: string;
position: string;
productId: string;
productName: string;
}
// ── Stock ─────────────────────────────────────────
export interface StockSummary {
productCount: number;
totalPieces: number;
totalRolls: number;
totalCostValue: string;
totalPans: number;
totalLengthM: string;
totalSalesValue: string;
}
@ -81,6 +163,8 @@ export interface StockAdjust {
createdAt: string;
}
// ── System ────────────────────────────────────────
export interface User {
userId: string;
username: string;
@ -104,12 +188,19 @@ export interface Role {
export interface Menu {
menuId: string;
parentId: string;
menuName: string;
path?: string;
icon?: string;
menuType: number;
path: string;
component: string;
permission: string;
icon: string;
sortOrder: number;
visible: number;
status: number;
children?: Menu[];
createdAt: string;
updatedAt: string;
}
export interface OperationLog {