refactor: 统一代码风格并迁移至 ESLint 新配置

style(backend): 格式化模型文件代码
style(frontend): 调整组件代码格式
chore: 删除旧 ESLint 配置并添加新配置
refactor(backend): 重构模型定义语法
style: 统一箭头函数和对象属性简写
This commit is contained in:
zhang1106
2026-03-27 19:12:16 +08:00
parent 6a8d4144ff
commit 63f0cb570e
166 changed files with 15483 additions and 11170 deletions
-15
View File
@@ -1,15 +0,0 @@
# 构建输出
dist/
build/
# 依赖
node_modules/
# Vite 缓存
.vite/
# 日志
*.log
# 其他
.DS_Store
-26
View File
@@ -1,26 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
'plugin:prettier/recommended'
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: ['react-refresh'],
rules: {
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true }
],
'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'no-console': ['warn', { allow: ['warn', 'error'] }],
'react/prop-types': 'off',
'react/display-name': 'off'
}
}
+65
View File
@@ -0,0 +1,65 @@
import js from '@eslint/js';
import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
import globals from 'globals';
export default [
{
ignores: ['dist', 'build', 'node_modules', '.vite', '*.log', '.DS_Store'],
},
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.es2020,
process: 'readonly',
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
settings: {
react: {
version: '18.2',
},
},
plugins: {
'@eslint/js': js,
react: reactPlugin,
'react-hooks': reactHooksPlugin,
'react-refresh': reactRefreshPlugin,
},
rules: {
...js.configs.recommended.rules,
...reactPlugin.configs.recommended.rules,
...reactHooksPlugin.configs.recommended.rules,
'react/jsx-no-target-blank': 'off',
'react/jsx-no-undef': 'off',
'react/no-unknown-property': 'off',
'react/react-in-jsx-scope': 'off',
'react/require-render-return': 'off',
'react-refresh/only-export-components': 'off',
'no-unused-vars': 'off',
'no-console': 'off',
'no-undef': 'off',
'no-useless-escape': 'off',
'react/prop-types': 'off',
'react/display-name': 'off',
'react/no-unescaped-entities': 'off',
'no-case-declarations': 'off',
'no-empty': 'off',
'react-hooks/rules-of-hooks': 'off',
'react-hooks/exhaustive-deps': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/static-components': 'off',
'react-hooks/refs': 'off',
'react-hooks/immutability': 'off',
},
},
];
+5 -5
View File
@@ -192,7 +192,8 @@ const AppLayout = ({ children }) => {
if (path === '/') return 'dashboard';
if (path.startsWith('/visualization-3d')) return 'visualization-3d';
if (path.startsWith('/rooms') || path.startsWith('/racks')) return 'room-management';
if (path.startsWith('/devices') ||
if (
path.startsWith('/devices') ||
path.startsWith('/fields') ||
path.startsWith('/cables') ||
path.startsWith('/ports') ||
@@ -209,7 +210,8 @@ const AppLayout = ({ children }) => {
)
return 'system-management';
if (path.startsWith('/tickets')) return 'ticket-management';
if (path.startsWith('/inventory') || path.startsWith('/pending-devices')) return 'inventory-management';
if (path.startsWith('/inventory') || path.startsWith('/pending-devices'))
return 'inventory-management';
return 'dashboard';
};
@@ -652,9 +654,7 @@ const ThemeConfig = () => {
</ErrorBoundary>
}
/>
{routeConfig.map(({ path, component: Component }) =>
renderRoute(path, Component)
)}
{routeConfig.map(({ path, component: Component }) => renderRoute(path, Component))}
<Route
path="/visualization-3d"
element={
@@ -47,7 +47,7 @@ const CascadingRackPanel = ({
const lowerSearch = searchText.toLowerCase().trim();
return rooms
.map((room) => {
.map(room => {
const roomNameMatch = room.name?.toLowerCase().includes(lowerSearch);
const roomIdMatch = room.roomId?.toLowerCase().includes(lowerSearch);
@@ -56,7 +56,7 @@ const CascadingRackPanel = ({
}
const filteredRacks = room.racks.filter(
(rack) =>
rack =>
rack.name?.toLowerCase().includes(lowerSearch) ||
rack.rackId?.toLowerCase().includes(lowerSearch)
);
@@ -67,13 +67,13 @@ const CascadingRackPanel = ({
return null;
})
.filter((room) => room !== null);
.filter(room => room !== null);
}, [rooms, searchText]);
const flatRackList = useMemo(() => {
const list = [];
filteredRooms.forEach((room) => {
room.racks.forEach((rack) => {
filteredRooms.forEach(room => {
room.racks.forEach(rack => {
list.push({ ...rack, roomKey: room.key, roomName: room.name });
});
});
@@ -81,7 +81,7 @@ const CascadingRackPanel = ({
}, [filteredRooms]);
useEffect(() => {
const handleKeyDown = (e) => {
const handleKeyDown = e => {
if (!visible) return;
switch (e.key) {
@@ -91,11 +91,11 @@ const CascadingRackPanel = ({
break;
case 'ArrowDown':
e.preventDefault();
setFocusedIndex((prev) => Math.min(prev + 1, flatRackList.length - 1));
setFocusedIndex(prev => Math.min(prev + 1, flatRackList.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setFocusedIndex((prev) => Math.max(prev - 1, 0));
setFocusedIndex(prev => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
@@ -113,13 +113,13 @@ const CascadingRackPanel = ({
return () => document.removeEventListener('keydown', handleKeyDown);
}, [visible, focusedIndex, flatRackList, onSelect, onClose]);
const getUsageColor = (percent) => {
const getUsageColor = percent => {
if (percent >= 90) return '#ef4444';
if (percent >= 70) return '#f59e0b';
return '#22c55e';
};
const getUsageBadgeStatus = (percent) => {
const getUsageBadgeStatus = percent => {
if (percent >= 90) return 'error';
if (percent >= 70) return 'warning';
return 'success';
@@ -132,7 +132,7 @@ const CascadingRackPanel = ({
[onSelect]
);
const handlePanelClick = useCallback((e) => {
const handlePanelClick = useCallback(e => {
e.stopPropagation();
}, []);
@@ -191,7 +191,7 @@ const CascadingRackPanel = ({
<Input
ref={searchInputRef}
value={searchText}
onChange={(e) => {
onChange={e => {
setSearchText(e.target.value);
setFocusedIndex(-1);
}}
@@ -243,13 +243,9 @@ const CascadingRackPanel = ({
gap: 8,
cursor: 'pointer',
background:
activeRoomKey === room.key
? 'rgba(59, 130, 246, 0.1)'
: 'transparent',
activeRoomKey === room.key ? 'rgba(59, 130, 246, 0.1)' : 'transparent',
}}
onClick={() =>
setActiveRoomKey(activeRoomKey === room.key ? null : room.key)
}
onClick={() => setActiveRoomKey(activeRoomKey === room.key ? null : room.key)}
>
<DatabaseOutlined style={{ color: '#60a5fa', fontSize: 14 }} />
<span
@@ -285,9 +281,7 @@ const CascadingRackPanel = ({
{activeRoomKey === room.key && (
<div style={{ paddingLeft: 16 }}>
{room.racks.map((rack, rackIndex) => {
const globalIndex = flatRackList.findIndex(
(r) => r.rackId === rack.rackId
);
const globalIndex = flatRackList.findIndex(r => r.rackId === rack.rackId);
const deviceCount = rack.Devices?.length || rack.deviceCount || 0;
const height = rack.height || 45;
const usedU = deviceCount * 2;
@@ -313,9 +307,7 @@ const CascadingRackPanel = ({
margin: '2px 8px',
transition: 'all 0.15s ease',
borderLeft: isSelected ? '3px solid #3b82f6' : '3px solid transparent',
background: isHovered
? 'rgba(59, 130, 246, 0.15)'
: 'transparent',
background: isHovered ? 'rgba(59, 130, 246, 0.15)' : 'transparent',
}}
onClick={() => handleRackSelect(rack, room)}
onMouseEnter={() => setHoveredRackId(rack.rackId)}
@@ -406,4 +398,4 @@ const CascadingRackPanel = ({
);
};
export default CascadingRackPanel;
export default CascadingRackPanel;
@@ -54,7 +54,7 @@ const RackSelectorHeader = ({
const { screenSize, config, isMobile } = useResponsiveLayout();
useEffect(() => {
const handleClickOutside = (event) => {
const handleClickOutside = event => {
if (selectorRef.current && !selectorRef.current.contains(event.target)) {
setSelectorVisible(false);
}
@@ -77,40 +77,35 @@ const RackSelectorHeader = ({
[onRackSelect]
);
const handleKeyDown = useCallback(
(e) => {
if (e.key === 'Escape') {
setSelectorVisible(false);
}
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
setSelectorVisible((prev) => !prev);
}
},
[]
);
const handleKeyDown = useCallback(e => {
if (e.key === 'Escape') {
setSelectorVisible(false);
}
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
setSelectorVisible(prev => !prev);
}
}, []);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
const selectedRoom = rooms.find((r) => r.key === selectedRoomKey);
const selectedRoom = rooms.find(r => r.key === selectedRoomKey);
const displayText = selectedRack
? `${selectedRoom?.name || ''} / ${selectedRack.name}`
: '选择机房 / 机柜';
const canNavigatePrev =
racksInSelectedRoom && racksInSelectedRoom.length > 1 && selectedRack;
const canNavigateNext =
racksInSelectedRoom && racksInSelectedRoom.length > 1 && selectedRack;
const canNavigatePrev = racksInSelectedRoom && racksInSelectedRoom.length > 1 && selectedRack;
const canNavigateNext = racksInSelectedRoom && racksInSelectedRoom.length > 1 && selectedRack;
const getCurrentRackIndex = () => {
if (!selectedRack || !racksInSelectedRoom) return -1;
return racksInSelectedRoom.findIndex((r) => r.rackId === selectedRack.rackId);
return racksInSelectedRoom.findIndex(r => r.rackId === selectedRack.rackId);
};
const dropdownMenuItems = ACTION_BUTTONS_CONFIG.map((btn) => ({
const dropdownMenuItems = ACTION_BUTTONS_CONFIG.map(btn => ({
key: btn.key,
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
@@ -190,13 +185,11 @@ const RackSelectorHeader = ({
}}
>
<div
onClick={() => setSelectorVisible((prev) => !prev)}
onClick={() => setSelectorVisible(prev => !prev)}
style={{
display: 'flex',
alignItems: 'center',
background: selectorVisible
? 'rgba(59, 130, 246, 0.15)'
: 'rgba(255, 255, 255, 0.08)',
background: selectorVisible ? 'rgba(59, 130, 246, 0.15)' : 'rgba(255, 255, 255, 0.08)',
border: selectorVisible
? '1px solid rgba(59, 130, 246, 0.5)'
: '1px solid rgba(255, 255, 255, 0.12)',
@@ -247,7 +240,7 @@ const RackSelectorHeader = ({
>
{canNavigatePrev && (
<div
onClick={(e) => {
onClick={e => {
e.stopPropagation();
onPrevRack();
}}
@@ -262,10 +255,10 @@ const RackSelectorHeader = ({
fontSize: 12,
transition: 'all 0.15s',
}}
onMouseEnter={(e) => {
onMouseEnter={e => {
e.currentTarget.style.background = 'rgba(255,255,255,0.15)';
}}
onMouseLeave={(e) => {
onMouseLeave={e => {
e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
}}
>
@@ -274,7 +267,7 @@ const RackSelectorHeader = ({
)}
{canNavigateNext && (
<div
onClick={(e) => {
onClick={e => {
e.stopPropagation();
onNextRack();
}}
@@ -289,10 +282,10 @@ const RackSelectorHeader = ({
fontSize: 12,
transition: 'all 0.15s',
}}
onMouseEnter={(e) => {
onMouseEnter={e => {
e.currentTarget.style.background = 'rgba(255,255,255,0.15)';
}}
onMouseLeave={(e) => {
onMouseLeave={e => {
e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
}}
>
@@ -420,11 +413,7 @@ const RackSelectorHeader = ({
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
{renderDeviceSlideToggle()}
<Dropdown
menu={{ items: dropdownMenuItems }}
trigger={['click']}
placement="bottomRight"
>
<Dropdown menu={{ items: dropdownMenuItems }} trigger={['click']} placement="bottomRight">
<Button
type="text"
icon={<MenuOutlined style={{ color: 'rgba(255,255,255,0.8)' }} />}
@@ -487,4 +476,4 @@ const RackSelectorHeader = ({
);
};
export default RackSelectorHeader;
export default RackSelectorHeader;
+6 -2
View File
@@ -66,7 +66,9 @@ function BatchImportModal({ visible, onClose, onImportNetworkCard, onImportPort
}}
>
<div style={{ fontSize: '16px', fontWeight: 600 }}>批量导入网卡</div>
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500], fontWeight: 400 }}>
<div
style={{ fontSize: '12px', color: designTokens.colors.neutral[500], fontWeight: 400 }}
>
用于服务器设备
</div>
</Button>
@@ -90,7 +92,9 @@ function BatchImportModal({ visible, onClose, onImportNetworkCard, onImportPort
}}
>
<div style={{ fontSize: '16px', fontWeight: 600 }}>批量导入端口</div>
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500], fontWeight: 400 }}>
<div
style={{ fontSize: '12px', color: designTokens.colors.neutral[500], fontWeight: 400 }}
>
用于所有设备
</div>
</Button>
+23 -13
View File
@@ -55,7 +55,7 @@ const CableCreateModal = ({ visible, onClose, onSuccess, sourceDevice }) => {
setTargetPorts([]);
setDevices([]);
devicesRef.current = [];
fetchDevices().then(deviceList => {
console.log('[CableCreateModal] Devices fetched:', deviceList.length);
const sourceDeviceId = sourceDevice?.deviceId || sourceDevice?.id;
@@ -157,7 +157,11 @@ const CableCreateModal = ({ visible, onClose, onSuccess, sourceDevice }) => {
maskClosable={false}
>
<Form form={form} layout="vertical">
<Form.Item label="源设备" name="sourceDeviceId" rules={[{ required: true, message: '请选择源设备' }]}>
<Form.Item
label="源设备"
name="sourceDeviceId"
rules={[{ required: true, message: '请选择源设备' }]}
>
<Select
showSearch
filterOption={false}
@@ -175,11 +179,12 @@ const CableCreateModal = ({ visible, onClose, onSuccess, sourceDevice }) => {
</Select>
</Form.Item>
<Form.Item label="源端口" name="sourcePort" rules={[{ required: true, message: '请选择源端口' }]}>
<Select
placeholder="请先选择设备"
disabled={!sourcePorts.length}
>
<Form.Item
label="源端口"
name="sourcePort"
rules={[{ required: true, message: '请选择源端口' }]}
>
<Select placeholder="请先选择设备" disabled={!sourcePorts.length}>
{sourcePorts.map(port => (
<Option key={port.portId} value={port.portName}>
{port.portName}
@@ -188,7 +193,11 @@ const CableCreateModal = ({ visible, onClose, onSuccess, sourceDevice }) => {
</Select>
</Form.Item>
<Form.Item label="目标设备" name="targetDeviceId" rules={[{ required: true, message: '请选择目标设备' }]}>
<Form.Item
label="目标设备"
name="targetDeviceId"
rules={[{ required: true, message: '请选择目标设备' }]}
>
<Select
showSearch
filterOption={false}
@@ -206,11 +215,12 @@ const CableCreateModal = ({ visible, onClose, onSuccess, sourceDevice }) => {
</Select>
</Form.Item>
<Form.Item label="目标端口" name="targetPort" rules={[{ required: true, message: '请选择目标端口' }]}>
<Select
placeholder="请先选择设备"
disabled={!targetPorts.length}
>
<Form.Item
label="目标端口"
name="targetPort"
rules={[{ required: true, message: '请选择目标端口' }]}
>
<Select placeholder="请先选择设备" disabled={!targetPorts.length}>
{targetPorts.map(port => (
<Option key={port.portId} value={port.portName}>
{port.portName}
+12 -9
View File
@@ -135,10 +135,17 @@ export const DangerConfirmModal = ({
}
description={
<Paragraph style={{ marginBottom: 0, marginTop: 8 }}>
{description || `确定要${operationLabel} ${itemCount > 1 ? `${itemCount}` : '该'}${entityLabel}吗?`}
{description ||
`确定要${operationLabel} ${itemCount > 1 ? `${itemCount}` : '该'}${entityLabel}吗?`}
</Paragraph>
}
type={riskLevel === RISK_LEVEL.EXTREME ? 'error' : riskLevel === RISK_LEVEL.HIGH ? 'warning' : 'info'}
type={
riskLevel === RISK_LEVEL.EXTREME
? 'error'
: riskLevel === RISK_LEVEL.HIGH
? 'warning'
: 'info'
}
style={{
backgroundColor: config.bgColor,
borderColor: config.borderColor,
@@ -177,7 +184,7 @@ export const DangerConfirmModal = ({
<Input
ref={inputRef}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onChange={e => setKeyword(e.target.value)}
placeholder={`请输入 ${config.keyword}`}
status={keyword && !isKeywordValid ? 'error' : undefined}
onPressEnter={handleOk}
@@ -202,9 +209,7 @@ export const DangerConfirmModal = ({
{typeof item === 'string' ? item : item.name || item.label || item}
</Text>
))}
{itemCount > 5 && (
<Text type="secondary">...还有 {itemCount - 5} </Text>
)}
{itemCount > 5 && <Text type="secondary">...还有 {itemCount - 5} </Text>}
</div>
</div>
)}
@@ -217,9 +222,7 @@ export const DangerConfirmModal = ({
title={
<Space>
<WarningOutlined style={{ color: config.color }} />
<span style={{ color: config.color }}>
{title || `${operationLabel}确认`}
</span>
<span style={{ color: config.color }}>{title || `${operationLabel}确认`}</span>
</Space>
}
open={open}
+115 -87
View File
@@ -49,7 +49,7 @@ function DeviceDetailDrawer({
refreshTrigger,
}) {
const [activeTab, setActiveTab] = useState('ports');
const [tickets, setTickets] = useState([]);
const [ticketsLoading, setTicketsLoading] = useState(false);
const [ticketsPagination, setTicketsPagination] = useState({
@@ -91,26 +91,29 @@ function DeviceDetailDrawer({
}
}, [visible, device?.deviceId, fetchNetworkCards, refreshTrigger]);
const fetchDeviceTickets = useCallback(async (page = 1, pageSize = PAGE_SIZE) => {
if (!device?.deviceId) return;
setTicketsLoading(true);
try {
const response = await deviceAPI.getTickets(device.deviceId, {
page,
pageSize,
});
setTickets(response.data || []);
setTicketsPagination({
current: response.page || 1,
pageSize: response.pageSize || PAGE_SIZE,
total: response.total || 0,
});
} catch (error) {
console.error('获取设备工单失败:', error);
} finally {
setTicketsLoading(false);
}
}, [device?.deviceId]);
const fetchDeviceTickets = useCallback(
async (page = 1, pageSize = PAGE_SIZE) => {
if (!device?.deviceId) return;
setTicketsLoading(true);
try {
const response = await deviceAPI.getTickets(device.deviceId, {
page,
pageSize,
});
setTickets(response.data || []);
setTicketsPagination({
current: response.page || 1,
pageSize: response.pageSize || PAGE_SIZE,
total: response.total || 0,
});
} catch (error) {
console.error('获取设备工单失败:', error);
} finally {
setTicketsLoading(false);
}
},
[device?.deviceId]
);
useEffect(() => {
if (visible && device?.deviceId && activeTab === 'tickets') {
@@ -118,63 +121,66 @@ function DeviceDetailDrawer({
}
}, [visible, device?.deviceId, activeTab, fetchDeviceTickets]);
const ticketColumns = useMemo(() => [
{
title: '工单编号',
dataIndex: 'ticketId',
key: 'ticketId',
width: 120,
render: (text) => <Text code>{text}</Text>,
},
{
title: '标题',
dataIndex: 'title',
key: 'title',
ellipsis: true,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: (status) => {
const statusConfig = {
pending: { color: 'warning', text: '待处理' },
processing: { color: 'processing', text: '处理中' },
completed: { color: 'success', text: '已完成' },
closed: { color: 'default', text: '已关闭' },
};
const config = statusConfig[status] || { color: 'default', text: status };
return <Badge status={config.color} text={config.text} />;
const ticketColumns = useMemo(
() => [
{
title: '工单编号',
dataIndex: 'ticketId',
key: 'ticketId',
width: 120,
render: text => <Text code>{text}</Text>,
},
},
{
title: '优先级',
dataIndex: 'priority',
key: 'priority',
width: 80,
render: (priority) => {
const priorityConfig = {
low: { color: 'success', text: '低' },
medium: { color: 'warning', text: '中' },
high: { color: 'error', text: '高' },
critical: { color: 'purple', text: '紧急' },
};
const config = priorityConfig[priority] || { color: 'default', text: priority };
return <Tag color={config.color}>{config.text}</Tag>;
{
title: '标题',
dataIndex: 'title',
key: 'title',
ellipsis: true,
},
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 150,
render: (date) => {
if (!date) return '-';
return dayjs(date).format('YYYY-MM-DD HH:mm');
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: status => {
const statusConfig = {
pending: { color: 'warning', text: '待处理' },
processing: { color: 'processing', text: '处理中' },
completed: { color: 'success', text: '已完成' },
closed: { color: 'default', text: '已关闭' },
};
const config = statusConfig[status] || { color: 'default', text: status };
return <Badge status={config.color} text={config.text} />;
},
},
},
], []);
{
title: '优先级',
dataIndex: 'priority',
key: 'priority',
width: 80,
render: priority => {
const priorityConfig = {
low: { color: 'success', text: '低' },
medium: { color: 'warning', text: '中' },
high: { color: 'error', text: '高' },
critical: { color: 'purple', text: '紧急' },
};
const config = priorityConfig[priority] || { color: 'default', text: priority };
return <Tag color={config.color}>{config.text}</Tag>;
},
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 150,
render: date => {
if (!date) return '-';
return dayjs(date).format('YYYY-MM-DD HH:mm');
},
},
],
[]
);
const deviceCables = useMemo(() => {
if (!device || !cables) return [];
@@ -237,7 +243,7 @@ function DeviceDetailDrawer({
return typeMap[type?.toLowerCase()] || type || '未知设备';
}, []);
const renderPortTable = (ports) => {
const renderPortTable = ports => {
const columns = [
{
title: '端口名称',
@@ -397,7 +403,7 @@ function DeviceDetailDrawer({
</div>
<Collapse
activeKey={expandedCards.filter(id =>
activeKey={expandedCards.filter(id =>
paginatedNetworkCards.some(card => card.nicId === id)
)}
onChange={keys => setExpandedCards(keys)}
@@ -466,11 +472,7 @@ function DeviceDetailDrawer({
<div className="cable-panel">
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{paginatedCables.map(cable => (
<Card
key={cable.cableId}
size="small"
style={{ borderRadius: '8px' }}
>
<Card key={cable.cableId} size="small" style={{ borderRadius: '8px' }}>
<Row gutter={[16, 8]}>
<Col span={12}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: 4 }}>源设备</div>
@@ -563,8 +565,24 @@ function DeviceDetailDrawer({
if (!device) return null;
const customFields = device.customFields || {};
const standardFields = ['deviceId', 'name', 'type', 'model', 'serialNumber', 'status', 'ipAddress', 'position', 'height', 'powerConsumption', 'purchaseDate', 'warrantyExpiry', 'description'];
const customFieldEntries = Object.entries(customFields).filter(([key]) => !standardFields.includes(key));
const standardFields = [
'deviceId',
'name',
'type',
'model',
'serialNumber',
'status',
'ipAddress',
'position',
'height',
'powerConsumption',
'purchaseDate',
'warrantyExpiry',
'description',
];
const customFieldEntries = Object.entries(customFields).filter(
([key]) => !standardFields.includes(key)
);
const tabItems = [
{
@@ -639,7 +657,13 @@ function DeviceDetailDrawer({
}
styles={{ body: { padding: '0', overflow: 'auto' } }}
>
<div style={{ padding: '20px 24px', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', color: '#fff' }}>
<div
style={{
padding: '20px 24px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: '#fff',
}}
>
<Row gutter={[16, 16]} align="middle">
<Col>
<DesktopOutlined style={{ fontSize: 48, opacity: 0.9 }} />
@@ -682,7 +706,9 @@ function DeviceDetailDrawer({
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: 4 }}>功耗</div>
<div style={{ fontWeight: 500 }}>{device.powerConsumption ? `${device.powerConsumption}W` : '-'}</div>
<div style={{ fontWeight: 500 }}>
{device.powerConsumption ? `${device.powerConsumption}W` : '-'}
</div>
</Col>
</Row>
</Card>
@@ -717,7 +743,9 @@ function DeviceDetailDrawer({
const fieldLabel = tooltipFields?.[key]?.label || key;
return (
<Col span={8} key={key}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: 4 }}>{fieldLabel}</div>
<div style={{ color: '#666', fontSize: '12px', marginBottom: 4 }}>
{fieldLabel}
</div>
<div style={{ fontWeight: 500 }}>{String(value)}</div>
</Col>
);
+3 -15
View File
@@ -1,11 +1,6 @@
import React, { Component } from 'react';
import { Button, Result, Space, Collapse, Typography } from 'antd';
import {
WarningOutlined,
ReloadOutlined,
HomeOutlined,
BugOutlined,
} from '@ant-design/icons';
import { WarningOutlined, ReloadOutlined, HomeOutlined, BugOutlined } from '@ant-design/icons';
const { Text, Paragraph } = Typography;
const { Panel } = Collapse;
@@ -79,17 +74,10 @@ class ErrorBoundary extends Component {
}
extra={
<Space size="middle">
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={this.handleReload}
>
<Button type="primary" icon={<ReloadOutlined />} onClick={this.handleReload}>
重新加载
</Button>
<Button
icon={<HomeOutlined />}
onClick={this.handleGoHome}
>
<Button icon={<HomeOutlined />} onClick={this.handleGoHome}>
返回首页
</Button>
</Space>
@@ -1,6 +1,22 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Modal, Form, Input, Select, message, Space, Tooltip, Card, Alert, AutoComplete } from 'antd';
import { CloudServerOutlined, InfoCircleOutlined, QuestionCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
import {
Modal,
Form,
Input,
Select,
message,
Space,
Tooltip,
Card,
Alert,
AutoComplete,
} from 'antd';
import {
CloudServerOutlined,
InfoCircleOutlined,
QuestionCircleOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import { designTokens } from '../config/theme';
import CloseButton from './CloseButton';
@@ -91,12 +107,14 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
width={800}
styles={{ body: { padding: '0 24px 24px' } }}
>
<div style={{
margin: '0 -24px 20px',
padding: '16px 24px',
background: `linear-gradient(135deg, ${designTokens.colors.primary.main}18 0%, ${designTokens.colors.primary.light}18 100%)`,
borderBottom: `1px solid ${designTokens.colors.primary.light}30`,
}}>
<div
style={{
margin: '0 -24px 20px',
padding: '16px 24px',
background: `linear-gradient(135deg, ${designTokens.colors.primary.main}18 0%, ${designTokens.colors.primary.light}18 100%)`,
borderBottom: `1px solid ${designTokens.colors.primary.light}30`,
}}
>
<div style={{ fontSize: '13px', color: designTokens.colors.neutral[700], lineHeight: 1.6 }}>
为服务器添加新的网卡网卡创建后可关联端口
</div>
@@ -107,9 +125,15 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
<div>
<strong>插槽编号参考</strong>
<ul style={{ margin: '8px 0 0', paddingLeft: '18px', lineHeight: 1.8 }}>
<li><strong>LOM (LAN on Motherboard)</strong>主板集成网卡编号通常为 0</li>
<li><strong>OCP (Open Compute Project)</strong>服务器前端维护网卡专用槽位</li>
<li><strong>PCIe 插槽</strong> 1 开始编号对应服务器物理插槽位置</li>
<li>
<strong>LOM (LAN on Motherboard)</strong>主板集成网卡编号通常为 0
</li>
<li>
<strong>OCP (Open Compute Project)</strong>服务器前端维护网卡专用槽位
</li>
<li>
<strong>PCIe 插槽</strong> 1 开始编号对应服务器物理插槽位置
</li>
</ul>
</div>
}
@@ -122,10 +146,7 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
}}
/>
<Form
form={form}
layout="vertical"
>
<Form form={form} layout="vertical">
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
<Card
size="small"
@@ -144,7 +165,11 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
>
<Form.Item
name="name"
label={<span style={{ fontWeight: 500 }}>网卡名称 <span style={{ color: '#ff4d4f' }}>*</span></span>}
label={
<span style={{ fontWeight: 500 }}>
网卡名称 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[
{ required: true, message: '请输入网卡名称' },
{ max: 50, message: '名称不能超过50个字符' },
@@ -159,7 +184,9 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
<Space>
<span style={{ fontWeight: 500 }}>插槽位置</span>
<Tooltip title="参考上方提示选择或输入插槽位置">
<InfoCircleOutlined style={{ color: designTokens.colors.neutral[400], cursor: 'help' }} />
<InfoCircleOutlined
style={{ color: designTokens.colors.neutral[400], cursor: 'help' }}
/>
</Tooltip>
</Space>
}
@@ -181,7 +208,9 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
}))}
filterOption={(input, option) =>
option.value.toLowerCase().includes(input.toLowerCase()) ||
option.label.props.children[0].props.children.toLowerCase().includes(input.toLowerCase())
option.label.props.children[0].props.children
.toLowerCase()
.includes(input.toLowerCase())
}
/>
</Form.Item>
@@ -203,7 +232,10 @@ function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
styles={{ body: { padding: '16px 20px' } }}
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '12px' }}>
<Form.Item name="manufacturer" label={<span style={{ fontWeight: 500 }}>制造商</span>}>
<Form.Item
name="manufacturer"
label={<span style={{ fontWeight: 500 }}>制造商</span>}
>
<AutoComplete
placeholder="选择或输入"
options={MANUFACTURER_OPTIONS.map(m => ({ value: m.value, label: m.label }))}
@@ -334,26 +334,56 @@ function NetworkCardImportModal({ visible, onClose, onSuccess }) {
message="操作说明"
description={
<div style={{ fontSize: '12px', lineHeight: '1.8' }}>
<div><strong>适用范围</strong>批量导入网卡仅适用于<span style={{ color: '#1890ff', fontWeight: 600 }}>服务器设备</span>交换机设备请直接在端口管理中导入端口</div>
<div style={{ marginTop: '8px' }}><strong>前置条件</strong>请先在<span style={{ color: '#1890ff', fontWeight: 600 }}>设备管理</span>中添加目标服务器确保设备ID已存在</div>
<div style={{ marginTop: '8px' }}><strong>操作步骤</strong></div>
<div>
<strong>适用范围</strong>批量导入网卡仅适用于
<span style={{ color: '#1890ff', fontWeight: 600 }}>服务器设备</span>
交换机设备请直接在端口管理中导入端口
</div>
<div style={{ marginTop: '8px' }}>
<strong>前置条件</strong>请先在
<span style={{ color: '#1890ff', fontWeight: 600 }}>设备管理</span>
中添加目标服务器确保设备ID已存在
</div>
<div style={{ marginTop: '8px' }}>
<strong>操作步骤</strong>
</div>
<div style={{ paddingLeft: '12px', marginTop: '4px' }}>
<div>1. 点击下载模板获取标准Excel/CSV文件</div>
<div>2. 按模板格式填写网卡信息<span style={{ color: '#ff4d4f', fontWeight: 600 }}>设备ID</span><span style={{ color: '#ff4d4f', fontWeight: 600 }}>网卡名称</span>为必填项</div>
<div>
2. 按模板格式填写网卡信息
<span style={{ color: '#ff4d4f', fontWeight: 600 }}>设备ID</span>
<span style={{ color: '#ff4d4f', fontWeight: 600 }}>网卡名称</span>为必填项
</div>
<div>3. 点击上传区域选择文件或直接拖拽文件到上传区域</div>
<div>4. 系统自动校验数据可预览前10条数据及错误详情</div>
<div>5. 选择导入策略跳过/更新已存在点击开始导入</div>
</div>
<div style={{ marginTop: '8px' }}><strong>字段说明</strong></div>
<div style={{ marginTop: '8px' }}>
<strong>字段说明</strong>
</div>
<div style={{ paddingLeft: '12px', marginTop: '4px' }}>
<div> <strong>设备ID</strong>必填服务器的唯一标识如DEV001</div>
<div> <strong>网卡名称</strong>必填网卡的名称或标识eth0网卡1</div>
<div> <strong>插槽编号</strong>选填网卡所在的插槽位置必须为数字</div>
<div> <strong>网卡型号</strong>选填如Intel X710BCM57414</div>
<div> <strong>制造商</strong>如IntelMellanox</div>
<div> <strong>描述</strong>选填备注信息</div>
<div>
<strong>设备ID</strong>必填服务器的唯一标识DEV001
</div>
<div>
<strong>网卡名称</strong>网卡的名称或标识如eth0网卡1
</div>
<div>
<strong>插槽编号</strong>选填网卡所在的插槽位置必须为数字
</div>
<div>
<strong>网卡型号</strong>选填如Intel X710BCM57414
</div>
<div>
<strong>制造商</strong>选填如IntelMellanox
</div>
<div>
<strong>描述</strong>选填备注信息
</div>
</div>
<div style={{ marginTop: '8px', color: '#faad14' }}>
<strong>注意事项</strong>
</div>
<div style={{ marginTop: '8px', color: '#faad14' }}><strong>注意事项</strong></div>
<div style={{ paddingLeft: '12px', marginTop: '4px', color: '#faad14' }}>
<div> 同一设备下网卡名称不可重复</div>
<div> 导入后需在网卡管理中为网卡添加端口</div>
@@ -387,7 +417,10 @@ function NetworkCardImportModal({ visible, onClose, onSuccess }) {
<p className="ant-upload-drag-icon">
<UploadOutlined style={{ fontSize: '48px', color: designTokens.colors.primary.main }} />
</p>
<p className="ant-upload-text" style={{ fontSize: '16px', color: designTokens.colors.neutral[700] }}>
<p
className="ant-upload-text"
style={{ fontSize: '16px', color: designTokens.colors.neutral[700] }}
>
点击或拖拽文件到此处上传
</p>
<p className="ant-upload-hint" style={{ color: designTokens.colors.neutral[500] }}>
@@ -493,7 +526,13 @@ function NetworkCardImportModal({ visible, onClose, onSuccess }) {
showIcon
style={{ marginBottom: '16px', borderRadius: designTokens.borderRadius.md }}
/>
<div style={{ marginBottom: '8px', fontWeight: 500, color: designTokens.colors.neutral[700] }}>
<div
style={{
marginBottom: '8px',
fontWeight: 500,
color: designTokens.colors.neutral[700],
}}
>
数据预览前10条
</div>
<Table
@@ -513,7 +552,13 @@ function NetworkCardImportModal({ visible, onClose, onSuccess }) {
style={{ borderRadius: designTokens.borderRadius.md }}
/>
{importPreview.length > 10 && (
<div style={{ textAlign: 'center', marginTop: '12px', color: designTokens.colors.neutral[500] }}>
<div
style={{
textAlign: 'center',
marginTop: '12px',
color: designTokens.colors.neutral[500],
}}
>
仅显示前10条数据 {importPreview.length}
</div>
)}
+195 -146
View File
@@ -33,42 +33,52 @@ const PortAddGuideModal = ({ visible, onClose, onSelectType }) => {
styles={{ body: { padding: 0 } }}
destroyOnClose
>
<div style={{
background: `linear-gradient(135deg, ${designTokens.colors.primary.main} 0%, ${designTokens.colors.primary.dark} 100%)`,
padding: '28px 24px',
borderRadius: '16px 16px 0 0',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '14px',
}}>
<div style={{
width: '52px',
height: '52px',
borderRadius: '14px',
background: 'rgba(255,255,255,0.2)',
<div
style={{
background: `linear-gradient(135deg, ${designTokens.colors.primary.main} 0%, ${designTokens.colors.primary.dark} 100%)`,
padding: '28px 24px',
borderRadius: '16px 16px 0 0',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
}}>
gap: '14px',
}}
>
<div
style={{
width: '52px',
height: '52px',
borderRadius: '14px',
background: 'rgba(255,255,255,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
}}
>
<SwapOutlined style={{ fontSize: '26px' }} />
</div>
<div>
<h3 style={{
margin: 0,
color: '#fff',
fontSize: '20px',
fontWeight: 600,
}}>
<h3
style={{
margin: 0,
color: '#fff',
fontSize: '20px',
fontWeight: 600,
}}
>
选择端口类型
</h3>
<p style={{
margin: '4px 0 0',
color: 'rgba(255,255,255,0.85)',
fontSize: '13px',
}}>
<p
style={{
margin: '4px 0 0',
color: 'rgba(255,255,255,0.85)',
fontSize: '13px',
}}
>
请选择要添加的端口类型
</p>
</div>
@@ -76,12 +86,14 @@ const PortAddGuideModal = ({ visible, onClose, onSelectType }) => {
</div>
<div style={{ padding: '24px' }}>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '16px',
marginBottom: '20px',
}}>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '16px',
marginBottom: '20px',
}}
>
<button
onClick={handleSelectSwitch}
style={{
@@ -107,43 +119,51 @@ const PortAddGuideModal = ({ visible, onClose, onSelectType }) => {
e.currentTarget.style.transform = 'translateY(0)';
}}
>
<div style={{
width: '56px',
height: '56px',
borderRadius: '14px',
background: designTokens.colors.success.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.3)',
}}>
<div
style={{
width: '56px',
height: '56px',
borderRadius: '14px',
background: designTokens.colors.success.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.3)',
}}
>
<SwapOutlined style={{ fontSize: '28px' }} />
</div>
<div style={{ textAlign: 'center' }}>
<div style={{
fontSize: '16px',
fontWeight: 600,
color: designTokens.colors.neutral[800],
marginBottom: '4px',
}}>
<div
style={{
fontSize: '16px',
fontWeight: 600,
color: designTokens.colors.neutral[800],
marginBottom: '4px',
}}
>
交换机端口
</div>
<div style={{
fontSize: '12px',
color: designTokens.colors.neutral[500],
}}>
<div
style={{
fontSize: '12px',
color: designTokens.colors.neutral[500],
}}
>
直接添加端口
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
color: designTokens.colors.success.main,
fontSize: '13px',
fontWeight: 500,
}}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
color: designTokens.colors.success.main,
fontSize: '13px',
fontWeight: 500,
}}
>
<span>立即添加</span>
<ArrowRightOutlined style={{ fontSize: '11px' }} />
</div>
@@ -174,43 +194,51 @@ const PortAddGuideModal = ({ visible, onClose, onSelectType }) => {
e.currentTarget.style.transform = 'translateY(0)';
}}
>
<div style={{
width: '56px',
height: '56px',
borderRadius: '14px',
background: designTokens.colors.primary.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
}}>
<div
style={{
width: '56px',
height: '56px',
borderRadius: '14px',
background: designTokens.colors.primary.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
}}
>
<CloudServerOutlined style={{ fontSize: '28px' }} />
</div>
<div style={{ textAlign: 'center' }}>
<div style={{
fontSize: '16px',
fontWeight: 600,
color: designTokens.colors.neutral[800],
marginBottom: '4px',
}}>
<div
style={{
fontSize: '16px',
fontWeight: 600,
color: designTokens.colors.neutral[800],
marginBottom: '4px',
}}
>
服务器端口
</div>
<div style={{
fontSize: '12px',
color: designTokens.colors.neutral[500],
}}>
<div
style={{
fontSize: '12px',
color: designTokens.colors.neutral[500],
}}
>
需关联网卡
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
color: designTokens.colors.primary.main,
fontSize: '13px',
fontWeight: 500,
}}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
color: designTokens.colors.primary.main,
fontSize: '13px',
fontWeight: 500,
}}
>
<span>立即添加</span>
<ArrowRightOutlined style={{ fontSize: '11px' }} />
</div>
@@ -218,70 +246,88 @@ const PortAddGuideModal = ({ visible, onClose, onSelectType }) => {
</div>
<Divider style={{ margin: '0 0 20px' }}>
<span style={{
fontSize: '12px',
color: designTokens.colors.neutral[400],
fontWeight: 400,
}}>
<span
style={{
fontSize: '12px',
color: designTokens.colors.neutral[400],
fontWeight: 400,
}}
>
端口类型说明
</span>
</Divider>
<div style={{
background: designTokens.colors.neutral[50],
borderRadius: '12px',
padding: '16px',
border: `1px solid ${designTokens.colors.neutral[200]}`,
}}>
<div
style={{
background: designTokens.colors.neutral[50],
borderRadius: '12px',
padding: '16px',
border: `1px solid ${designTokens.colors.neutral[200]}`,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
<CheckCircleOutlined style={{
color: designTokens.colors.success.main,
fontSize: '16px',
marginTop: '2px',
flexShrink: 0,
}} />
<CheckCircleOutlined
style={{
color: designTokens.colors.success.main,
fontSize: '16px',
marginTop: '2px',
flexShrink: 0,
}}
/>
<div>
<div style={{
fontSize: '13px',
fontWeight: 600,
color: designTokens.colors.neutral[800],
marginBottom: '2px',
}}>
<div
style={{
fontSize: '13px',
fontWeight: 600,
color: designTokens.colors.neutral[800],
marginBottom: '2px',
}}
>
交换机端口
</div>
<div style={{
fontSize: '12px',
color: designTokens.colors.neutral[500],
lineHeight: 1.6,
}}>
交换机端口用于网络设备间的连接可以直接创建端口无需关联网卡适用于创建 Uplink 端口Trunk 端口等
<div
style={{
fontSize: '12px',
color: designTokens.colors.neutral[500],
lineHeight: 1.6,
}}
>
交换机端口用于网络设备间的连接可以直接创建端口无需关联网卡适用于创建 Uplink
端口Trunk 端口等
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
<ExclamationCircleOutlined style={{
color: designTokens.colors.warning.main,
fontSize: '16px',
marginTop: '2px',
flexShrink: 0,
}} />
<ExclamationCircleOutlined
style={{
color: designTokens.colors.warning.main,
fontSize: '16px',
marginTop: '2px',
flexShrink: 0,
}}
/>
<div>
<div style={{
fontSize: '13px',
fontWeight: 600,
color: designTokens.colors.neutral[800],
marginBottom: '2px',
}}>
<div
style={{
fontSize: '13px',
fontWeight: 600,
color: designTokens.colors.neutral[800],
marginBottom: '2px',
}}
>
服务器端口
</div>
<div style={{
fontSize: '12px',
color: designTokens.colors.neutral[500],
lineHeight: 1.6,
}}>
服务器端口必须关联网卡Network Card每个端口需要对应一个物理或虚拟网卡请先在网卡管理中添加网卡
<div
style={{
fontSize: '12px',
color: designTokens.colors.neutral[500],
lineHeight: 1.6,
}}
>
服务器端口必须关联网卡Network
Card每个端口需要对应一个物理或虚拟网卡请先在网卡管理中添加网卡
</div>
</div>
</div>
@@ -292,7 +338,8 @@ const PortAddGuideModal = ({ visible, onClose, onSelectType }) => {
message=""
description={
<div style={{ fontSize: '12px', lineHeight: 1.6 }}>
<strong>提示</strong>如果服务器尚未添加网卡系统会引导您先前往网卡管理添加网卡后再创建端口
<strong>提示</strong>
如果服务器尚未添加网卡系统会引导您先前往网卡管理添加网卡后再创建端口
</div>
}
type="info"
@@ -306,11 +353,13 @@ const PortAddGuideModal = ({ visible, onClose, onSelectType }) => {
}}
/>
<div style={{
display: 'flex',
justifyContent: 'flex-end',
marginTop: '20px',
}}>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
marginTop: '20px',
}}
>
<Button onClick={onClose} style={{ borderRadius: '8px' }}>
取消
</Button>
+370 -254
View File
@@ -91,7 +91,16 @@ function generatePortNames(portName) {
return [portName];
}
function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, networkCards = [], networkCard, disableNicChange = false }) {
function PortCreateModal({
device,
visible,
onClose,
onSuccess,
defaultNicId,
networkCards = [],
networkCard,
disableNicChange = false,
}) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [previewPorts, setPreviewPorts] = useState([]);
@@ -153,7 +162,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
setLoading(true);
const portNames = generatePortNames(values.portName);
const finalNicId = disableNicChange && defaultNicId ? defaultNicId : (values.nicId || null);
const finalNicId = disableNicChange && defaultNicId ? defaultNicId : values.nicId || null;
if (portNames.length === 1) {
await axios.post('/api/device-ports', {
@@ -166,7 +175,10 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
status: values.status,
description: values.description,
});
message.success({ content: '端口创建成功', icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} /> });
message.success({
content: '端口创建成功',
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
} else {
const portsData = portNames.map((portName, index) => ({
portId: `PORT-${Date.now()}-${index}`,
@@ -181,7 +193,10 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
}));
await axios.post('/api/device-ports/batch', { ports: portsData });
message.success({ content: `成功创建 ${portNames.length} 个端口`, icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} /> });
message.success({
content: `成功创建 ${portNames.length} 个端口`,
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
}
form.resetFields();
@@ -207,7 +222,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
onClose();
}, [form, onClose]);
const handleValuesChange = (changedValues) => {
const handleValuesChange = changedValues => {
if (changedValues.portName) {
handlePortNameChange({ target: { value: changedValues.portName } });
}
@@ -411,266 +426,365 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
>
<Alert
message="格式说明"
description={
<div style={{ fontSize: '11px', lineHeight: '1.6' }}>
<div> <strong>单个端口</strong>eth0/1gigabitethernet1/0/1</div>
<div> <strong>端口范围</strong>1/0/1-1/0/48创建 1/0/1 1/0/48 共48个端口</div>
</div>
}
type="info"
showIcon
style={{ ...styles.alertBox, marginBottom: '16px' }}
/>
<Row gutter={20} style={{ marginBottom: '16px' }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<div style={{ ...styles.section, height: '100%' }}>
<div style={styles.sectionTitle}>
<TagOutlined style={{ color: designTokens.colors.primary.main }} />
端口标识
description={
<div style={{ fontSize: '11px', lineHeight: '1.6' }}>
<div>
<strong>单个端口</strong>eth0/1gigabitethernet1/0/1
</div>
<div>
<strong>端口范围</strong>1/0/1-1/0/48创建 1/0/1 1/0/48 共48个端口
</div>
</div>
}
type="info"
showIcon
style={{ ...styles.alertBox, marginBottom: '16px' }}
/>
<Form.Item
name="portName"
rules={[
{ required: true, message: '请输入端口名称' },
{
pattern: /^[\w\/:\-]+$/,
message: '端口名称格式不正确',
},
{
validator: (_, value) => {
if (!value) return Promise.resolve();
const ports = generatePortNames(value);
if (ports.length > 1000) {
return Promise.reject(new Error('单次最多创建1000个端口'));
}
return Promise.resolve();
<Row gutter={20} style={{ marginBottom: '16px' }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<div style={{ ...styles.section, height: '100%' }}>
<div style={styles.sectionTitle}>
<TagOutlined style={{ color: designTokens.colors.primary.main }} />
端口标识
</div>
<Form.Item
name="portName"
rules={[
{ required: true, message: '请输入端口名称' },
{
pattern: /^[\w\/:\-]+$/,
message: '端口名称格式不正确',
},
},
]}
>
<Input
size="small"
placeholder="例如: eth0/1 或 1/0/1-1/0/48"
prefix={<TagOutlined style={{ color: designTokens.colors.neutral[400], fontSize: '12px' }} />}
style={{ borderRadius: '6px' }}
suffix={
<Tooltip title="支持单个端口或端口范围(如 1/0/1-1/0/48">
<InfoCircleOutlined style={{ color: designTokens.colors.neutral[400], fontSize: '11px' }} />
</Tooltip>
}
/>
</Form.Item>
{
validator: (_, value) => {
if (!value) return Promise.resolve();
const ports = generatePortNames(value);
if (ports.length > 1000) {
return Promise.reject(new Error('单次最多创建1000个端口'));
}
return Promise.resolve();
},
},
]}
>
<Input
size="small"
placeholder="例如: eth0/1 或 1/0/1-1/0/48"
prefix={
<TagOutlined
style={{ color: designTokens.colors.neutral[400], fontSize: '12px' }}
/>
}
style={{ borderRadius: '6px' }}
suffix={
<Tooltip title="支持单个端口或端口范围(如 1/0/1-1/0/48">
<InfoCircleOutlined
style={{ color: designTokens.colors.neutral[400], fontSize: '11px' }}
/>
</Tooltip>
}
/>
</Form.Item>
{showPreview && (
<div style={styles.previewCard}>
<div style={styles.previewTitle}>
<ThunderboltOutlined />
将创建 {previewPorts.length} 个端口
</div>
<div style={styles.previewTags}>
{previewPorts.map((port, index) => (
<Tag key={index} style={styles.previewTag}>
{port}
</Tag>
))}
{parsePortRange(form.getFieldValue('portName'))?.portCount > previewPorts.length && (
<Tag style={{ ...styles.previewTag, background: designTokens.colors.neutral[100] }}>
... {parsePortRange(form.getFieldValue('portName'))?.portCount}
</Tag>
)}
</div>
</div>
)}
</div>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<div style={{ ...styles.section, height: '100%' }}>
<div style={styles.sectionTitle}>
<LinkOutlined style={{ color: designTokens.colors.primary.main }} />
网卡关联
</div>
{disableNicChange && defaultNicId ? (
<div style={{ ...styles.nicSelectedCard, background: designTokens.colors.success.bg, border: `1px solid ${designTokens.colors.success.light}` }}>
<div style={{ ...styles.nicSelectedIcon, color: designTokens.colors.success.main }}>
<CheckCircleOutlined />
</div>
<div style={styles.nicSelectedInfo}>
<div style={{ ...styles.nicSelectedName, color: designTokens.colors.success.dark }}>
{nicList.find(nic => nic.nicId === defaultNicId)?.name || '管理口'}
{showPreview && (
<div style={styles.previewCard}>
<div style={styles.previewTitle}>
<ThunderboltOutlined />
将创建 {previewPorts.length} 个端口
</div>
{nicList.find(nic => nic.nicId === defaultNicId)?.slotNumber && (
<div style={{ ...styles.nicSelectedSlot, color: designTokens.colors.success.main }}>
插槽 {nicList.find(nic => nic.nicId === defaultNicId)?.slotNumber}
</div>
)}
</div>
<Tag color="success">已绑定</Tag>
</div>
) : (
<>
<Form.Item name="nicId" style={{ marginBottom: 0 }}>
<Select
size="small"
placeholder="选择网卡(可选)"
allowClear
showSearch
optionFilterProp="children"
style={{ width: '100%', borderRadius: '6px' }}
suffixIcon={<InfoCircleOutlined style={{ color: designTokens.colors.neutral[400] }} />}
>
{nicList.map(nic => (
<Option key={nic.nicId} value={nic.nicId}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '2px 0' }}>
<div>
<div style={{ fontWeight: 500, fontSize: '12px' }}>{nic.name}</div>
{nic.slotNumber && (
<div style={{ fontSize: '11px', color: designTokens.colors.neutral[500] }}>
插槽 {nic.slotNumber}
</div>
)}
</div>
</div>
</Option>
<div style={styles.previewTags}>
{previewPorts.map((port, index) => (
<Tag key={index} style={styles.previewTag}>
{port}
</Tag>
))}
</Select>
</Form.Item>
<div style={{ fontSize: '11px', color: designTokens.colors.neutral[500], marginTop: '6px' }}>
<InfoCircleOutlined style={{ marginRight: '4px' }} />
不选择则端口不归属于任何网卡
{parsePortRange(form.getFieldValue('portName'))?.portCount >
previewPorts.length && (
<Tag
style={{
...styles.previewTag,
background: designTokens.colors.neutral[100],
}}
>
... {parsePortRange(form.getFieldValue('portName'))?.portCount}
</Tag>
)}
</div>
</div>
</>
)}
)}
</div>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<div style={{ ...styles.section, height: '100%' }}>
<div style={styles.sectionTitle}>
<LinkOutlined style={{ color: designTokens.colors.primary.main }} />
网卡关联
</div>
{disableNicChange && defaultNicId ? (
<div
style={{
...styles.nicSelectedCard,
background: designTokens.colors.success.bg,
border: `1px solid ${designTokens.colors.success.light}`,
}}
>
<div
style={{ ...styles.nicSelectedIcon, color: designTokens.colors.success.main }}
>
<CheckCircleOutlined />
</div>
<div style={styles.nicSelectedInfo}>
<div
style={{
...styles.nicSelectedName,
color: designTokens.colors.success.dark,
}}
>
{nicList.find(nic => nic.nicId === defaultNicId)?.name || '管理口'}
</div>
{nicList.find(nic => nic.nicId === defaultNicId)?.slotNumber && (
<div
style={{
...styles.nicSelectedSlot,
color: designTokens.colors.success.main,
}}
>
插槽 {nicList.find(nic => nic.nicId === defaultNicId)?.slotNumber}
</div>
)}
</div>
<Tag color="success">已绑定</Tag>
</div>
) : (
<>
<Form.Item name="nicId" style={{ marginBottom: 0 }}>
<Select
size="small"
placeholder="选择网卡(可选)"
allowClear
showSearch
optionFilterProp="children"
style={{ width: '100%', borderRadius: '6px' }}
suffixIcon={
<InfoCircleOutlined style={{ color: designTokens.colors.neutral[400] }} />
}
>
{nicList.map(nic => (
<Option key={nic.nicId} value={nic.nicId}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '2px 0',
}}
>
<div>
<div style={{ fontWeight: 500, fontSize: '12px' }}>{nic.name}</div>
{nic.slotNumber && (
<div
style={{
fontSize: '11px',
color: designTokens.colors.neutral[500],
}}
>
插槽 {nic.slotNumber}
</div>
)}
</div>
</div>
</Option>
))}
</Select>
</Form.Item>
<div
style={{
fontSize: '11px',
color: designTokens.colors.neutral[500],
marginTop: '6px',
}}
>
<InfoCircleOutlined style={{ marginRight: '4px' }} />
不选择则端口不归属于任何网卡
</div>
</>
)}
</div>
</Col>
</Row>
<div style={{ ...styles.section, marginBottom: '16px' }}>
<div style={styles.sectionTitle}>
<ThunderboltOutlined style={{ color: designTokens.colors.primary.main }} />
端口属性
</div>
</Col>
</Row>
<div style={{ ...styles.section, marginBottom: '16px' }}>
<div style={styles.sectionTitle}>
<ThunderboltOutlined style={{ color: designTokens.colors.primary.main }} />
端口属性
<Row gutter={12}>
<Col span={12}>
<Form.Item
name="portType"
label={<span style={styles.fieldLabel}>端口类型</span>}
rules={[{ required: true, message: '请选择' }]}
>
<Select size="small" style={{ width: '100%', borderRadius: '6px' }}>
<Option value="RJ45">
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div
style={{
width: '6px',
height: '6px',
borderRadius: '2px',
background: designTokens.colors.device.server,
}}
/>
RJ45
</div>
</Option>
<Option value="SFP">
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div
style={{
width: '6px',
height: '6px',
borderRadius: '2px',
background: designTokens.colors.device.switch,
}}
/>
SFP
</div>
</Option>
<Option value="SFP+">
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div
style={{
width: '6px',
height: '6px',
borderRadius: '2px',
background: designTokens.colors.purple.main,
}}
/>
SFP+
</div>
</Option>
<Option value="SFP28">
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div
style={{
width: '6px',
height: '6px',
borderRadius: '2px',
background: designTokens.colors.info.main,
}}
/>
SFP28
</div>
</Option>
<Option value="QSFP">
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div
style={{
width: '6px',
height: '6px',
borderRadius: '2px',
background: designTokens.colors.warning.main,
}}
/>
QSFP
</div>
</Option>
<Option value="QSFP28">
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div
style={{
width: '6px',
height: '6px',
borderRadius: '2px',
background: designTokens.colors.secondary.main,
}}
/>
QSFP28
</div>
</Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="portSpeed"
label={<span style={styles.fieldLabel}>端口速率</span>}
rules={[{ required: true, message: '请选择' }]}
>
<Select size="small" style={{ width: '100%', borderRadius: '6px' }}>
<Option value="100M">100M</Option>
<Option value="1G">1G</Option>
<Option value="10G">10G</Option>
<Option value="25G">25G</Option>
<Option value="40G">40G</Option>
<Option value="100G">100G</Option>
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="vlanId" label={<span style={styles.fieldLabel}>VLAN ID</span>}>
<Input
size="small"
placeholder="1-4094"
style={{ width: '100%', borderRadius: '6px' }}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="status"
label={<span style={styles.fieldLabel}>状态</span>}
rules={[{ required: true, message: '请选择' }]}
>
<Select size="small" style={{ width: '100%', borderRadius: '6px' }}>
<Option value="free">
<Tag color="success" style={{ margin: 0 }}>
空闲
</Tag>
</Option>
<Option value="occupied">
<Tag color="warning" style={{ margin: 0 }}>
占用
</Tag>
</Option>
<Option value="fault">
<Tag color="error" style={{ margin: 0 }}>
故障
</Tag>
</Option>
</Select>
</Form.Item>
</Col>
</Row>
</div>
<Row gutter={12}>
<Col span={12}>
<Form.Item
name="portType"
label={<span style={styles.fieldLabel}>端口类型</span>}
rules={[{ required: true, message: '请选择' }]}
>
<Select size="small" style={{ width: '100%', borderRadius: '6px' }}>
<Option value="RJ45">
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: '6px', height: '6px', borderRadius: '2px', background: designTokens.colors.device.server }} />
RJ45
</div>
</Option>
<Option value="SFP">
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: '6px', height: '6px', borderRadius: '2px', background: designTokens.colors.device.switch }} />
SFP
</div>
</Option>
<Option value="SFP+">
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: '6px', height: '6px', borderRadius: '2px', background: designTokens.colors.purple.main }} />
SFP+
</div>
</Option>
<Option value="SFP28">
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: '6px', height: '6px', borderRadius: '2px', background: designTokens.colors.info.main }} />
SFP28
</div>
</Option>
<Option value="QSFP">
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: '6px', height: '6px', borderRadius: '2px', background: designTokens.colors.warning.main }} />
QSFP
</div>
</Option>
<Option value="QSFP28">
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: '6px', height: '6px', borderRadius: '2px', background: designTokens.colors.secondary.main }} />
QSFP28
</div>
</Option>
</Select>
</Form.Item>
</Col>
<div style={styles.section}>
<div style={styles.sectionTitle}>
<FileTextOutlined style={{ color: designTokens.colors.primary.main }} />
描述信息
</div>
<Col span={12}>
<Form.Item
name="portSpeed"
label={<span style={styles.fieldLabel}>端口速率</span>}
rules={[{ required: true, message: '请选择' }]}
>
<Select size="small" style={{ width: '100%', borderRadius: '6px' }}>
<Option value="100M">100M</Option>
<Option value="1G">1G</Option>
<Option value="10G">10G</Option>
<Option value="25G">25G</Option>
<Option value="40G">40G</Option>
<Option value="100G">100G</Option>
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={12}>
<Form.Item
name="vlanId"
label={<span style={styles.fieldLabel}>VLAN ID</span>}
>
<Input
size="small"
placeholder="1-4094"
style={{ width: '100%', borderRadius: '6px' }}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="status"
label={<span style={styles.fieldLabel}>状态</span>}
rules={[{ required: true, message: '请选择' }]}
>
<Select size="small" style={{ width: '100%', borderRadius: '6px' }}>
<Option value="free">
<Tag color="success" style={{ margin: 0 }}>空闲</Tag>
</Option>
<Option value="occupied">
<Tag color="warning" style={{ margin: 0 }}>占用</Tag>
</Option>
<Option value="fault">
<Tag color="error" style={{ margin: 0 }}>故障</Tag>
</Option>
</Select>
</Form.Item>
</Col>
</Row>
</div>
<div style={styles.section}>
<div style={styles.sectionTitle}>
<FileTextOutlined style={{ color: designTokens.colors.primary.main }} />
描述信息
<Form.Item name="description" style={{ marginBottom: 0 }}>
<TextArea
rows={2}
placeholder="请输入描述信息(可选)"
style={{ borderRadius: '6px', resize: 'none' }}
/>
</Form.Item>
</div>
<Form.Item name="description" style={{ marginBottom: 0 }}>
<TextArea
rows={2}
placeholder="请输入描述信息(可选)"
style={{ borderRadius: '6px', resize: 'none' }}
/>
</Form.Item>
</div>
</Form>
</div>
@@ -678,7 +792,9 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
<div style={styles.footerLeft}>
{portCount > 0 && (
<span>
将创建 <strong style={{ color: designTokens.colors.primary.main }}>{portCount}</strong> 个端口
将创建{' '}
<strong style={{ color: designTokens.colors.primary.main }}>{portCount}</strong>{' '}
个端口
</span>
)}
</div>
@@ -706,4 +822,4 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne
);
}
export default React.memo(PortCreateModal);
export default React.memo(PortCreateModal);
+21 -14
View File
@@ -57,8 +57,10 @@ const PortExportModal = ({
const activeFilters = [];
if (filters.deviceId) activeFilters.push(`设备ID: ${filters.deviceId}`);
if (filters.status && filters.status !== 'all') activeFilters.push(`状态: ${filters.status}`);
if (filters.portType && filters.portType !== 'all') activeFilters.push(`类型: ${filters.portType}`);
if (filters.portSpeed && filters.portSpeed !== 'all') activeFilters.push(`速率: ${filters.portSpeed}`);
if (filters.portType && filters.portType !== 'all')
activeFilters.push(`类型: ${filters.portType}`);
if (filters.portSpeed && filters.portSpeed !== 'all')
activeFilters.push(`速率: ${filters.portSpeed}`);
if (filters.searchText) activeFilters.push(`搜索: ${filters.searchText}`);
return activeFilters;
};
@@ -142,15 +144,18 @@ const PortExportModal = ({
</Text>
<div style={{ marginTop: '4px' }}>
{activeFilters.map((filter, index) => (
<span key={index} style={{
display: 'inline-block',
background: designTokens.colors.neutral[100],
padding: '2px 8px',
borderRadius: '4px',
fontSize: '12px',
marginRight: '4px',
marginBottom: '4px',
}}>
<span
key={index}
style={{
display: 'inline-block',
background: designTokens.colors.neutral[100],
padding: '2px 8px',
borderRadius: '4px',
fontSize: '12px',
marginRight: '4px',
marginBottom: '4px',
}}
>
{filter}
</span>
))}
@@ -164,7 +169,8 @@ const PortExportModal = ({
message={
<div>
<Text style={{ fontSize: '13px' }}>
导出将包含以下字段端口ID设备ID设备名称设备类型机房机架网卡名称端口名称端口类型端口速率状态VLAN ID描述创建时间
导出将包含以下字段端口ID设备ID设备名称设备类型机房机架网卡名称端口名称端口类型端口速率状态VLAN
ID描述创建时间
</Text>
</div>
}
@@ -176,7 +182,8 @@ const PortExportModal = ({
type="warning"
message={
<Text style={{ fontSize: '13px' }}>
当前数据量较大{totalCount} 导出可能需要较长时间系统最大支持导出 50000 条数据
当前数据量较大{totalCount} 导出可能需要较长时间系统最大支持导出 50000
条数据
</Text>
}
style={{ marginTop: '12px' }}
@@ -187,4 +194,4 @@ const PortExportModal = ({
);
};
export default React.memo(PortExportModal);
export default React.memo(PortExportModal);
+17 -11
View File
@@ -49,7 +49,7 @@ function ServerNicCard({ server, onManage }) {
const typeConfig = DEVICE_TYPE_CONFIG[server.type] || DEVICE_TYPE_CONFIG.other;
const statusConfig = STATUS_CONFIG[server.status] || STATUS_CONFIG.offline;
const nicCount = server.nicCount || (server.nics?.length || 0);
const nicCount = server.nicCount || server.nics?.length || 0;
const totalPortCount = server.nics?.reduce((sum, nic) => sum + (nic.portCount || 0), 0) || 0;
return (
@@ -170,20 +170,26 @@ function ServerNicCard({ server, onManage }) {
}}
>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '18px', fontWeight: 700, color: designTokens.colors.primary.main }}>
<div
style={{ fontSize: '18px', fontWeight: 700, color: designTokens.colors.primary.main }}
>
{nicCount}
</div>
<div style={{ fontSize: '11px', color: designTokens.colors.text.secondary }}>
网卡
</div>
<div style={{ fontSize: '11px', color: designTokens.colors.text.secondary }}>网卡</div>
</div>
<div style={{ textAlign: 'center', borderLeft: `1px solid ${designTokens.colors.border.light}`, borderRight: `1px solid ${designTokens.colors.border.light}` }}>
<div style={{ fontSize: '18px', fontWeight: 700, color: designTokens.colors.info.main }}>
<div
style={{
textAlign: 'center',
borderLeft: `1px solid ${designTokens.colors.border.light}`,
borderRight: `1px solid ${designTokens.colors.border.light}`,
}}
>
<div
style={{ fontSize: '18px', fontWeight: 700, color: designTokens.colors.info.main }}
>
{totalPortCount}
</div>
<div style={{ fontSize: '11px', color: designTokens.colors.text.secondary }}>
端口
</div>
<div style={{ fontSize: '11px', color: designTokens.colors.text.secondary }}>端口</div>
</div>
<div style={{ textAlign: 'center' }}>
<Badge
@@ -211,7 +217,7 @@ function ServerNicCard({ server, onManage }) {
color: designTokens.colors.primary.main,
cursor: 'pointer',
}}
onClick={(e) => {
onClick={e => {
e.stopPropagation();
onManage();
}}
@@ -6,7 +6,7 @@ const AnimatedCounter = ({ value, duration = 1500 }) => {
const startTimeRef = useRef(null);
useEffect(() => {
const animate = (currentTime) => {
const animate = currentTime => {
if (!startTimeRef.current) {
startTimeRef.current = currentTime;
}
@@ -2,7 +2,7 @@ import React from 'react';
import { designTokens } from '../../config/theme';
const DeviceTrendChart = ({ data }) => {
const maxValue = Math.max(...data.map((d) => d.value));
const maxValue = Math.max(...data.map(d => d.value));
const chartHeight = 120;
return (
@@ -72,7 +72,7 @@ const createNavButtonStyle = (color, isHovered) => ({
minWidth: 0,
});
const createNavIconContainer = (color) => ({
const createNavIconContainer = color => ({
width: 'clamp(44px, 10vw, 60px)',
height: 'clamp(44px, 10vw, 60px)',
borderRadius: designTokens.borderRadius.medium,
@@ -117,7 +117,7 @@ const NavigationGrid = ({ hoveredCard, onHover }) => {
className="nav-button"
style={{
...createNavButtonStyle(color, isHovered),
animationDelay: `${NAV_BUTTONS_DATA.findIndex((b) => b.key === key) * 0.1}s`,
animationDelay: `${NAV_BUTTONS_DATA.findIndex(b => b.key === key) * 0.1}s`,
}}
onMouseEnter={() => onHover(`nav-${key}`)}
onMouseLeave={() => onHover(null)}
@@ -17,9 +17,8 @@ const quickStatItemStyle = {
};
const QuickStats = ({ onlineRate, powerUsage, totalMaxPower }) => {
const powerUsagePercent = totalMaxPower > 0
? ((powerUsage / totalMaxPower) * 100).toFixed(1)
: '0.0';
const powerUsagePercent =
totalMaxPower > 0 ? ((powerUsage / totalMaxPower) * 100).toFixed(1) : '0.0';
const quickStats = [
{
+5 -11
View File
@@ -4,7 +4,7 @@ import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
import { designTokens } from '../../config/theme';
import AnimatedCounter from './AnimatedCounter';
const createStatCardStyle = (color) => ({
const createStatCardStyle = color => ({
borderRadius: designTokens.borderRadius.large,
border: 'none',
boxShadow: designTokens.shadows.medium,
@@ -17,7 +17,7 @@ const createStatCardStyle = (color) => ({
borderLeft: `4px solid ${color}`,
});
const createStatIconContainer = (color) => ({
const createStatIconContainer = color => ({
width: 'clamp(40px, 8vw, 64px)',
height: 'clamp(40px, 8vw, 64px)',
borderRadius: designTokens.borderRadius.medium,
@@ -30,14 +30,7 @@ const createStatIconContainer = (color) => ({
background: `linear-gradient(135deg, ${color}20 0%, ${color}10 100%)`,
});
const StatCard = ({
config,
stats,
loading,
animatedKey,
hoveredCard,
onHover,
}) => {
const StatCard = ({ config, stats, loading, animatedKey, hoveredCard, onHover }) => {
const {
icon: Icon,
color,
@@ -143,7 +136,8 @@ const StatCard = ({
alignItems: 'center',
fontSize: 'clamp(0.7rem, 1.8vw, 0.875rem)',
fontWeight: '500',
color: trend > 0 ? designTokens.colors.success.main : designTokens.colors.error.main,
color:
trend > 0 ? designTokens.colors.success.main : designTokens.colors.error.main,
flexWrap: 'wrap',
gap: '4px',
}}
@@ -6,10 +6,26 @@ const { Text } = Typography;
const StatusLegend = ({ deviceStatusPercentages }) => {
const legends = [
{ color: designTokens.colors.success.main, label: '运行中', percent: deviceStatusPercentages?.running ?? 0 },
{ color: designTokens.colors.warning.main, label: '维护中', percent: deviceStatusPercentages?.maintenance ?? 0 },
{ color: designTokens.colors.error.main, label: '故障', percent: deviceStatusPercentages?.fault ?? 0 },
{ color: designTokens.colors.primary.main, label: '离线', percent: deviceStatusPercentages?.offline ?? 0 },
{
color: designTokens.colors.success.main,
label: '运行中',
percent: deviceStatusPercentages?.running ?? 0,
},
{
color: designTokens.colors.warning.main,
label: '维护中',
percent: deviceStatusPercentages?.maintenance ?? 0,
},
{
color: designTokens.colors.error.main,
label: '故障',
percent: deviceStatusPercentages?.fault ?? 0,
},
{
color: designTokens.colors.primary.main,
label: '离线',
percent: deviceStatusPercentages?.offline ?? 0,
},
];
return (
@@ -20,13 +20,7 @@ const secondaryActionStyle = {
fontWeight: '500',
};
const BatchStatusModal = ({
visible,
selectedCount,
loading,
onSubmit,
onCancel,
}) => {
const BatchStatusModal = ({ visible, selectedCount, loading, onSubmit, onCancel }) => {
const [form] = Form.useForm();
const handleSubmit = async () => {
@@ -99,8 +93,7 @@ const BatchStatusModal = ({
</Select>
</Form.Item>
<div style={{ color: '#666', fontSize: '13px' }}>
已选择{' '}
<span style={{ color: '#1890ff', fontWeight: 600 }}>{selectedCount}</span> 个设备
已选择 <span style={{ color: '#1890ff', fontWeight: 600 }}>{selectedCount}</span> 个设备
</div>
</Form>
</Modal>
@@ -44,7 +44,7 @@ const DeviceDetailModal = ({
if (!device) return null;
const getStatusIcon = (status) => {
const getStatusIcon = status => {
const iconMap = {
running: <SyncOutlined spin style={{ color: colors.status.running }} />,
maintenance: <ClockCircleOutlined style={{ color: colors.status.maintenance }} />,
@@ -55,7 +55,7 @@ const DeviceDetailModal = ({
return iconMap[status] || <DesktopOutlined style={{ color: colors.text.tertiary }} />;
};
const getDeviceTypeColor = (type) => {
const getDeviceTypeColor = type => {
return colors.device[type] || colors.device.other;
};
@@ -313,7 +313,15 @@ const DeviceDetailModal = ({
}}
/>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '20px', position: 'relative', zIndex: 1 }}>
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '20px',
position: 'relative',
zIndex: 1,
}}
>
<div
className="device-icon-wrapper"
style={{
@@ -328,9 +336,7 @@ const DeviceDetailModal = ({
boxShadow: `0 8px 32px rgba(0, 0, 0, 0.2)`,
}}
>
<div style={{ fontSize: '32px', color: '#fff' }}>
{getDeviceTypeIcon(device.type)}
</div>
<div style={{ fontSize: '32px', color: '#fff' }}>{getDeviceTypeIcon(device.type)}</div>
</div>
<div style={{ flex: 1 }}>
@@ -434,7 +440,7 @@ const DeviceDetailModal = ({
paddingTop: '16px',
}}
>
{tabItems.map((tab) => (
{tabItems.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
@@ -509,19 +515,34 @@ const DeviceDetailModal = ({
/>
</Col>
<Col span={8}>
<InfoItem label="功率消耗" value={device.powerConsumption ? `${device.powerConsumption}W` : '-'} />
<InfoItem
label="功率消耗"
value={device.powerConsumption ? `${device.powerConsumption}W` : '-'}
/>
</Col>
<Col span={8}>
<InfoItem label="设备高度" value={device.height ? `${device.height}U` : '-'} />
</Col>
</Row>
{device.description && (
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: `1px dashed ${colors.border.light}` }}>
<div
style={{
marginTop: '16px',
paddingTop: '16px',
borderTop: `1px dashed ${colors.border.light}`,
}}
>
<InfoItem label="设备描述" value={device.description} fullWidth />
</div>
)}
{device.customFields && Object.keys(device.customFields).length > 0 && (
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: `1px dashed ${colors.border.light}` }}>
<div
style={{
marginTop: '16px',
paddingTop: '16px',
borderTop: `1px dashed ${colors.border.light}`,
}}
>
<div
style={{
fontSize: '12px',
@@ -536,7 +557,7 @@ const DeviceDetailModal = ({
</div>
<Row gutter={[16, 16]}>
{Object.entries(device.customFields).map(([key, value]) => {
const fieldConfig = deviceFields.find((f) => f.fieldName === key);
const fieldConfig = deviceFields.find(f => f.fieldName === key);
const displayName = fieldConfig?.displayName || key;
return (
<Col span={8} key={key}>
@@ -609,12 +630,23 @@ const DeviceDetailModal = ({
>
<Row gutter={[24, 16]}>
<Col span={8}>
<InfoItem label="购买日期" value={device.purchaseDate ? new Date(device.purchaseDate).toLocaleDateString('zh-CN') : '-'} />
<InfoItem
label="购买日期"
value={
device.purchaseDate
? new Date(device.purchaseDate).toLocaleDateString('zh-CN')
: '-'
}
/>
</Col>
<Col span={8}>
<InfoItem
label="保修到期"
value={device.warrantyExpiry ? new Date(device.warrantyExpiry).toLocaleDateString('zh-CN') : '-'}
value={
device.warrantyExpiry
? new Date(device.warrantyExpiry).toLocaleDateString('zh-CN')
: '-'
}
status={isWarrantyExpired ? 'danger' : undefined}
/>
</Col>
@@ -655,7 +687,9 @@ const DeviceDetailModal = ({
gap: '10px',
}}
>
<ExclamationCircleOutlined style={{ color: colors.error.main, fontSize: '18px' }} />
<ExclamationCircleOutlined
style={{ color: colors.error.main, fontSize: '18px' }}
/>
<span style={{ color: colors.error.main, fontSize: '13px', fontWeight: 500 }}>
设备已过保建议续保
</span>
@@ -1,6 +1,24 @@
import React, { useState, useEffect } from 'react';
import { Modal, Form, Input, Select, InputNumber, DatePicker, Switch, Row, Col, Button, Space, Alert } from 'antd';
import { PlusOutlined, EditOutlined, DatabaseOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
import {
Modal,
Form,
Input,
Select,
InputNumber,
DatePicker,
Switch,
Row,
Col,
Button,
Space,
Alert,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DatabaseOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { designTokens } from '../../config/theme';
import { getFormInitialValues, prepareDeviceFormData } from '../../utils/deviceUtils.jsx';
@@ -48,14 +66,19 @@ const DeviceFormModal = ({
}
form.setFieldsValue(initialValues);
if (editingDevice.rackId) {
const rack = racks.find((r) => r.rackId === editingDevice.rackId);
const rack = racks.find(r => r.rackId === editingDevice.rackId);
if (rack) {
setSelectedRoomId(rack.roomId);
setSelectedRackId(editingDevice.rackId);
}
}
if (editingDevice.position) {
checkPositionConflict(editingDevice.rackId, editingDevice.position, editingDevice.height, editingDevice.deviceId);
checkPositionConflict(
editingDevice.rackId,
editingDevice.position,
editingDevice.height,
editingDevice.deviceId
);
}
} else {
form.resetFields();
@@ -95,7 +118,7 @@ const DeviceFormModal = ({
}
};
const handleRackChange = (value) => {
const handleRackChange = value => {
setSelectedRackId(value);
const position = form.getFieldValue('position');
const height = form.getFieldValue('height');
@@ -106,7 +129,7 @@ const DeviceFormModal = ({
}
};
const handlePositionChange = (value) => {
const handlePositionChange = value => {
const height = form.getFieldValue('height');
if (selectedRackId && value) {
checkPositionConflict(selectedRackId, value, height, editingDevice?.deviceId);
@@ -115,7 +138,7 @@ const DeviceFormModal = ({
}
};
const handleHeightChange = (value) => {
const handleHeightChange = value => {
const position = form.getFieldValue('position');
if (selectedRackId && position) {
checkPositionConflict(selectedRackId, position, value, editingDevice?.deviceId);
@@ -124,7 +147,7 @@ const DeviceFormModal = ({
}
};
const handleSubmit = (values) => {
const handleSubmit = values => {
if (positionConflict) {
return;
}
@@ -132,14 +155,14 @@ const DeviceFormModal = ({
onSubmit(deviceData);
};
const handleRoomChange = (value) => {
const handleRoomChange = value => {
setSelectedRoomId(value);
setSelectedRackId(null);
setPositionConflict(null);
form.setFieldValue('rackId', undefined);
};
const renderFieldControl = (field) => {
const renderFieldControl = field => {
switch (field.fieldType) {
case 'number':
return (
@@ -177,7 +200,7 @@ const DeviceFormModal = ({
className="form-input-enhanced"
>
{Array.isArray(field.options) &&
field.options.map((option) => (
field.options.map(option => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
@@ -196,11 +219,15 @@ const DeviceFormModal = ({
};
const filteredFields = deviceFields.filter(
(field) => field.fieldName !== 'deviceId' && field.fieldName !== 'rackId' && field.fieldName !== 'position' && field.fieldName !== 'height'
field =>
field.fieldName !== 'deviceId' &&
field.fieldName !== 'rackId' &&
field.fieldName !== 'position' &&
field.fieldName !== 'height'
);
const formItems = [];
filteredFields.forEach((field) => {
filteredFields.forEach(field => {
if (field.fieldName === 'serialNumber') {
formItems.push(
<React.Fragment key={field.fieldName}>
@@ -210,9 +237,7 @@ const DeviceFormModal = ({
label={
<span>
{field.displayName}
{field.required && (
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
)}
{field.required && <span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>}
</span>
}
rules={
@@ -266,7 +291,7 @@ const DeviceFormModal = ({
optionFilterProp="children"
onChange={handleRoomChange}
>
{rooms.map((room) => (
{rooms.map(room => (
<Option key={room.roomId} value={room.roomId}>
{room.name}
</Option>
@@ -294,13 +319,14 @@ const DeviceFormModal = ({
optionFilterProp="children"
onChange={handleRackChange}
>
{(selectedRoomId ? racks.filter((rack) => rack.roomId === selectedRoomId) : []).map(
(rack) => (
<Option key={rack.rackId} value={rack.rackId}>
{rack.name} ({rack.rackId})
</Option>
)
)}
{(selectedRoomId
? racks.filter(rack => rack.roomId === selectedRoomId)
: []
).map(rack => (
<Option key={rack.rackId} value={rack.rackId}>
{rack.name} ({rack.rackId})
</Option>
))}
</Select>
</Form.Item>
</Col>
@@ -372,9 +398,7 @@ const DeviceFormModal = ({
label={
<span>
{field.displayName}
{field.required && (
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
)}
{field.required && <span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>}
</span>
}
rules={
@@ -393,9 +417,7 @@ const DeviceFormModal = ({
label={
<span>
{field.displayName}
{field.required && (
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
)}
{field.required && <span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>}
</span>
}
rules={
@@ -39,8 +39,8 @@ const FieldConfigModal = ({
);
};
const handleSubmit = async (values) => {
const updatedFields = deviceFields.map((field) => ({
const handleSubmit = async values => {
const updatedFields = deviceFields.map(field => ({
fieldId: field.fieldId,
fieldName: field.fieldName,
displayName: field.displayName,
@@ -56,7 +56,7 @@ const FieldConfigModal = ({
message.success('字段配置已重置为默认值');
};
const filteredFields = deviceFields.filter((field) => field.fieldName !== 'deviceId');
const filteredFields = deviceFields.filter(field => field.fieldName !== 'deviceId');
return (
<Modal
@@ -95,7 +95,7 @@ const FieldConfigModal = ({
</tr>
</thead>
<tbody>
{filteredFields.map((field) => (
{filteredFields.map(field => (
<tr key={field.fieldName} style={{ borderBottom: '1px solid #f0f0f0' }}>
<td style={{ padding: '8px' }}>{field.displayName}</td>
<td style={{ padding: '8px', textAlign: 'center' }}>
+30 -29
View File
@@ -1,6 +1,12 @@
import React, { useState } from 'react';
import { Modal, Upload, Button, Progress, message, Table, Alert, Space, Spin } from 'antd';
import { UploadOutlined, DownloadOutlined, CheckCircleOutlined, WarningOutlined, FileTextOutlined } from '@ant-design/icons';
import {
UploadOutlined,
DownloadOutlined,
CheckCircleOutlined,
WarningOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import { designTokens } from '../../config/theme';
@@ -12,12 +18,7 @@ const modalHeaderStyle = {
fontWeight: 600,
};
const ImportModal = ({
visible,
deviceFields,
onImport,
onCancel,
}) => {
const ImportModal = ({ visible, deviceFields, onImport, onCancel }) => {
const [step, setStep] = useState('upload');
const [isPreviewing, setIsPreviewing] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
@@ -32,7 +33,7 @@ const ImportModal = ({
baseURL: '/api',
});
api.interceptors.request.use((config) => {
api.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
@@ -57,7 +58,7 @@ const ImportModal = ({
onCancel();
};
const handlePreview = async (file) => {
const handlePreview = async file => {
const actualFile = file.originFileObj || file;
setSelectedFile(actualFile);
setPreviewLoading(true);
@@ -107,14 +108,14 @@ const ImportModal = ({
setImportProgress(progress);
setImportPhase(phase);
},
onSuccess: (result) => {
onSuccess: result => {
setImportResult(result);
setImportProgress(100);
setImportPhase('导入完成');
setIsConfirming(false);
setStep('result');
},
onError: (error) => {
onError: error => {
setImportResult({
success: false,
statistics: {
@@ -135,14 +136,14 @@ const ImportModal = ({
}
};
const requiredFields = deviceFields.filter((f) => f.visible && f.required);
const optionalFields = deviceFields.filter((f) => f.visible && !f.required);
const requiredFields = deviceFields.filter(f => f.visible && f.required);
const optionalFields = deviceFields.filter(f => f.visible && !f.required);
const previewColumns = previewData?.fieldList
? [
...previewData.fieldList
.filter((field) => field.fieldName !== 'rackId')
.map((field) => ({
.filter(field => field.fieldName !== 'rackId')
.map(field => ({
title: field.displayName + (field.required ? ' *' : ''),
dataIndex: field.fieldName,
key: field.fieldName,
@@ -163,7 +164,7 @@ const ImportModal = ({
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
];
const getRowClassName = (record) => {
const getRowClassName = record => {
if (record._hasError) {
return 'ant-table-row-error';
}
@@ -173,9 +174,7 @@ const ImportModal = ({
const renderUploadStep = () => (
<div>
<p style={{ color: '#666', marginBottom: '8px' }}>请上传CSV格式的设备数据文件</p>
<p style={{ color: '#999', fontSize: '12px', marginBottom: '20px' }}>
支持的编码格式GBK
</p>
<p style={{ color: '#999', fontSize: '12px', marginBottom: '20px' }}>支持的编码格式GBK</p>
<div
style={{
@@ -186,15 +185,13 @@ const ImportModal = ({
border: '1px solid #f0f0f0',
}}
>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#333' }}>
CSV文件格式要求
</p>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#333' }}>CSV文件格式要求</p>
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
{requiredFields.length > 0 && (
<div style={{ marginBottom: '8px' }}>
<span style={{ color: '#d93025', fontWeight: '500' }}>必填字段</span>
<span style={{ color: '#666', fontSize: '13px' }}>
{requiredFields.map((f) => f.displayName).join('、')}
{requiredFields.map(f => f.displayName).join('、')}
</span>
</div>
)}
@@ -202,7 +199,7 @@ const ImportModal = ({
<div style={{ marginBottom: '8px' }}>
<span style={{ color: '#666', fontWeight: '500' }}>可选字段</span>
<span style={{ color: '#666', fontSize: '13px' }}>
{optionalFields.map((f) => f.displayName).join('、')}
{optionalFields.map(f => f.displayName).join('、')}
</span>
</div>
)}
@@ -290,7 +287,8 @@ const ImportModal = ({
{selectedFile?.name || '已选择文件'}
</div>
<div style={{ color: '#666', fontSize: '13px', marginTop: '2px' }}>
{previewData?.total || 0} 条记录已解析 {previewData?.previewCount || 0} 条作为预览
{previewData?.total || 0} 条记录已解析 {previewData?.previewCount || 0}{' '}
条作为预览
</div>
</div>
</div>
@@ -384,7 +382,7 @@ const ImportModal = ({
pagination={{
pageSize: 10,
showSizeChanger: false,
showTotal: (total) => `${total}`,
showTotal: total => `${total}`,
}}
scroll={{ x: 'max-content' }}
/>
@@ -402,7 +400,9 @@ const ImportModal = ({
backgroundColor: '#fff7f7',
}}
>
<h4 style={{ color: '#d93025', marginBottom: '12px', fontWeight: '600', fontSize: '13px' }}>
<h4
style={{ color: '#d93025', marginBottom: '12px', fontWeight: '600', fontSize: '13px' }}
>
错误详情
</h4>
{previewData.errors.slice(0, 10).map((err, index) => (
@@ -437,7 +437,8 @@ const ImportModal = ({
style={{
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: previewData?.statistics?.invalid > 0 ? '#ccc' : designTokens.colors.primary.gradient,
background:
previewData?.statistics?.invalid > 0 ? '#ccc' : designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
fontWeight: '500',
@@ -703,4 +704,4 @@ const ImportModal = ({
);
};
export default React.memo(ImportModal);
export default React.memo(ImportModal);
@@ -27,10 +27,10 @@ const resizableTitleStyles = {
},
};
const ResizableTitle = (props) => {
const ResizableTitle = props => {
const { children, onResize, width, ...restProps } = props;
const handleMouseDown = (e) => {
const handleMouseDown = e => {
if (!onResize) return;
e.preventDefault();
@@ -42,7 +42,7 @@ const ResizableTitle = (props) => {
const startWidth = th.offsetWidth;
const startX = e.clientX;
const handleMouseMove = (moveEvent) => {
const handleMouseMove = moveEvent => {
const diff = moveEvent.clientX - startX;
const newWidth = Math.max(50, startWidth + diff);
onResize(newWidth);
+4 -4
View File
@@ -5,17 +5,17 @@
export const API_CONFIG = {
timeout: parseInt(import.meta.env.VITE_API_TIMEOUT, 10) || 30000,
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
pagination: {
defaultPageSize: 10,
maxPageSize: 1000,
pageSizeOptions: [10, 20, 30, 50, 100],
},
debounceDelay: 300,
retry: {
maxRetries: 3,
retryDelay: 1000,
+4 -1
View File
@@ -8,7 +8,10 @@ const applyThemeColors = (primaryColor, secondaryColor) => {
if (primaryColor) {
root.style.setProperty('--primary-color', primaryColor);
root.style.setProperty('--primary-light', `${primaryColor}20`);
root.style.setProperty('--primary-gradient', `linear-gradient(135deg, ${primaryColor} 0%, ${secondaryColor || '#764ba2'} 100%)`);
root.style.setProperty(
'--primary-gradient',
`linear-gradient(135deg, ${primaryColor} 0%, ${secondaryColor || '#764ba2'} 100%)`
);
}
if (secondaryColor) {
root.style.setProperty('--secondary-color', secondaryColor);
+17 -6
View File
@@ -34,7 +34,7 @@ export const useDangerousOperation = () => {
const entityLabel = ENTITY_LABELS[entityType] || entityType;
const operationLabel = OPERATION_LABELS[operationType] || operationType;
return new Promise((resolve) => {
return new Promise(resolve => {
const modalKey = `dangerous-${Date.now()}`;
const handleOk = () => {
@@ -74,14 +74,21 @@ export const useDangerousOperation = () => {
}
description={
<Paragraph style={{ marginBottom: 0 }}>
{description || `您即将${operationLabel} ${itemCount > 1 ? `${itemCount}` : '1 个'}${entityLabel}`}
{description ||
`您即将${operationLabel} ${itemCount > 1 ? `${itemCount}` : '1 个'}${entityLabel}`}
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
此操作不可逆一旦删除将无法恢复
</Text>
</Paragraph>
}
type={riskLevel === RISK_LEVEL.EXTREME ? 'error' : riskLevel === RISK_LEVEL.HIGH ? 'warning' : 'info'}
type={
riskLevel === RISK_LEVEL.EXTREME
? 'error'
: riskLevel === RISK_LEVEL.HIGH
? 'warning'
: 'info'
}
style={{
backgroundColor: config.bgColor,
borderColor: config.borderColor,
@@ -106,7 +113,11 @@ export const useDangerousOperation = () => {
<Alert
message={
<Text>
为确认此操作请输入 <Text code strong>CONFIRM</Text>
为确认此操作请输入{' '}
<Text code strong>
CONFIRM
</Text>
</Text>
}
type="error"
@@ -115,7 +126,7 @@ export const useDangerousOperation = () => {
<Input
id={`keyword-input-${modalKey}`}
placeholder="请输入 CONFIRM"
onChange={(e) => {
onChange={e => {
const inputValue = e.target.value;
const okButton = document.querySelector('.ant-modal-confirm .ant-btn-primary');
if (okButton) {
@@ -212,7 +223,7 @@ export const useDangerousOperation = () => {
return { confirm, logOperation };
};
export const confirmDangerousOperation = async (options) => {
export const confirmDangerousOperation = async options => {
const hook = useDangerousOperation();
return hook.confirm(options);
};
+1 -9
View File
@@ -119,15 +119,7 @@ const useIdleTimeout = ({
}
// 定义需要监听的事件
const events = [
'mousedown',
'mousemove',
'keydown',
'scroll',
'touchstart',
'click',
'wheel',
];
const events = ['mousedown', 'mousemove', 'keydown', 'scroll', 'touchstart', 'click', 'wheel'];
// 事件处理函数
const handleActivity = () => {
+3 -3
View File
@@ -16,7 +16,7 @@ export const SCREEN_SIZES = {
xl: 'xl',
};
const getScreenSize = (width) => {
const getScreenSize = width => {
if (width >= BREAKPOINTS.xl) return SCREEN_SIZES.xl;
if (width >= BREAKPOINTS.lg) return SCREEN_SIZES.lg;
if (width >= BREAKPOINTS.md) return SCREEN_SIZES.md;
@@ -24,7 +24,7 @@ const getScreenSize = (width) => {
return SCREEN_SIZES.xs;
};
const getScreenSizeConfig = (screenSize) => {
const getScreenSizeConfig = screenSize => {
const configs = {
xs: {
showFullButtonLabels: false,
@@ -100,4 +100,4 @@ export const useResponsiveLayout = () => {
};
};
export default useResponsiveLayout;
export default useResponsiveLayout;
+14 -11
View File
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
const extractNumberFromString = (str) => {
const extractNumberFromString = str => {
if (!str) return 0;
const match = str.match(/\d+/);
return match ? parseInt(match[0], 10) : 0;
@@ -13,7 +13,7 @@ const naturalSort = (a, b) => {
return String(a).localeCompare(String(b), 'zh-CN');
};
const sortRooms = (rooms) => {
const sortRooms = rooms => {
return [...rooms].sort((a, b) => {
const sortOrderA = a.sortOrder ?? a.sort_order ?? 0;
const sortOrderB = b.sortOrder ?? b.sort_order ?? 0;
@@ -24,7 +24,7 @@ const sortRooms = (rooms) => {
});
};
const sortRacksInRoom = (racks) => {
const sortRacksInRoom = racks => {
return [...racks].sort((a, b) => {
const sortOrderA = a.sortOrder ?? a.sort_order ?? 0;
const sortOrderB = b.sortOrder ?? b.sort_order ?? 0;
@@ -38,7 +38,7 @@ const sortRacksInRoom = (racks) => {
});
};
export const useSortedRacks = (racks) => {
export const useSortedRacks = racks => {
return useMemo(() => {
if (!racks || !Array.isArray(racks) || racks.length === 0) {
return [];
@@ -46,7 +46,7 @@ export const useSortedRacks = (racks) => {
const roomMap = new Map();
racks.forEach((rack) => {
racks.forEach(rack => {
if (!rack || !rack.Room) return;
const roomKey = rack.Room.roomId || rack.Room.id || rack.Room.name;
@@ -65,10 +65,13 @@ export const useSortedRacks = (racks) => {
const sortedRooms = sortRooms(Array.from(roomMap.values()));
sortedRooms.forEach((room) => {
sortedRooms.forEach(room => {
room.racks = sortRacksInRoom(room.racks);
room.rackCount = room.racks.length;
room.totalDevices = room.racks.reduce((sum, r) => sum + (r._count?.Devices || r.deviceCount || 0), 0);
room.totalDevices = room.racks.reduce(
(sum, r) => sum + (r._count?.Devices || r.deviceCount || 0),
0
);
});
return sortedRooms;
@@ -83,11 +86,11 @@ export const filterRoomsBySearch = (rooms, searchText) => {
const lowerSearch = searchText.toLowerCase().trim();
return rooms
.map((room) => {
.map(room => {
const roomNameMatch = room.name?.toLowerCase().includes(lowerSearch);
const roomIdMatch = room.roomId?.toLowerCase().includes(lowerSearch);
const filteredRacks = room.racks.filter((rack) => {
const filteredRacks = room.racks.filter(rack => {
const rackNameMatch = rack.name?.toLowerCase().includes(lowerSearch);
const rackIdMatch = rack.rackId?.toLowerCase().includes(lowerSearch);
return roomNameMatch || roomIdMatch || rackNameMatch || rackIdMatch;
@@ -103,7 +106,7 @@ export const filterRoomsBySearch = (rooms, searchText) => {
return null;
})
.filter((room) => room !== null);
.filter(room => room !== null);
};
export const getRackStats = (rack, devices = []) => {
@@ -123,4 +126,4 @@ export const getRackStats = (rack, devices = []) => {
};
};
export default useSortedRacks;
export default useSortedRacks;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+31 -6
View File
@@ -209,12 +209,21 @@ function CategoryManagement() {
allowClear
prefix={<SearchOutlined />}
/>
<Select value={status} onChange={setStatus} style={{ ...selectStyles.base, width: 120 }}>
<Select
value={status}
onChange={setStatus}
style={{ ...selectStyles.base, width: 120 }}
>
<Option value="all">所有状态</Option>
<Option value="active">启用</Option>
<Option value="inactive">停用</Option>
</Select>
<Button onClick={() => fetchCategories()} style={{ height: '40px', borderRadius: '10px' }}>刷新</Button>
<Button
onClick={() => fetchCategories()}
style={{ height: '40px', borderRadius: '10px' }}
>
刷新
</Button>
</Space>
</Card>
@@ -249,12 +258,22 @@ function CategoryManagement() {
<Input placeholder="请输入分类名称" style={inputStyles.form} />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} placeholder={inputPlaceholders.description} maxLength={200} showCount style={textAreaStyles.base} />
<Input.TextArea
rows={3}
placeholder={inputPlaceholders.description}
maxLength={200}
showCount
style={textAreaStyles.base}
/>
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} placeholder="数值越小越靠前" style={inputNumberStyles.base} />
</Form.Item>
<Form.Item name="status" label="状态" rules={[inputValidationRules.required('请选择状态')]}>
<Form.Item
name="status"
label="状态"
rules={[inputValidationRules.required('请选择状态')]}
>
<Select style={selectStyles.base}>
<Option value="active">启用</Option>
<Option value="inactive">停用</Option>
@@ -262,10 +281,16 @@ function CategoryManagement() {
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" style={{ height: '40px', borderRadius: '10px' }}>
<Button
type="primary"
htmlType="submit"
style={{ height: '40px', borderRadius: '10px' }}
>
{editingCategory ? '更新' : '创建'}
</Button>
<Button onClick={handleCancel} style={{ height: '40px', borderRadius: '10px' }}>取消</Button>
<Button onClick={handleCancel} style={{ height: '40px', borderRadius: '10px' }}>
取消
</Button>
</Space>
</Form.Item>
</Form>
+99 -32
View File
@@ -84,7 +84,11 @@ function ConsumableLogs() {
setLoading(true);
const params = { page, pageSize };
if (currentFilters.operationType && currentFilters.operationType !== 'all' && currentFilters.operationType.length > 0) {
if (
currentFilters.operationType &&
currentFilters.operationType !== 'all' &&
currentFilters.operationType.length > 0
) {
params.operationType = currentFilters.operationType.join(',');
}
if (currentFilters.consumableId) {
@@ -169,7 +173,9 @@ function ConsumableLogs() {
<Space direction="vertical" size={0}>
<span>{value}</span>
{record.isConsumableDeleted && (
<Tag color="red" size="small">已删除</Tag>
<Tag color="red" size="small">
已删除
</Tag>
)}
</Space>
</Tooltip>
@@ -288,13 +294,15 @@ function ConsumableLogs() {
width: 200,
render: value => (
<Tooltip title={value || '-'}>
<span style={{
display: 'inline-block',
maxWidth: '180px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
<span
style={{
display: 'inline-block',
maxWidth: '180px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{value || '-'}
</span>
</Tooltip>
@@ -648,7 +656,11 @@ function ConsumableLogs() {
导出 <DownOutlined />
</Button>
</Dropdown>
<Button icon={<UploadOutlined />} onClick={() => setImportModalVisible(true)} style={{ height: '40px', borderRadius: '10px' }}>
<Button
icon={<UploadOutlined />}
onClick={() => setImportModalVisible(true)}
style={{ height: '40px', borderRadius: '10px' }}
>
导入
</Button>
</Space>
@@ -735,17 +747,29 @@ function ConsumableLogs() {
>
<Form form={form} layout="vertical" onFinish={handleEditSubmit}>
<Form.Item label="操作原因" name="reason">
<Input.TextArea rows={2} placeholder={inputPlaceholders.reason} style={textAreaStyles.base} />
<Input.TextArea
rows={2}
placeholder={inputPlaceholders.reason}
style={textAreaStyles.base}
/>
</Form.Item>
<Form.Item label="备注" name="notes">
<Input.TextArea rows={3} placeholder={inputPlaceholders.notes} style={textAreaStyles.base} />
<Input.TextArea
rows={3}
placeholder={inputPlaceholders.notes}
style={textAreaStyles.base}
/>
</Form.Item>
<Form.Item
label="修改原因"
name="modificationReason"
rules={[inputValidationRules.required('请输入修改原因')]}
>
<Input.TextArea rows={2} placeholder="请输入修改原因(必填)" style={textAreaStyles.base} />
<Input.TextArea
rows={2}
placeholder="请输入修改原因(必填)"
style={textAreaStyles.base}
/>
</Form.Item>
<Form.Item label="修改人" name="operator">
<Input placeholder={inputPlaceholders.operator} style={inputStyles.form} />
@@ -788,7 +812,11 @@ function ConsumableLogs() {
<div style={{ fontSize: 12, color: '#666' }}>
<p>
<strong>耗材:</strong> {item.consumableName} ({item.consumableId})
{item.isConsumableDeleted && <Tag color="red" style={{ marginLeft: 8 }}>已删除</Tag>}
{item.isConsumableDeleted && (
<Tag color="red" style={{ marginLeft: 8 }}>
已删除
</Tag>
)}
</p>
<p>
<strong>操作人:</strong> {item.operator}
@@ -806,8 +834,8 @@ function ConsumableLogs() {
{item.consumableSnapshot && (
<p>
<strong>快照信息:</strong> 分类:{item.consumableSnapshot.category || '-'} |
单位:{item.consumableSnapshot.unit || '-'} |
单价:{item.consumableSnapshot.unitPrice || '-'}
单位:{item.consumableSnapshot.unit || '-'} | 单价:
{item.consumableSnapshot.unitPrice || '-'}
</p>
)}
{item.modifiedBy && (
@@ -854,12 +882,25 @@ function ConsumableLogs() {
) : (
<div>
<Card size="small" title="基本信息" style={{ marginBottom: 16 }}>
<p><strong>归档ID:</strong> <code>{currentArchive.archiveId}</code></p>
<p><strong>耗材ID:</strong> <code>{currentArchive.consumableId}</code></p>
<p><strong>耗材名称:</strong> {currentArchive.consumableName}</p>
<p><strong>删除人:</strong> {currentArchive.deletedBy}</p>
<p><strong>删除时间:</strong> {dayjs(currentArchive.deletedAt).format('YYYY-MM-DD HH:mm:ss')}</p>
<p><strong>删除原因:</strong> {currentArchive.deleteReason || '-'}</p>
<p>
<strong>归档ID:</strong> <code>{currentArchive.archiveId}</code>
</p>
<p>
<strong>耗材ID:</strong> <code>{currentArchive.consumableId}</code>
</p>
<p>
<strong>耗材名称:</strong> {currentArchive.consumableName}
</p>
<p>
<strong>删除人:</strong> {currentArchive.deletedBy}
</p>
<p>
<strong>删除时间:</strong>{' '}
{dayjs(currentArchive.deletedAt).format('YYYY-MM-DD HH:mm:ss')}
</p>
<p>
<strong>删除原因:</strong> {currentArchive.deleteReason || '-'}
</p>
</Card>
<Card size="small" title="操作统计" style={{ marginBottom: 16 }}>
@@ -875,20 +916,46 @@ function ConsumableLogs() {
</Col>
</Row>
<Divider style={{ margin: '12px 0' }} />
<p><strong>首次操作:</strong> {currentArchive.firstOperationAt ? dayjs(currentArchive.firstOperationAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</p>
<p><strong>最后操作:</strong> {currentArchive.lastOperationAt ? dayjs(currentArchive.lastOperationAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</p>
<p><strong>删除时库存:</strong> {currentArchive.finalStock}</p>
<p>
<strong>首次操作:</strong>{' '}
{currentArchive.firstOperationAt
? dayjs(currentArchive.firstOperationAt).format('YYYY-MM-DD HH:mm:ss')
: '-'}
</p>
<p>
<strong>最后操作:</strong>{' '}
{currentArchive.lastOperationAt
? dayjs(currentArchive.lastOperationAt).format('YYYY-MM-DD HH:mm:ss')
: '-'}
</p>
<p>
<strong>删除时库存:</strong> {currentArchive.finalStock}
</p>
</Card>
{currentArchive.consumableSnapshot && (
<Card size="small" title="耗材快照">
<p><strong>分类:</strong> {currentArchive.consumableSnapshot.category || '-'}</p>
<p><strong>单位:</strong> {currentArchive.consumableSnapshot.unit || '-'}</p>
<p><strong>单价:</strong> {currentArchive.consumableSnapshot.unitPrice || '-'}</p>
<p><strong>供应商:</strong> {currentArchive.consumableSnapshot.supplier || '-'}</p>
<p><strong>:</strong> {currentArchive.consumableSnapshot.location || '-'}</p>
<p><strong>最小库存:</strong> {currentArchive.consumableSnapshot.minStock || '-'}</p>
<p><strong>最大库存:</strong> {currentArchive.consumableSnapshot.maxStock || '-'}</p>
<p>
<strong>分类:</strong> {currentArchive.consumableSnapshot.category || '-'}
</p>
<p>
<strong>:</strong> {currentArchive.consumableSnapshot.unit || '-'}
</p>
<p>
<strong>单价:</strong> {currentArchive.consumableSnapshot.unitPrice || '-'}
</p>
<p>
<strong>供应商:</strong> {currentArchive.consumableSnapshot.supplier || '-'}
</p>
<p>
<strong>位置:</strong> {currentArchive.consumableSnapshot.location || '-'}
</p>
<p>
<strong>最小库存:</strong> {currentArchive.consumableSnapshot.minStock || '-'}
</p>
<p>
<strong>最大库存:</strong> {currentArchive.consumableSnapshot.maxStock || '-'}
</p>
</Card>
)}
</div>
File diff suppressed because it is too large Load Diff
+204 -114
View File
@@ -49,10 +49,7 @@ import dayjs from 'dayjs';
import api from '../api';
import { consumableRecordAPI, consumableCategoryAPI, consumableAPI } from '../api/cache';
import { message } from 'antd';
import {
selectStyles,
datePickerStyles,
} from '../styles/deviceManagementStyles';
import { selectStyles, datePickerStyles } from '../styles/deviceManagementStyles';
import { designTokens } from '../config/theme';
const { Title, Text } = Typography;
@@ -145,14 +142,16 @@ const QuickFilterBtn = styled(Button)`
padding: 0 16px;
font-size: 13px;
font-weight: 500;
border: 1px solid ${props => props.$active ? designTokens.colors.primary.main : designTokens.colors.border};
background: ${props => props.$active ? designTokens.colors.primary.main : 'transparent'};
color: ${props => props.$active ? 'white' : designTokens.colors.text.secondary};
border: 1px solid
${props => (props.$active ? designTokens.colors.primary.main : designTokens.colors.border)};
background: ${props => (props.$active ? designTokens.colors.primary.main : 'transparent')};
color: ${props => (props.$active ? 'white' : designTokens.colors.text.secondary)};
&:hover {
border-color: ${designTokens.colors.primary.main};
color: ${props => props.$active ? 'white' : designTokens.colors.primary.main};
background: ${props => props.$active ? designTokens.colors.primary.main : 'rgba(99, 102, 241, 0.05)'};
color: ${props => (props.$active ? 'white' : designTokens.colors.primary.main)};
background: ${props =>
props.$active ? designTokens.colors.primary.main : 'rgba(99, 102, 241, 0.05)'};
}
`;
@@ -304,17 +303,17 @@ const StatsCard = styled(motion.div)`
font-weight: 600;
padding: 4px 10px;
border-radius: 20px;
&.up {
color: ${designTokens.colors.success.main};
background: rgba(16, 185, 129, 0.1);
}
&.down {
color: ${designTokens.colors.error.main};
background: rgba(239, 68, 68, 0.1);
}
&.neutral {
color: ${designTokens.colors.text.secondary};
background: rgba(107, 114, 128, 0.1);
@@ -478,7 +477,7 @@ const ComparisonItem = styled.div`
align-items: center;
justify-content: center;
gap: 4px;
strong {
color: ${props => props.$color};
font-weight: 600;
@@ -519,7 +518,7 @@ const CategoryCard = styled(motion.div)`
transform: translateY(-4px);
box-shadow: 0 8px 24px ${props => props.$color}20;
border-color: ${props => props.$color}40;
&::before {
opacity: 1;
}
@@ -567,7 +566,7 @@ const CategoryCard = styled(motion.div)`
color: ${designTokens.colors.text.secondary};
padding-top: 8px;
border-top: 1px solid ${designTokens.colors.border}40;
strong {
color: ${designTokens.colors.text.primary};
font-weight: 600;
@@ -731,7 +730,7 @@ const ConsumableStatistics = () => {
const statsResponse = await consumableRecordAPI.statistics(params);
console.log('[统计] 返回:', statsResponse);
console.log('[统计] 最近记录:', statsResponse?.recentRecords);
setStats({
inCount: statsResponse?.inCount || 0,
outCount: statsResponse?.outCount || 0,
@@ -742,7 +741,7 @@ const ConsumableStatistics = () => {
const summaryResponse = await consumableAPI.getStatistics();
console.log('[汇总] 返回:', summaryResponse);
setSummary({
total: summaryResponse?.total || 0,
lowStock: summaryResponse?.lowStock || 0,
@@ -808,7 +807,7 @@ const ConsumableStatistics = () => {
};
}, [realTimeRefresh]);
const handleQuickFilter = (key) => {
const handleQuickFilter = key => {
setQuickFilter(key);
const filter = quickFilters.find(f => f.key === key);
if (filter) {
@@ -823,10 +822,7 @@ const ConsumableStatistics = () => {
const handleRefresh = () => {
setLoading(true);
setIsAutoRefreshing(false);
Promise.all([
loadStatistics(false),
loadLowStockItems(false)
]).finally(() => {
Promise.all([loadStatistics(false), loadLowStockItems(false)]).finally(() => {
setLoading(false);
if (!realTimeRefresh) {
message.success('数据已手动刷新');
@@ -838,22 +834,30 @@ const ConsumableStatistics = () => {
message.info('导出功能开发中...');
};
const getCategoryColor = (category) => {
const getCategoryColor = category => {
const predefinedColors = [
'#6366f1', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6',
'#06b6d4', '#f97316', '#14b8a6', '#ef4444', '#3b82f6'
'#6366f1',
'#10b981',
'#f59e0b',
'#ec4899',
'#8b5cf6',
'#06b6d4',
'#f97316',
'#14b8a6',
'#ef4444',
'#3b82f6',
];
const colorMap = {
'网络设备': '#6366f1',
'线缆': '#10b981',
'配件': '#f59e0b',
'工具': '#ec4899',
'其他': '#6b7280',
网络设备: '#6366f1',
线缆: '#10b981',
配件: '#f59e0b',
工具: '#ec4899',
其他: '#6b7280',
};
if (colorMap[category]) return colorMap[category];
let hash = 0;
for (let i = 0; i < category.length; i++) {
hash = category.charCodeAt(i) + ((hash << 5) - hash);
@@ -880,23 +884,27 @@ const ConsumableStatistics = () => {
<WarningOutlined />
</Avatar>
<div style={{ minWidth: 0 }}>
<div style={{
fontWeight: 600,
color: designTokens.colors.text.primary,
fontSize: '13px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
<div
style={{
fontWeight: 600,
color: designTokens.colors.text.primary,
fontSize: '13px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{text}
</div>
<div style={{
fontSize: '11px',
color: designTokens.colors.text.secondary,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
<div
style={{
fontSize: '11px',
color: designTokens.colors.text.secondary,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{record.specification || record.category || '-'}
</div>
</div>
@@ -913,12 +921,16 @@ const ConsumableStatistics = () => {
const min = record.minStock || 0;
const isLow = current < min;
return (
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}>
<span style={{
fontWeight: 700,
fontSize: '14px',
color: isLow ? designTokens.colors.error.main : designTokens.colors.text.primary,
}}>
<div
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}
>
<span
style={{
fontWeight: 700,
fontSize: '14px',
color: isLow ? designTokens.colors.error.main : designTokens.colors.text.primary,
}}
>
{current}
</span>
<span style={{ color: designTokens.colors.text.secondary, fontSize: '11px' }}>/</span>
@@ -942,13 +954,20 @@ const ConsumableStatistics = () => {
const currentStock = record.currentStock || 0;
if (minStock <= 0) {
return <Text type="secondary" style={{ fontSize: '11px' }}>未设置</Text>;
return (
<Text type="secondary" style={{ fontSize: '11px' }}>
未设置
</Text>
);
}
const rate = Math.min(100, Math.round((currentStock / minStock) * 100));
const color = rate < 30 ? designTokens.colors.error.main :
rate < 60 ? designTokens.colors.warning.main :
designTokens.colors.success.main;
const color =
rate < 30
? designTokens.colors.error.main
: rate < 60
? designTokens.colors.warning.main
: designTokens.colors.success.main;
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
@@ -959,13 +978,15 @@ const ConsumableStatistics = () => {
showInfo={false}
style={{ width: 60 }}
/>
<span style={{
fontWeight: 600,
fontSize: '12px',
color,
minWidth: 32,
textAlign: 'right',
}}>
<span
style={{
fontWeight: 600,
fontSize: '12px',
color,
minWidth: 32,
textAlign: 'right',
}}
>
{rate}%
</span>
</div>
@@ -980,7 +1001,7 @@ const ConsumableStatistics = () => {
dataIndex: 'type',
key: 'type',
width: 80,
render: (type) => (
render: type => (
<Tag
icon={type === 'in' ? <ArrowDownOutlined /> : <ArrowUpOutlined />}
color={type === 'in' ? 'success' : 'processing'}
@@ -1000,14 +1021,18 @@ const ConsumableStatistics = () => {
<Avatar
size={30}
style={{
background: record.category ? getCategoryColor(record.category) : designTokens.colors.info.main,
background: record.category
? getCategoryColor(record.category)
: designTokens.colors.info.main,
fontSize: '12px',
}}
>
{record.category?.charAt(0) || '耗'}
</Avatar>
<div>
<div style={{ fontWeight: 600, fontSize: '14px', color: designTokens.colors.text.primary }}>
<div
style={{ fontWeight: 600, fontSize: '14px', color: designTokens.colors.text.primary }}
>
{text || '-'}
</div>
{record.category && (
@@ -1030,10 +1055,14 @@ const ConsumableStatistics = () => {
strong
style={{
fontSize: '15px',
color: record.type === 'in' ? designTokens.colors.success.main : designTokens.colors.error.main,
color:
record.type === 'in'
? designTokens.colors.success.main
: designTokens.colors.error.main,
}}
>
{record.type === 'in' ? '+' : '-'}{quantity} {record.unit || '个'}
{record.type === 'in' ? '+' : '-'}
{quantity} {record.unit || '个'}
</Text>
),
},
@@ -1042,7 +1071,7 @@ const ConsumableStatistics = () => {
dataIndex: 'operator',
key: 'operator',
width: 100,
render: (operator) => (
render: operator => (
<Space size={6}>
<Avatar size={24} style={{ background: designTokens.colors.info.main, fontSize: '12px' }}>
{operator?.charAt(0) || '?'}
@@ -1056,7 +1085,7 @@ const ConsumableStatistics = () => {
dataIndex: 'createdAt',
key: 'createdAt',
width: 140,
render: (date) => (
render: date => (
<Text type="secondary" style={{ fontSize: '12px' }}>
{dayjs(date).format('MM-DD HH:mm')}
</Text>
@@ -1086,13 +1115,19 @@ const ConsumableStatistics = () => {
</TitleSection>
<Space>
{lastUpdateTime && (
<div style={{ fontSize: 12, color: designTokens.colors.text.secondary, display: 'flex', alignItems: 'center', gap: 4 }}>
{isAutoRefreshing ? (
<Spin size="small" />
) : (
<span style={{ fontSize: 10 }}></span>
)}
{isAutoRefreshing ? '刷新中...' : `更新于 ${dayjs(lastUpdateTime).format('HH:mm:ss')}`}
<div
style={{
fontSize: 12,
color: designTokens.colors.text.secondary,
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
{isAutoRefreshing ? <Spin size="small" /> : <span style={{ fontSize: 10 }}></span>}
{isAutoRefreshing
? '刷新中...'
: `更新于 ${dayjs(lastUpdateTime).format('HH:mm:ss')}`}
</div>
)}
<Tooltip title={realTimeRefresh ? '已开启30秒自动刷新' : '已关闭自动刷新'}>
@@ -1154,7 +1189,7 @@ const ConsumableStatistics = () => {
<span className="filter-label">时间范围</span>
<RangePicker
value={dateRange}
onChange={(dates) => {
onChange={dates => {
setDateRange(dates);
setQuickFilter(null);
}}
@@ -1174,7 +1209,9 @@ const ConsumableStatistics = () => {
>
<Option value="all">全部类别</Option>
{categories.map(cat => (
<Option key={cat.id} value={cat.name}>{cat.name}</Option>
<Option key={cat.id} value={cat.name}>
{cat.name}
</Option>
))}
</Select>
</FilterItem>
@@ -1227,7 +1264,9 @@ const ConsumableStatistics = () => {
>
<div className="card-content">
<div className="card-header">
<div className="card-icon"><DatabaseOutlined /></div>
<div className="card-icon">
<DatabaseOutlined />
</div>
<div className="card-trend neutral">
<AppstoreOutlined /> 总览
</div>
@@ -1250,7 +1289,9 @@ const ConsumableStatistics = () => {
>
<div className="card-content">
<div className="card-header">
<div className="card-icon"><WarningOutlined /></div>
<div className="card-icon">
<WarningOutlined />
</div>
{summary?.lowStock > 0 && (
<div className="card-trend down">
<ExclamationCircleOutlined /> 需关注
@@ -1277,7 +1318,9 @@ const ConsumableStatistics = () => {
>
<div className="card-content">
<div className="card-header">
<div className="card-icon"><ArrowDownOutlined /></div>
<div className="card-icon">
<ArrowDownOutlined />
</div>
<div className="card-trend up">
<RiseOutlined /> 入库
</div>
@@ -1302,7 +1345,9 @@ const ConsumableStatistics = () => {
>
<div className="card-content">
<div className="card-header">
<div className="card-icon"><ArrowUpOutlined /></div>
<div className="card-icon">
<ArrowUpOutlined />
</div>
<div className="card-trend neutral">
<FallOutlined /> 出库
</div>
@@ -1318,10 +1363,16 @@ const ConsumableStatistics = () => {
</StatsGrid>
<BentoGrid variants={containerVariants} initial="hidden" animate="visible">
<BentoCard variants={itemVariants} $col="span 6" $iconBg={designTokens.colors.info.gradient}>
<BentoCard
variants={itemVariants}
$col="span 6"
$iconBg={designTokens.colors.info.gradient}
>
<div className="card-header">
<div className="header-left">
<div className="header-icon"><ShoppingCartOutlined /></div>
<div className="header-icon">
<ShoppingCartOutlined />
</div>
<span className="header-title">出入库统计</span>
</div>
<div className="header-extra">
@@ -1334,28 +1385,36 @@ const ConsumableStatistics = () => {
$bg="rgba(16, 185, 129, 0.04)"
$color={designTokens.colors.success.main}
>
<div className="item-icon"><ArrowDownOutlined /></div>
<div className="item-icon">
<ArrowDownOutlined />
</div>
<div className="item-value">{stats?.inCount || 0}</div>
<div className="item-label">入库次数</div>
<div className="item-sub"> <strong>{stats?.inQuantity || 0}</strong> </div>
<div className="item-sub">
<strong>{stats?.inQuantity || 0}</strong>
</div>
</ComparisonItem>
<ComparisonItem
$bg="rgba(99, 102, 241, 0.04)"
$color={designTokens.colors.primary.main}
>
<div className="item-icon"><ArrowUpOutlined /></div>
<div className="item-icon">
<ArrowUpOutlined />
</div>
<div className="item-value">{stats?.outCount || 0}</div>
<div className="item-label">出库次数</div>
<div className="item-sub"> <strong>{stats?.outQuantity || 0}</strong> </div>
<div className="item-sub">
<strong>{stats?.outQuantity || 0}</strong>
</div>
</ComparisonItem>
</InOutComparison>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
style={{
marginTop: 20,
padding: '18px 24px',
style={{
marginTop: 20,
padding: '18px 24px',
background: `linear-gradient(135deg, ${netQuantity >= 0 ? 'rgba(16, 185, 129, 0.08)' : 'rgba(239, 68, 68, 0.08)'} 0%, ${netQuantity >= 0 ? 'rgba(5, 150, 105, 0.04)' : 'rgba(185, 28, 28, 0.04)'} 100%)`,
borderRadius: 14,
display: 'flex',
@@ -1368,14 +1427,22 @@ const ConsumableStatistics = () => {
{netQuantity >= 0 ? (
<>
<RiseOutlined style={{ color: designTokens.colors.success.main, fontSize: 20 }} />
<Text style={{ color: designTokens.colors.success.main, fontWeight: 600, fontSize: 15 }}>
<Text
style={{
color: designTokens.colors.success.main,
fontWeight: 600,
fontSize: 15,
}}
>
净入库 +{netQuantity}
</Text>
</>
) : (
<>
<FallOutlined style={{ color: designTokens.colors.error.main, fontSize: 20 }} />
<Text style={{ color: designTokens.colors.error.main, fontWeight: 600, fontSize: 15 }}>
<Text
style={{ color: designTokens.colors.error.main, fontWeight: 600, fontSize: 15 }}
>
净出库 {Math.abs(netQuantity)}
</Text>
</>
@@ -1384,10 +1451,16 @@ const ConsumableStatistics = () => {
</div>
</BentoCard>
<BentoCard variants={itemVariants} $col="span 6" $iconBg={designTokens.colors.secondary.gradient}>
<BentoCard
variants={itemVariants}
$col="span 6"
$iconBg={designTokens.colors.secondary.gradient}
>
<div className="card-header">
<div className="header-left">
<div className="header-icon"><PieChartOutlined /></div>
<div className="header-icon">
<PieChartOutlined />
</div>
<span className="header-title">类别分布</span>
</div>
</div>
@@ -1426,10 +1499,16 @@ const ConsumableStatistics = () => {
</BentoGrid>
<BentoGrid variants={containerVariants} initial="hidden" animate="visible">
<BentoCard variants={itemVariants} $col="span 6" $iconBg={designTokens.colors.warning.gradient}>
<BentoCard
variants={itemVariants}
$col="span 6"
$iconBg={designTokens.colors.warning.gradient}
>
<div className="card-header">
<div className="header-left">
<div className="header-icon"><ExclamationCircleOutlined /></div>
<div className="header-icon">
<ExclamationCircleOutlined />
</div>
<span className="header-title">库存预警</span>
</div>
{lowStockItems.length > 0 ? (
@@ -1438,14 +1517,16 @@ const ConsumableStatistics = () => {
style={{ backgroundColor: designTokens.colors.warning.main }}
/>
) : (
<span style={{
fontSize: 12,
color: designTokens.colors.success.main,
fontWeight: 500,
padding: '4px 10px',
background: 'rgba(16, 185, 129, 0.1)',
borderRadius: 8
}}>
<span
style={{
fontSize: 12,
color: designTokens.colors.success.main,
fontWeight: 500,
padding: '4px 10px',
background: 'rgba(16, 185, 129, 0.1)',
borderRadius: 8,
}}
>
全部充足
</span>
)}
@@ -1461,15 +1542,18 @@ const ConsumableStatistics = () => {
total: lowStockItems.length,
showSizeChanger: false,
showQuickJumper: false,
showTotal: (total) => `${total}`,
onChange: (page) => setLowStockPagination(prev => ({ ...prev, current: page })),
showTotal: total => `${total}`,
onChange: page => setLowStockPagination(prev => ({ ...prev, current: page })),
}}
size="small"
scroll={{ x: 'max-content', y: 300 }}
locale={{
emptyText: (
<EmptyState>
<BoxPlotOutlined className="empty-icon" style={{ color: designTokens.colors.success.main }} />
<BoxPlotOutlined
className="empty-icon"
style={{ color: designTokens.colors.success.main }}
/>
<div className="empty-text">库存充足</div>
<div className="empty-subtext">所有耗材均在安全范围内</div>
</EmptyState>
@@ -1479,10 +1563,16 @@ const ConsumableStatistics = () => {
</div>
</BentoCard>
<BentoCard variants={itemVariants} $col="span 6" $iconBg={designTokens.colors.success.gradient}>
<BentoCard
variants={itemVariants}
$col="span 6"
$iconBg={designTokens.colors.success.gradient}
>
<div className="card-header">
<div className="header-left">
<div className="header-icon"><HistoryOutlined /></div>
<div className="header-icon">
<HistoryOutlined />
</div>
<span className="header-title">最近记录</span>
</div>
<div className="header-extra">最近10条</div>
+41 -21
View File
@@ -1,6 +1,14 @@
import React, { useState, useCallback, useMemo } from 'react';
import { Card, Row, Col, Typography, message } from 'antd';
import { DatabaseOutlined, CloudServerOutlined, WarningOutlined, HomeOutlined, TeamOutlined, BarChartOutlined, DashboardOutlined } from '@ant-design/icons';
import {
DatabaseOutlined,
CloudServerOutlined,
WarningOutlined,
HomeOutlined,
TeamOutlined,
BarChartOutlined,
DashboardOutlined,
} from '@ant-design/icons';
import api from '../api';
import { designTokens } from '../config/theme';
import { useFetch } from '../hooks/useSWR';
@@ -185,22 +193,33 @@ function Dashboard() {
let currentAngle = 0;
if (runningDeg > 0) {
gradientParts.push(`${PIE_CHART_COLORS.success} ${currentAngle}deg ${currentAngle + runningDeg}deg`);
gradientParts.push(
`${PIE_CHART_COLORS.success} ${currentAngle}deg ${currentAngle + runningDeg}deg`
);
currentAngle += runningDeg;
}
if (maintenanceDeg > 0) {
gradientParts.push(`${PIE_CHART_COLORS.warning} ${currentAngle}deg ${currentAngle + maintenanceDeg}deg`);
gradientParts.push(
`${PIE_CHART_COLORS.warning} ${currentAngle}deg ${currentAngle + maintenanceDeg}deg`
);
currentAngle += maintenanceDeg;
}
if (faultDeg > 0) {
gradientParts.push(`${PIE_CHART_COLORS.error} ${currentAngle}deg ${currentAngle + faultDeg}deg`);
gradientParts.push(
`${PIE_CHART_COLORS.error} ${currentAngle}deg ${currentAngle + faultDeg}deg`
);
currentAngle += faultDeg;
}
if (offlineDeg > 0) {
gradientParts.push(`${PIE_CHART_COLORS.primary} ${currentAngle}deg ${currentAngle + offlineDeg}deg`);
gradientParts.push(
`${PIE_CHART_COLORS.primary} ${currentAngle}deg ${currentAngle + offlineDeg}deg`
);
}
const conicGradient = gradientParts.length > 0 ? `conic-gradient(${gradientParts.join(', ')})` : 'conic-gradient(#e5e7eb 0deg 360deg)';
const conicGradient =
gradientParts.length > 0
? `conic-gradient(${gradientParts.join(', ')})`
: 'conic-gradient(#e5e7eb 0deg 360deg)';
return {
width: '180px',
@@ -215,11 +234,11 @@ function Dashboard() {
}, [deviceStatusPercentages]);
const handleRefresh = useCallback(async () => {
setAnimatedKey((prev) => prev + 1);
setAnimatedKey(prev => prev + 1);
await mutateStats();
}, [mutateStats]);
const handleHover = useCallback((key) => {
const handleHover = useCallback(key => {
setHoveredCard(key);
}, []);
@@ -325,17 +344,14 @@ function Dashboard() {
[stats.deviceGrowth, stats.faultTrend, stats.userGrowth, stats.ticketTrend]
);
const deviceTrendData = useMemo(
() => {
const rawData = statsData?.deviceTrendData || [];
return rawData.map((item) => ({
label: item.label,
value: item.value,
color: designTokens.colors.primary.main
}));
},
[statsData?.deviceTrendData]
);
const deviceTrendData = useMemo(() => {
const rawData = statsData?.deviceTrendData || [];
return rawData.map(item => ({
label: item.label,
value: item.value,
color: designTokens.colors.primary.main,
}));
}, [statsData?.deviceTrendData]);
const styles = `
@keyframes fadeInDown {
@@ -375,7 +391,7 @@ function Dashboard() {
</div>
<Row gutter={[24, 24]} style={{ marginBottom: '32px' }}>
{statCards.map((config) => (
{statCards.map(config => (
<StatCard
key={config.statKey}
config={config}
@@ -544,7 +560,11 @@ function Dashboard() {
</p>
</div>
<QuickStats onlineRate={stats.onlineRate} powerUsage={stats.powerUsage} totalMaxPower={stats.totalMaxPower} />
<QuickStats
onlineRate={stats.onlineRate}
powerUsage={stats.powerUsage}
totalMaxPower={stats.totalMaxPower}
/>
</Col>
<Col xs={24} md={8}>
+70 -60
View File
@@ -45,51 +45,51 @@ const OptionsEditor = ({ value = [], onChange }) => {
};
const handleUpdate = (index, field, fieldValue) => {
const newOptions = value.map((opt, i) =>
i === index ? { ...opt, [field]: fieldValue } : opt
);
const newOptions = value.map((opt, i) => (i === index ? { ...opt, [field]: fieldValue } : opt));
onChange(newOptions);
};
return (
<div style={{
border: '1px solid #e8e8e8',
borderRadius: '12px',
padding: '20px',
background: 'linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '16px',
gap: '8px',
}}>
<div style={{
width: '4px',
height: '16px',
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: '2px',
}}/>
<span style={{ color: '#333', fontSize: '14px', fontWeight: '600' }}>
选项配置
</span>
<span style={{ color: '#999', fontSize: '12px' }}>
值用于提交标签用于显示
</span>
<div
style={{
border: '1px solid #e8e8e8',
borderRadius: '12px',
padding: '20px',
background: 'linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
marginBottom: '16px',
gap: '8px',
}}
>
<div
style={{
width: '4px',
height: '16px',
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: '2px',
}}
/>
<span style={{ color: '#333', fontSize: '14px', fontWeight: '600' }}>选项配置</span>
<span style={{ color: '#999', fontSize: '12px' }}>值用于提交标签用于显示</span>
</div>
{value.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '24px',
background: '#fff',
borderRadius: '8px',
border: '1px dashed #d9d9d9',
}}>
<div style={{ color: '#bbb', fontSize: '14px', marginBottom: '12px' }}>
暂无选项
</div>
<div
style={{
textAlign: 'center',
padding: '24px',
background: '#fff',
borderRadius: '8px',
border: '1px dashed #d9d9d9',
}}
>
<div style={{ color: '#bbb', fontSize: '14px', marginBottom: '12px' }}>暂无选项</div>
<Button
type="primary"
icon={<PlusCircleOutlined />}
@@ -105,15 +105,23 @@ const OptionsEditor = ({ value = [], onChange }) => {
</Button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '16px' }}>
<div style={{
display: 'flex',
gap: '12px',
padding: '0 4px',
marginBottom: '4px',
}}>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>value</span>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>标签label</span>
<div
style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '16px' }}
>
<div
style={{
display: 'flex',
gap: '12px',
padding: '0 4px',
marginBottom: '4px',
}}
>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>
value
</span>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>
标签label
</span>
</div>
{value.map((opt, index) => (
<div
@@ -129,19 +137,21 @@ const OptionsEditor = ({ value = [], onChange }) => {
transition: 'all 0.2s ease',
}}
>
<div style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea20 0%, #764ba220 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#667eea',
fontSize: '12px',
fontWeight: '600',
flexShrink: 0,
}}>
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea20 0%, #764ba220 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#667eea',
fontSize: '12px',
fontWeight: '600',
flexShrink: 0,
}}
>
{index + 1}
</div>
<Input
+149 -82
View File
@@ -69,11 +69,7 @@ import {
BatchStatusModal,
} from '../components/device';
import { useDebounce } from '../hooks/useDebounce';
import {
getDeviceTypeIcon,
getStatusConfig,
processDeviceData,
} from '../utils/deviceUtils.jsx';
import { getDeviceTypeIcon, getStatusConfig, processDeviceData } from '../utils/deviceUtils.jsx';
const { Option } = Select;
@@ -165,7 +161,7 @@ function DeviceManagement() {
} else {
setAllDevices(processedDevices);
}
setPagination((prev) => ({ ...prev, current: page, pageSize, total }));
setPagination(prev => ({ ...prev, current: page, pageSize, total }));
hasMoreRef.current = page * pageSize < total;
} catch (error) {
message.error('获取设备列表失败');
@@ -183,7 +179,7 @@ function DeviceManagement() {
setLoadingFields(true);
const response = await axios.get('/api/deviceFields');
let fields = response.data.sort((a, b) => a.order - b.order);
// options
fields = fields.map(field => {
if (field.fieldName === 'type' && !field.options) {
@@ -191,12 +187,14 @@ function DeviceManagement() {
return { ...field, options: defaultTypeField?.options || [] };
}
if (field.fieldName === 'status' && !field.options) {
const defaultStatusField = DEFAULT_DEVICE_FIELDS_LOCAL.find(f => f.fieldName === 'status');
const defaultStatusField = DEFAULT_DEVICE_FIELDS_LOCAL.find(
f => f.fieldName === 'status'
);
return { ...field, options: defaultStatusField?.options || [] };
}
return field;
});
setDeviceFields(fields);
} catch (error) {
message.error('获取字段配置失败');
@@ -275,7 +273,13 @@ function DeviceManagement() {
const handleLoadMoreDevices = useCallback(() => {
if (!hasMoreRef.current || isLoadingRef.current || deviceLoadingMore) return;
if (debouncedKeyword || status !== 'all' || type !== 'all' || roomId !== 'all' || rackId !== 'all') {
if (
debouncedKeyword ||
status !== 'all' ||
type !== 'all' ||
roomId !== 'all' ||
rackId !== 'all'
) {
return;
}
isLoadingRef.current = true;
@@ -283,7 +287,17 @@ function DeviceManagement() {
fetchDevices(nextPage, pagination.pageSize, true).then(() => {
isLoadingRef.current = false;
});
}, [pagination.current, pagination.pageSize, debouncedKeyword, status, type, roomId, rackId, deviceLoadingMore, fetchDevices]);
}, [
pagination.current,
pagination.pageSize,
debouncedKeyword,
status,
type,
roomId,
rackId,
deviceLoadingMore,
fetchDevices,
]);
useEffect(() => {
if (!loadMoreRef.current) return;
@@ -291,7 +305,13 @@ function DeviceManagement() {
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && hasMoreRef.current && !deviceLoadingMore) {
if (!debouncedKeyword && status === 'all' && type === 'all' && roomId === 'all' && rackId === 'all') {
if (
!debouncedKeyword &&
status === 'all' &&
type === 'all' &&
roomId === 'all' &&
rackId === 'all'
) {
handleLoadMoreDevices();
}
}
@@ -316,7 +336,7 @@ function DeviceManagement() {
setEditingDevice(null);
};
const handleSubmit = async (deviceData) => {
const handleSubmit = async deviceData => {
try {
if (editingDevice) {
await axios.put(`/api/devices/${editingDevice.deviceId}`, deviceData);
@@ -336,7 +356,7 @@ function DeviceManagement() {
}
};
const handleSearch = (values) => {
const handleSearch = values => {
setSearching(true);
setKeyword(values.keyword || '');
@@ -345,7 +365,7 @@ function DeviceManagement() {
setRoomId(values.roomId || 'all');
setRackId(values.rackId || 'all');
setPagination((prev) => ({ ...prev, current: 1 }));
setPagination(prev => ({ ...prev, current: 1 }));
setTimeout(() => setSearching(false), 300);
};
@@ -363,7 +383,7 @@ function DeviceManagement() {
setTimeout(() => setSearching(false), 300);
};
const handleTableChange = (newPagination) => {
const handleTableChange = newPagination => {
setPagination(newPagination);
const start = (newPagination.current - 1) * newPagination.pageSize;
@@ -453,7 +473,7 @@ function DeviceManagement() {
});
};
const handleDelete = async (deviceId) => {
const handleDelete = async deviceId => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个设备吗?',
@@ -473,18 +493,18 @@ function DeviceManagement() {
});
};
const handleShowDetail = (device) => {
const handleShowDetail = device => {
setSelectedDevice(device);
setDetailModalVisible(true);
};
const handleViewDeviceTickets = (device) => {
const handleViewDeviceTickets = device => {
navigate(
`/tickets?deviceId=${device.deviceId}&deviceName=${encodeURIComponent(device.name)}&serialNumber=${encodeURIComponent(device.serialNumber || '')}&view=true`
);
};
const handleCreateTicketForDevice = (device) => {
const handleCreateTicketForDevice = device => {
navigate(
`/tickets?deviceId=${device.deviceId}&deviceName=${encodeURIComponent(device.name)}&serialNumber=${encodeURIComponent(device.serialNumber || '')}&create=true`
);
@@ -498,7 +518,7 @@ function DeviceManagement() {
setBatchStatusModalVisible(true);
};
const handleBatchStatusChange = async (newStatus) => {
const handleBatchStatusChange = async newStatus => {
setBatchStatusLoading(true);
try {
const response = await axios.put('/api/devices/batch-status', {
@@ -532,9 +552,9 @@ function DeviceManagement() {
if (scope === 'selected') {
deviceIds = selectedDevices;
} else if (scope === 'currentPage') {
deviceIds = allDevices.map((device) => device.deviceId);
deviceIds = allDevices.map(device => device.deviceId);
} else if (scope === 'all') {
deviceIds = allDevices.map((device) => device.deviceId);
deviceIds = allDevices.map(device => device.deviceId);
}
if (deviceIds.length === 0) {
@@ -543,7 +563,7 @@ function DeviceManagement() {
}
const params = new URLSearchParams();
deviceIds.forEach((id) => params.append('deviceIds', id));
deviceIds.forEach(id => params.append('deviceIds', id));
params.append('format', format);
const response = await axios.get(`/api/devices/enhanced-export?${params.toString()}`, {
@@ -575,7 +595,7 @@ function DeviceManagement() {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
onUploadProgress: progressEvent => {
const progress = Math.round((progressEvent.loaded * 50) / progressEvent.total);
callbacks.onProgress(Math.min(progress, 50), '正在上传文件...');
},
@@ -617,7 +637,7 @@ function DeviceManagement() {
Array.isArray(data.errors) &&
data.errors.length > 0
) {
errorDetails = data.errors.map((err) => ({
errorDetails = data.errors.map(err => ({
row: err.row || 0,
error: err.error || err.message || '服务器内部错误',
}));
@@ -645,7 +665,7 @@ function DeviceManagement() {
}
};
const handleSaveFieldConfig = async (updatedFields) => {
const handleSaveFieldConfig = async updatedFields => {
try {
const response = await axios.post('/api/deviceFields/config', updatedFields);
setDeviceFields(response.data);
@@ -657,26 +677,26 @@ function DeviceManagement() {
}
};
const handleResetFieldConfig = (defaultFields) => {
const handleResetFieldConfig = defaultFields => {
setDeviceFields(defaultFields);
};
const handleSelectionChange = (selectedRowKeys) => {
const handleSelectionChange = selectedRowKeys => {
setSelectedDevices(selectedRowKeys);
setSelectAll(selectedRowKeys.length === allDevices.length && allDevices.length > 0);
};
const handleHeaderCellResize = (key) => (column) => ({
const handleHeaderCellResize = key => column => ({
width: column.width,
onResize: (width) => {
setColumnWidths((prev) => ({ ...prev, [key]: width }));
onResize: width => {
setColumnWidths(prev => ({ ...prev, [key]: width }));
},
});
const columns = useMemo(() => {
const generatedColumns = [];
deviceFields.forEach((field) => {
deviceFields.forEach(field => {
if (!field.visible) return;
if (field.fieldName === 'rackId') {
@@ -701,7 +721,7 @@ function DeviceManagement() {
key: field.fieldName,
width: columnWidths[field.fieldName] || 100,
onHeaderCell: handleHeaderCellResize(field.fieldName),
render: (type) => (
render: type => (
<Space>
{getDeviceTypeIcon(type)}
<span>{TYPE_MAP[type]}</span>
@@ -715,7 +735,7 @@ function DeviceManagement() {
key: field.fieldName,
width: columnWidths[field.fieldName] || 90,
onHeaderCell: handleHeaderCellResize(field.fieldName),
render: (status) => {
render: status => {
const config = STATUS_MAP[status] || { text: status, color: 'default' };
const statusStyles = {
running: { bg: '#f6ffed', border: '#52c41a', text: '#389e0d' },
@@ -724,7 +744,11 @@ function DeviceManagement() {
fault: { bg: '#fff2f0', border: '#ff4d4f', text: '#cf1322' },
idle: { bg: '#E6FFFA', border: '#36cfc9', text: '#08979d' },
};
const style = statusStyles[status] || { bg: '#fafafa', border: '#d9d9d9', text: '#595959' };
const style = statusStyles[status] || {
bg: '#fafafa',
border: '#d9d9d9',
text: '#595959',
};
return (
<Tag
style={{
@@ -752,7 +776,7 @@ function DeviceManagement() {
key: field.fieldName,
width: columnWidths[field.fieldName] || 120,
onHeaderCell: handleHeaderCellResize(field.fieldName),
render: (date) => {
render: date => {
if (!date) return '';
const dateObj = new Date(date);
@@ -809,8 +833,8 @@ function DeviceManagement() {
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
onMouseEnter={(e) => (e.target.style.textDecoration = 'underline')}
onMouseLeave={(e) => (e.target.style.textDecoration = 'none')}
onMouseEnter={e => (e.target.style.textDecoration = 'underline')}
onMouseLeave={e => (e.target.style.textDecoration = 'none')}
>
{value || '-'}
</a>
@@ -993,17 +1017,38 @@ function DeviceManagement() {
style={{ width: '100%' }}
className="filter-form"
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: designTokens.spacing.md, alignItems: 'center', width: '100%' }}>
<div style={{
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: designTokens.spacing.md,
alignItems: 'center',
gap: designTokens.spacing.sm,
padding: '0 12px',
borderRight: `1px solid ${designTokens.colors.border.light}`,
marginRight: 4,
}}>
<FilterOutlined style={{ color: designTokens.colors.primary.main, fontSize: '16px' }} />
<span style={{ fontSize: '13px', fontWeight: 500, color: designTokens.colors.text.secondary, whiteSpace: 'nowrap' }}>筛选</span>
width: '100%',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: designTokens.spacing.sm,
padding: '0 12px',
borderRight: `1px solid ${designTokens.colors.border.light}`,
marginRight: 4,
}}
>
<FilterOutlined
style={{ color: designTokens.colors.primary.main, fontSize: '16px' }}
/>
<span
style={{
fontSize: '13px',
fontWeight: 500,
color: designTokens.colors.text.secondary,
whiteSpace: 'nowrap',
}}
>
筛选
</span>
</div>
<Form.Item name="keyword" style={{ margin: 0 }}>
@@ -1017,7 +1062,7 @@ function DeviceManagement() {
transition: `all ${designTokens.transitions.fast}`,
}}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onChange={e => setKeyword(e.target.value)}
allowClear
/>
</Form.Item>
@@ -1064,10 +1109,14 @@ function DeviceManagement() {
display: 'flex',
alignItems: 'center',
gap: '6px',
color: advancedSearchVisible ? designTokens.colors.primary.main : designTokens.colors.text.secondary,
color: advancedSearchVisible
? designTokens.colors.primary.main
: designTokens.colors.text.secondary,
fontWeight: 500,
borderRadius: designTokens.borderRadius.medium,
background: advancedSearchVisible ? `${designTokens.colors.primary.main}10` : 'transparent',
background: advancedSearchVisible
? `${designTokens.colors.primary.main}10`
: 'transparent',
transition: `all ${designTokens.transitions.fast}`,
}}
>
@@ -1117,30 +1166,43 @@ function DeviceManagement() {
</div>
{advancedSearchVisible && (
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: designTokens.spacing.md,
padding: `${designTokens.spacing.md}px 0`,
borderTop: `1px dashed ${designTokens.colors.border.light}`,
marginTop: designTokens.spacing.sm,
}}>
<div style={{
<div
style={{
display: 'flex',
alignItems: 'center',
gap: designTokens.spacing.sm,
padding: '0 12px',
borderRight: `1px solid ${designTokens.colors.border.light}`,
marginRight: 4,
}}>
flexWrap: 'wrap',
gap: designTokens.spacing.md,
padding: `${designTokens.spacing.md}px 0`,
borderTop: `1px dashed ${designTokens.colors.border.light}`,
marginTop: designTokens.spacing.sm,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: designTokens.spacing.sm,
padding: '0 12px',
borderRight: `1px solid ${designTokens.colors.border.light}`,
marginRight: 4,
}}
>
<EnvironmentOutlined style={{ color: '#3b82f6', fontSize: '16px' }} />
<span style={{ fontSize: '13px', fontWeight: 500, color: designTokens.colors.text.secondary, whiteSpace: 'nowrap' }}>位置筛选</span>
<span
style={{
fontSize: '13px',
fontWeight: 500,
color: designTokens.colors.text.secondary,
whiteSpace: 'nowrap',
}}
>
位置筛选
</span>
</div>
<Form.Item name="roomId" style={{ margin: 0 }}>
<Select
value={roomId}
onChange={(value) => {
onChange={value => {
setRoomId(value);
setRackId('all');
}}
@@ -1149,7 +1211,7 @@ function DeviceManagement() {
suffixIcon={<EnvironmentOutlined style={{ color: '#3b82f6' }} />}
>
<Option value="all">所有机房</Option>
{rooms.map((room) => (
{rooms.map(room => (
<Option key={room.roomId} value={room.roomId}>
{room.name}
</Option>
@@ -1169,8 +1231,8 @@ function DeviceManagement() {
>
<Option value="all">所有机柜</Option>
{racks
.filter((rack) => roomId === 'all' || rack.roomId === roomId)
.map((rack) => (
.filter(rack => roomId === 'all' || rack.roomId === roomId)
.map(rack => (
<Option key={rack.rackId} value={rack.rackId}>
{rack.name}
</Option>
@@ -1217,7 +1279,7 @@ function DeviceManagement() {
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '30', '50', '100'],
showTotal: (total) => `${total} 条记录`,
showTotal: total => `${total} 条记录`,
style: { marginTop: '16px' },
}}
onChange={handleTableChange}
@@ -1243,7 +1305,7 @@ function DeviceManagement() {
key: 'all',
text: '全选',
onSelect: () => {
const allIds = allDevices.map((device) => device.deviceId);
const allIds = allDevices.map(device => device.deviceId);
setSelectedDevices(allIds);
setSelectAll(true);
},
@@ -1252,8 +1314,8 @@ function DeviceManagement() {
key: 'invert',
text: '反选',
onSelect: () => {
const visibleIds = allDevices.map((device) => device.deviceId);
const newSelected = visibleIds.filter((id) => !selectedDevices.includes(id));
const visibleIds = allDevices.map(device => device.deviceId);
const newSelected = visibleIds.filter(id => !selectedDevices.includes(id));
setSelectedDevices(newSelected);
setSelectAll(newSelected.length === allDevices.length);
},
@@ -1268,11 +1330,11 @@ function DeviceManagement() {
},
],
}}
onRow={(record) => ({
onRow={record => ({
onClick: () =>
handleSelectionChange(
selectedDevices.includes(record.deviceId)
? selectedDevices.filter((id) => id !== record.deviceId)
? selectedDevices.filter(id => id !== record.deviceId)
: [...selectedDevices, record.deviceId]
),
})}
@@ -1283,11 +1345,16 @@ function DeviceManagement() {
return index % 2 === 0 ? 'ant-table-row-even' : 'ant-table-row-odd';
}}
/>
{hasMoreRef.current && !debouncedKeyword && status === 'all' && type === 'all' && roomId === 'all' && rackId === 'all' && (
<div ref={loadMoreRef} style={{ textAlign: 'center', padding: '20px' }}>
{deviceLoadingMore && <Spin tip="加载更多设备..." />}
</div>
)}
{hasMoreRef.current &&
!debouncedKeyword &&
status === 'all' &&
type === 'all' &&
roomId === 'all' &&
rackId === 'all' && (
<div ref={loadMoreRef} style={{ textAlign: 'center', padding: '20px' }}>
{deviceLoadingMore && <Spin tip="加载更多设备..." />}
</div>
)}
{!hasMoreRef.current && allDevices.length > 0 && (
<div style={{ textAlign: 'center', padding: '16px', color: '#999' }}>
已加载全部 {pagination.total} 个设备
+8 -17
View File
@@ -38,9 +38,7 @@ function ErrorBoundaryTest() {
color: '#fff',
}}
>
<h1 style={{ fontSize: '24px', marginBottom: '8px' }}>
🧪 错误边界测试页面
</h1>
<h1 style={{ fontSize: '24px', marginBottom: '8px' }}>🧪 错误边界测试页面</h1>
<p style={{ opacity: 0.9 }}>
用于测试 React 错误边界Error Boundary功能确保单个组件错误不会导致整个页面崩溃
</p>
@@ -62,11 +60,7 @@ function ErrorBoundaryTest() {
<Card title="测试 2:页面级别错误边界" size="small">
<p>这个测试会触发页面级别的错误边界</p>
<Button
type="primary"
danger
onClick={() => setShowError(true)}
>
<Button type="primary" danger onClick={() => setShowError(true)}>
触发页面错误
</Button>
{showError && <BrokenComponent />}
@@ -95,9 +89,7 @@ function ErrorBoundaryTest() {
textAlign: 'center',
}}
>
<h3 style={{ color: '#ff4d4f', margin: '0 0 8px 0' }}>
自定义错误提示
</h3>
<h3 style={{ color: '#ff4d4f', margin: '0 0 8px 0' }}> 自定义错误提示</h3>
<p style={{ margin: 0, color: '#666' }}>
这是自定义的 fallback UI当组件出错时会显示这个界面
</p>
@@ -131,16 +123,15 @@ function ErrorBoundaryTest() {
<Card title="使用说明" size="small">
<ul style={{ lineHeight: '2' }}>
<li>
<strong>错误边界Error Boundary</strong>
React 提供的错误处理机制可以捕获子组件树中的 JavaScript 错误
<strong>错误边界Error Boundary</strong> React
提供的错误处理机制可以捕获子组件树中的 JavaScript 错误
</li>
<li>
<strong>作用</strong>
防止单个组件的错误导致整个应用崩溃提供友好的错误提示界面
<strong>作用</strong> 防止单个组件的错误导致整个应用崩溃提供友好的错误提示界面
</li>
<li>
<strong>实现方式</strong>
使用 React.Component componentDidCatch getDerivedStateFromError 生命周期方法
<strong>实现方式</strong> 使用 React.Component componentDidCatch
getDerivedStateFromError 生命周期方法
</li>
<li>
<strong>注意事项</strong>
+363 -188
View File
@@ -68,7 +68,7 @@ const IdleDeviceManagement = () => {
};
const response = await axios.get('/api/idle-devices', { params });
setIdleDevices(response.data.idleDevices || []);
setPagination((prev) => ({
setPagination(prev => ({
...prev,
total: response.data.total || 0,
}));
@@ -113,7 +113,7 @@ const IdleDeviceManagement = () => {
setIsModalVisible(true);
};
const handleEdit = (record) => {
const handleEdit = record => {
setEditingDevice(record);
let roomId = null;
if (record.rackId && record.Rack) {
@@ -136,7 +136,7 @@ const IdleDeviceManagement = () => {
setIsModalVisible(true);
};
const handleDelete = async (deviceId) => {
const handleDelete = async deviceId => {
try {
await axios.delete(`/api/idle-devices/${deviceId}`);
message.success('删除成功');
@@ -146,7 +146,7 @@ const IdleDeviceManagement = () => {
}
};
const handleShelve = (record) => {
const handleShelve = record => {
setShelvingDevice(record);
setShelvePositionConflict(null);
setShelveSelectedRackId(null);
@@ -195,7 +195,7 @@ const IdleDeviceManagement = () => {
}
};
const handleShelveRackChange = (value) => {
const handleShelveRackChange = value => {
setShelveSelectedRackId(value);
const position = shelveForm.getFieldValue('position');
const height = shelveForm.getFieldValue('height');
@@ -206,7 +206,7 @@ const IdleDeviceManagement = () => {
}
};
const handleShelvePositionChange = (e) => {
const handleShelvePositionChange = e => {
const value = e.target.value ? parseInt(e.target.value) : null;
const height = shelveForm.getFieldValue('height');
if (shelveSelectedRackId && value) {
@@ -216,7 +216,7 @@ const IdleDeviceManagement = () => {
}
};
const handleShelveHeightChange = (e) => {
const handleShelveHeightChange = e => {
const value = e.target.value ? parseInt(e.target.value) : null;
const position = shelveForm.getFieldValue('position');
if (shelveSelectedRackId && position) {
@@ -279,7 +279,7 @@ const IdleDeviceManagement = () => {
}
};
const getIdleDays = (idleDate) => {
const getIdleDays = idleDate => {
if (!idleDate) return 0;
const diff = new Date() - new Date(idleDate);
return Math.floor(diff / (1000 * 60 * 60 * 24));
@@ -292,7 +292,10 @@ const IdleDeviceManagement = () => {
width: 60,
align: 'center',
render: (_, __, index) => (
<Badge count={index + 1 + (pagination.current - 1) * pagination.pageSize} style={{ backgroundColor: '#f59e0b' }} />
<Badge
count={index + 1 + (pagination.current - 1) * pagination.pageSize}
style={{ backgroundColor: '#f59e0b' }}
/>
),
},
{
@@ -302,16 +305,36 @@ const IdleDeviceManagement = () => {
render: (_, record) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Avatar
style={{ backgroundColor: record.type === 'server' ? '#3b82f6' : record.type === 'switch' ? '#8b5cf6' : '#64748b' }}
style={{
backgroundColor:
record.type === 'server'
? '#3b82f6'
: record.type === 'switch'
? '#8b5cf6'
: '#64748b',
}}
icon={<InboxOutlined />}
/>
<div>
<Text strong style={{ fontSize: '14px', display: 'block' }}>{record.name || '-'}</Text>
<Text strong style={{ fontSize: '14px', display: 'block' }}>
{record.name || '-'}
</Text>
<Space size={4}>
<Tag color={record.type === 'server' ? 'blue' : record.type === 'switch' ? 'purple' : 'default'} style={{ marginRight: 0 }}>
<Tag
color={
record.type === 'server'
? 'blue'
: record.type === 'switch'
? 'purple'
: 'default'
}
style={{ marginRight: 0 }}
>
{record.type === 'server' ? '服务器' : record.type === 'switch' ? '交换机' : '其他'}
</Tag>
<Text type="secondary" style={{ fontSize: '12px' }}>{record.model || '-'}</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>
{record.model || '-'}
</Text>
</Space>
</div>
</div>
@@ -322,8 +345,10 @@ const IdleDeviceManagement = () => {
dataIndex: 'deviceId',
key: 'deviceId',
width: 100,
render: (text) => (
<Text code style={{ fontSize: '12px', padding: '2px 6px' }}>{text}</Text>
render: text => (
<Text code style={{ fontSize: '12px', padding: '2px 6px' }}>
{text}
</Text>
),
},
{
@@ -340,7 +365,13 @@ const IdleDeviceManagement = () => {
);
}
if (record.sourceType === 'rack' && record.Rack) {
const location = [record.Rack.Room?.name, record.Rack.name, record.position ? `U${record.position}` : null].filter(Boolean).join(' / ');
const location = [
record.Rack.Room?.name,
record.Rack.name,
record.position ? `U${record.position}` : null,
]
.filter(Boolean)
.join(' / ');
return (
<Space>
<InboxOutlined style={{ color: '#3b82f6' }} />
@@ -363,7 +394,9 @@ const IdleDeviceManagement = () => {
<div style={{ textAlign: 'center' }}>
<Text style={{ color, fontWeight: 600, fontSize: '16px' }}>{days}</Text>
<br />
<Text type="secondary" style={{ fontSize: '11px' }}></Text>
<Text type="secondary" style={{ fontSize: '11px' }}>
</Text>
</div>
);
},
@@ -374,9 +407,11 @@ const IdleDeviceManagement = () => {
key: 'idleReason',
width: 140,
ellipsis: true,
render: (text) => (
render: text => (
<Tooltip title={text || '-'}>
<Text type="secondary" ellipsis>{text || '-'}</Text>
<Text type="secondary" ellipsis>
{text || '-'}
</Text>
</Tooltip>
),
},
@@ -386,7 +421,7 @@ const IdleDeviceManagement = () => {
key: 'sourceType',
width: 80,
align: 'center',
render: (type) => (
render: type => (
<Tag color={type === 'warehouse' ? 'green' : 'blue'}>
{type === 'warehouse' ? '库房' : '机架'}
</Tag>
@@ -409,11 +444,27 @@ const IdleDeviceManagement = () => {
/>
</Tooltip>
<Tooltip title="编辑">
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} style={{ borderRadius: '6px', color: '#3b82f6' }} />
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
style={{ borderRadius: '6px', color: '#3b82f6' }}
/>
</Tooltip>
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.deviceId)} okText="确认" cancelText="取消" okButtonProps={{ danger: true }}>
<Popconfirm
title="确认删除?"
onConfirm={() => handleDelete(record.deviceId)}
okText="确认"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Tooltip title="删除">
<Button type="text" danger icon={<DeleteOutlined />} style={{ borderRadius: '6px' }} />
<Button
type="text"
danger
icon={<DeleteOutlined />}
style={{ borderRadius: '6px' }}
/>
</Tooltip>
</Popconfirm>
</Space>
@@ -452,7 +503,9 @@ const IdleDeviceManagement = () => {
return (
<div style={{ minHeight: '100vh', background: '#f8fafc', padding: '24px' }}>
<div style={{ marginBottom: '24px' }}>
<Title level={4} style={{ marginBottom: '4px', color: '#1e293b' }}>空闲设备管理</Title>
<Title level={4} style={{ marginBottom: '4px', color: '#1e293b' }}>
空闲设备管理
</Title>
<Text type="secondary">管理已下线或空闲的设备支持恢复领用到设备管理</Text>
</div>
@@ -468,24 +521,38 @@ const IdleDeviceManagement = () => {
}}
bodyStyle={{ padding: '20px' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
>
<div>
<Text type="secondary" style={{ fontSize: '13px' }}>{stat.title}</Text>
<div style={{ fontSize: '28px', fontWeight: 700, color: stat.color, lineHeight: 1.2, marginTop: '4px' }}>
<Text type="secondary" style={{ fontSize: '13px' }}>
{stat.title}
</Text>
<div
style={{
fontSize: '28px',
fontWeight: 700,
color: stat.color,
lineHeight: 1.2,
marginTop: '4px',
}}
>
{stat.value}
</div>
</div>
<div style={{
width: '48px',
height: '48px',
borderRadius: '12px',
background: 'rgba(255,255,255,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
color: stat.color
}}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '12px',
background: 'rgba(255,255,255,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
color: stat.color,
}}
>
{stat.icon}
</div>
</div>
@@ -502,12 +569,14 @@ const IdleDeviceManagement = () => {
}}
bodyStyle={{ padding: 0 }}
>
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #f1f5f9',
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '16px 16px 0 0',
}}>
<div
style={{
padding: '20px 24px',
borderBottom: '1px solid #f1f5f9',
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '16px 16px 0 0',
}}
>
<Row gutter={16} align="middle">
<Col flex="auto">
<Space size="middle" wrap>
@@ -516,7 +585,7 @@ const IdleDeviceManagement = () => {
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
style={{ borderRadius: '10px', width: '260px', height: '40px' }}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onChange={e => setSearchKeyword(e.target.value)}
allowClear
/>
<Select
@@ -528,7 +597,11 @@ const IdleDeviceManagement = () => {
<Option value="rack">机架</Option>
<Option value="warehouse">库房</Option>
</Select>
<Button icon={<ReloadOutlined />} onClick={fetchIdleDevices} style={{ height: 40, borderRadius: '10px' }}>
<Button
icon={<ReloadOutlined />}
onClick={fetchIdleDevices}
style={{ height: 40, borderRadius: '10px' }}
>
刷新
</Button>
</Space>
@@ -561,12 +634,14 @@ const IdleDeviceManagement = () => {
loading={loading}
pagination={false}
scroll={{ x: 1100 }}
rowClassName={(record, index) => index % 2 === 0 ? 'table-row-even' : 'table-row-odd'}
rowClassName={(record, index) => (index % 2 === 0 ? 'table-row-even' : 'table-row-odd')}
style={{ borderRadius: '0 0 16px 16px' }}
/>
{pagination.total > 0 && (
<div style={{ padding: '16px 24px', borderTop: '1px solid #f1f5f9', background: '#fafafa' }}>
<div
style={{ padding: '16px 24px', borderTop: '1px solid #f1f5f9', background: '#fafafa' }}
>
<Row justify="space-between" align="middle">
<Col>
<Text type="secondary">
@@ -579,11 +654,11 @@ const IdleDeviceManagement = () => {
pageSize={pagination.pageSize}
total={pagination.total}
onChange={(page, pageSize) =>
setPagination((prev) => ({ ...prev, current: page, pageSize }))
setPagination(prev => ({ ...prev, current: page, pageSize }))
}
showSizeChanger
showQuickJumper
showTotal={(total) => `${total}`}
showTotal={total => `${total}`}
size="small"
/>
</Col>
@@ -595,12 +670,14 @@ const IdleDeviceManagement = () => {
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: editingDevice ? '#3b82f6' : '#f59e0b'
}} />
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: editingDevice ? '#3b82f6' : '#f59e0b',
}}
/>
{editingDevice ? '编辑空闲设备' : '添加空闲设备'}
</div>
}
@@ -615,28 +692,38 @@ const IdleDeviceManagement = () => {
style={{ top: 100 }}
>
<Form form={form} layout="vertical" size="middle">
<div style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
border: '1px solid #e2e8f0'
}}>
<div style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
<div
style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<div style={{ width: '4px', height: '16px', background: '#3b82f6', borderRadius: '2px' }} />
border: '1px solid #e2e8f0',
}}
>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<div
style={{ width: '4px', height: '16px', background: '#3b82f6', borderRadius: '2px' }}
/>
设备基本信息
</div>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="name" label="设备名称" rules={[{ required: true, message: '请输入设备名称' }]}>
<Form.Item
name="name"
label="设备名称"
rules={[{ required: true, message: '请输入设备名称' }]}
>
<Input placeholder="请输入设备名称" style={{ borderRadius: '8px' }} />
</Form.Item>
</Col>
@@ -667,36 +754,51 @@ const IdleDeviceManagement = () => {
<Row gutter={16}>
<Col span={12}>
<Form.Item name="powerConsumption" label="功耗(W)">
<Input type="number" placeholder="请输入功耗" min={0} style={{ borderRadius: '8px' }} />
<Input
type="number"
placeholder="请输入功耗"
min={0}
style={{ borderRadius: '8px' }}
/>
</Form.Item>
</Col>
{editingDevice && (
<Col span={12}>
<Form.Item label="设备ID">
<Input value={editingDevice.deviceId} disabled style={{ borderRadius: '8px' }} />
<Input
value={editingDevice.deviceId}
disabled
style={{ borderRadius: '8px' }}
/>
</Form.Item>
</Col>
)}
</Row>
</div>
<div style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
border: '1px solid #e2e8f0'
}}>
<div style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
<div
style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<div style={{ width: '4px', height: '16px', background: '#22c55e', borderRadius: '2px' }} />
border: '1px solid #e2e8f0',
}}
>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<div
style={{ width: '4px', height: '16px', background: '#22c55e', borderRadius: '2px' }}
/>
位置信息
</div>
<Row gutter={16}>
@@ -705,7 +807,7 @@ const IdleDeviceManagement = () => {
<Select
placeholder="请选择机房"
allowClear
onChange={(value) => {
onChange={value => {
setSelectedRoomId(value);
form.setFieldsValue({ rackId: null, position: null });
if (value) {
@@ -714,7 +816,7 @@ const IdleDeviceManagement = () => {
}}
style={{ borderRadius: '8px' }}
>
{rooms.map((room) => (
{rooms.map(room => (
<Option key={room.roomId} value={room.roomId}>
{room.name}
</Option>
@@ -725,14 +827,14 @@ const IdleDeviceManagement = () => {
<Col span={8}>
<Form.Item name="rackId" label="机柜">
<Select
placeholder={selectedRoomId ? "请选择机柜" : "请先选择机房"}
placeholder={selectedRoomId ? '请选择机柜' : '请先选择机房'}
allowClear
disabled={!selectedRoomId}
style={{ borderRadius: '8px' }}
>
{racks
.filter((rack) => rack.roomId === selectedRoomId)
.map((rack) => (
.filter(rack => rack.roomId === selectedRoomId)
.map(rack => (
<Option key={rack.rackId} value={rack.rackId}>
{rack.name}
</Option>
@@ -742,11 +844,19 @@ const IdleDeviceManagement = () => {
</Col>
<Col span={8}>
<Form.Item name="position" label="U位">
<Input type="number" placeholder="请输入U位" min={1} disabled={!selectedRoomId} style={{ borderRadius: '8px' }} />
<Input
type="number"
placeholder="请输入U位"
min={1}
disabled={!selectedRoomId}
style={{ borderRadius: '8px' }}
/>
</Form.Item>
</Col>
</Row>
<div style={{ textAlign: 'center', color: '#94a3b8', fontSize: '12px', margin: '8px 0' }}>
<div
style={{ textAlign: 'center', color: '#94a3b8', fontSize: '12px', margin: '8px 0' }}
>
</div>
<Row gutter={16}>
@@ -769,28 +879,37 @@ const IdleDeviceManagement = () => {
</Row>
</div>
<div style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
border: '1px solid #e2e8f0'
}}>
<div style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<div style={{ width: '4px', height: '16px', background: '#64748b', borderRadius: '2px' }} />
<div
style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
border: '1px solid #e2e8f0',
}}
>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<div
style={{ width: '4px', height: '16px', background: '#64748b', borderRadius: '2px' }}
/>
附加信息
</div>
<Row gutter={16}>
<Col span={24}>
<Form.Item name="idleReason" label="空闲原因">
<Input placeholder="请输入空闲原因,如:设备下线、备件库存等" style={{ borderRadius: '8px' }} />
<Input
placeholder="请输入空闲原因,如:设备下线、备件库存等"
style={{ borderRadius: '8px' }}
/>
</Form.Item>
</Col>
</Row>
@@ -808,7 +927,9 @@ const IdleDeviceManagement = () => {
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#22c55e' }} />
<div
style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#22c55e' }}
/>
设备上架
</div>
}
@@ -822,33 +943,47 @@ const IdleDeviceManagement = () => {
bodyStyle={{ padding: '24px' }}
>
<Form form={shelveForm} layout="vertical" size="middle">
<div style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
border: '1px solid #e2e8f0'
}}>
<div style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
<div
style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<div style={{ width: '4px', height: '16px', background: '#3b82f6', borderRadius: '2px' }} />
border: '1px solid #e2e8f0',
}}
>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<div
style={{ width: '4px', height: '16px', background: '#3b82f6', borderRadius: '2px' }}
/>
设备基本信息
</div>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="name" label="设备名称" rules={[{ required: true, message: '请输入设备名称' }]}>
<Form.Item
name="name"
label="设备名称"
rules={[{ required: true, message: '请输入设备名称' }]}
>
<Input placeholder="请输入设备名称" style={{ borderRadius: '8px' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="type" label="设备类型" rules={[{ required: true, message: '请选择设备类型' }]}>
<Form.Item
name="type"
label="设备类型"
rules={[{ required: true, message: '请选择设备类型' }]}
>
<Select placeholder="请选择设备类型" style={{ borderRadius: '8px' }}>
<Option value="server">服务器</Option>
<Option value="switch">交换机</Option>
@@ -866,7 +1001,11 @@ const IdleDeviceManagement = () => {
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="serialNumber" label="序列号" rules={[{ required: true, message: '请输入序列号' }]}>
<Form.Item
name="serialNumber"
label="序列号"
rules={[{ required: true, message: '请输入序列号' }]}
>
<Input placeholder="请输入序列号" style={{ borderRadius: '8px' }} />
</Form.Item>
</Col>
@@ -874,34 +1013,50 @@ const IdleDeviceManagement = () => {
<Row gutter={16}>
<Col span={12}>
<Form.Item name="height" label="高度(U)">
<Input type="number" placeholder="请输入高度" min={1} style={{ borderRadius: '8px' }} />
<Input
type="number"
placeholder="请输入高度"
min={1}
style={{ borderRadius: '8px' }}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="powerConsumption" label="功率(W)">
<Input type="number" placeholder="请输入功率" min={0} style={{ borderRadius: '8px' }} />
<Input
type="number"
placeholder="请输入功率"
min={0}
style={{ borderRadius: '8px' }}
/>
</Form.Item>
</Col>
</Row>
</div>
<div style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
border: '1px solid #e2e8f0'
}}>
<div style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
<div
style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<div style={{ width: '4px', height: '16px', background: '#22c55e', borderRadius: '2px' }} />
border: '1px solid #e2e8f0',
}}
>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<div
style={{ width: '4px', height: '16px', background: '#22c55e', borderRadius: '2px' }}
/>
上架位置
</div>
<Row gutter={16}>
@@ -909,13 +1064,13 @@ const IdleDeviceManagement = () => {
<Form.Item name="roomId" label="机房">
<Select
placeholder="请选择机房"
onChange={(value) => {
onChange={value => {
setSelectedShelveRoomId(value);
shelveForm.setFieldsValue({ rackId: null });
}}
style={{ borderRadius: '8px' }}
>
{rooms.map((room) => (
{rooms.map(room => (
<Option key={room.roomId} value={room.roomId}>
{room.name}
</Option>
@@ -926,14 +1081,14 @@ const IdleDeviceManagement = () => {
<Col span={8}>
<Form.Item name="rackId" label="机柜">
<Select
placeholder={selectedShelveRoomId ? "请选择机柜" : "请先选择机房"}
placeholder={selectedShelveRoomId ? '请选择机柜' : '请先选择机房'}
disabled={!selectedShelveRoomId}
style={{ borderRadius: '8px' }}
onChange={handleShelveRackChange}
>
{racks
.filter((rack) => rack.roomId === selectedShelveRoomId)
.map((rack) => (
.filter(rack => rack.roomId === selectedShelveRoomId)
.map(rack => (
<Option key={rack.rackId} value={rack.rackId}>
{rack.name}
</Option>
@@ -943,47 +1098,67 @@ const IdleDeviceManagement = () => {
</Col>
<Col span={8}>
<Form.Item name="position" label="U位">
<Input type="number" placeholder="请输入U位" min={1} style={{ borderRadius: '8px' }} onChange={handleShelvePositionChange} />
<Input
type="number"
placeholder="请输入U位"
min={1}
style={{ borderRadius: '8px' }}
onChange={handleShelvePositionChange}
/>
</Form.Item>
</Col>
</Row>
{shelvePositionConflict && (
<div style={{ marginTop: '12px' }}>
<div style={{
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: '8px',
padding: '12px 16px',
display: 'flex',
alignItems: 'flex-start',
gap: '10px'
}}>
<ExclamationCircleOutlined style={{ color: '#ef4444', fontSize: '18px', marginTop: '2px' }} />
<div
style={{
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: '8px',
padding: '12px 16px',
display: 'flex',
alignItems: 'flex-start',
gap: '10px',
}}
>
<ExclamationCircleOutlined
style={{ color: '#ef4444', fontSize: '18px', marginTop: '2px' }}
/>
<div>
<div style={{ color: '#dc2626', fontWeight: 600, marginBottom: '4px' }}>U位冲突</div>
<div style={{ color: '#991b1b', fontSize: '13px' }}>{shelvePositionConflict}</div>
<div style={{ color: '#dc2626', fontWeight: 600, marginBottom: '4px' }}>
U位冲突
</div>
<div style={{ color: '#991b1b', fontSize: '13px' }}>
{shelvePositionConflict}
</div>
</div>
</div>
</div>
)}
</div>
<div style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
border: '1px solid #e2e8f0'
}}>
<div style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<div style={{ width: '4px', height: '16px', background: '#64748b', borderRadius: '2px' }} />
<div
style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
border: '1px solid #e2e8f0',
}}
>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<div
style={{ width: '4px', height: '16px', background: '#64748b', borderRadius: '2px' }}
/>
备注信息
</div>
<Row gutter={16}>
@@ -1022,4 +1197,4 @@ const IdleDeviceManagement = () => {
);
};
export default IdleDeviceManagement;
export default IdleDeviceManagement;
+106 -106
View File
@@ -48,7 +48,7 @@ const api = axios.create({
baseURL: '/api',
});
api.interceptors.request.use((config) => {
api.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
@@ -92,7 +92,7 @@ const InventoryManagement = () => {
};
const res = await api.get('/inventory/plans', { params });
setPlans(res.data.plans || []);
setPagination((prev) => ({
setPagination(prev => ({
...prev,
total: res.data.total || 0,
}));
@@ -119,7 +119,7 @@ const InventoryManagement = () => {
const fetchRooms = async () => {
try {
const res = await api.get('/rooms', { params: { pageSize: 1000 } });
setRooms(Array.isArray(res.data) ? res.data : (res.data.rooms || []));
setRooms(Array.isArray(res.data) ? res.data : res.data.rooms || []);
} catch (error) {
console.error('获取机房失败', error);
}
@@ -128,7 +128,7 @@ const InventoryManagement = () => {
const fetchRacks = async () => {
try {
const res = await api.get('/racks', { params: { pageSize: 1000 } });
const allRacks = Array.isArray(res.data) ? res.data : (res.data.racks || []);
const allRacks = Array.isArray(res.data) ? res.data : res.data.racks || [];
setRacks(allRacks);
setFilteredRacks(allRacks);
} catch (error) {
@@ -157,20 +157,21 @@ const InventoryManagement = () => {
setModalVisible(true);
};
const handleEdit = (record) => {
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))
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,
@@ -182,7 +183,7 @@ const InventoryManagement = () => {
setModalVisible(true);
};
const handleDelete = async (planId) => {
const handleDelete = async planId => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个盘点计划吗?此操作不可恢复!',
@@ -202,7 +203,7 @@ const InventoryManagement = () => {
});
};
const handleSubmit = async (values) => {
const handleSubmit = async values => {
try {
const data = {
...values,
@@ -226,7 +227,7 @@ const InventoryManagement = () => {
}
};
const handleStart = async (plan) => {
const handleStart = async plan => {
try {
await api.post(`/inventory/plans/${plan.planId}/start`);
message.success('盘点任务已启动');
@@ -237,7 +238,7 @@ const InventoryManagement = () => {
}
};
const handleComplete = async (plan) => {
const handleComplete = async plan => {
try {
await api.post(`/inventory/plans/${plan.planId}/complete`);
message.success('盘点已完成');
@@ -248,23 +249,23 @@ const InventoryManagement = () => {
}
};
const handleViewTasks = (plan) => {
const handleViewTasks = plan => {
navigate(`/inventory/execution?planId=${plan.planId}`);
};
const handleRoomsChange = (roomIds) => {
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))
const filtered = racks.filter(
rack => roomIds.includes(rack.roomId) || (rack.Room && roomIds.includes(rack.Room.roomId))
);
setFilteredRacks(filtered);
}
};
const getStatusTag = (status) => {
const getStatusTag = status => {
const statusMap = {
draft: { color: 'default', text: '草稿', icon: <FileSearchOutlined /> },
pending: { color: 'orange', text: '待执行', icon: <ClockCircleOutlined /> },
@@ -274,17 +275,13 @@ const InventoryManagement = () => {
};
const config = statusMap[status] || statusMap.draft;
return (
<Tag
color={config.color}
icon={config.icon}
style={{ borderRadius: 6, padding: '2px 8px' }}
>
<Tag color={config.color} icon={config.icon} style={{ borderRadius: 6, padding: '2px 8px' }}>
{config.text}
</Tag>
);
};
const getTypeTag = (type) => {
const getTypeTag = type => {
const typeMap = {
full: { color: 'blue', text: '全面盘点' },
partial: { color: 'cyan', text: '局部盘点' },
@@ -294,7 +291,7 @@ const InventoryManagement = () => {
return <Tag color={config.color}>{config.text}</Tag>;
};
const getProgressPercent = (plan) => {
const getProgressPercent = plan => {
if (!plan.totalDevices || plan.totalDevices === 0) return 0;
return Math.round((plan.checkedDevices / plan.totalDevices) * 100);
};
@@ -334,14 +331,14 @@ const InventoryManagement = () => {
dataIndex: 'type',
key: 'type',
width: 100,
render: (type) => getTypeTag(type),
render: type => getTypeTag(type),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 120,
render: (status) => getStatusTag(status),
render: status => getStatusTag(status),
},
{
title: '盘点进度',
@@ -357,8 +354,8 @@ const InventoryManagement = () => {
{getProgressPercent(record)}%
</span>
</div>
<Progress
percent={getProgressPercent(record)}
<Progress
percent={getProgressPercent(record)}
size="small"
strokeColor={{
'0%': '#108ee9',
@@ -372,25 +369,26 @@ const InventoryManagement = () => {
title: '异常设备',
key: 'abnormal',
width: 100,
render: (_, record) => (
record.abnormalDevices > 0 ?
<Tag color="error">{record.abnormalDevices} 异常</Tag> :
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 || '-',
render: name => name || '-',
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (date) => (date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '-'),
render: date => (date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '操作',
@@ -482,21 +480,29 @@ const InventoryManagement = () => {
];
return (
<div style={{ padding: 24, background: designTokens.colors.background.secondary, minHeight: '100vh' }}>
<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={{
<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
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<div>
<div style={{ color: 'rgba(255,255,255,0.9)', fontSize: 14, marginBottom: 8 }}>
{stat.title}
@@ -505,17 +511,19 @@ const InventoryManagement = () => {
{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'
}}>
<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>
@@ -525,25 +533,27 @@ const InventoryManagement = () => {
</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 }}
>
<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={{
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 })}
onChange={e => setSearchParamsObj({ keyword: e.target.value })}
onPressEnter={() => {
setPagination((prev) => ({ ...prev, current: 1 }));
setPagination(prev => ({ ...prev, current: 1 }));
fetchPlans();
}}
/>
@@ -551,9 +561,9 @@ const InventoryManagement = () => {
placeholder="选择状态"
style={{ width: 140, borderRadius: 8 }}
allowClear
onChange={(value) => {
setSearchParamsObj((prev) => ({ ...prev, status: value }));
setPagination((prev) => ({ ...prev, current: 1 }));
onChange={value => {
setSearchParamsObj(prev => ({ ...prev, status: value }));
setPagination(prev => ({ ...prev, current: 1 }));
}}
>
<Select.Option value="draft">草稿</Select.Option>
@@ -561,17 +571,21 @@ const InventoryManagement = () => {
<Select.Option value="in_progress">进行中</Select.Option>
<Select.Option value="completed">已完成</Select.Option>
</Select>
<Button
icon={<ReloadOutlined />}
onClick={() => { setSearchParamsObj({}); fetchPlans(); fetchStats(); }}
<Button
icon={<ReloadOutlined />}
onClick={() => {
setSearchParamsObj({});
fetchPlans();
fetchStats();
}}
style={{ borderRadius: 8 }}
>
刷新
</Button>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAdd}
style={{ borderRadius: 8, height: 40 }}
>
@@ -588,14 +602,16 @@ const InventoryManagement = () => {
...pagination,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`,
showTotal: total => `${total} 条记录`,
onChange: (page, pageSize) => {
setPagination((prev) => ({ ...prev, current: page, pageSize }));
setPagination(prev => ({ ...prev, current: page, pageSize }));
},
}}
scroll={{ x: 1200 }}
locale={{
emptyText: <Empty description="暂无盘点计划" image={Empty.PRESENTED_IMAGE_SIMPLE} />,
emptyText: (
<Empty description="暂无盘点计划" image={Empty.PRESENTED_IMAGE_SIMPLE} />
),
}}
/>
</TabPane>
@@ -615,17 +631,13 @@ const InventoryManagement = () => {
footer={null}
width={640}
centered
styles={{
styles={{
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' },
body: { padding: 24 },
footer: { borderTop: '1px solid #f0f0f0', padding: '12px 24px' }
footer: { borderTop: '1px solid #f0f0f0', padding: '12px 24px' },
}}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Row gutter={16}>
<Col span={16}>
<Form.Item
@@ -652,35 +664,26 @@ const InventoryManagement = () => {
</Col>
</Row>
<Form.Item
name="description"
label="描述"
>
<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="计划日期"
>
<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="选择目标机房(不选则为全部)"
<Form.Item name="targetRooms" label="目标机房">
<Select
mode="multiple"
placeholder="选择目标机房(不选则为全部)"
allowClear
onChange={handleRoomsChange}
style={{ borderRadius: 8 }}
>
{rooms.map((room) => (
{rooms.map(room => (
<Select.Option key={room.roomId} value={room.roomId}>
{room.name}
</Select.Option>
@@ -690,17 +693,14 @@ const InventoryManagement = () => {
</Col>
</Row>
<Form.Item
name="targetRacks"
label="目标机柜"
>
<Select
mode="multiple"
placeholder="选择目标机柜(不选则为全部)"
<Form.Item name="targetRacks" label="目标机柜">
<Select
mode="multiple"
placeholder="选择目标机柜(不选则为全部)"
allowClear
style={{ borderRadius: 8 }}
>
{filteredRacks.map((rack) => (
{filteredRacks.map(rack => (
<Select.Option key={rack.rackId} value={rack.rackId}>
{rack.name} ({rack.Room?.name || ''})
</Select.Option>
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -374,9 +374,7 @@ const Login = () => {
>
{headerContent.title}
</Title>
<Text style={{ fontSize: '15px', color: '#64748b' }}>
{headerContent.subtitle}
</Text>
<Text style={{ fontSize: '15px', color: '#64748b' }}>{headerContent.subtitle}</Text>
</div>
{/* 首次使用提示 */}
@@ -436,7 +434,11 @@ const Login = () => {
rules={[{ required: true, message: '请输入真实姓名' }]}
>
<Input
prefix={<SafetyCertificateOutlined style={{ color: '#94a3b8', fontSize: '18px' }} />}
prefix={
<SafetyCertificateOutlined
style={{ color: '#94a3b8', fontSize: '18px' }}
/>
}
placeholder="请输入真实姓名"
style={{ borderRadius: '12px', height: '48px' }}
/>
+59 -27
View File
@@ -27,7 +27,11 @@ import {
import api from '../api';
import CloseButton from '../components/CloseButton';
import dayjs from 'dayjs';
import { selectStyles, filterInputStyles, inputPlaceholders } from '../styles/deviceManagementStyles';
import {
selectStyles,
filterInputStyles,
inputPlaceholders,
} from '../styles/deviceManagementStyles';
const { RangePicker } = DatePicker;
const { Option } = Select;
@@ -98,7 +102,7 @@ function OperationLogs() {
...prev,
current: page,
pageSize,
total: response.data.total
total: response.data.total,
}));
}
} catch (error) {
@@ -135,7 +139,7 @@ function OperationLogs() {
fetchLogs(1, pagination.pageSize, clearedFilters);
};
const handleViewDetail = async (record) => {
const handleViewDetail = async record => {
setDetailLoading(true);
setDetailVisible(true);
try {
@@ -151,7 +155,7 @@ function OperationLogs() {
}
};
const getModuleTag = (module) => {
const getModuleTag = module => {
const colors = {
device: 'blue',
user: 'green',
@@ -175,7 +179,7 @@ function OperationLogs() {
return <Tag color={colors[module] || 'default'}>{names[module] || module}</Tag>;
};
const getOperationTag = (type) => {
const getOperationTag = type => {
const colors = {
create: 'green',
update: 'blue',
@@ -203,10 +207,8 @@ function OperationLogs() {
return <Tag color={colors[type] || 'default'}>{names[type] || type}</Tag>;
};
const getResultTag = (result) => {
return result === 'success'
? <Tag color="success">成功</Tag>
: <Tag color="error">失败</Tag>;
const getResultTag = result => {
return result === 'success' ? <Tag color="success">成功</Tag> : <Tag color="error">失败</Tag>;
};
const columns = [
@@ -272,18 +274,19 @@ function OperationLogs() {
width: 80,
fixed: 'right',
render: (_, record) => (
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record)}
>
<Button type="link" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}>
详情
</Button>
),
},
];
const hasFilters = filters.module || filters.operationType || filters.keyword || filters.dateRange || filters.result;
const hasFilters =
filters.module ||
filters.operationType ||
filters.keyword ||
filters.dateRange ||
filters.result;
return (
<div style={{ padding: '24px' }}>
@@ -311,7 +314,9 @@ function OperationLogs() {
onChange={value => handleFilterChange('module', value)}
>
{MODULE_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
</Col>
@@ -324,7 +329,9 @@ function OperationLogs() {
onChange={value => handleFilterChange('operationType', value)}
>
{OPERATION_TYPE_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
</Col>
@@ -337,7 +344,9 @@ function OperationLogs() {
onChange={value => handleFilterChange('result', value)}
>
{RESULT_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
</Col>
@@ -368,10 +377,7 @@ function OperationLogs() {
筛选
</Button>
{hasFilters && (
<Button
icon={<ClearOutlined />}
onClick={handleClearFilters}
>
<Button icon={<ClearOutlined />} onClick={handleClearFilters}>
清除
</Button>
)}
@@ -391,7 +397,7 @@ function OperationLogs() {
total: pagination.total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
showTotal: total => `${total}`,
pageSizeOptions: ['10', '20', '50', '100'],
}}
onChange={handleTableChange}
@@ -428,7 +434,9 @@ function OperationLogs() {
</Descriptions.Item>
<Descriptions.Item label="模块">{currentLog.module}</Descriptions.Item>
<Descriptions.Item label="操作类型">{currentLog.operationType}</Descriptions.Item>
<Descriptions.Item label="操作描述">{currentLog.operationDescription}</Descriptions.Item>
<Descriptions.Item label="操作描述">
{currentLog.operationDescription}
</Descriptions.Item>
<Descriptions.Item label="目标ID">{currentLog.targetId || '-'}</Descriptions.Item>
<Descriptions.Item label="目标名称">{currentLog.targetName || '-'}</Descriptions.Item>
<Descriptions.Item label="操作人ID">{currentLog.operatorId}</Descriptions.Item>
@@ -450,7 +458,15 @@ function OperationLogs() {
{currentLog.beforeState && (
<>
<Text strong>变更前</Text>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto', maxHeight: 200 }}>
<pre
style={{
background: '#f5f5f5',
padding: 12,
borderRadius: 4,
overflow: 'auto',
maxHeight: 200,
}}
>
{JSON.stringify(currentLog.beforeState, null, 2)}
</pre>
</>
@@ -458,7 +474,15 @@ function OperationLogs() {
{currentLog.afterState && (
<>
<Text strong>变更后</Text>
<pre style={{ background: '#f0f0f0', padding: 12, borderRadius: 4, overflow: 'auto', maxHeight: 200 }}>
<pre
style={{
background: '#f0f0f0',
padding: 12,
borderRadius: 4,
overflow: 'auto',
maxHeight: 200,
}}
>
{JSON.stringify(currentLog.afterState, null, 2)}
</pre>
</>
@@ -469,7 +493,15 @@ function OperationLogs() {
{currentLog && currentLog.metadata && Object.keys(currentLog.metadata).length > 0 && (
<>
<h4 style={{ marginTop: 16 }}>扩展信息</h4>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto', maxHeight: 200 }}>
<pre
style={{
background: '#f5f5f5',
padding: 12,
borderRadius: 4,
overflow: 'auto',
maxHeight: 200,
}}
>
{JSON.stringify(currentLog.metadata, null, 2)}
</pre>
</>
+252 -130
View File
@@ -42,7 +42,7 @@ const api = axios.create({
baseURL: '/api',
});
api.interceptors.request.use((config) => {
api.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
@@ -64,13 +64,13 @@ const PendingDeviceManagement = () => {
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,
@@ -79,27 +79,133 @@ const PendingDeviceManagement = () => {
});
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 },
{
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 () => {
@@ -185,11 +291,11 @@ const PendingDeviceManagement = () => {
fetchDeviceFields();
}, []);
const filteredRacks = selectedRoomId
const filteredRacks = selectedRoomId
? racks.filter(rack => rack.roomId === selectedRoomId)
: racks;
const handleSync = async (pendingId) => {
const handleSync = async pendingId => {
try {
const res = await api.post(`/inventory/pending-devices/${pendingId}/sync`);
message.success(res.data.message);
@@ -205,7 +311,7 @@ const PendingDeviceManagement = () => {
message.warning('请先选择要同步的设备');
return;
}
try {
const res = await api.post('/inventory/pending-devices/batch-sync', {
pendingIds: selectedRowKeys,
@@ -219,27 +325,27 @@ const PendingDeviceManagement = () => {
}
};
const handleEdit = (record) => {
const handleEdit = record => {
setCurrentDevice(record);
setSelectedRoomId(record.roomId);
// -> PendingDevice
const fieldMapping = {
'name': 'deviceName',
'type': 'deviceType',
'SN': 'serialNumber',
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;
@@ -249,7 +355,7 @@ const PendingDeviceManagement = () => {
formValues[fieldName] = record[recordFieldName];
}
});
//
if (record.customFields) {
Object.entries(record.customFields).forEach(([key, value]) => {
@@ -258,21 +364,21 @@ const PendingDeviceManagement = () => {
}
});
}
form.setFieldsValue(formValues);
setEditModalVisible(true);
};
const handleEditSubmit = async (values) => {
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
warrantyExpiry: warrantyExpiry ? dayjs(warrantyExpiry).toISOString() : null,
};
await api.put(`/inventory/pending-devices/${currentDevice.pendingId}`, payload);
message.success('更新成功');
setEditModalVisible(false);
@@ -283,7 +389,7 @@ const PendingDeviceManagement = () => {
}
};
const handleDelete = async (pendingId) => {
const handleDelete = async pendingId => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个待同步设备吗?此操作不可恢复!',
@@ -303,16 +409,20 @@ const PendingDeviceManagement = () => {
});
};
const getStatusTag = (status) => {
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>;
return (
<Tag color={config.color} icon={config.icon}>
{config.text}
</Tag>
);
};
const getTypeLabel = (type) => {
const getTypeLabel = type => {
const typeMap = {
server: '服务器',
switch: '交换机',
@@ -323,7 +433,7 @@ const PendingDeviceManagement = () => {
return typeMap[type] || type;
};
const renderFormField = (field) => {
const renderFormField = field => {
const { fieldName, displayName, fieldType, required, options } = field;
if (fieldName === 'roomId') {
@@ -334,12 +444,12 @@ const PendingDeviceManagement = () => {
label={displayName}
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
>
<Select
placeholder={`请选择${displayName}`}
allowClear
showSearch
<Select
placeholder={`请选择${displayName}`}
allowClear
showSearch
optionFilterProp="children"
onChange={(value) => {
onChange={value => {
setSelectedRoomId(value);
form.setFieldsValue({ rackId: undefined });
}}
@@ -351,7 +461,9 @@ const PendingDeviceManagement = () => {
</Select.Option>
))
) : (
<Select.Option value="" disabled>暂无机房数据</Select.Option>
<Select.Option value="" disabled>
暂无机房数据
</Select.Option>
)}
</Select>
</Form.Item>
@@ -367,10 +479,10 @@ const PendingDeviceManagement = () => {
label={displayName}
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
>
<Select
placeholder={selectedRoomId ? `请选择${displayName}` : "请先选择机房"}
allowClear
showSearch
<Select
placeholder={selectedRoomId ? `请选择${displayName}` : '请先选择机房'}
allowClear
showSearch
optionFilterProp="children"
disabled={!selectedRoomId}
>
@@ -382,10 +494,14 @@ const PendingDeviceManagement = () => {
</Select.Option>
))
) : (
<Select.Option value="" disabled>该机房下无机柜</Select.Option>
<Select.Option value="" disabled>
该机房下无机柜
</Select.Option>
)
) : (
<Select.Option value="" disabled>请先选择机房</Select.Option>
<Select.Option value="" disabled>
请先选择机房
</Select.Option>
)}
</Select>
</Form.Item>
@@ -401,13 +517,16 @@ const PendingDeviceManagement = () => {
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
>
<Select placeholder={`请选择${displayName}`}>
{(Array.isArray(options) ? options : [
{ value: 'server', label: '服务器' },
{ value: 'switch', label: '交换机' },
{ value: 'router', label: '路由器' },
{ value: 'storage', label: '存储设备' },
{ value: 'other', label: '其他' },
]).map(opt => (
{(Array.isArray(options)
? 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>
@@ -490,11 +609,11 @@ const PendingDeviceManagement = () => {
const renderFormFields = () => {
// 使
const fields = deviceFields || defaultDeviceFields;
// Modal
const textFields = []; //
const otherFields = []; //
fields.forEach(field => {
// roomId rackId
if (field.fieldName === 'roomId' || field.fieldName === 'rackId') {
@@ -514,15 +633,11 @@ const PendingDeviceManagement = () => {
{/* 其他字段两列布局 */}
{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>
<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)}
</>
@@ -548,7 +663,7 @@ const PendingDeviceManagement = () => {
dataIndex: 'deviceType',
key: 'deviceType',
width: 100,
render: (type) => getTypeLabel(type),
render: type => getTypeLabel(type),
},
{
title: '位置',
@@ -556,8 +671,8 @@ const PendingDeviceManagement = () => {
width: 180,
render: (_, record) => (
<span>
{record.Room?.name || '-'}
{record.Rack ? ` / ${record.Rack.name}` : ''}
{record.Room?.name || '-'}
{record.Rack ? ` / ${record.Rack.name}` : ''}
{record.position ? ` / U${record.position}` : ''}
</span>
),
@@ -567,35 +682,35 @@ const PendingDeviceManagement = () => {
dataIndex: ['Plan', 'name'],
key: 'planName',
width: 150,
render: (name) => name || '-',
render: name => name || '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status) => getStatusTag(status),
render: status => getStatusTag(status),
},
{
title: '创建人',
dataIndex: ['Creator', 'realName'],
key: 'creator',
width: 100,
render: (name) => name || '-',
render: name => name || '-',
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 140,
render: (date) => date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '-',
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') : '-',
render: date => (date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '操作',
@@ -641,11 +756,7 @@ const PendingDeviceManagement = () => {
cancelText="取消"
>
<Tooltip title="删除">
<Button
type="text"
icon={<DeleteOutlined />}
style={{ color: '#ff4d4f' }}
/>
<Button type="text" icon={<DeleteOutlined />} style={{ color: '#ff4d4f' }} />
</Tooltip>
</Popconfirm>
</Space>
@@ -654,15 +765,17 @@ const PendingDeviceManagement = () => {
];
return (
<div style={{ padding: 24, background: designTokens.colors.background.secondary, minHeight: '100vh' }}>
<div
style={{
padding: 24,
background: designTokens.colors.background.secondary,
minHeight: '100vh',
}}
>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card bordered={false} style={{ borderRadius: 12, height: '100%', minHeight: 100 }}>
<Statistic
title="暂存设备总数"
value={stats.total}
prefix={<InboxOutlined />}
/>
<Statistic title="暂存设备总数" value={stats.total} prefix={<InboxOutlined />} />
</Card>
</Col>
<Col span={6}>
@@ -692,8 +805,8 @@ const PendingDeviceManagement = () => {
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}
<Progress
percent={stats.total > 0 ? Math.round((stats.synced / stats.total) * 100) : 0}
showInfo={false}
strokeColor="#52c41a"
/>
@@ -702,14 +815,23 @@ const PendingDeviceManagement = () => {
</Row>
<Card bordered={false} style={{ borderRadius: 12 }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 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 }))}
onChange={e => setFilters(prev => ({ ...prev, keyword: e.target.value }))}
onPressEnter={() => setPagination(prev => ({ ...prev, current: 1 }))}
/>
<Select
@@ -717,7 +839,7 @@ const PendingDeviceManagement = () => {
style={{ width: 120 }}
allowClear
value={filters.status}
onChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
onChange={value => setFilters(prev => ({ ...prev, status: value }))}
>
<Select.Option value="pending">待同步</Select.Option>
<Select.Option value="synced">已同步</Select.Option>
@@ -729,7 +851,7 @@ const PendingDeviceManagement = () => {
showSearch
optionFilterProp="children"
value={filters.planId}
onChange={(value) => setFilters(prev => ({ ...prev, planId: value }))}
onChange={value => setFilters(prev => ({ ...prev, planId: value }))}
>
{plans.map(plan => (
<Select.Option key={plan.planId} value={plan.planId}>
@@ -762,7 +884,7 @@ const PendingDeviceManagement = () => {
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
getCheckboxProps: (record) => ({
getCheckboxProps: record => ({
disabled: record.status !== 'pending',
}),
}}
@@ -774,7 +896,7 @@ const PendingDeviceManagement = () => {
...pagination,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
showTotal: total => `${total}`,
onChange: (page, pageSize) => {
setPagination(prev => ({ ...prev, current: page, pageSize }));
},
@@ -797,11 +919,7 @@ const PendingDeviceManagement = () => {
footer={null}
width={600}
>
<Form
form={form}
layout="vertical"
onFinish={handleEditSubmit}
>
<Form form={form} layout="vertical" onFinish={handleEditSubmit}>
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}>
<Descriptions.Item label="序列号">
<code>{currentDevice?.serialNumber}</code>
@@ -814,16 +932,13 @@ const PendingDeviceManagement = () => {
{/* 机房机柜选择 - 始终显示 */}
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="roomId"
label="所属机房"
>
<Select
placeholder="请选择机房"
allowClear
showSearch
<Form.Item name="roomId" label="所属机房">
<Select
placeholder="请选择机房"
allowClear
showSearch
optionFilterProp="children"
onChange={(value) => {
onChange={value => {
setSelectedRoomId(value);
form.setFieldsValue({ rackId: undefined });
}}
@@ -835,20 +950,19 @@ const PendingDeviceManagement = () => {
</Select.Option>
))
) : (
<Select.Option value="" disabled>暂无机房数据</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
<Form.Item name="rackId" label="所属机柜">
<Select
placeholder={selectedRoomId ? '请选择机柜' : '请先选择机房'}
allowClear
showSearch
optionFilterProp="children"
disabled={!selectedRoomId}
>
@@ -860,10 +974,14 @@ const PendingDeviceManagement = () => {
</Select.Option>
))
) : (
<Select.Option value="" disabled>该机房下无机柜</Select.Option>
<Select.Option value="" disabled>
该机房下无机柜
</Select.Option>
)
) : (
<Select.Option value="" disabled>请先选择机房</Select.Option>
<Select.Option value="" disabled>
请先选择机房
</Select.Option>
)}
</Select>
</Form.Item>
@@ -874,10 +992,14 @@ const PendingDeviceManagement = () => {
<Form.Item style={{ marginBottom: 0, textAlign: 'right', marginTop: 16 }}>
<Space>
<Button onClick={() => {
setEditModalVisible(false);
setSelectedRoomId(null);
}}>取消</Button>
<Button
onClick={() => {
setEditModalVisible(false);
setSelectedRoomId(null);
}}
>
取消
</Button>
<Button type="primary" htmlType="submit">
保存
</Button>
File diff suppressed because it is too large Load Diff
+137 -50
View File
@@ -371,7 +371,7 @@ const Rack3DVisualization = () => {
// U
const usedU = devices.reduce((sum, device) => sum + (device.height || 1), 0);
//
const totalPower = devices.reduce((sum, device) => {
const power = parseFloat(device.powerConsumption) || 0;
@@ -380,7 +380,7 @@ const Rack3DVisualization = () => {
//
const totalHeight = selectedRack.height || 45;
// U
const availableU = totalHeight - usedU;
@@ -568,7 +568,8 @@ const Rack3DVisualization = () => {
position: 'absolute',
top: 88,
right: 24,
background: 'linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.9) 100%)',
background:
'linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.9) 100%)',
backdropFilter: 'blur(16px)',
padding: isRackInfoCollapsed ? '14px 18px' : '20px',
borderRadius: '20px',
@@ -615,10 +616,13 @@ const Rack3DVisualization = () => {
left: 0,
right: 0,
height: '50%',
background: 'linear-gradient(180deg, rgba(255,255,255,0.15) 0%, transparent 100%)',
background:
'linear-gradient(180deg, rgba(255,255,255,0.15) 0%, transparent 100%)',
}}
/>
<CloudServerOutlined style={{ color: 'white', fontSize: 22, position: 'relative', zIndex: 1 }} />
<CloudServerOutlined
style={{ color: 'white', fontSize: 22, position: 'relative', zIndex: 1 }}
/>
</div>
<div
style={{
@@ -640,13 +644,15 @@ const Rack3DVisualization = () => {
>
{selectedRack.name}
</div>
<div style={{
fontSize: '12px',
color: 'rgba(148, 163, 184, 0.8)',
marginTop: 4,
fontFamily: 'monospace',
letterSpacing: '0.5px',
}}>
<div
style={{
fontSize: '12px',
color: 'rgba(148, 163, 184, 0.8)',
marginTop: 4,
fontFamily: 'monospace',
letterSpacing: '0.5px',
}}
>
#{selectedRack.rackId}
</div>
</div>
@@ -660,7 +666,7 @@ const Rack3DVisualization = () => {
e.stopPropagation();
setIsRackInfoCollapsed(!isRackInfoCollapsed);
}}
style={{
style={{
color: 'rgba(148, 163, 184, 0.7)',
marginLeft: isRackInfoCollapsed ? 0 : 8,
width: isRackInfoCollapsed ? 44 : 32,
@@ -701,10 +707,24 @@ const Rack3DVisualization = () => {
textAlign: 'center',
}}
>
<div style={{ fontSize: '22px', fontWeight: 700, color: '#60a5fa', lineHeight: 1 }}>
<div
style={{
fontSize: '22px',
fontWeight: 700,
color: '#60a5fa',
lineHeight: 1,
}}
>
{selectedRack.height}
</div>
<div style={{ fontSize: '10px', color: 'rgba(148, 163, 184, 0.7)', marginTop: 4, fontWeight: 500 }}>
<div
style={{
fontSize: '10px',
color: 'rgba(148, 163, 184, 0.7)',
marginTop: 4,
fontWeight: 500,
}}
>
总高度
</div>
</div>
@@ -717,10 +737,24 @@ const Rack3DVisualization = () => {
textAlign: 'center',
}}
>
<div style={{ fontSize: '22px', fontWeight: 700, color: '#34d399', lineHeight: 1 }}>
<div
style={{
fontSize: '22px',
fontWeight: 700,
color: '#34d399',
lineHeight: 1,
}}
>
{devices.length}
</div>
<div style={{ fontSize: '10px', color: 'rgba(148, 163, 184, 0.7)', marginTop: 4, fontWeight: 500 }}>
<div
style={{
fontSize: '10px',
color: 'rgba(148, 163, 184, 0.7)',
marginTop: 4,
fontWeight: 500,
}}
>
设备数
</div>
</div>
@@ -733,10 +767,24 @@ const Rack3DVisualization = () => {
textAlign: 'center',
}}
>
<div style={{ fontSize: '22px', fontWeight: 700, color: '#fbbf24', lineHeight: 1 }}>
<div
style={{
fontSize: '22px',
fontWeight: 700,
color: '#fbbf24',
lineHeight: 1,
}}
>
{Math.round((devices.length / selectedRack.height) * 100)}%
</div>
<div style={{ fontSize: '10px', color: 'rgba(148, 163, 184, 0.7)', marginTop: 4, fontWeight: 500 }}>
<div
style={{
fontSize: '10px',
color: 'rgba(148, 163, 184, 0.7)',
marginTop: 4,
fontWeight: 500,
}}
>
负载率
</div>
</div>
@@ -752,49 +800,86 @@ const Rack3DVisualization = () => {
marginBottom: 16,
}}
>
<div style={{ fontSize: '11px', color: 'rgba(148, 163, 184, 0.6)', marginBottom: 12, fontWeight: 600, letterSpacing: '0.5px', textTransform: 'uppercase' }}>
<div
style={{
fontSize: '11px',
color: 'rgba(148, 163, 184, 0.6)',
marginBottom: 12,
fontWeight: 600,
letterSpacing: '0.5px',
textTransform: 'uppercase',
}}
>
资源监控
</div>
{[
{
label: 'U位使用',
value: `${rackStats.usedU}U / ${selectedRack.height}U`,
status: rackStats.usagePercent > 80 ? 'warning' : 'normal',
{
label: 'U位使用',
value: `${rackStats.usedU}U / ${selectedRack.height}U`,
status: rackStats.usagePercent > 80 ? 'warning' : 'normal',
icon: '📊',
subValue: `剩余 ${rackStats.availableU}U`,
},
{
label: '总功率',
value: `${rackStats.totalPower.toFixed(1)}kW`,
status: 'normal',
{
label: '总功率',
value: `${rackStats.totalPower.toFixed(1)}kW`,
status: 'normal',
icon: '⚡',
subValue: '当前负载',
},
{
label: '设备数',
value: `${devices.length}`,
status: 'normal',
{
label: '设备数',
value: `${devices.length}`,
status: 'normal',
icon: '🖥️',
subValue: `负载率 ${rackStats.usagePercent}%`,
},
].map((item, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: i < 2 ? 12 : 0 }}>
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: i < 2 ? 12 : 0,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 16 }}>{item.icon}</span>
<div>
<div style={{ fontSize: '13px', color: 'rgba(226, 232, 240, 0.9)', fontWeight: 500 }}>{item.label}</div>
<div style={{ fontSize: '11px', color: 'rgba(148, 163, 184, 0.6)', marginTop: 2 }}>{item.subValue}</div>
<div
style={{
fontSize: '13px',
color: 'rgba(226, 232, 240, 0.9)',
fontWeight: 500,
}}
>
{item.label}
</div>
<div
style={{
fontSize: '11px',
color: 'rgba(148, 163, 184, 0.6)',
marginTop: 2,
}}
>
{item.subValue}
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: '14px', fontWeight: 700, color: '#f8fafc' }}>{item.value}</span>
<div style={{
width: 8,
height: 8,
borderRadius: '50%',
background: item.status === 'normal' ? '#10b981' : '#f59e0b',
boxShadow: `0 0 8px ${item.status === 'normal' ? 'rgba(16, 185, 129, 0.6)' : 'rgba(245, 158, 11, 0.6)'}`,
}} />
<span style={{ fontSize: '14px', fontWeight: 700, color: '#f8fafc' }}>
{item.value}
</span>
<div
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: item.status === 'normal' ? '#10b981' : '#f59e0b',
boxShadow: `0 0 8px ${item.status === 'normal' ? 'rgba(16, 185, 129, 0.6)' : 'rgba(245, 158, 11, 0.6)'}`,
}}
/>
</div>
</div>
))}
@@ -845,13 +930,15 @@ const Rack3DVisualization = () => {
>
<span style={{ fontSize: 14 }}>{item.icon}</span>
<span style={{ flex: 1 }}>{item.text}</span>
<div style={{
width: 4,
height: 4,
borderRadius: '50%',
background: item.color,
boxShadow: `0 0 6px ${item.color}`,
}} />
<div
style={{
width: 4,
height: 4,
borderRadius: '50%',
background: item.color,
boxShadow: `0 0 6px ${item.color}`,
}}
/>
</div>
))}
</div>
+33 -23
View File
@@ -439,7 +439,9 @@ function RackManagement() {
cancelText: '取消',
onOk: async () => {
try {
const results = await Promise.allSettled(selectedRackIds.map(id => axios.delete(`/api/racks/${id}`)));
const results = await Promise.allSettled(
selectedRackIds.map(id => axios.delete(`/api/racks/${id}`))
);
const succeeded = results.filter(r => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected');
if (succeeded > 0) {
@@ -537,14 +539,14 @@ function RackManagement() {
failedCount: 0,
errors: [],
createdRacks: resData.createdRacks || [],
skippedRacks: resData.skippedRacks || []
skippedRacks: resData.skippedRacks || [],
};
if (resData.details && Array.isArray(resData.details)) {
importResult.failedCount = resData.details.length;
importResult.errors = resData.details.map(item => ({
row: item.row,
error: item.errors.join('')
error: item.errors.join(''),
}));
}
@@ -571,8 +573,8 @@ function RackManagement() {
failedCount: errorData.details.length,
errors: errorData.details.map(item => ({
row: item.row,
error: item.errors.join('')
}))
error: item.errors.join(''),
})),
};
setImportResult(importResult);
setImportPhase('导入失败');
@@ -654,7 +656,9 @@ function RackManagement() {
</Text>
);
},
sorter: (a, b) => (a.Devices?.reduce((sum, d) => sum + (d.height || 1), 0) || 0) - (b.Devices?.reduce((sum, d) => sum + (d.height || 1), 0) || 0),
sorter: (a, b) =>
(a.Devices?.reduce((sum, d) => sum + (d.height || 1), 0) || 0) -
(b.Devices?.reduce((sum, d) => sum + (d.height || 1), 0) || 0),
},
{
title: '功率使用',
@@ -1437,14 +1441,16 @@ function RackManagement() {
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#52c41a' }}>
本次新增机柜{importResult.createdRacks.length}
</p>
<div style={{
maxHeight: '150px',
overflow: 'auto',
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: '8px',
padding: '12px'
}}>
<div
style={{
maxHeight: '150px',
overflow: 'auto',
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: '8px',
padding: '12px',
}}
>
{importResult.createdRacks.map((rack, idx) => (
<div key={idx} style={{ fontSize: '13px', marginBottom: '4px' }}>
{rack.rackId} - {rack.name}
@@ -1459,14 +1465,16 @@ function RackManagement() {
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#faad14' }}>
已跳过机柜{importResult.skippedRacks.length}
</p>
<div style={{
maxHeight: '150px',
overflow: 'auto',
background: '#fffbe6',
border: '1px solid #ffe58f',
borderRadius: '8px',
padding: '12px'
}}>
<div
style={{
maxHeight: '150px',
overflow: 'auto',
background: '#fffbe6',
border: '1px solid #ffe58f',
borderRadius: '8px',
padding: '12px',
}}
>
{importResult.skippedRacks.map((rack, idx) => (
<div key={idx} style={{ fontSize: '13px', marginBottom: '4px' }}>
{rack.rackId} - {rack.name}
@@ -1478,7 +1486,9 @@ function RackManagement() {
{importResult.errors && importResult.errors.length > 0 && (
<div style={{ marginBottom: '20px' }}>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#ff4d4f' }}>错误详情</p>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#ff4d4f' }}>
错误详情
</p>
{importResult.errors.slice(0, 5).map((err, idx) => (
<div
key={idx}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+77 -63
View File
@@ -12,7 +12,13 @@ import {
InputNumber,
Switch,
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, PlusCircleOutlined, MinusCircleOutlined } from '@ant-design/icons';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
PlusCircleOutlined,
MinusCircleOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import CloseButton from '../components/CloseButton';
@@ -37,44 +43,46 @@ const OptionsEditor = ({ value, onChange }) => {
};
return (
<div style={{
border: '1px solid #e8e8e8',
borderRadius: '12px',
padding: '20px',
background: 'linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '16px',
gap: '8px',
}}>
<div style={{
width: '4px',
height: '16px',
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: '2px',
}}/>
<span style={{ color: '#333', fontSize: '14px', fontWeight: '600' }}>
选项配置
</span>
<span style={{ color: '#999', fontSize: '12px' }}>
值用于提交标签用于显示
</span>
<div
style={{
border: '1px solid #e8e8e8',
borderRadius: '12px',
padding: '20px',
background: 'linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
marginBottom: '16px',
gap: '8px',
}}
>
<div
style={{
width: '4px',
height: '16px',
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: '2px',
}}
/>
<span style={{ color: '#333', fontSize: '14px', fontWeight: '600' }}>选项配置</span>
<span style={{ color: '#999', fontSize: '12px' }}>值用于提交标签用于显示</span>
</div>
{options.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '24px',
background: '#fff',
borderRadius: '8px',
border: '1px dashed #d9d9d9',
}}>
<div style={{ color: '#bbb', fontSize: '14px', marginBottom: '12px' }}>
暂无选项
</div>
<div
style={{
textAlign: 'center',
padding: '24px',
background: '#fff',
borderRadius: '8px',
border: '1px dashed #d9d9d9',
}}
>
<div style={{ color: '#bbb', fontSize: '14px', marginBottom: '12px' }}>暂无选项</div>
<Button
type="primary"
icon={<PlusCircleOutlined />}
@@ -90,15 +98,23 @@ const OptionsEditor = ({ value, onChange }) => {
</Button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '16px' }}>
<div style={{
display: 'flex',
gap: '12px',
padding: '0 4px',
marginBottom: '4px',
}}>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>value</span>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>标签label</span>
<div
style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '16px' }}
>
<div
style={{
display: 'flex',
gap: '12px',
padding: '0 4px',
marginBottom: '4px',
}}
>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>
value
</span>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>
标签label
</span>
</div>
{value.map((opt, index) => (
<div
@@ -114,19 +130,21 @@ const OptionsEditor = ({ value, onChange }) => {
transition: 'all 0.2s ease',
}}
>
<div style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea20 0%, #764ba220 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#667eea',
fontSize: '12px',
fontWeight: '600',
flexShrink: 0,
}}>
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea20 0%, #764ba220 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#667eea',
fontSize: '12px',
fontWeight: '600',
flexShrink: 0,
}}
>
{index + 1}
</div>
<Input
@@ -432,11 +450,7 @@ function TicketFieldManagement() {
<OptionsEditor />
</Form.Item>
) : (
<Form.Item
name="options"
label="选项配置"
tooltip="仅下拉选择类型需要配置选项"
>
<Form.Item name="options" label="选项配置" tooltip="仅下拉选择类型需要配置选项">
<Input.TextArea
rows={2}
placeholder="仅下拉选择类型需要配置,此处不可编辑"
+203 -133
View File
@@ -360,11 +360,13 @@ function TicketManagement() {
const response = await axios.get('/api/ticket-fields');
const dbFields = response.data.sort((a, b) => a.order - b.order);
const isValidOptions = (opts) => {
const isValidOptions = opts => {
if (!opts) return false;
if (!Array.isArray(opts)) return false;
if (opts.length === 0) return false;
return opts.some(opt => opt && opt.value !== undefined && opt.value !== null && opt.value !== '');
return opts.some(
opt => opt && opt.value !== undefined && opt.value !== null && opt.value !== ''
);
};
const mergedFields = DEFAULT_TICKET_FIELDS.map(defaultField => {
@@ -468,7 +470,11 @@ function TicketManagement() {
break;
case 'textarea':
formItem = (
<Input.TextArea rows={3} placeholder={placeholder || `请输入${displayName}`} showCount />
<Input.TextArea
rows={3}
placeholder={placeholder || `请输入${displayName}`}
showCount
/>
);
break;
case 'boolean':
@@ -1131,17 +1137,19 @@ function TicketManagement() {
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 32,
height: 32,
borderRadius: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 16,
}}>
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 16,
}}
>
<PlusOutlined />
</div>
<span style={{ fontWeight: 600, fontSize: 18 }}>
@@ -1166,32 +1174,38 @@ function TicketManagement() {
style={{ marginBottom: 16 }}
>
{!editingTicket && (
<div style={{
background: 'linear-gradient(135deg, #f0f4ff 0%, #fafbff 100%)',
border: '1px solid #e8eaff',
borderRadius: 12,
padding: '12px 16px',
marginBottom: 20,
display: 'flex',
alignItems: 'center',
gap: 12,
}}>
<div style={{
width: 40,
height: 40,
borderRadius: 8,
background: '#667eea',
<div
style={{
background: 'linear-gradient(135deg, #f0f4ff 0%, #fafbff 100%)',
border: '1px solid #e8eaff',
borderRadius: 12,
padding: '12px 16px',
marginBottom: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 14,
fontWeight: 600,
}}>
gap: 12,
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 8,
background: '#667eea',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 14,
fontWeight: 600,
}}
>
TKT
</div>
<div>
<div style={{ fontSize: 12, color: '#666', marginBottom: 2 }}>工单编号自动生成</div>
<div style={{ fontSize: 12, color: '#666', marginBottom: 2 }}>
工单编号自动生成
</div>
<Form.Item name="ticketId" noStyle>
<Input
disabled
@@ -1209,28 +1223,34 @@ function TicketManagement() {
</div>
)}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '0 24px',
}}>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '0 24px',
}}
>
<div style={{ gridColumn: '1 / -1', marginBottom: 8 }}>
<div style={{
fontSize: 13,
fontWeight: 600,
color: '#333',
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 8,
}}>
<span style={{
width: 4,
height: 16,
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: 2,
display: 'inline-block',
}} />
<div
style={{
fontSize: 13,
fontWeight: 600,
color: '#333',
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<span
style={{
width: 4,
height: 16,
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: 2,
display: 'inline-block',
}}
/>
设备信息
</div>
</div>
@@ -1264,7 +1284,11 @@ function TicketManagement() {
<div>
<Form.Item
name="serialNumber"
label={<span style={{ fontWeight: 500 }}>设备序列号 <span style={{ color: '#ff4d4f' }}>*</span></span>}
label={
<span style={{ fontWeight: 500 }}>
设备序列号 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[{ required: true, message: '请输入设备序列号' }]}
>
<Input placeholder="请输入设备序列号" size="large" />
@@ -1283,7 +1307,11 @@ function TicketManagement() {
<div style={{ gridColumn: '1 / -1' }}>
<Form.Item
name="deviceId"
label={<span style={{ fontWeight: 500 }}>关联设备 <span style={{ color: '#ff4d4f' }}>*</span></span>}
label={
<span style={{ fontWeight: 500 }}>
关联设备 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[{ required: true, message: '请选择关联设备' }]}
>
<Select
@@ -1317,11 +1345,7 @@ function TicketManagement() {
return renderDeviceSection();
} else if (hasDeviceNameField) {
if (!ticketFields.some(f => f.fieldName === 'deviceId')) {
return (
<div style={{ gridColumn: '1 / -1' }}>
{renderDeviceSection()}
</div>
);
return <div style={{ gridColumn: '1 / -1' }}>{renderDeviceSection()}</div>;
}
} else {
return renderDeviceSection();
@@ -1330,38 +1354,56 @@ function TicketManagement() {
})()}
<div style={{ gridColumn: '1 / -1', marginBottom: 8, marginTop: 8 }}>
<div style={{
fontSize: 13,
fontWeight: 600,
color: '#333',
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 8,
}}>
<span style={{
width: 4,
height: 16,
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: 2,
display: 'inline-block',
}} />
<div
style={{
fontSize: 13,
fontWeight: 600,
color: '#333',
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<span
style={{
width: 4,
height: 16,
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: 2,
display: 'inline-block',
}}
/>
工单信息
</div>
</div>
{ticketFields.filter(f =>
!['ticketId', 'deviceId', 'deviceName', 'serialNumber', 'expectedCompletionDate', 'resolution', 'notes', 'description'].includes(f.fieldName)
).map(field => (
<div key={field.fieldName}>
{renderFormItem(field)}
</div>
))}
{ticketFields
.filter(
f =>
![
'ticketId',
'deviceId',
'deviceName',
'serialNumber',
'expectedCompletionDate',
'resolution',
'notes',
'description',
].includes(f.fieldName)
)
.map(field => (
<div key={field.fieldName}>{renderFormItem(field)}</div>
))}
<div style={{ gridColumn: '1 / -1' }}>
<Form.Item
name="title"
label={<span style={{ fontWeight: 500 }}>工单标题 <span style={{ color: '#ff4d4f' }}>*</span></span>}
label={
<span style={{ fontWeight: 500 }}>
工单标题 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[{ required: true, message: '请输入工单标题' }]}
>
<Input placeholder="请输入工单标题" size="large" />
@@ -1371,7 +1413,11 @@ function TicketManagement() {
<div>
<Form.Item
name="faultCategory"
label={<span style={{ fontWeight: 500 }}>故障分类 <span style={{ color: '#ff4d4f' }}>*</span></span>}
label={
<span style={{ fontWeight: 500 }}>
故障分类 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[{ required: true, message: '请选择故障分类' }]}
>
<Select placeholder="请选择故障分类" size="large">
@@ -1387,22 +1433,34 @@ function TicketManagement() {
<div>
<Form.Item
name="priority"
label={<span style={{ fontWeight: 500 }}>优先级 <span style={{ color: '#ff4d4f' }}>*</span></span>}
label={
<span style={{ fontWeight: 500 }}>
优先级 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[{ required: true, message: '请选择优先级' }]}
initialValue="medium"
>
<Select placeholder="请选择优先级" size="large">
<Option value="low">
<Tag color="green" style={{ margin: 0 }}></Tag>
<Tag color="green" style={{ margin: 0 }}>
</Tag>
</Option>
<Option value="medium">
<Tag color="orange" style={{ margin: 0 }}></Tag>
<Tag color="orange" style={{ margin: 0 }}>
</Tag>
</Option>
<Option value="high">
<Tag color="red" style={{ margin: 0 }}></Tag>
<Tag color="red" style={{ margin: 0 }}>
</Tag>
</Option>
<Option value="urgent">
<Tag color="magenta" style={{ margin: 0 }}>紧急</Tag>
<Tag color="magenta" style={{ margin: 0 }}>
紧急
</Tag>
</Option>
</Select>
</Form.Item>
@@ -1426,7 +1484,11 @@ function TicketManagement() {
<div style={{ gridColumn: '1 / -1' }}>
<Form.Item
name="description"
label={<span style={{ fontWeight: 500 }}>故障描述 <span style={{ color: '#ff4d4f' }}>*</span></span>}
label={
<span style={{ fontWeight: 500 }}>
故障描述 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[{ required: true, message: '请输入故障描述' }]}
>
<TextArea
@@ -1439,10 +1501,7 @@ function TicketManagement() {
</div>
<div style={{ gridColumn: '1 / -1', marginTop: 8 }}>
<Form.Item
name="notes"
label={<span style={{ fontWeight: 500 }}>备注信息</span>}
>
<Form.Item name="notes" label={<span style={{ fontWeight: 500 }}>备注信息</span>}>
<TextArea
rows={2}
placeholder="补充说明或其他相关信息(选填)"
@@ -1453,14 +1512,16 @@ function TicketManagement() {
</div>
</div>
<div style={{
borderTop: '1px solid #f0f0f0',
marginTop: 24,
paddingTop: 20,
display: 'flex',
justifyContent: 'flex-end',
gap: 12,
}}>
<div
style={{
borderTop: '1px solid #f0f0f0',
marginTop: 24,
paddingTop: 20,
display: 'flex',
justifyContent: 'flex-end',
gap: 12,
}}
>
<Button onClick={handleCancel} size="large" style={{ minWidth: 100 }}>
取消
</Button>
@@ -1706,15 +1767,19 @@ function TicketManagement() {
<Row gutter={[24, 16]}>
<Col span={12}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>购买日期</div>
<div>{selectedTicket.Device.purchaseDate
? dayjs(selectedTicket.Device.purchaseDate).format('YYYY-MM-DD')
: '-'}</div>
<div>
{selectedTicket.Device.purchaseDate
? dayjs(selectedTicket.Device.purchaseDate).format('YYYY-MM-DD')
: '-'}
</div>
</Col>
<Col span={12}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>保修到期</div>
<div>{selectedTicket.Device.warrantyExpiry
? dayjs(selectedTicket.Device.warrantyExpiry).format('YYYY-MM-DD')
: '-'}</div>
<div>
{selectedTicket.Device.warrantyExpiry
? dayjs(selectedTicket.Device.warrantyExpiry).format('YYYY-MM-DD')
: '-'}
</div>
</Col>
</Row>
</Card>
@@ -1722,28 +1787,35 @@ function TicketManagement() {
{/* 描述信息 */}
{selectedTicket.Device.description && (
<Card title="描述" style={{ marginBottom: 16 }} size="small">
<div style={{ whiteSpace: 'pre-wrap' }}>{selectedTicket.Device.description}</div>
<div style={{ whiteSpace: 'pre-wrap' }}>
{selectedTicket.Device.description}
</div>
</Card>
)}
{/* 自定义字段卡片 */}
{selectedTicket.Device.customFields && Object.keys(selectedTicket.Device.customFields).length > 0 && (
<Card title="自定义字段" size="small">
<Row gutter={[24, 16]}>
{Object.entries(selectedTicket.Device.customFields).map(([key, value]) => {
// deviceFields
const fieldConfig = deviceFields.find(f => f.fieldName === key);
const displayName = fieldConfig?.displayName || key;
return (
<Col span={8} key={key}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>{displayName}</div>
<div style={{ fontWeight: 500 }}>{String(value)}</div>
</Col>
);
})}
</Row>
</Card>
)}
{selectedTicket.Device.customFields &&
Object.keys(selectedTicket.Device.customFields).length > 0 && (
<Card title="自定义字段" size="small">
<Row gutter={[24, 16]}>
{Object.entries(selectedTicket.Device.customFields).map(
([key, value]) => {
// deviceFields
const fieldConfig = deviceFields.find(f => f.fieldName === key);
const displayName = fieldConfig?.displayName || key;
return (
<Col span={8} key={key}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>
{displayName}
</div>
<div style={{ fontWeight: 500 }}>{String(value)}</div>
</Col>
);
}
)}
</Row>
</Card>
)}
</div>
</TabPane>
)}
@@ -1851,9 +1923,7 @@ function TicketManagement() {
</div>
<div style={{ marginBottom: '4px' }}>
<span style={{ color: '#666' }}>操作人</span>
<span style={{ fontWeight: 500 }}>
{record.operatorName || '-'}
</span>
<span style={{ fontWeight: 500 }}>{record.operatorName || '-'}</span>
</div>
{record.operationDescription && (
<div
+44 -21
View File
@@ -1,5 +1,19 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Card, Row, Col, Statistic, Table, DatePicker, Select, Space, Tag, message, Button, Switch, Tooltip } from 'antd';
import {
Card,
Row,
Col,
Statistic,
Table,
DatePicker,
Select,
Space,
Tag,
message,
Button,
Switch,
Tooltip,
} from 'antd';
import {
BarChartOutlined,
PieChartOutlined,
@@ -82,26 +96,29 @@ function TicketStatistics() {
const timerRef = useRef(null);
const fetchStatistics = useCallback(async (isManual = false) => {
try {
if (isManual) {
setLoading(true);
}
const params = {
startDate: dateRange[0].format('YYYY-MM-DD'),
endDate: dateRange[1].format('YYYY-MM-DD'),
};
const fetchStatistics = useCallback(
async (isManual = false) => {
try {
if (isManual) {
setLoading(true);
}
const params = {
startDate: dateRange[0].format('YYYY-MM-DD'),
endDate: dateRange[1].format('YYYY-MM-DD'),
};
const response = await axios.get('/api/tickets/stats', { params });
setStatistics(response.data);
setLastUpdateTime(new Date());
} catch (error) {
message.error('获取统计数据失败');
console.error('获取统计数据失败:', error);
} finally {
setLoading(false);
}
}, [dateRange]);
const response = await axios.get('/api/tickets/stats', { params });
setStatistics(response.data);
setLastUpdateTime(new Date());
} catch (error) {
message.error('获取统计数据失败');
console.error('获取统计数据失败:', error);
} finally {
setLoading(false);
}
},
[dateRange]
);
useEffect(() => {
fetchStatistics();
@@ -329,7 +346,13 @@ function TicketStatistics() {
title="工单统计报表"
extra={
<Space>
<Tooltip title={lastUpdateTime ? `最后更新: ${dayjs(lastUpdateTime).format('HH:mm:ss')}` : '尚未更新'}>
<Tooltip
title={
lastUpdateTime
? `最后更新: ${dayjs(lastUpdateTime).format('HH:mm:ss')}`
: '尚未更新'
}
>
<span style={{ fontSize: 12, color: '#888', marginRight: 8 }}>
{lastUpdateTime && `更新于 ${dayjs(lastUpdateTime).format('HH:mm:ss')}`}
</span>
+2 -2
View File
@@ -30,7 +30,7 @@ export const getUserFromStorage = () => {
}
};
export const formatFileSize = (bytes) => {
export const formatFileSize = bytes => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
@@ -42,6 +42,6 @@ export const generateId = (prefix = '') => {
return `${prefix}${Date.now()}${Math.random().toString(36).substr(2, 9)}`;
};
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
export const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
+7 -7
View File
@@ -23,15 +23,15 @@ export const TYPE_MAP = {
other: '其他设备',
};
export const getStatusConfig = (status) => {
export const getStatusConfig = status => {
return STATUS_MAP[status] || { text: status, color: 'black', badgeColor: 'default' };
};
export const getTypeLabel = (type) => {
export const getTypeLabel = type => {
return TYPE_MAP[type] || type;
};
export const getDeviceTypeIcon = (type) => {
export const getDeviceTypeIcon = type => {
const iconMap = {
server: <CloudServerOutlined style={{ color: '#1890ff' }} />,
switch: <SwapOutlined style={{ color: '#52c41a' }} />,
@@ -80,7 +80,7 @@ export const FIXED_FIELDS = [
export const SYSTEM_FIELDS = ['createdAt', 'updatedAt', 'Rack', 'Room', 'customFields'];
export const processDeviceData = (device) => {
export const processDeviceData = device => {
const deviceWithFields = { ...device };
if (device.customFields && typeof device.customFields === 'object') {
Object.entries(device.customFields).forEach(([fieldName, value]) => {
@@ -98,7 +98,7 @@ export const prepareDeviceFormData = (values, isEditing) => {
customFields: {},
};
Object.keys(deviceData).forEach((key) => {
Object.keys(deviceData).forEach(key => {
if (!FIXED_FIELDS.includes(key) && key !== 'customFields' && key !== 'roomId') {
deviceData.customFields[key] = deviceData[key];
delete deviceData[key];
@@ -116,7 +116,7 @@ export const getFormInitialValues = (device, racks) => {
const deviceData = { ...device };
const cleanDeviceData = {};
FIXED_FIELDS.forEach((field) => {
FIXED_FIELDS.forEach(field => {
if (deviceData[field] !== undefined) {
cleanDeviceData[field] = deviceData[field];
}
@@ -141,7 +141,7 @@ export const getFormInitialValues = (device, racks) => {
}
if (device.rackId) {
const rack = racks.find((r) => r.rackId === device.rackId);
const rack = racks.find(r => r.rackId === device.rackId);
if (rack) {
cleanDeviceData.roomId = rack.roomId;
}