feat(资产盘点): 新增暂存设备管理功能
This commit is contained in:
@@ -34,6 +34,7 @@ import {
|
||||
ApiOutlined,
|
||||
PartitionOutlined,
|
||||
CodepenOutlined,
|
||||
CloudUploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
@@ -73,6 +74,7 @@ const CableManagement = lazy(() => import('./pages/CableManagement'));
|
||||
const PortManagement = lazy(() => import('./pages/PortManagement'));
|
||||
const InventoryManagement = lazy(() => import('./pages/InventoryManagement'));
|
||||
const InventoryTaskExecution = lazy(() => import('./pages/InventoryTaskExecution'));
|
||||
const PendingDeviceManagement = lazy(() => import('./pages/PendingDeviceManagement'));
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
@@ -199,6 +201,7 @@ const AppLayout = ({ children }) => {
|
||||
)
|
||||
return 'system-management';
|
||||
if (path.startsWith('/tickets')) return 'ticket-management';
|
||||
if (path.startsWith('/inventory') || path.startsWith('/pending-devices')) return 'inventory-management';
|
||||
return 'dashboard';
|
||||
};
|
||||
|
||||
@@ -321,6 +324,11 @@ const AppLayout = ({ children }) => {
|
||||
icon: <InboxOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/inventory">盘点计划</Link>,
|
||||
},
|
||||
{
|
||||
key: 'pending-devices',
|
||||
icon: <CloudUploadOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/pending-devices">暂存设备</Link>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -721,6 +729,14 @@ const ThemeConfig = () => {
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/pending-devices"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<PendingDeviceManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ports"
|
||||
element={
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { designTokens } from '../config/theme';
|
||||
import {
|
||||
PAGINATION_CONFIG,
|
||||
@@ -226,6 +226,7 @@ const ResizableTitle = props => {
|
||||
|
||||
function DeviceManagement() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [allDevices, setAllDevices] = useState([]);
|
||||
const [racks, setRacks] = useState([]);
|
||||
@@ -517,6 +518,35 @@ function DeviceManagement() {
|
||||
fetchDeviceFields();
|
||||
}, [fetchDevices]);
|
||||
|
||||
// 处理URL参数中的设备ID,自动打开设备详情
|
||||
useEffect(() => {
|
||||
const deviceIdFromUrl = searchParams.get('deviceId');
|
||||
if (deviceIdFromUrl) {
|
||||
const fetchDeviceDetail = async () => {
|
||||
try {
|
||||
const response = await axios.get(`/api/devices/${deviceIdFromUrl}`);
|
||||
if (response.data) {
|
||||
setSelectedDevice(response.data);
|
||||
setDetailModalVisible(true);
|
||||
setSearchParams({});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取设备详情失败:', error);
|
||||
const errorStatus = error.response?.status;
|
||||
const errorMessage = error.response?.data?.error;
|
||||
|
||||
if (errorStatus === 404 || errorMessage === '设备不存在') {
|
||||
message.warning('该设备已被删除,无法查看详情');
|
||||
} else {
|
||||
message.error(errorMessage || '获取设备详情失败');
|
||||
}
|
||||
setSearchParams({});
|
||||
}
|
||||
};
|
||||
fetchDeviceDetail();
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
// 同步当前页设备数据
|
||||
useEffect(() => {
|
||||
if (filteredDevicesMemo.length > 0) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,881 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
InputNumber,
|
||||
DatePicker,
|
||||
message,
|
||||
Card,
|
||||
Space,
|
||||
Tag,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Progress,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Tooltip,
|
||||
Popconfirm,
|
||||
} from 'antd';
|
||||
import {
|
||||
SyncOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
InboxOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloudUploadOutlined,
|
||||
CloudServerOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { designTokens } from '../config/theme';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
const PendingDeviceManagement = () => {
|
||||
const navigate = useNavigate();
|
||||
const [pendingDevices, setPendingDevices] = useState([]);
|
||||
const [rooms, setRooms] = useState([]);
|
||||
const [racks, setRacks] = useState([]);
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [deviceFields, setDeviceFields] = useState(null); // 初始值改为 null,表示未加载
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState({ total: 0, pending: 0, synced: 0 });
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [currentDevice, setCurrentDevice] = useState(null);
|
||||
const [form] = Form.useForm();
|
||||
const [selectedRoomId, setSelectedRoomId] = useState(null);
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
status: null,
|
||||
planId: null,
|
||||
roomId: null,
|
||||
keyword: '',
|
||||
});
|
||||
|
||||
const defaultDeviceFields = [
|
||||
{ fieldName: 'deviceName', displayName: '设备名称', fieldType: 'string', required: true, visible: true, order: 1 },
|
||||
{ fieldName: 'deviceType', displayName: '设备类型', fieldType: 'select', required: true, visible: true, order: 2, options: [
|
||||
{ value: 'server', label: '服务器' },
|
||||
{ value: 'switch', label: '交换机' },
|
||||
{ value: 'router', label: '路由器' },
|
||||
{ value: 'storage', label: '存储设备' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]},
|
||||
{ fieldName: 'model', displayName: '型号', fieldType: 'string', required: false, visible: true, order: 3 },
|
||||
{ fieldName: 'serialNumber', displayName: '序列号', fieldType: 'string', required: true, visible: true, order: 4 },
|
||||
{ fieldName: 'roomId', displayName: '所属机房', fieldType: 'select', required: false, visible: true, order: 5 },
|
||||
{ fieldName: 'rackId', displayName: '所属机柜', fieldType: 'select', required: false, visible: true, order: 6 },
|
||||
{ fieldName: 'position', displayName: '位置(U)', fieldType: 'number', required: false, visible: true, order: 7 },
|
||||
{ fieldName: 'height', displayName: '高度(U)', fieldType: 'number', required: false, visible: true, order: 8 },
|
||||
{ fieldName: 'powerConsumption', displayName: '功率(W)', fieldType: 'number', required: false, visible: true, order: 9 },
|
||||
{ fieldName: 'purchaseDate', displayName: '购买日期', fieldType: 'date', required: false, visible: true, order: 10 },
|
||||
{ fieldName: 'warrantyExpiry', displayName: '保修到期', fieldType: 'date', required: false, visible: true, order: 11 },
|
||||
{ fieldName: 'ipAddress', displayName: 'IP地址', fieldType: 'string', required: false, visible: true, order: 12 },
|
||||
{ fieldName: 'brand', displayName: '品牌', fieldType: 'string', required: false, visible: true, order: 13 },
|
||||
{ fieldName: 'description', displayName: '描述', fieldType: 'textarea', required: false, visible: true, order: 14 },
|
||||
{ fieldName: 'remark', displayName: '备注', fieldType: 'textarea', required: false, visible: true, order: 15 },
|
||||
];
|
||||
|
||||
const fetchDeviceFields = async () => {
|
||||
try {
|
||||
const res = await api.get('/deviceFields');
|
||||
const sortedFields = res.data.sort((a, b) => a.order - b.order);
|
||||
setDeviceFields(sortedFields);
|
||||
} catch (error) {
|
||||
console.error('获取字段配置失败:', error);
|
||||
setDeviceFields(defaultDeviceFields);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPendingDevices = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
...filters,
|
||||
};
|
||||
Object.keys(params).forEach(key => {
|
||||
if (params[key] === null || params[key] === '' || params[key] === undefined) {
|
||||
delete params[key];
|
||||
}
|
||||
});
|
||||
|
||||
const res = await api.get('/inventory/pending-devices', { params });
|
||||
setPendingDevices(res.data.pendingDevices || []);
|
||||
setPagination(prev => ({ ...prev, total: res.data.total }));
|
||||
} catch (error) {
|
||||
message.error('获取暂存设备列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pagination.current, pagination.pageSize, filters]);
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await api.get('/inventory/pending-devices/stats');
|
||||
setStats(res.data);
|
||||
} catch (error) {
|
||||
console.error('获取统计失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
const res = await api.get('/rooms');
|
||||
setRooms(res.data.rooms || res.data || []);
|
||||
} catch (error) {
|
||||
console.error('获取机房列表失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRacks = async () => {
|
||||
try {
|
||||
const res = await api.get('/racks', { params: { pageSize: 1000 } });
|
||||
setRacks(res.data.racks || res.data || []);
|
||||
} catch (error) {
|
||||
console.error('获取机柜列表失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
const res = await api.get('/inventory/plans', { params: { pageSize: 100 } });
|
||||
setPlans(res.data.plans || []);
|
||||
} catch (error) {
|
||||
console.error('获取盘点计划列表失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPendingDevices();
|
||||
fetchStats();
|
||||
}, [fetchPendingDevices]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms();
|
||||
fetchRacks();
|
||||
fetchPlans();
|
||||
fetchDeviceFields();
|
||||
}, []);
|
||||
|
||||
const filteredRacks = selectedRoomId
|
||||
? racks.filter(rack => rack.roomId === selectedRoomId)
|
||||
: racks;
|
||||
|
||||
const handleSync = async (pendingId) => {
|
||||
try {
|
||||
const res = await api.post(`/inventory/pending-devices/${pendingId}/sync`);
|
||||
message.success(res.data.message);
|
||||
fetchPendingDevices();
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || '同步失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchSync = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning('请先选择要同步的设备');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.post('/inventory/pending-devices/batch-sync', {
|
||||
pendingIds: selectedRowKeys,
|
||||
});
|
||||
message.success(res.data.message);
|
||||
setSelectedRowKeys([]);
|
||||
fetchPendingDevices();
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || '批量同步失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (record) => {
|
||||
setCurrentDevice(record);
|
||||
setSelectedRoomId(record.roomId);
|
||||
|
||||
// 字段名映射:数据库字段名 -> PendingDevice 模型字段名
|
||||
const fieldMapping = {
|
||||
'name': 'deviceName',
|
||||
'type': 'deviceType',
|
||||
'SN': 'serialNumber',
|
||||
};
|
||||
|
||||
// 确保使用 deviceFields 中定义的字段名来设置表单值
|
||||
const formValues = {
|
||||
serialNumber: record.serialNumber,
|
||||
roomId: record.roomId,
|
||||
rackId: record.rackId,
|
||||
};
|
||||
|
||||
// 使用已加载的字段配置或默认配置
|
||||
const fields = deviceFields || defaultDeviceFields;
|
||||
|
||||
// 遍历字段,将 record 中对应的值设置到表单
|
||||
fields.forEach(field => {
|
||||
const fieldName = field.fieldName;
|
||||
// 获取实际在 record 中的字段名(使用映射)
|
||||
const recordFieldName = fieldMapping[fieldName] || fieldName;
|
||||
if (record[recordFieldName] !== undefined && record[recordFieldName] !== null) {
|
||||
formValues[fieldName] = record[recordFieldName];
|
||||
}
|
||||
});
|
||||
|
||||
// 处理自定义字段
|
||||
if (record.customFields) {
|
||||
Object.entries(record.customFields).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
formValues[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
form.setFieldsValue(formValues);
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEditSubmit = async (values) => {
|
||||
try {
|
||||
const { purchaseDate, warrantyExpiry, ...otherValues } = values;
|
||||
|
||||
const payload = {
|
||||
...otherValues,
|
||||
purchaseDate: purchaseDate ? dayjs(purchaseDate).toISOString() : null,
|
||||
warrantyExpiry: warrantyExpiry ? dayjs(warrantyExpiry).toISOString() : null
|
||||
};
|
||||
|
||||
await api.put(`/inventory/pending-devices/${currentDevice.pendingId}`, payload);
|
||||
message.success('更新成功');
|
||||
setEditModalVisible(false);
|
||||
setSelectedRoomId(null);
|
||||
fetchPendingDevices();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || '更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (pendingId) => {
|
||||
try {
|
||||
await api.delete(`/inventory/pending-devices/${pendingId}`);
|
||||
message.success('删除成功');
|
||||
fetchPendingDevices();
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusTag = (status) => {
|
||||
const statusMap = {
|
||||
pending: { color: 'processing', text: '待同步', icon: <SyncOutlined spin /> },
|
||||
synced: { color: 'success', text: '已同步', icon: <CheckCircleOutlined /> },
|
||||
};
|
||||
const config = statusMap[status] || statusMap.pending;
|
||||
return <Tag color={config.color} icon={config.icon}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const getTypeLabel = (type) => {
|
||||
const typeMap = {
|
||||
server: '服务器',
|
||||
switch: '交换机',
|
||||
router: '路由器',
|
||||
storage: '存储设备',
|
||||
other: '其他',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
const renderFormField = (field) => {
|
||||
const { fieldName, displayName, fieldType, required, options } = field;
|
||||
|
||||
if (fieldName === 'roomId') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
label={displayName}
|
||||
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
|
||||
>
|
||||
<Select
|
||||
placeholder={`请选择${displayName}`}
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
onChange={(value) => {
|
||||
setSelectedRoomId(value);
|
||||
form.setFieldsValue({ rackId: undefined });
|
||||
}}
|
||||
>
|
||||
{rooms.length > 0 ? (
|
||||
rooms.map(room => (
|
||||
<Select.Option key={room.roomId} value={room.roomId}>
|
||||
{room.name}
|
||||
</Select.Option>
|
||||
))
|
||||
) : (
|
||||
<Select.Option value="" disabled>暂无机房数据</Select.Option>
|
||||
)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldName === 'rackId') {
|
||||
const hasRoom = selectedRoomId || rooms.length === 0;
|
||||
return (
|
||||
<Form.Item
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
label={displayName}
|
||||
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
|
||||
>
|
||||
<Select
|
||||
placeholder={selectedRoomId ? `请选择${displayName}` : "请先选择机房"}
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
disabled={!selectedRoomId}
|
||||
>
|
||||
{selectedRoomId ? (
|
||||
filteredRacks.length > 0 ? (
|
||||
filteredRacks.map(rack => (
|
||||
<Select.Option key={rack.rackId} value={rack.rackId}>
|
||||
{rack.name}
|
||||
</Select.Option>
|
||||
))
|
||||
) : (
|
||||
<Select.Option value="" disabled>该机房下无机柜</Select.Option>
|
||||
)
|
||||
) : (
|
||||
<Select.Option value="" disabled>请先选择机房</Select.Option>
|
||||
)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldName === 'deviceType') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
label={displayName}
|
||||
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
|
||||
>
|
||||
<Select placeholder={`请选择${displayName}`}>
|
||||
{(options || [
|
||||
{ value: 'server', label: '服务器' },
|
||||
{ value: 'switch', label: '交换机' },
|
||||
{ value: 'router', label: '路由器' },
|
||||
{ value: 'storage', label: '存储设备' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]).map(opt => (
|
||||
<Select.Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldType === 'select') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
label={displayName}
|
||||
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
|
||||
>
|
||||
<Select placeholder={`请选择${displayName}`} allowClear>
|
||||
{(options || []).map(opt => (
|
||||
<Select.Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldType === 'number') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
label={displayName}
|
||||
rules={required ? [{ required: true, message: `请输入${displayName}` }] : []}
|
||||
>
|
||||
<InputNumber placeholder={`请输入${displayName}`} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldType === 'date') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
label={displayName}
|
||||
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} placeholder={`请选择${displayName}`} />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldType === 'text' || fieldType === 'textarea') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
label={displayName}
|
||||
rules={required ? [{ required: true, message: `请输入${displayName}` }] : []}
|
||||
>
|
||||
<Input.TextArea rows={2} placeholder={`请输入${displayName}`} />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
label={displayName}
|
||||
rules={required ? [{ required: true, message: `请输入${displayName}` }] : []}
|
||||
>
|
||||
<Input placeholder={`请输入${displayName}`} />
|
||||
</Form.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFormFields = () => {
|
||||
// 如果字段配置未加载,使用默认配置
|
||||
const fields = deviceFields || defaultDeviceFields;
|
||||
|
||||
// 排除机房和机柜字段(已在Modal中单独渲染)
|
||||
const textFields = []; // 描述、备注等全文本字段
|
||||
const otherFields = []; // 其他字段
|
||||
|
||||
fields.forEach(field => {
|
||||
// 排除 roomId 和 rackId
|
||||
if (field.fieldName === 'roomId' || field.fieldName === 'rackId') {
|
||||
return;
|
||||
}
|
||||
if (field.visible !== false) {
|
||||
if (field.fieldType === 'text') {
|
||||
textFields.push(field);
|
||||
} else {
|
||||
otherFields.push(field);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 其他字段两列布局 */}
|
||||
{otherFields.length > 0 && (
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
{otherFields.filter((_, i) => i % 2 === 0).map(renderFormField)}
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
{otherFields.filter((_, i) => i % 2 === 1).map(renderFormField)}
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* 文本字段全文本显示 */}
|
||||
{textFields.map(renderFormField)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '设备信息',
|
||||
key: 'deviceInfo',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{record.deviceName}</div>
|
||||
<div style={{ fontSize: '12px', color: designTokens.colors.text.tertiary }}>
|
||||
<code>{record.serialNumber}</code>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '设备类型',
|
||||
dataIndex: 'deviceType',
|
||||
key: 'deviceType',
|
||||
width: 100,
|
||||
render: (type) => getTypeLabel(type),
|
||||
},
|
||||
{
|
||||
title: '位置',
|
||||
key: 'location',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<span>
|
||||
{record.Room?.name || '-'}
|
||||
{record.Rack ? ` / ${record.Rack.name}` : ''}
|
||||
{record.position ? ` / U${record.position}` : ''}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '盘点计划',
|
||||
dataIndex: ['Plan', 'name'],
|
||||
key: 'planName',
|
||||
width: 150,
|
||||
render: (name) => name || '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status) => getStatusTag(status),
|
||||
},
|
||||
{
|
||||
title: '创建人',
|
||||
dataIndex: ['Creator', 'realName'],
|
||||
key: 'creator',
|
||||
width: 100,
|
||||
render: (name) => name || '-',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 140,
|
||||
render: (date) => date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '-',
|
||||
},
|
||||
{
|
||||
title: '同步时间',
|
||||
dataIndex: 'syncedAt',
|
||||
key: 'syncedAt',
|
||||
width: 140,
|
||||
render: (date) => date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
{record.status === 'pending' && (
|
||||
<>
|
||||
<Tooltip title="同步到设备管理">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloudUploadOutlined />}
|
||||
onClick={() => handleSync(record.pendingId)}
|
||||
style={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="编辑">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
style={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{record.status === 'synced' && record.syncedDeviceId && (
|
||||
<Tooltip title="查看设备">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloudServerOutlined />}
|
||||
onClick={() => navigate(`/devices?deviceId=${record.syncedDeviceId}`)}
|
||||
style={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确定要删除此暂存设备吗?"
|
||||
onConfirm={() => handleDelete(record.pendingId)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
style={{ color: '#ff4d4f' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, background: designTokens.colors.bg, minHeight: '100vh' }}>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ borderRadius: 12 }}>
|
||||
<Statistic
|
||||
title="暂存设备总数"
|
||||
value={stats.total}
|
||||
prefix={<InboxOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ borderRadius: 12 }}>
|
||||
<Statistic
|
||||
title="待同步"
|
||||
value={stats.pending}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
prefix={<SyncOutlined spin />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ borderRadius: 12 }}>
|
||||
<Statistic
|
||||
title="已同步"
|
||||
value={stats.synced}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ borderRadius: 12 }}>
|
||||
<Statistic
|
||||
title="同步进度"
|
||||
value={stats.total > 0 ? Math.round((stats.synced / stats.total) * 100) : 0}
|
||||
suffix="%"
|
||||
/>
|
||||
<Progress
|
||||
percent={stats.total > 0 ? Math.round((stats.synced / stats.total) * 100) : 0}
|
||||
showInfo={false}
|
||||
strokeColor="#52c41a"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card bordered={false} style={{ borderRadius: 12 }}>
|
||||
<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: 200 }}
|
||||
value={filters.keyword}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, keyword: e.target.value }))}
|
||||
onPressEnter={() => setPagination(prev => ({ ...prev, current: 1 }))}
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态筛选"
|
||||
style={{ width: 120 }}
|
||||
allowClear
|
||||
value={filters.status}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
|
||||
>
|
||||
<Select.Option value="pending">待同步</Select.Option>
|
||||
<Select.Option value="synced">已同步</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
placeholder="盘点计划"
|
||||
style={{ width: 180 }}
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
value={filters.planId}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, planId: value }))}
|
||||
>
|
||||
{plans.map(plan => (
|
||||
<Select.Option key={plan.planId} value={plan.planId}>
|
||||
{plan.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
fetchPendingDevices();
|
||||
fetchStats();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
onClick={handleBatchSync}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
批量同步 ({selectedRowKeys.length})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: record.status !== 'pending',
|
||||
}),
|
||||
}}
|
||||
columns={columns}
|
||||
dataSource={pendingDevices}
|
||||
rowKey="pendingId"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
...pagination,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPagination(prev => ({ ...prev, current: page, pageSize }));
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1400 }}
|
||||
locale={{
|
||||
emptyText: <Empty description="暂无暂存设备" image={Empty.PRESENTED_IMAGE_SIMPLE} />,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="编辑暂存设备"
|
||||
open={editModalVisible}
|
||||
onCancel={() => {
|
||||
setEditModalVisible(false);
|
||||
setSelectedRoomId(null);
|
||||
}}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleEditSubmit}
|
||||
>
|
||||
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="序列号">
|
||||
<code>{currentDevice?.serialNumber}</code>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{getStatusTag(currentDevice?.status)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{/* 机房机柜选择 - 始终显示 */}
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="roomId"
|
||||
label="所属机房"
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择机房"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
onChange={(value) => {
|
||||
setSelectedRoomId(value);
|
||||
form.setFieldsValue({ rackId: undefined });
|
||||
}}
|
||||
>
|
||||
{rooms.length > 0 ? (
|
||||
rooms.map(room => (
|
||||
<Select.Option key={room.roomId} value={room.roomId}>
|
||||
{room.name}
|
||||
</Select.Option>
|
||||
))
|
||||
) : (
|
||||
<Select.Option value="" disabled>暂无机房数据</Select.Option>
|
||||
)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="rackId"
|
||||
label="所属机柜"
|
||||
>
|
||||
<Select
|
||||
placeholder={selectedRoomId ? "请选择机柜" : "请先选择机房"}
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
disabled={!selectedRoomId}
|
||||
>
|
||||
{selectedRoomId ? (
|
||||
filteredRacks.length > 0 ? (
|
||||
filteredRacks.map(rack => (
|
||||
<Select.Option key={rack.rackId} value={rack.rackId}>
|
||||
{rack.name}
|
||||
</Select.Option>
|
||||
))
|
||||
) : (
|
||||
<Select.Option value="" disabled>该机房下无机柜</Select.Option>
|
||||
)
|
||||
) : (
|
||||
<Select.Option value="" disabled>请先选择机房</Select.Option>
|
||||
)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{renderFormFields()}
|
||||
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'right', marginTop: 16 }}>
|
||||
<Space>
|
||||
<Button onClick={() => {
|
||||
setEditModalVisible(false);
|
||||
setSelectedRoomId(null);
|
||||
}}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PendingDeviceManagement;
|
||||
Reference in New Issue
Block a user