feat: 添加空闲设备状态支持并优化耗材导入功能
refactor(设备管理): 重构状态筛选和高级搜索界面 perf(机柜管理): 优化U位计算方式为基于设备高度 fix(耗材管理): 修复导入时库存计算不准确的问题 style(耗材统计): 调整表格样式和实时刷新功能 docs: 更新设备状态映射包含空闲状态
This commit is contained in:
@@ -104,7 +104,8 @@ const defaultDeviceFields = [
|
||||
{ value: 'running', label: '运行中' },
|
||||
{ value: 'maintenance', label: '维护中' },
|
||||
{ value: 'offline', label: '离线' },
|
||||
{ value: 'fault', label: '故障' }
|
||||
{ value: 'fault', label: '故障' },
|
||||
{ value: 'idle', label: '空闲' }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -176,6 +177,18 @@ async function initDeviceFields() {
|
||||
if (field.options && !existingField.options) {
|
||||
await existingField.update({ options: field.options });
|
||||
console.log(`更新字段 options: ${field.displayName}`);
|
||||
} else if (field.options && existingField.options) {
|
||||
// 如果系统字段已有 options,检查是否缺少默认选项,补充缺失的选项
|
||||
const existingValues = existingField.options.map(o => o.value);
|
||||
const defaultValues = field.options.map(o => o.value);
|
||||
const missingOptions = field.options.filter(o => !existingValues.includes(o.value));
|
||||
if (missingOptions.length > 0) {
|
||||
const updatedOptions = [...existingField.options, ...missingOptions];
|
||||
await existingField.update({ options: updatedOptions });
|
||||
console.log(`补充缺失的 options: ${field.displayName},新增: ${missingOptions.map(o => o.label).join(', ')}`);
|
||||
} else {
|
||||
console.log(`跳过已存在字段: ${field.displayName}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`跳过已存在字段: ${field.displayName}`);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ router.post('/', async (req, res) => {
|
||||
...req.body,
|
||||
consumableId: req.body.consumableId || `CON${Date.now()}`
|
||||
};
|
||||
if (Array.isArray(consumableData.snList)) {
|
||||
consumableData.currentStock = consumableData.snList.length;
|
||||
}
|
||||
const consumable = await Consumable.create(consumableData, { transaction });
|
||||
|
||||
await ConsumableLog.create({
|
||||
@@ -107,7 +110,7 @@ router.post('/', async (req, res) => {
|
||||
router.post('/import', async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const { items, operator = '系统' } = req.body;
|
||||
const { items, operator = '系统', mode = 'create' } = req.body;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
await transaction.rollback();
|
||||
@@ -117,45 +120,93 @@ router.post('/import', async (req, res) => {
|
||||
const results = {
|
||||
success: 0,
|
||||
failed: 0,
|
||||
errors: []
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
details: []
|
||||
};
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const rowNumber = i + 1;
|
||||
|
||||
try {
|
||||
let consumableId = item.耗材ID || item.consumableId;
|
||||
const name = item.名称 || item.name;
|
||||
const category = item.分类 || item.category;
|
||||
|
||||
if (!name || !category) {
|
||||
results.failed++;
|
||||
results.errors.push(`第 ${rowNumber} 行: 名称和分类为必填项`);
|
||||
results.details.push({ row: rowNumber, status: 'failed', error: '名称和分类为必填项' });
|
||||
continue;
|
||||
}
|
||||
|
||||
let snList = [];
|
||||
if (item.SN序列号 || item.snList) {
|
||||
const snStr = item.SN序列号 || item.snList;
|
||||
if (typeof snStr === 'string') {
|
||||
snList = snStr.split(/[,,;;\n]/).map(s => s.trim()).filter(Boolean);
|
||||
} else if (Array.isArray(snStr)) {
|
||||
snList = snStr;
|
||||
}
|
||||
}
|
||||
|
||||
const consumableData = {
|
||||
consumableId: item.耗材ID || item.consumableId || `CON${Date.now()}${i}`,
|
||||
name: item.名称 || item.name,
|
||||
category: item.分类 || item.category,
|
||||
consumableId: consumableId || `CON${Date.now()}${i}`,
|
||||
name,
|
||||
category,
|
||||
unit: item.单位 || item.unit || '个',
|
||||
currentStock: parseInt(item.当前库存 || item.currentStock) || 0,
|
||||
currentStock: snList.length > 0 ? snList.length : (parseInt(item.当前库存 || item.currentStock) || 0),
|
||||
minStock: parseInt(item.最小库存 || item.minStock) || 10,
|
||||
maxStock: parseInt(item.最大库存 || item.maxStock) || 100,
|
||||
maxStock: parseInt(item.最大库存 || item.maxStock) || 0,
|
||||
unitPrice: parseFloat(item.单价 || item.unitPrice) || 0,
|
||||
supplier: item.供应商 || item.supplier || '',
|
||||
location: item.存放位置 || item.location || '',
|
||||
description: item.描述 || item.description || '',
|
||||
status: item.状态 || item.status || 'active'
|
||||
status: item.状态 || item.status || 'active',
|
||||
snList
|
||||
};
|
||||
|
||||
if (!consumableData.name || !consumableData.category) {
|
||||
results.failed++;
|
||||
results.errors.push(`第 ${i + 1} 行: 名称和分类为必填项`);
|
||||
continue;
|
||||
let existingConsumable = null;
|
||||
if (consumableId) {
|
||||
existingConsumable = await Consumable.findByPk(consumableId, { transaction });
|
||||
}
|
||||
|
||||
const consumable = await Consumable.create(consumableData, { transaction });
|
||||
let consumable;
|
||||
let operationType;
|
||||
let previousStock = 0;
|
||||
|
||||
if (existingConsumable) {
|
||||
if (mode === 'update') {
|
||||
previousStock = existingConsumable.currentStock;
|
||||
await existingConsumable.update(consumableData, { transaction });
|
||||
consumable = existingConsumable;
|
||||
operationType = 'import_update';
|
||||
results.updated++;
|
||||
results.details.push({ row: rowNumber, status: 'updated', consumableId: consumable.consumableId, name: consumable.name });
|
||||
} else {
|
||||
results.skipped++;
|
||||
results.details.push({ row: rowNumber, status: 'skipped', reason: '耗材已存在', consumableId: consumableId });
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
consumable = await Consumable.create(consumableData, { transaction });
|
||||
operationType = 'import';
|
||||
results.success++;
|
||||
results.details.push({ row: rowNumber, status: 'created', consumableId: consumable.consumableId, name: consumable.name });
|
||||
}
|
||||
|
||||
await ConsumableLog.create({
|
||||
consumableId: consumable.consumableId,
|
||||
consumableName: consumable.name,
|
||||
operationType: 'import',
|
||||
operationType,
|
||||
quantity: consumable.currentStock,
|
||||
previousStock: 0,
|
||||
previousStock,
|
||||
currentStock: consumable.currentStock,
|
||||
operator,
|
||||
reason: '批量导入',
|
||||
notes: '',
|
||||
notes: existingConsumable ? '更新现有耗材' : '',
|
||||
consumableSnapshot: {
|
||||
category: consumable.category,
|
||||
unit: consumable.unit,
|
||||
@@ -167,16 +218,16 @@ router.post('/import', async (req, res) => {
|
||||
}
|
||||
}, { transaction });
|
||||
|
||||
results.success++;
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push(`第 ${i + 1} 行: ${error.message}`);
|
||||
results.errors.push(`第 ${rowNumber} 行: ${error.message}`);
|
||||
results.details.push({ row: rowNumber, status: 'failed', error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
res.json({
|
||||
message: `导入完成,成功 ${results.success} 条,失败 ${results.failed} 条`,
|
||||
message: `导入完成,成功 ${results.success} 条,更新 ${results.updated} 条,跳过 ${results.skipped} 条,失败 ${results.failed} 条`,
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -872,9 +923,13 @@ router.put('/:id', async (req, res) => {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({ error: '耗材不存在' });
|
||||
}
|
||||
|
||||
|
||||
const oldData = consumable.toJSON();
|
||||
await consumable.update(req.body, { transaction });
|
||||
const updateData = { ...req.body };
|
||||
if (Array.isArray(updateData.snList)) {
|
||||
updateData.currentStock = updateData.snList.length;
|
||||
}
|
||||
await consumable.update(updateData, { transaction });
|
||||
|
||||
await ConsumableLog.create({
|
||||
consumableId: consumable.consumableId,
|
||||
|
||||
@@ -1798,6 +1798,7 @@ router.put('/:deviceId/to-idle', async (req, res) => {
|
||||
|
||||
await device.update({
|
||||
isIdle: true,
|
||||
status: 'idle',
|
||||
idleDate: new Date(),
|
||||
idleReason: idleReason || `从设备管理转入`
|
||||
}, { transaction: t });
|
||||
|
||||
@@ -181,6 +181,7 @@ router.post('/from-device/:deviceId', async (req, res) => {
|
||||
|
||||
await device.update({
|
||||
isIdle: true,
|
||||
status: 'idle',
|
||||
idleDate: new Date(),
|
||||
idleReason: idleReason || `从设备管理转入`,
|
||||
sourceType: 'rack'
|
||||
@@ -230,6 +231,7 @@ router.post('/batch-from-devices', async (req, res) => {
|
||||
await Device.update(
|
||||
{
|
||||
isIdle: true,
|
||||
status: 'idle',
|
||||
idleDate: new Date(),
|
||||
idleReason: idleReason || `批量转入`
|
||||
},
|
||||
|
||||
@@ -53,7 +53,7 @@ router.get('/', async (req, res) => {
|
||||
const rackIds = racks.map(r => r.rackId);
|
||||
const devices = await Device.findAll({
|
||||
where: { rackId: rackIds },
|
||||
attributes: ['deviceId', 'rackId', 'name', 'powerConsumption']
|
||||
attributes: ['deviceId', 'rackId', 'name', 'powerConsumption', 'height']
|
||||
});
|
||||
|
||||
// 将设备信息关联到对应的机柜
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const Joi = require('joi');
|
||||
|
||||
const DEVICE_TYPES = ['server', 'switch', 'router', 'storage', 'other'];
|
||||
const DEVICE_STATUS = ['running', 'maintenance', 'offline', 'fault'];
|
||||
const DEVICE_STATUS = ['running', 'maintenance', 'offline', 'fault', 'idle'];
|
||||
|
||||
const createDeviceSchema = Joi.object({
|
||||
name: Joi.string().required().max(100).messages({
|
||||
|
||||
@@ -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