feat: 添加空闲设备状态支持并优化耗材导入功能

refactor(设备管理): 重构状态筛选和高级搜索界面

perf(机柜管理): 优化U位计算方式为基于设备高度

fix(耗材管理): 修复导入时库存计算不准确的问题

style(耗材统计): 调整表格样式和实时刷新功能

docs: 更新设备状态映射包含空闲状态
This commit is contained in:
zhang1106
2026-03-24 14:54:39 +08:00
parent f82b8202fe
commit 1b898814ed
13 changed files with 1385 additions and 456 deletions
@@ -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
+178 -61
View File
@@ -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>
+195 -137
View File
@@ -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}>
+8 -21
View File
@@ -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: '功率使用',