Files
yunrui_asset/frontend/src/pages/InventoryManagement.jsx
T
zhangbing 1d815b812e feat: 实现安全存储和批量导入功能增强
refactor(前端): 重构认证和存储逻辑使用secureStorage
feat(前端): 添加安全存储工具和日志工具
feat(前端): 增强批量导入功能支持服务器和交换机设备
fix(后端): 改进设备端口和网卡批量导入接口
docs: 更新导入模板和操作说明
2026-03-29 10:06:32 +08:00

716 lines
22 KiB
React

import React, { useState, useEffect, useCallback } from 'react';
import {
Table,
Button,
Modal,
Form,
Input,
Select,
DatePicker,
message,
Card,
Space,
Tag,
Row,
Col,
Statistic,
Popconfirm,
Tooltip,
Empty,
Progress,
Tabs,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
PlayCircleOutlined,
CheckCircleOutlined,
SearchOutlined,
ReloadOutlined,
InboxOutlined,
InfoCircleOutlined,
FileSearchOutlined,
ClockCircleOutlined,
CheckSquareOutlined,
SyncOutlined,
} from '@ant-design/icons';
import api from '../api';
import dayjs from 'dayjs';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { designTokens } from '../config/theme';
import CloseButton from '../components/CloseButton';
const { RangePicker } = DatePicker;
const { TabPane } = Tabs;
const InventoryManagement = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const planIdParam = searchParams.get('planId');
const [activeTab, setActiveTab] = useState(planIdParam ? 'execution' : 'list');
const [plans, setPlans] = useState([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingPlan, setEditingPlan] = useState(null);
const [rooms, setRooms] = useState([]);
const [racks, setRacks] = useState([]);
const [filteredRacks, setFilteredRacks] = useState([]);
const [selectedRooms, setSelectedRooms] = useState([]);
const [stats, setStats] = useState({
totalPlans: 0,
completedPlans: 0,
inProgressPlans: 0,
});
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const [searchParamsObj, setSearchParamsObj] = useState({});
const [form] = Form.useForm();
const fetchPlans = useCallback(async () => {
setLoading(true);
try {
const params = {
page: pagination.current,
pageSize: pagination.pageSize,
...searchParamsObj,
};
const res = await api.get('/inventory/plans', { params });
setPlans(res.data.plans || []);
setPagination(prev => ({
...prev,
total: res.data.total || 0,
}));
} catch (error) {
message.error('获取盘点计划失败');
} finally {
setLoading(false);
}
}, [pagination.current, pagination.pageSize, searchParamsObj]);
const fetchStats = async () => {
try {
const res = await api.get('/inventory/stats/dashboard');
setStats({
totalPlans: res.data.totalPlans || 0,
completedPlans: res.data.completedPlans || 0,
inProgressPlans: res.data.inProgressPlans || 0,
});
} catch (error) {
console.error('获取统计失败', error);
}
};
const fetchRooms = async () => {
try {
const res = await api.get('/rooms', { params: { pageSize: 1000 } });
setRooms(Array.isArray(res.data) ? res.data : res.data.rooms || []);
} catch (error) {
console.error('获取机房失败', error);
}
};
const fetchRacks = async () => {
try {
const res = await api.get('/racks', { params: { pageSize: 1000 } });
const allRacks = Array.isArray(res.data) ? res.data : res.data.racks || [];
setRacks(allRacks);
setFilteredRacks(allRacks);
} catch (error) {
console.error('获取机柜失败', error);
}
};
useEffect(() => {
fetchPlans();
fetchStats();
fetchRooms();
fetchRacks();
}, [fetchPlans]);
useEffect(() => {
if (planIdParam) {
setActiveTab('execution');
}
}, [planIdParam]);
const handleAdd = () => {
setEditingPlan(null);
form.resetFields();
setSelectedRooms([]);
setFilteredRacks(racks);
setModalVisible(true);
};
const handleEdit = record => {
setEditingPlan(record);
const targetRooms = record.targetRooms || [];
setSelectedRooms(targetRooms);
if (targetRooms.length > 0) {
const filtered = racks.filter(
rack =>
targetRooms.includes(rack.roomId) || (rack.Room && targetRooms.includes(rack.Room.roomId))
);
setFilteredRacks(filtered);
} else {
setFilteredRacks(racks);
}
form.setFieldsValue({
name: record.name,
type: record.type,
description: record.description,
scheduledDate: record.scheduledDate ? dayjs(record.scheduledDate) : null,
targetRooms: targetRooms,
targetRacks: record.targetRacks || [],
});
setModalVisible(true);
};
const handleDelete = async planId => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个盘点计划吗?此操作不可恢复!',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await api.delete(`/inventory/plans/${planId}`);
message.success('删除成功');
fetchPlans();
fetchStats();
} catch (error) {
message.error('删除失败');
}
},
});
};
const handleSubmit = async values => {
try {
const data = {
...values,
scheduledDate: values.scheduledDate?.toISOString(),
targetRooms: values.targetRooms || [],
targetRacks: values.targetRacks || [],
};
if (editingPlan) {
await api.put(`/inventory/plans/${editingPlan.planId}`, data);
message.success('更新成功');
} else {
await api.post('/inventory/plans', data);
message.success('创建成功');
}
setModalVisible(false);
fetchPlans();
fetchStats();
} catch (error) {
message.error(error.response?.data?.error || '操作失败');
}
};
const handleStart = async plan => {
try {
await api.post(`/inventory/plans/${plan.planId}/start`);
message.success('盘点任务已启动');
fetchPlans();
fetchStats();
} catch (error) {
message.error(error.response?.data?.error || '启动失败');
}
};
const handleComplete = async plan => {
try {
await api.post(`/inventory/plans/${plan.planId}/complete`);
message.success('盘点已完成');
fetchPlans();
fetchStats();
} catch (error) {
message.error(error.response?.data?.error || '完成失败');
}
};
const handleViewTasks = plan => {
navigate(`/inventory/execution?planId=${plan.planId}`);
};
const handleRoomsChange = roomIds => {
setSelectedRooms(roomIds || []);
if (!roomIds || roomIds.length === 0) {
setFilteredRacks(racks);
} else {
const filtered = racks.filter(
rack => roomIds.includes(rack.roomId) || (rack.Room && roomIds.includes(rack.Room.roomId))
);
setFilteredRacks(filtered);
}
};
const getStatusTag = status => {
const statusMap = {
draft: { color: 'default', text: '草稿', icon: <FileSearchOutlined /> },
pending: { color: 'orange', text: '待执行', icon: <ClockCircleOutlined /> },
in_progress: { color: 'processing', text: '进行中', icon: <SyncOutlined spin /> },
completed: { color: 'success', text: '已完成', icon: <CheckSquareOutlined /> },
cancelled: { color: 'error', text: '已取消', icon: <DeleteOutlined /> },
};
const config = statusMap[status] || statusMap.draft;
return (
<Tag color={config.color} icon={config.icon} style={{ borderRadius: 6, padding: '2px 8px' }}>
{config.text}
</Tag>
);
};
const getTypeTag = type => {
const typeMap = {
full: { color: 'blue', text: '全面盘点' },
partial: { color: 'cyan', text: '局部盘点' },
sample: { color: 'purple', text: '抽样盘点' },
};
const config = typeMap[type] || typeMap.full;
return <Tag color={config.color}>{config.text}</Tag>;
};
const getProgressPercent = plan => {
if (!plan.totalDevices || plan.totalDevices === 0) return 0;
return Math.round((plan.checkedDevices / plan.totalDevices) * 100);
};
const columns = [
{
title: '盘点计划',
key: 'planInfo',
render: (_, record) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '44px',
height: '44px',
borderRadius: '10px',
background: 'linear-gradient(135deg, #1890ff15 0%, #1890ff08 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<InboxOutlined style={{ fontSize: '22px', color: designTokens.colors.primary.main }} />
</div>
<div>
<div style={{ fontWeight: 600, color: designTokens.colors.text.primary }}>
{record.name}
</div>
<div style={{ fontSize: '12px', color: designTokens.colors.text.tertiary }}>
{record.planId}
</div>
</div>
</div>
),
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 100,
render: type => getTypeTag(type),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 120,
render: status => getStatusTag(status),
},
{
title: '盘点进度',
key: 'progress',
width: 180,
render: (_, record) => (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: '#8c8c8c' }}>
已盘: {record.checkedDevices || 0} / {record.totalDevices || 0}
</span>
<span style={{ fontSize: 12, color: '#52c41a', fontWeight: 600 }}>
{getProgressPercent(record)}%
</span>
</div>
<Progress
percent={getProgressPercent(record)}
size="small"
strokeColor={{
'0%': '#108ee9',
'100%': '#52c41a',
}}
/>
</div>
),
},
{
title: '异常设备',
key: 'abnormal',
width: 100,
render: (_, record) =>
record.abnormalDevices > 0 ? (
<Tag color="error">{record.abnormalDevices} 异常</Tag>
) : (
<span style={{ color: '#8c8c8c' }}>-</span>
),
},
{
title: '创建人',
dataIndex: ['Creator', 'realName'],
key: 'creator',
width: 100,
render: name => name || '-',
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: date => (date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right',
render: (_, record) => (
<Space>
<Tooltip title="查看任务">
<Button
type="text"
icon={<InfoCircleOutlined />}
onClick={() => handleViewTasks(record)}
style={{ color: designTokens.colors.text.secondary }}
/>
</Tooltip>
{record.status === 'draft' || record.status === 'pending' ? (
<Tooltip title="启动盘点">
<Button
type="text"
icon={<PlayCircleOutlined />}
onClick={() => handleStart(record)}
style={{ color: '#52c41a' }}
/>
</Tooltip>
) : null}
{record.status === 'in_progress' ? (
<>
<Tooltip title="继续盘点">
<Button
type="text"
icon={<PlayCircleOutlined />}
onClick={() => handleViewTasks(record)}
style={{ color: '#1890ff' }}
/>
</Tooltip>
<Tooltip title="完成盘点">
<Button
type="text"
icon={<CheckCircleOutlined />}
onClick={() => handleComplete(record)}
style={{ color: '#52c41a' }}
/>
</Tooltip>
</>
) : null}
<Tooltip title="编辑">
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
style={{ color: designTokens.colors.primary.main }}
/>
</Tooltip>
<Popconfirm
title="确定删除此盘点计划吗?"
onConfirm={() => handleDelete(record.planId)}
okText="确定"
cancelText="取消"
>
<Tooltip title="删除">
<Button type="text" icon={<DeleteOutlined />} danger />
</Tooltip>
</Popconfirm>
</Space>
),
},
];
const statCards = [
{
title: '总计划',
value: stats.totalPlans,
icon: <InboxOutlined />,
gradient: 'linear-gradient(135deg, #1890ff 0%, #096dd9 100%)',
},
{
title: '已完成',
value: stats.completedPlans,
icon: <CheckCircleOutlined />,
gradient: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)',
},
{
title: '进行中',
value: stats.inProgressPlans,
icon: <SyncOutlined spin />,
gradient: 'linear-gradient(135deg, #faad14 0%, #d48806 100%)',
},
];
return (
<div
style={{
padding: 24,
background: designTokens.colors.background.secondary,
minHeight: '100vh',
}}
>
<div style={{ marginBottom: 24 }}>
<Row gutter={[16, 16]}>
{statCards.map((stat, index) => (
<Col xs={24} sm={8} key={index}>
<Card
bordered={false}
style={{
borderRadius: 16,
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
background: stat.gradient,
}}
bodyStyle={{ padding: 20 }}
>
<div
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<div>
<div style={{ color: 'rgba(255,255,255,0.9)', fontSize: 14, marginBottom: 8 }}>
{stat.title}
</div>
<div style={{ color: '#fff', fontSize: 28, fontWeight: 'bold' }}>
{stat.value || 0}
</div>
</div>
<div
style={{
width: 56,
height: 56,
borderRadius: 12,
background: 'rgba(255,255,255,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
color: '#fff',
}}
>
{stat.icon}
</div>
</div>
</Card>
</Col>
))}
</Row>
</div>
<Card bordered={false} style={{ borderRadius: 16, boxShadow: '0 2px 8px rgba(0,0,0,0.06)' }}>
<Tabs activeKey={activeTab} onChange={setActiveTab} style={{ marginBottom: 16 }}>
<TabPane tab="盘点计划列表" key="list">
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 12,
}}
>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<Input
placeholder="搜索计划名称"
prefix={<SearchOutlined />}
style={{ width: 240, borderRadius: 8 }}
onChange={e => setSearchParamsObj({ keyword: e.target.value })}
onPressEnter={() => {
setPagination(prev => ({ ...prev, current: 1 }));
fetchPlans();
}}
/>
<Select
placeholder="选择状态"
style={{ width: 140, borderRadius: 8 }}
allowClear
onChange={value => {
setSearchParamsObj(prev => ({ ...prev, status: value }));
setPagination(prev => ({ ...prev, current: 1 }));
}}
>
<Select.Option value="draft">草稿</Select.Option>
<Select.Option value="pending">待执行</Select.Option>
<Select.Option value="in_progress">进行中</Select.Option>
<Select.Option value="completed">已完成</Select.Option>
</Select>
<Button
icon={<ReloadOutlined />}
onClick={() => {
setSearchParamsObj({});
fetchPlans();
fetchStats();
}}
style={{ borderRadius: 8 }}
>
刷新
</Button>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAdd}
style={{ borderRadius: 8, height: 40 }}
>
新建盘点计划
</Button>
</div>
<Table
columns={columns}
dataSource={plans}
loading={loading}
rowKey="planId"
pagination={{
...pagination,
showSizeChanger: true,
showQuickJumper: true,
showTotal: total => `共 ${total} 条记录`,
onChange: (page, pageSize) => {
setPagination(prev => ({ ...prev, current: page, pageSize }));
},
}}
scroll={{ x: 1200 }}
locale={{
emptyText: (
<Empty description="暂无盘点计划" image={Empty.PRESENTED_IMAGE_SIMPLE} />
),
}}
/>
</TabPane>
</Tabs>
</Card>
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<InboxOutlined style={{ fontSize: 18, color: '#1890ff' }} />
<span>{editingPlan ? '编辑盘点计划' : '新建盘点计划'}</span>
</div>
}
open={modalVisible}
closeIcon={<CloseButton />}
onCancel={() => setModalVisible(false)}
footer={null}
width={640}
centered
styles={{
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' },
body: { padding: 24 },
footer: { borderTop: '1px solid #f0f0f0', padding: '12px 24px' },
}}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Row gutter={16}>
<Col span={16}>
<Form.Item
name="name"
label="计划名称"
rules={[{ required: true, message: '请输入计划名称' }]}
>
<Input placeholder="请输入计划名称" style={{ borderRadius: 8 }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="type"
label="盘点类型"
rules={[{ required: true, message: '请选择盘点类型' }]}
initialValue="full"
>
<Select style={{ borderRadius: 8 }}>
<Select.Option value="full">全面盘点</Select.Option>
<Select.Option value="partial">局部盘点</Select.Option>
<Select.Option value="sample">抽样盘点</Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item name="description" label="描述">
<Input.TextArea rows={2} placeholder="请输入描述" style={{ borderRadius: 8 }} />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="scheduledDate" label="计划日期">
<DatePicker style={{ width: '100%', borderRadius: 8 }} placeholder="选择计划日期" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="targetRooms" label="目标机房">
<Select
mode="multiple"
placeholder="选择目标机房(不选则为全部)"
allowClear
onChange={handleRoomsChange}
style={{ borderRadius: 8 }}
>
{rooms.map(room => (
<Select.Option key={room.roomId} value={room.roomId}>
{room.name}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item name="targetRacks" label="目标机柜">
<Select
mode="multiple"
placeholder="选择目标机柜(不选则为全部)"
allowClear
style={{ borderRadius: 8 }}
>
{filteredRacks.map(rack => (
<Select.Option key={rack.rackId} value={rack.rackId}>
{rack.name} ({rack.Room?.name || ''})
</Select.Option>
))}
</Select>
</Form.Item>
<div style={{ textAlign: 'right', marginTop: 16 }}>
<Space>
<Button onClick={() => setModalVisible(false)} style={{ borderRadius: 8 }}>
取消
</Button>
<Button type="primary" htmlType="submit" style={{ borderRadius: 8 }}>
{editingPlan ? '更新' : '创建'}
</Button>
</Space>
</div>
</Form>
</Modal>
</div>
);
};
export default InventoryManagement;