feat: 添加空闲设备状态支持并优化耗材导入功能
refactor(设备管理): 重构状态筛选和高级搜索界面 perf(机柜管理): 优化U位计算方式为基于设备高度 fix(耗材管理): 修复导入时库存计算不准确的问题 style(耗材统计): 调整表格样式和实时刷新功能 docs: 更新设备状态映射包含空闲状态
This commit is contained in:
@@ -469,12 +469,15 @@ function DeviceDetailDrawer({
|
||||
{customFieldEntries.length > 0 && (
|
||||
<Card title="自定义字段" size="small" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={[24, 16]}>
|
||||
{customFieldEntries.map(([key, value]) => (
|
||||
<Col span={8} key={key}>
|
||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ fontWeight: 500 }}>{String(value)}</div>
|
||||
</Col>
|
||||
))}
|
||||
{customFieldEntries.map(([key, value]) => {
|
||||
const fieldLabel = tooltipFields?.[key]?.label || key;
|
||||
return (
|
||||
<Col span={8} key={key}>
|
||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: 4 }}>{fieldLabel}</div>
|
||||
<div style={{ fontWeight: 500 }}>{String(value)}</div>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -163,7 +163,7 @@ const DeviceDetailModal = ({
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>功率</div>
|
||||
<div style={{ fontWeight: 500 }}>{device.power ? `${device.power}W` : '-'}</div>
|
||||
<div style={{ fontWeight: 500 }}>{device.powerConsumption ? `${device.powerConsumption}W` : '-'}</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>状态</div>
|
||||
|
||||
@@ -125,6 +125,7 @@ export const DEFAULT_DEVICE_FIELDS = [
|
||||
{ value: 'maintenance', label: '维护中' },
|
||||
{ value: 'offline', label: '离线' },
|
||||
{ value: 'fault', label: '故障' },
|
||||
{ value: 'idle', label: '空闲' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -240,6 +241,7 @@ export const DEVICE_STATUS_OPTIONS = [
|
||||
{ value: 'maintenance', label: '维护中' },
|
||||
{ value: 'offline', label: '离线' },
|
||||
{ value: 'fault', label: '故障' },
|
||||
{ value: 'idle', label: '空闲' },
|
||||
];
|
||||
|
||||
// 表格列宽配置
|
||||
@@ -283,6 +285,7 @@ export const STATUS_MAP = {
|
||||
maintenance: { text: '维护中', color: 'orange' },
|
||||
offline: { text: '离线', color: 'gray' },
|
||||
fault: { text: '故障', color: 'red' },
|
||||
idle: { text: '空闲', color: 'cyan' },
|
||||
};
|
||||
|
||||
// 设备类型映射
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Dropdown,
|
||||
Menu,
|
||||
Statistic,
|
||||
Switch,
|
||||
} from 'antd';
|
||||
import {
|
||||
PieChartOutlined,
|
||||
@@ -577,25 +578,29 @@ const CategoryCard = styled(motion.div)`
|
||||
const StyledTable = styled(Table)`
|
||||
.ant-table {
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
background: linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: ${designTokens.colors.text.secondary};
|
||||
border-bottom: 1px solid ${designTokens.colors.border};
|
||||
padding: 12px 16px;
|
||||
padding: 10px 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
padding: 14px 16px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid ${designTokens.colors.border}40;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: rgba(99, 102, 241, 0.03);
|
||||
}
|
||||
@@ -603,6 +608,13 @@ const StyledTable = styled(Table)`
|
||||
.ant-table-wrapper {
|
||||
border-radius: 0 0 16px 16px;
|
||||
}
|
||||
|
||||
.ant-pagination {
|
||||
padding: 12px 16px;
|
||||
margin: 0;
|
||||
background: ${designTokens.colors.background.main};
|
||||
border-top: 1px solid ${designTokens.colors.border};
|
||||
}
|
||||
`;
|
||||
|
||||
const ProgressBar = styled.div`
|
||||
@@ -664,6 +676,10 @@ const LoadingOverlay = styled.div`
|
||||
|
||||
const ConsumableStatistics = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [realTimeRefresh, setRealTimeRefresh] = useState(true);
|
||||
const [lastUpdateTime, setLastUpdateTime] = useState(null);
|
||||
const [isAutoRefreshing, setIsAutoRefreshing] = useState(false);
|
||||
const refreshIntervalRef = useRef(null);
|
||||
const [stats, setStats] = useState({
|
||||
inCount: 0,
|
||||
outCount: 0,
|
||||
@@ -678,6 +694,7 @@ const ConsumableStatistics = () => {
|
||||
byCategory: [],
|
||||
});
|
||||
const [lowStockItems, setLowStockItems] = useState([]);
|
||||
const [lowStockPagination, setLowStockPagination] = useState({ current: 1, pageSize: 5 });
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [dateRange, setDateRange] = useState([dayjs().subtract(30, 'days'), dayjs()]);
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
@@ -700,9 +717,11 @@ const ConsumableStatistics = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadStatistics = async () => {
|
||||
const loadStatistics = async (isAuto = false) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if (!isAuto) {
|
||||
setLoading(true);
|
||||
}
|
||||
const params = {
|
||||
startDate: dateRange[0]?.format('YYYY-MM-DD'),
|
||||
endDate: dateRange[1]?.format('YYYY-MM-DD'),
|
||||
@@ -730,18 +749,27 @@ const ConsumableStatistics = () => {
|
||||
totalValue: summaryResponse?.totalValue || 0,
|
||||
byCategory: summaryResponse?.byCategory || [],
|
||||
});
|
||||
|
||||
if (isAuto) {
|
||||
setLastUpdateTime(new Date());
|
||||
setIsAutoRefreshing(false);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error?.message || error || '未知错误';
|
||||
message.error('加载统计数据失败: ' + errorMsg);
|
||||
if (!isAuto) {
|
||||
message.error('加载统计数据失败: ' + errorMsg);
|
||||
}
|
||||
console.error('加载统计数据失败:', error);
|
||||
setStats({ inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0, recentRecords: [] });
|
||||
setSummary({ total: 0, lowStock: 0, totalValue: 0, byCategory: [] });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!isAuto) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadLowStockItems = async () => {
|
||||
const loadLowStockItems = async (isAuto = false) => {
|
||||
try {
|
||||
const response = await consumableAPI.getLowStock();
|
||||
console.log('[低库存] 返回:', response);
|
||||
@@ -758,6 +786,28 @@ const ConsumableStatistics = () => {
|
||||
loadLowStockItems();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (realTimeRefresh) {
|
||||
refreshIntervalRef.current = setInterval(() => {
|
||||
setIsAutoRefreshing(true);
|
||||
loadStatistics(true);
|
||||
loadLowStockItems(true);
|
||||
setLastUpdateTime(new Date());
|
||||
}, 30000);
|
||||
} else {
|
||||
if (refreshIntervalRef.current) {
|
||||
clearInterval(refreshIntervalRef.current);
|
||||
refreshIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (refreshIntervalRef.current) {
|
||||
clearInterval(refreshIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [realTimeRefresh]);
|
||||
|
||||
const handleQuickFilter = (key) => {
|
||||
setQuickFilter(key);
|
||||
const filter = quickFilters.find(f => f.key === key);
|
||||
@@ -771,9 +821,17 @@ const ConsumableStatistics = () => {
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadStatistics();
|
||||
loadLowStockItems();
|
||||
message.success('数据已刷新');
|
||||
setLoading(true);
|
||||
setIsAutoRefreshing(false);
|
||||
Promise.all([
|
||||
loadStatistics(false),
|
||||
loadLowStockItems(false)
|
||||
]).finally(() => {
|
||||
setLoading(false);
|
||||
if (!realTimeRefresh) {
|
||||
message.success('数据已手动刷新');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
@@ -808,80 +866,109 @@ const ConsumableStatistics = () => {
|
||||
title: '耗材名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: '35%',
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Avatar
|
||||
size={34}
|
||||
size={28}
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${designTokens.colors.warning.main}, #fb923c)`,
|
||||
fontSize: '14px',
|
||||
fontSize: '12px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<WarningOutlined />
|
||||
</Avatar>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: designTokens.colors.text.primary, fontSize: '14px' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontWeight: 600,
|
||||
color: designTokens.colors.text.primary,
|
||||
fontSize: '13px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: designTokens.colors.text.secondary }}>
|
||||
{record.specification || '-'}
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: designTokens.colors.text.secondary,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{record.specification || record.category || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '当前库存',
|
||||
dataIndex: 'currentStock',
|
||||
key: 'currentStock',
|
||||
title: '库存状态',
|
||||
key: 'stockStatus',
|
||||
width: '40%',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
render: (currentStock, record) => (
|
||||
<Text strong style={{ fontSize: '15px', color: designTokens.colors.error.main }}>
|
||||
{currentStock} {record.unit}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '安全库存',
|
||||
dataIndex: 'minStock',
|
||||
key: 'minStock',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
render: (minStock, record) => (
|
||||
<Text type="secondary">{minStock} {record.unit}</Text>
|
||||
),
|
||||
render: (_, record) => {
|
||||
const current = record.currentStock || 0;
|
||||
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,
|
||||
}}>
|
||||
{current}
|
||||
</span>
|
||||
<span style={{ color: designTokens.colors.text.secondary, fontSize: '11px' }}>/</span>
|
||||
<span style={{ color: designTokens.colors.text.secondary, fontSize: '12px' }}>
|
||||
{min}
|
||||
</span>
|
||||
<span style={{ color: designTokens.colors.text.secondary, fontSize: '11px' }}>
|
||||
{record.unit || '个'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '充足率',
|
||||
key: 'rate',
|
||||
width: '25%',
|
||||
align: 'center',
|
||||
width: 140,
|
||||
render: (_, record) => {
|
||||
const minStock = record.minStock || 0;
|
||||
const currentStock = record.currentStock || 0;
|
||||
|
||||
|
||||
if (minStock <= 0) {
|
||||
return <Text type="secondary" style={{ fontSize: '12px' }}>未设置</Text>;
|
||||
return <Text type="secondary" style={{ fontSize: '11px' }}>未设置</Text>;
|
||||
}
|
||||
|
||||
|
||||
const rate = Math.min(100, Math.round((currentStock / minStock) * 100));
|
||||
const color = rate < 50 ? designTokens.colors.error.main :
|
||||
rate < 100 ? designTokens.colors.warning.main :
|
||||
const color = rate < 30 ? designTokens.colors.error.main :
|
||||
rate < 60 ? designTokens.colors.warning.main :
|
||||
designTokens.colors.success.main;
|
||||
|
||||
return (
|
||||
<ProgressBar>
|
||||
<div className="progress-wrapper">
|
||||
<Progress
|
||||
percent={rate}
|
||||
size="small"
|
||||
strokeColor={color}
|
||||
showInfo={false}
|
||||
/>
|
||||
<span className="progress-text" style={{ color }}>{rate}%</span>
|
||||
</div>
|
||||
</ProgressBar>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
|
||||
<Progress
|
||||
percent={rate}
|
||||
size="small"
|
||||
strokeColor={color}
|
||||
showInfo={false}
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
<span style={{
|
||||
fontWeight: 600,
|
||||
fontSize: '12px',
|
||||
color,
|
||||
minWidth: 32,
|
||||
textAlign: 'right',
|
||||
}}>
|
||||
{rate}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -998,6 +1085,28 @@ const ConsumableStatistics = () => {
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
<Tooltip title={realTimeRefresh ? '已开启30秒自动刷新' : '已关闭自动刷新'}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, color: designTokens.colors.text.secondary }}>实时</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={realTimeRefresh}
|
||||
onChange={setRealTimeRefresh}
|
||||
checkedChildren="开"
|
||||
unCheckedChildren="关"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Button
|
||||
icon={<ExportOutlined />}
|
||||
onClick={handleExport}
|
||||
@@ -1011,9 +1120,9 @@ const ConsumableStatistics = () => {
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
icon={<ReloadOutlined spin={isAutoRefreshing} />}
|
||||
onClick={handleRefresh}
|
||||
loading={loading}
|
||||
loading={loading && !isAutoRefreshing}
|
||||
style={{
|
||||
height: 38,
|
||||
borderRadius: 10,
|
||||
@@ -1346,9 +1455,17 @@ const ConsumableStatistics = () => {
|
||||
columns={lowStockColumns}
|
||||
dataSource={lowStockItems}
|
||||
rowKey="consumableId"
|
||||
pagination={false}
|
||||
pagination={{
|
||||
current: lowStockPagination.current,
|
||||
pageSize: lowStockPagination.pageSize,
|
||||
total: lowStockItems.length,
|
||||
showSizeChanger: false,
|
||||
showQuickJumper: false,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page) => setLowStockPagination(prev => ({ ...prev, current: page })),
|
||||
}}
|
||||
size="small"
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 'max-content', y: 300 }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<EmptyState>
|
||||
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
SettingOutlined,
|
||||
CloudServerOutlined,
|
||||
ReloadOutlined,
|
||||
AppstoreOutlined,
|
||||
ToolOutlined,
|
||||
EnvironmentOutlined,
|
||||
FilterOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
@@ -89,7 +94,6 @@ function DeviceManagement() {
|
||||
const [type, setType] = useState('all');
|
||||
const [roomId, setRoomId] = useState('all');
|
||||
const [rackId, setRackId] = useState('all');
|
||||
const [isIdle, setIsIdle] = useState('');
|
||||
const [searchForm] = Form.useForm();
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
@@ -122,6 +126,7 @@ function DeviceManagement() {
|
||||
const [selectedDevices, setSelectedDevices] = useState([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
const [columnWidths, setColumnWidths] = useState({});
|
||||
const [advancedSearchVisible, setAdvancedSearchVisible] = useState(false);
|
||||
|
||||
const debouncedKeyword = useDebounce(keyword, DEBOUNCE_DELAY);
|
||||
|
||||
@@ -138,7 +143,6 @@ function DeviceManagement() {
|
||||
type: type !== 'all' ? type : undefined,
|
||||
roomId: roomId !== 'all' ? roomId : undefined,
|
||||
rackId: rackId !== 'all' ? rackId : undefined,
|
||||
isIdle: isIdle || undefined,
|
||||
};
|
||||
|
||||
const response = await axios.get('/api/devices', { params });
|
||||
@@ -293,7 +297,6 @@ function DeviceManagement() {
|
||||
setType(values.type || 'all');
|
||||
setRoomId(values.roomId || 'all');
|
||||
setRackId(values.rackId || 'all');
|
||||
setIsIdle(values.isIdle || '');
|
||||
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
|
||||
@@ -308,7 +311,6 @@ function DeviceManagement() {
|
||||
setType('all');
|
||||
setRoomId('all');
|
||||
setRackId('all');
|
||||
setIsIdle('');
|
||||
searchForm.resetFields();
|
||||
|
||||
setTimeout(() => setSearching(false), 300);
|
||||
@@ -664,7 +666,7 @@ function DeviceManagement() {
|
||||
title: field.displayName,
|
||||
dataIndex: field.fieldName,
|
||||
key: field.fieldName,
|
||||
width: columnWidths[field.fieldName] || 100,
|
||||
width: columnWidths[field.fieldName] || 90,
|
||||
onHeaderCell: handleHeaderCellResize(field.fieldName),
|
||||
render: (status) => {
|
||||
const config = STATUS_MAP[status] || { text: status, color: 'default' };
|
||||
@@ -673,6 +675,7 @@ function DeviceManagement() {
|
||||
maintenance: { bg: '#fffbE6', border: '#faad14', text: '#d48806' },
|
||||
offline: { bg: '#f5f5f5', border: '#8c8c8c', text: '#595959' },
|
||||
fault: { bg: '#fff2f0', border: '#ff4d4f', text: '#cf1322' },
|
||||
idle: { bg: '#E6FFFA', border: '#36cfc9', text: '#08979d' },
|
||||
};
|
||||
const style = statusStyles[status] || { bg: '#fafafa', border: '#d9d9d9', text: '#595959' };
|
||||
return (
|
||||
@@ -684,6 +687,10 @@ function DeviceManagement() {
|
||||
borderRadius: '4px',
|
||||
fontWeight: 500,
|
||||
boxShadow: `0 1px 2px ${style.border}30`,
|
||||
minWidth: '80px',
|
||||
textAlign: 'center',
|
||||
padding: '2px 8px',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{config.text}
|
||||
@@ -927,9 +934,8 @@ function DeviceManagement() {
|
||||
body: {
|
||||
padding: `${designTokens.spacing.md}px ${designTokens.spacing.lg}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
gap: designTokens.spacing.md,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -937,144 +943,196 @@ function DeviceManagement() {
|
||||
form={searchForm}
|
||||
layout="inline"
|
||||
onFinish={handleSearch}
|
||||
style={{ flex: 1, display: 'flex', flexWrap: 'wrap', gap: designTokens.spacing.md }}
|
||||
style={{ width: '100%' }}
|
||||
className="filter-form"
|
||||
>
|
||||
<Form.Item name="keyword" style={{ margin: 0 }}>
|
||||
<Input
|
||||
placeholder="搜索设备..."
|
||||
prefix={<SearchOutlined style={{ color: designTokens.colors.primary.main }} />}
|
||||
style={{
|
||||
width: '280px',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
border: `1px solid ${designTokens.colors.border.light}`,
|
||||
transition: `all ${designTokens.transitions.fast}`,
|
||||
}}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: designTokens.spacing.md, alignItems: 'center', 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="status" style={{ margin: 0 }}>
|
||||
<Select
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
style={{ width: '140px', borderRadius: designTokens.borderRadius.medium }}
|
||||
>
|
||||
<Option value="all">所有状态</Option>
|
||||
<Option value="running">运行中</Option>
|
||||
<Option value="maintenance">维护中</Option>
|
||||
<Option value="offline">离线</Option>
|
||||
<Option value="fault">故障</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="type" style={{ margin: 0 }}>
|
||||
<Select
|
||||
value={type}
|
||||
onChange={setType}
|
||||
style={{ width: '140px', borderRadius: designTokens.borderRadius.medium }}
|
||||
>
|
||||
<Option value="all">所有类型</Option>
|
||||
<Option value="server">服务器</Option>
|
||||
<Option value="switch">交换机</Option>
|
||||
<Option value="router">路由器</Option>
|
||||
<Option value="storage">存储设备</Option>
|
||||
<Option value="other">其他设备</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="roomId" style={{ margin: 0 }}>
|
||||
<Select
|
||||
value={roomId}
|
||||
onChange={(value) => {
|
||||
setRoomId(value);
|
||||
setRackId('all');
|
||||
}}
|
||||
style={{ width: '140px', borderRadius: designTokens.borderRadius.medium }}
|
||||
>
|
||||
<Option value="all">所有机房</Option>
|
||||
{rooms.map((room) => (
|
||||
<Option key={room.roomId} value={room.roomId}>
|
||||
{room.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="rackId" style={{ margin: 0 }}>
|
||||
<Select
|
||||
value={rackId}
|
||||
onChange={setRackId}
|
||||
style={{ width: '140px', borderRadius: designTokens.borderRadius.medium }}
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
>
|
||||
<Option value="all">所有机柜</Option>
|
||||
{racks
|
||||
.filter((rack) => roomId === 'all' || rack.roomId === roomId)
|
||||
.map((rack) => (
|
||||
<Option key={rack.rackId} value={rack.rackId}>
|
||||
{rack.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="isIdle" style={{ margin: 0 }}>
|
||||
<Select
|
||||
style={{ width: 140, borderRadius: designTokens.borderRadius.medium }}
|
||||
>
|
||||
<Option value="">所有设备</Option>
|
||||
<Option value="false">在用设备</Option>
|
||||
<Option value="true">空闲设备</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ margin: 0 }}>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
<Form.Item name="keyword" style={{ margin: 0 }}>
|
||||
<Input
|
||||
placeholder="搜索设备名称、型号、序列号..."
|
||||
prefix={<SearchOutlined style={{ color: designTokens.colors.primary.main }} />}
|
||||
style={{
|
||||
height: '36px',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
background: designTokens.colors.primary.gradient,
|
||||
border: 'none',
|
||||
boxShadow: designTokens.shadows.small,
|
||||
}}
|
||||
icon={<SearchOutlined />}
|
||||
htmlType="submit"
|
||||
loading={searching}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
<Button
|
||||
style={{
|
||||
height: '36px',
|
||||
width: '280px',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
border: `1px solid ${designTokens.colors.border.light}`,
|
||||
transition: `all ${designTokens.transitions.fast}`,
|
||||
}}
|
||||
onClick={handleReset}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: designTokens.spacing.sm }}>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => fetchDevices(1, pagination.pageSize, true)}
|
||||
style={{
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
border: `1px solid ${designTokens.colors.border.light}`,
|
||||
height: '36px',
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Form.Item name="status" style={{ margin: 0 }}>
|
||||
<Select
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
style={{ width: '140px', borderRadius: designTokens.borderRadius.medium }}
|
||||
suffixIcon={<ToolOutlined style={{ color: '#10b981' }} />}
|
||||
>
|
||||
<Option value="all">所有状态</Option>
|
||||
<Option value="running">运行中</Option>
|
||||
<Option value="maintenance">维护中</Option>
|
||||
<Option value="offline">离线</Option>
|
||||
<Option value="fault">故障</Option>
|
||||
<Option value="idle">空闲</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="type" style={{ margin: 0 }}>
|
||||
<Select
|
||||
value={type}
|
||||
onChange={setType}
|
||||
style={{ width: '140px', borderRadius: designTokens.borderRadius.medium }}
|
||||
suffixIcon={<AppstoreOutlined style={{ color: '#8b5cf6' }} />}
|
||||
>
|
||||
<Option value="all">所有类型</Option>
|
||||
<Option value="server">服务器</Option>
|
||||
<Option value="switch">交换机</Option>
|
||||
<Option value="router">路由器</Option>
|
||||
<Option value="storage">存储设备</Option>
|
||||
<Option value="other">其他设备</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
icon={advancedSearchVisible ? <UnorderedListOutlined /> : <UnorderedListOutlined />}
|
||||
onClick={() => setAdvancedSearchVisible(!advancedSearchVisible)}
|
||||
style={{
|
||||
height: '36px',
|
||||
padding: '0 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
color: advancedSearchVisible ? designTokens.colors.primary.main : designTokens.colors.text.secondary,
|
||||
fontWeight: 500,
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
background: advancedSearchVisible ? `${designTokens.colors.primary.main}10` : 'transparent',
|
||||
transition: `all ${designTokens.transitions.fast}`,
|
||||
}}
|
||||
>
|
||||
高级筛选 {advancedSearchVisible ? '▲' : '▼'}
|
||||
</Button>
|
||||
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: designTokens.spacing.sm }}>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{
|
||||
height: '36px',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
background: designTokens.colors.primary.gradient,
|
||||
border: 'none',
|
||||
boxShadow: designTokens.shadows.small,
|
||||
}}
|
||||
icon={<SearchOutlined />}
|
||||
htmlType="submit"
|
||||
loading={searching}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
<Button
|
||||
style={{
|
||||
height: '36px',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
border: `1px solid ${designTokens.colors.border.light}`,
|
||||
}}
|
||||
onClick={handleReset}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => fetchDevices(1, pagination.pageSize, true)}
|
||||
style={{
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
border: `1px solid ${designTokens.colors.border.light}`,
|
||||
height: '36px',
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</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={{
|
||||
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>
|
||||
</div>
|
||||
|
||||
<Form.Item name="roomId" style={{ margin: 0 }}>
|
||||
<Select
|
||||
value={roomId}
|
||||
onChange={(value) => {
|
||||
setRoomId(value);
|
||||
setRackId('all');
|
||||
}}
|
||||
style={{ width: '160px', borderRadius: designTokens.borderRadius.medium }}
|
||||
placeholder="选择机房"
|
||||
suffixIcon={<EnvironmentOutlined style={{ color: '#3b82f6' }} />}
|
||||
>
|
||||
<Option value="all">所有机房</Option>
|
||||
{rooms.map((room) => (
|
||||
<Option key={room.roomId} value={room.roomId}>
|
||||
{room.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="rackId" style={{ margin: 0 }}>
|
||||
<Select
|
||||
value={rackId}
|
||||
onChange={setRackId}
|
||||
style={{ width: '160px', borderRadius: designTokens.borderRadius.medium }}
|
||||
placeholder="选择机柜"
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
suffixIcon={<EnvironmentOutlined style={{ color: '#f59e0b' }} />}
|
||||
>
|
||||
<Option value="all">所有机柜</Option>
|
||||
{racks
|
||||
.filter((rack) => roomId === 'all' || rack.roomId === roomId)
|
||||
.map((rack) => (
|
||||
<Option key={rack.rackId} value={rack.rackId}>
|
||||
{rack.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card style={cardStyle}>
|
||||
|
||||
@@ -151,10 +151,10 @@ const PowerGauge = ({ current, max }) => {
|
||||
};
|
||||
|
||||
const RackCard = ({ rack, onEdit, onDelete, onView, selected, onSelect }) => {
|
||||
const deviceCount = rack.Devices?.length || 0;
|
||||
const usedU = rack.Devices?.reduce((sum, d) => sum + (d.height || 1), 0) || 0;
|
||||
const powerUsage = (rack.currentPower / rack.maxPower) * 100;
|
||||
const statusInfo = statusConfig[rack.status];
|
||||
const availableU = rack.height - deviceCount;
|
||||
const availableU = rack.height - usedU;
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -218,7 +218,7 @@ const RackCard = ({ rack, onEdit, onDelete, onView, selected, onSelect }) => {
|
||||
<div
|
||||
style={{ fontSize: '14px', fontWeight: '600', color: designTokens.colors.text.primary }}
|
||||
>
|
||||
{rack.height}U / {deviceCount}
|
||||
{rack.height}U / {usedU}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '12px', background: '#fafafa', borderRadius: '8px' }}>
|
||||
@@ -602,27 +602,14 @@ function RackManagement() {
|
||||
title: '高度/已用U位',
|
||||
key: 'heightUsage',
|
||||
render: (_, record) => {
|
||||
const used = record.Devices?.length || 0;
|
||||
const percentage = (used / record.height) * 100;
|
||||
const used = record.Devices?.reduce((sum, d) => sum + (d.height || 1), 0) || 0;
|
||||
return (
|
||||
<div>
|
||||
<Text style={{ fontSize: '13px' }}>{record.height}U</Text>
|
||||
<Progress
|
||||
percent={percentage}
|
||||
size="small"
|
||||
strokeColor={
|
||||
percentage >= 90 ? designTokens.colors.error.main : designTokens.colors.success.main
|
||||
}
|
||||
trailColor="#f0f0f0"
|
||||
style={{ marginTop: '4px', marginBottom: 0 }}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
已用 {used} U位
|
||||
</Text>
|
||||
</div>
|
||||
<Text style={{ fontSize: '13px' }}>
|
||||
{record.height}U / 已用 {used} U位
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
sorter: (a, b) => (a.Devices?.length || 0) - (b.Devices?.length || 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: '功率使用',
|
||||
|
||||
Reference in New Issue
Block a user