feat: 添加空闲设备状态支持并优化耗材导入功能
refactor(设备管理): 重构状态筛选和高级搜索界面 perf(机柜管理): 优化U位计算方式为基于设备高度 fix(耗材管理): 修复导入时库存计算不准确的问题 style(耗材统计): 调整表格样式和实时刷新功能 docs: 更新设备状态映射包含空闲状态
This commit is contained in:
@@ -104,7 +104,8 @@ const defaultDeviceFields = [
|
|||||||
{ value: 'running', label: '运行中' },
|
{ value: 'running', label: '运行中' },
|
||||||
{ value: 'maintenance', label: '维护中' },
|
{ value: 'maintenance', label: '维护中' },
|
||||||
{ value: 'offline', 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) {
|
if (field.options && !existingField.options) {
|
||||||
await existingField.update({ options: field.options });
|
await existingField.update({ options: field.options });
|
||||||
console.log(`更新字段 options: ${field.displayName}`);
|
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 {
|
} else {
|
||||||
console.log(`跳过已存在字段: ${field.displayName}`);
|
console.log(`跳过已存在字段: ${field.displayName}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ router.post('/', async (req, res) => {
|
|||||||
...req.body,
|
...req.body,
|
||||||
consumableId: req.body.consumableId || `CON${Date.now()}`
|
consumableId: req.body.consumableId || `CON${Date.now()}`
|
||||||
};
|
};
|
||||||
|
if (Array.isArray(consumableData.snList)) {
|
||||||
|
consumableData.currentStock = consumableData.snList.length;
|
||||||
|
}
|
||||||
const consumable = await Consumable.create(consumableData, { transaction });
|
const consumable = await Consumable.create(consumableData, { transaction });
|
||||||
|
|
||||||
await ConsumableLog.create({
|
await ConsumableLog.create({
|
||||||
@@ -107,7 +110,7 @@ router.post('/', async (req, res) => {
|
|||||||
router.post('/import', async (req, res) => {
|
router.post('/import', async (req, res) => {
|
||||||
const transaction = await sequelize.transaction();
|
const transaction = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const { items, operator = '系统' } = req.body;
|
const { items, operator = '系统', mode = 'create' } = req.body;
|
||||||
|
|
||||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
@@ -117,45 +120,93 @@ router.post('/import', async (req, res) => {
|
|||||||
const results = {
|
const results = {
|
||||||
success: 0,
|
success: 0,
|
||||||
failed: 0,
|
failed: 0,
|
||||||
errors: []
|
updated: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: [],
|
||||||
|
details: []
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const item = items[i];
|
const item = items[i];
|
||||||
|
const rowNumber = i + 1;
|
||||||
|
|
||||||
try {
|
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 = {
|
const consumableData = {
|
||||||
consumableId: item.耗材ID || item.consumableId || `CON${Date.now()}${i}`,
|
consumableId: consumableId || `CON${Date.now()}${i}`,
|
||||||
name: item.名称 || item.name,
|
name,
|
||||||
category: item.分类 || item.category,
|
category,
|
||||||
unit: item.单位 || item.unit || '个',
|
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,
|
minStock: parseInt(item.最小库存 || item.minStock) || 10,
|
||||||
maxStock: parseInt(item.最大库存 || item.maxStock) || 100,
|
maxStock: parseInt(item.最大库存 || item.maxStock) || 0,
|
||||||
unitPrice: parseFloat(item.单价 || item.unitPrice) || 0,
|
unitPrice: parseFloat(item.单价 || item.unitPrice) || 0,
|
||||||
supplier: item.供应商 || item.supplier || '',
|
supplier: item.供应商 || item.supplier || '',
|
||||||
location: item.存放位置 || item.location || '',
|
location: item.存放位置 || item.location || '',
|
||||||
description: item.描述 || item.description || '',
|
description: item.描述 || item.description || '',
|
||||||
status: item.状态 || item.status || 'active'
|
status: item.状态 || item.status || 'active',
|
||||||
|
snList
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!consumableData.name || !consumableData.category) {
|
let existingConsumable = null;
|
||||||
results.failed++;
|
if (consumableId) {
|
||||||
results.errors.push(`第 ${i + 1} 行: 名称和分类为必填项`);
|
existingConsumable = await Consumable.findByPk(consumableId, { transaction });
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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({
|
await ConsumableLog.create({
|
||||||
consumableId: consumable.consumableId,
|
consumableId: consumable.consumableId,
|
||||||
consumableName: consumable.name,
|
consumableName: consumable.name,
|
||||||
operationType: 'import',
|
operationType,
|
||||||
quantity: consumable.currentStock,
|
quantity: consumable.currentStock,
|
||||||
previousStock: 0,
|
previousStock,
|
||||||
currentStock: consumable.currentStock,
|
currentStock: consumable.currentStock,
|
||||||
operator,
|
operator,
|
||||||
reason: '批量导入',
|
reason: '批量导入',
|
||||||
notes: '',
|
notes: existingConsumable ? '更新现有耗材' : '',
|
||||||
consumableSnapshot: {
|
consumableSnapshot: {
|
||||||
category: consumable.category,
|
category: consumable.category,
|
||||||
unit: consumable.unit,
|
unit: consumable.unit,
|
||||||
@@ -167,16 +218,16 @@ router.post('/import', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
|
|
||||||
results.success++;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.failed++;
|
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();
|
await transaction.commit();
|
||||||
res.json({
|
res.json({
|
||||||
message: `导入完成,成功 ${results.success} 条,失败 ${results.failed} 条`,
|
message: `导入完成,成功 ${results.success} 条,更新 ${results.updated} 条,跳过 ${results.skipped} 条,失败 ${results.failed} 条`,
|
||||||
results
|
results
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -872,9 +923,13 @@ router.put('/:id', async (req, res) => {
|
|||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
return res.status(404).json({ error: '耗材不存在' });
|
return res.status(404).json({ error: '耗材不存在' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldData = consumable.toJSON();
|
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({
|
await ConsumableLog.create({
|
||||||
consumableId: consumable.consumableId,
|
consumableId: consumable.consumableId,
|
||||||
|
|||||||
@@ -1798,6 +1798,7 @@ router.put('/:deviceId/to-idle', async (req, res) => {
|
|||||||
|
|
||||||
await device.update({
|
await device.update({
|
||||||
isIdle: true,
|
isIdle: true,
|
||||||
|
status: 'idle',
|
||||||
idleDate: new Date(),
|
idleDate: new Date(),
|
||||||
idleReason: idleReason || `从设备管理转入`
|
idleReason: idleReason || `从设备管理转入`
|
||||||
}, { transaction: t });
|
}, { transaction: t });
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ router.post('/from-device/:deviceId', async (req, res) => {
|
|||||||
|
|
||||||
await device.update({
|
await device.update({
|
||||||
isIdle: true,
|
isIdle: true,
|
||||||
|
status: 'idle',
|
||||||
idleDate: new Date(),
|
idleDate: new Date(),
|
||||||
idleReason: idleReason || `从设备管理转入`,
|
idleReason: idleReason || `从设备管理转入`,
|
||||||
sourceType: 'rack'
|
sourceType: 'rack'
|
||||||
@@ -230,6 +231,7 @@ router.post('/batch-from-devices', async (req, res) => {
|
|||||||
await Device.update(
|
await Device.update(
|
||||||
{
|
{
|
||||||
isIdle: true,
|
isIdle: true,
|
||||||
|
status: 'idle',
|
||||||
idleDate: new Date(),
|
idleDate: new Date(),
|
||||||
idleReason: idleReason || `批量转入`
|
idleReason: idleReason || `批量转入`
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ router.get('/', async (req, res) => {
|
|||||||
const rackIds = racks.map(r => r.rackId);
|
const rackIds = racks.map(r => r.rackId);
|
||||||
const devices = await Device.findAll({
|
const devices = await Device.findAll({
|
||||||
where: { rackId: rackIds },
|
where: { rackId: rackIds },
|
||||||
attributes: ['deviceId', 'rackId', 'name', 'powerConsumption']
|
attributes: ['deviceId', 'rackId', 'name', 'powerConsumption', 'height']
|
||||||
});
|
});
|
||||||
|
|
||||||
// 将设备信息关联到对应的机柜
|
// 将设备信息关联到对应的机柜
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const Joi = require('joi');
|
const Joi = require('joi');
|
||||||
|
|
||||||
const DEVICE_TYPES = ['server', 'switch', 'router', 'storage', 'other'];
|
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({
|
const createDeviceSchema = Joi.object({
|
||||||
name: Joi.string().required().max(100).messages({
|
name: Joi.string().required().max(100).messages({
|
||||||
|
|||||||
@@ -469,12 +469,15 @@ function DeviceDetailDrawer({
|
|||||||
{customFieldEntries.length > 0 && (
|
{customFieldEntries.length > 0 && (
|
||||||
<Card title="自定义字段" size="small" style={{ marginBottom: 16 }}>
|
<Card title="自定义字段" size="small" style={{ marginBottom: 16 }}>
|
||||||
<Row gutter={[24, 16]}>
|
<Row gutter={[24, 16]}>
|
||||||
{customFieldEntries.map(([key, value]) => (
|
{customFieldEntries.map(([key, value]) => {
|
||||||
<Col span={8} key={key}>
|
const fieldLabel = tooltipFields?.[key]?.label || key;
|
||||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: 4 }}>{key}</div>
|
return (
|
||||||
<div style={{ fontWeight: 500 }}>{String(value)}</div>
|
<Col span={8} key={key}>
|
||||||
</Col>
|
<div style={{ color: '#666', fontSize: '12px', marginBottom: 4 }}>{fieldLabel}</div>
|
||||||
))}
|
<div style={{ fontWeight: 500 }}>{String(value)}</div>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ const DeviceDetailModal = ({
|
|||||||
</Col>
|
</Col>
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>功率</div>
|
<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>
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>状态</div>
|
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>状态</div>
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ export const DEFAULT_DEVICE_FIELDS = [
|
|||||||
{ value: 'maintenance', label: '维护中' },
|
{ value: 'maintenance', label: '维护中' },
|
||||||
{ value: 'offline', label: '离线' },
|
{ value: 'offline', label: '离线' },
|
||||||
{ value: 'fault', label: '故障' },
|
{ value: 'fault', label: '故障' },
|
||||||
|
{ value: 'idle', label: '空闲' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -240,6 +241,7 @@ export const DEVICE_STATUS_OPTIONS = [
|
|||||||
{ value: 'maintenance', label: '维护中' },
|
{ value: 'maintenance', label: '维护中' },
|
||||||
{ value: 'offline', label: '离线' },
|
{ value: 'offline', label: '离线' },
|
||||||
{ value: 'fault', label: '故障' },
|
{ value: 'fault', label: '故障' },
|
||||||
|
{ value: 'idle', label: '空闲' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 表格列宽配置
|
// 表格列宽配置
|
||||||
@@ -283,6 +285,7 @@ export const STATUS_MAP = {
|
|||||||
maintenance: { text: '维护中', color: 'orange' },
|
maintenance: { text: '维护中', color: 'orange' },
|
||||||
offline: { text: '离线', color: 'gray' },
|
offline: { text: '离线', color: 'gray' },
|
||||||
fault: { text: '故障', color: 'red' },
|
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 {
|
import {
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Dropdown,
|
Dropdown,
|
||||||
Menu,
|
Menu,
|
||||||
Statistic,
|
Statistic,
|
||||||
|
Switch,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PieChartOutlined,
|
PieChartOutlined,
|
||||||
@@ -577,25 +578,29 @@ const CategoryCard = styled(motion.div)`
|
|||||||
const StyledTable = styled(Table)`
|
const StyledTable = styled(Table)`
|
||||||
.ant-table {
|
.ant-table {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-table-thead > tr > th {
|
.ant-table-thead > tr > th {
|
||||||
background: linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%);
|
background: linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: ${designTokens.colors.text.secondary};
|
color: ${designTokens.colors.text.secondary};
|
||||||
border-bottom: 1px solid ${designTokens.colors.border};
|
border-bottom: 1px solid ${designTokens.colors.border};
|
||||||
padding: 12px 16px;
|
padding: 10px 12px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-table-tbody > tr > td {
|
.ant-table-tbody > tr > td {
|
||||||
padding: 14px 16px;
|
padding: 8px 12px;
|
||||||
border-bottom: 1px solid ${designTokens.colors.border}40;
|
border-bottom: 1px solid ${designTokens.colors.border}40;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-table-tbody > tr {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
.ant-table-tbody > tr:hover > td {
|
.ant-table-tbody > tr:hover > td {
|
||||||
background: rgba(99, 102, 241, 0.03);
|
background: rgba(99, 102, 241, 0.03);
|
||||||
}
|
}
|
||||||
@@ -603,6 +608,13 @@ const StyledTable = styled(Table)`
|
|||||||
.ant-table-wrapper {
|
.ant-table-wrapper {
|
||||||
border-radius: 0 0 16px 16px;
|
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`
|
const ProgressBar = styled.div`
|
||||||
@@ -664,6 +676,10 @@ const LoadingOverlay = styled.div`
|
|||||||
|
|
||||||
const ConsumableStatistics = () => {
|
const ConsumableStatistics = () => {
|
||||||
const [loading, setLoading] = useState(true);
|
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({
|
const [stats, setStats] = useState({
|
||||||
inCount: 0,
|
inCount: 0,
|
||||||
outCount: 0,
|
outCount: 0,
|
||||||
@@ -678,6 +694,7 @@ const ConsumableStatistics = () => {
|
|||||||
byCategory: [],
|
byCategory: [],
|
||||||
});
|
});
|
||||||
const [lowStockItems, setLowStockItems] = useState([]);
|
const [lowStockItems, setLowStockItems] = useState([]);
|
||||||
|
const [lowStockPagination, setLowStockPagination] = useState({ current: 1, pageSize: 5 });
|
||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState([]);
|
||||||
const [dateRange, setDateRange] = useState([dayjs().subtract(30, 'days'), dayjs()]);
|
const [dateRange, setDateRange] = useState([dayjs().subtract(30, 'days'), dayjs()]);
|
||||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||||
@@ -700,9 +717,11 @@ const ConsumableStatistics = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadStatistics = async () => {
|
const loadStatistics = async (isAuto = false) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
if (!isAuto) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
const params = {
|
const params = {
|
||||||
startDate: dateRange[0]?.format('YYYY-MM-DD'),
|
startDate: dateRange[0]?.format('YYYY-MM-DD'),
|
||||||
endDate: dateRange[1]?.format('YYYY-MM-DD'),
|
endDate: dateRange[1]?.format('YYYY-MM-DD'),
|
||||||
@@ -730,18 +749,27 @@ const ConsumableStatistics = () => {
|
|||||||
totalValue: summaryResponse?.totalValue || 0,
|
totalValue: summaryResponse?.totalValue || 0,
|
||||||
byCategory: summaryResponse?.byCategory || [],
|
byCategory: summaryResponse?.byCategory || [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isAuto) {
|
||||||
|
setLastUpdateTime(new Date());
|
||||||
|
setIsAutoRefreshing(false);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = error?.message || error || '未知错误';
|
const errorMsg = error?.message || error || '未知错误';
|
||||||
message.error('加载统计数据失败: ' + errorMsg);
|
if (!isAuto) {
|
||||||
|
message.error('加载统计数据失败: ' + errorMsg);
|
||||||
|
}
|
||||||
console.error('加载统计数据失败:', error);
|
console.error('加载统计数据失败:', error);
|
||||||
setStats({ inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0, recentRecords: [] });
|
setStats({ inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0, recentRecords: [] });
|
||||||
setSummary({ total: 0, lowStock: 0, totalValue: 0, byCategory: [] });
|
setSummary({ total: 0, lowStock: 0, totalValue: 0, byCategory: [] });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (!isAuto) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadLowStockItems = async () => {
|
const loadLowStockItems = async (isAuto = false) => {
|
||||||
try {
|
try {
|
||||||
const response = await consumableAPI.getLowStock();
|
const response = await consumableAPI.getLowStock();
|
||||||
console.log('[低库存] 返回:', response);
|
console.log('[低库存] 返回:', response);
|
||||||
@@ -758,6 +786,28 @@ const ConsumableStatistics = () => {
|
|||||||
loadLowStockItems();
|
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) => {
|
const handleQuickFilter = (key) => {
|
||||||
setQuickFilter(key);
|
setQuickFilter(key);
|
||||||
const filter = quickFilters.find(f => f.key === key);
|
const filter = quickFilters.find(f => f.key === key);
|
||||||
@@ -771,9 +821,17 @@ const ConsumableStatistics = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
loadStatistics();
|
setLoading(true);
|
||||||
loadLowStockItems();
|
setIsAutoRefreshing(false);
|
||||||
message.success('数据已刷新');
|
Promise.all([
|
||||||
|
loadStatistics(false),
|
||||||
|
loadLowStockItems(false)
|
||||||
|
]).finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
if (!realTimeRefresh) {
|
||||||
|
message.success('数据已手动刷新');
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
@@ -808,80 +866,109 @@ const ConsumableStatistics = () => {
|
|||||||
title: '耗材名称',
|
title: '耗材名称',
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
key: 'name',
|
key: 'name',
|
||||||
|
width: '35%',
|
||||||
render: (text, record) => (
|
render: (text, record) => (
|
||||||
<Space>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
size={34}
|
size={28}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${designTokens.colors.warning.main}, #fb923c)`,
|
background: `linear-gradient(135deg, ${designTokens.colors.warning.main}, #fb923c)`,
|
||||||
fontSize: '14px',
|
fontSize: '12px',
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<WarningOutlined />
|
<WarningOutlined />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ fontWeight: 600, color: designTokens.colors.text.primary, fontSize: '14px' }}>
|
<div style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: designTokens.colors.text.primary,
|
||||||
|
fontSize: '13px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}>
|
||||||
{text}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '12px', color: designTokens.colors.text.secondary }}>
|
<div style={{
|
||||||
{record.specification || '-'}
|
fontSize: '11px',
|
||||||
|
color: designTokens.colors.text.secondary,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}>
|
||||||
|
{record.specification || record.category || '-'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '当前库存',
|
title: '库存状态',
|
||||||
dataIndex: 'currentStock',
|
key: 'stockStatus',
|
||||||
key: 'currentStock',
|
width: '40%',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 100,
|
render: (_, record) => {
|
||||||
render: (currentStock, record) => (
|
const current = record.currentStock || 0;
|
||||||
<Text strong style={{ fontSize: '15px', color: designTokens.colors.error.main }}>
|
const min = record.minStock || 0;
|
||||||
{currentStock} {record.unit}
|
const isLow = current < min;
|
||||||
</Text>
|
return (
|
||||||
),
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}>
|
||||||
},
|
<span style={{
|
||||||
{
|
fontWeight: 700,
|
||||||
title: '安全库存',
|
fontSize: '14px',
|
||||||
dataIndex: 'minStock',
|
color: isLow ? designTokens.colors.error.main : designTokens.colors.text.primary,
|
||||||
key: 'minStock',
|
}}>
|
||||||
align: 'center',
|
{current}
|
||||||
width: 100,
|
</span>
|
||||||
render: (minStock, record) => (
|
<span style={{ color: designTokens.colors.text.secondary, fontSize: '11px' }}>/</span>
|
||||||
<Text type="secondary">{minStock} {record.unit}</Text>
|
<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: '充足率',
|
title: '充足率',
|
||||||
key: 'rate',
|
key: 'rate',
|
||||||
|
width: '25%',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 140,
|
|
||||||
render: (_, record) => {
|
render: (_, record) => {
|
||||||
const minStock = record.minStock || 0;
|
const minStock = record.minStock || 0;
|
||||||
const currentStock = record.currentStock || 0;
|
const currentStock = record.currentStock || 0;
|
||||||
|
|
||||||
if (minStock <= 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 rate = Math.min(100, Math.round((currentStock / minStock) * 100));
|
||||||
const color = rate < 50 ? designTokens.colors.error.main :
|
const color = rate < 30 ? designTokens.colors.error.main :
|
||||||
rate < 100 ? designTokens.colors.warning.main :
|
rate < 60 ? designTokens.colors.warning.main :
|
||||||
designTokens.colors.success.main;
|
designTokens.colors.success.main;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProgressBar>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
|
||||||
<div className="progress-wrapper">
|
<Progress
|
||||||
<Progress
|
percent={rate}
|
||||||
percent={rate}
|
size="small"
|
||||||
size="small"
|
strokeColor={color}
|
||||||
strokeColor={color}
|
showInfo={false}
|
||||||
showInfo={false}
|
style={{ width: 60 }}
|
||||||
/>
|
/>
|
||||||
<span className="progress-text" style={{ color }}>{rate}%</span>
|
<span style={{
|
||||||
</div>
|
fontWeight: 600,
|
||||||
</ProgressBar>
|
fontSize: '12px',
|
||||||
|
color,
|
||||||
|
minWidth: 32,
|
||||||
|
textAlign: 'right',
|
||||||
|
}}>
|
||||||
|
{rate}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -998,6 +1085,28 @@ const ConsumableStatistics = () => {
|
|||||||
</div>
|
</div>
|
||||||
</TitleSection>
|
</TitleSection>
|
||||||
<Space>
|
<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
|
<Button
|
||||||
icon={<ExportOutlined />}
|
icon={<ExportOutlined />}
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
@@ -1011,9 +1120,9 @@ const ConsumableStatistics = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<ReloadOutlined />}
|
icon={<ReloadOutlined spin={isAutoRefreshing} />}
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
loading={loading}
|
loading={loading && !isAutoRefreshing}
|
||||||
style={{
|
style={{
|
||||||
height: 38,
|
height: 38,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
@@ -1346,9 +1455,17 @@ const ConsumableStatistics = () => {
|
|||||||
columns={lowStockColumns}
|
columns={lowStockColumns}
|
||||||
dataSource={lowStockItems}
|
dataSource={lowStockItems}
|
||||||
rowKey="consumableId"
|
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"
|
size="small"
|
||||||
scroll={{ x: 'max-content' }}
|
scroll={{ x: 'max-content', y: 300 }}
|
||||||
locale={{
|
locale={{
|
||||||
emptyText: (
|
emptyText: (
|
||||||
<EmptyState>
|
<EmptyState>
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import {
|
|||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
|
AppstoreOutlined,
|
||||||
|
ToolOutlined,
|
||||||
|
EnvironmentOutlined,
|
||||||
|
FilterOutlined,
|
||||||
|
UnorderedListOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
@@ -89,7 +94,6 @@ function DeviceManagement() {
|
|||||||
const [type, setType] = useState('all');
|
const [type, setType] = useState('all');
|
||||||
const [roomId, setRoomId] = useState('all');
|
const [roomId, setRoomId] = useState('all');
|
||||||
const [rackId, setRackId] = useState('all');
|
const [rackId, setRackId] = useState('all');
|
||||||
const [isIdle, setIsIdle] = useState('');
|
|
||||||
const [searchForm] = Form.useForm();
|
const [searchForm] = Form.useForm();
|
||||||
|
|
||||||
const [pagination, setPagination] = useState({
|
const [pagination, setPagination] = useState({
|
||||||
@@ -122,6 +126,7 @@ function DeviceManagement() {
|
|||||||
const [selectedDevices, setSelectedDevices] = useState([]);
|
const [selectedDevices, setSelectedDevices] = useState([]);
|
||||||
const [selectAll, setSelectAll] = useState(false);
|
const [selectAll, setSelectAll] = useState(false);
|
||||||
const [columnWidths, setColumnWidths] = useState({});
|
const [columnWidths, setColumnWidths] = useState({});
|
||||||
|
const [advancedSearchVisible, setAdvancedSearchVisible] = useState(false);
|
||||||
|
|
||||||
const debouncedKeyword = useDebounce(keyword, DEBOUNCE_DELAY);
|
const debouncedKeyword = useDebounce(keyword, DEBOUNCE_DELAY);
|
||||||
|
|
||||||
@@ -138,7 +143,6 @@ function DeviceManagement() {
|
|||||||
type: type !== 'all' ? type : undefined,
|
type: type !== 'all' ? type : undefined,
|
||||||
roomId: roomId !== 'all' ? roomId : undefined,
|
roomId: roomId !== 'all' ? roomId : undefined,
|
||||||
rackId: rackId !== 'all' ? rackId : undefined,
|
rackId: rackId !== 'all' ? rackId : undefined,
|
||||||
isIdle: isIdle || undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await axios.get('/api/devices', { params });
|
const response = await axios.get('/api/devices', { params });
|
||||||
@@ -293,7 +297,6 @@ function DeviceManagement() {
|
|||||||
setType(values.type || 'all');
|
setType(values.type || 'all');
|
||||||
setRoomId(values.roomId || 'all');
|
setRoomId(values.roomId || 'all');
|
||||||
setRackId(values.rackId || 'all');
|
setRackId(values.rackId || 'all');
|
||||||
setIsIdle(values.isIdle || '');
|
|
||||||
|
|
||||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||||
|
|
||||||
@@ -308,7 +311,6 @@ function DeviceManagement() {
|
|||||||
setType('all');
|
setType('all');
|
||||||
setRoomId('all');
|
setRoomId('all');
|
||||||
setRackId('all');
|
setRackId('all');
|
||||||
setIsIdle('');
|
|
||||||
searchForm.resetFields();
|
searchForm.resetFields();
|
||||||
|
|
||||||
setTimeout(() => setSearching(false), 300);
|
setTimeout(() => setSearching(false), 300);
|
||||||
@@ -664,7 +666,7 @@ function DeviceManagement() {
|
|||||||
title: field.displayName,
|
title: field.displayName,
|
||||||
dataIndex: field.fieldName,
|
dataIndex: field.fieldName,
|
||||||
key: field.fieldName,
|
key: field.fieldName,
|
||||||
width: columnWidths[field.fieldName] || 100,
|
width: columnWidths[field.fieldName] || 90,
|
||||||
onHeaderCell: handleHeaderCellResize(field.fieldName),
|
onHeaderCell: handleHeaderCellResize(field.fieldName),
|
||||||
render: (status) => {
|
render: (status) => {
|
||||||
const config = STATUS_MAP[status] || { text: status, color: 'default' };
|
const config = STATUS_MAP[status] || { text: status, color: 'default' };
|
||||||
@@ -673,6 +675,7 @@ function DeviceManagement() {
|
|||||||
maintenance: { bg: '#fffbE6', border: '#faad14', text: '#d48806' },
|
maintenance: { bg: '#fffbE6', border: '#faad14', text: '#d48806' },
|
||||||
offline: { bg: '#f5f5f5', border: '#8c8c8c', text: '#595959' },
|
offline: { bg: '#f5f5f5', border: '#8c8c8c', text: '#595959' },
|
||||||
fault: { bg: '#fff2f0', border: '#ff4d4f', text: '#cf1322' },
|
fault: { bg: '#fff2f0', border: '#ff4d4f', text: '#cf1322' },
|
||||||
|
idle: { bg: '#E6FFFA', border: '#36cfc9', text: '#08979d' },
|
||||||
};
|
};
|
||||||
const style = statusStyles[status] || { bg: '#fafafa', border: '#d9d9d9', text: '#595959' };
|
const style = statusStyles[status] || { bg: '#fafafa', border: '#d9d9d9', text: '#595959' };
|
||||||
return (
|
return (
|
||||||
@@ -684,6 +687,10 @@ function DeviceManagement() {
|
|||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
boxShadow: `0 1px 2px ${style.border}30`,
|
boxShadow: `0 1px 2px ${style.border}30`,
|
||||||
|
minWidth: '80px',
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '2px 8px',
|
||||||
|
display: 'block',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{config.text}
|
{config.text}
|
||||||
@@ -927,9 +934,8 @@ function DeviceManagement() {
|
|||||||
body: {
|
body: {
|
||||||
padding: `${designTokens.spacing.md}px ${designTokens.spacing.lg}px`,
|
padding: `${designTokens.spacing.md}px ${designTokens.spacing.lg}px`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
flexDirection: 'column',
|
||||||
gap: designTokens.spacing.md,
|
gap: designTokens.spacing.md,
|
||||||
flexWrap: 'wrap',
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -937,144 +943,196 @@ function DeviceManagement() {
|
|||||||
form={searchForm}
|
form={searchForm}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
onFinish={handleSearch}
|
onFinish={handleSearch}
|
||||||
style={{ flex: 1, display: 'flex', flexWrap: 'wrap', gap: designTokens.spacing.md }}
|
style={{ width: '100%' }}
|
||||||
className="filter-form"
|
className="filter-form"
|
||||||
>
|
>
|
||||||
<Form.Item name="keyword" style={{ margin: 0 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: designTokens.spacing.md, alignItems: 'center', width: '100%' }}>
|
||||||
<Input
|
<div style={{
|
||||||
placeholder="搜索设备..."
|
display: 'flex',
|
||||||
prefix={<SearchOutlined style={{ color: designTokens.colors.primary.main }} />}
|
alignItems: 'center',
|
||||||
style={{
|
gap: designTokens.spacing.sm,
|
||||||
width: '280px',
|
padding: '0 12px',
|
||||||
borderRadius: designTokens.borderRadius.medium,
|
borderRight: `1px solid ${designTokens.colors.border.light}`,
|
||||||
border: `1px solid ${designTokens.colors.border.light}`,
|
marginRight: 4,
|
||||||
transition: `all ${designTokens.transitions.fast}`,
|
}}>
|
||||||
}}
|
<FilterOutlined style={{ color: designTokens.colors.primary.main, fontSize: '16px' }} />
|
||||||
value={keyword}
|
<span style={{ fontSize: '13px', fontWeight: 500, color: designTokens.colors.text.secondary, whiteSpace: 'nowrap' }}>筛选</span>
|
||||||
onChange={(e) => setKeyword(e.target.value)}
|
</div>
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="status" style={{ margin: 0 }}>
|
<Form.Item name="keyword" style={{ margin: 0 }}>
|
||||||
<Select
|
<Input
|
||||||
value={status}
|
placeholder="搜索设备名称、型号、序列号..."
|
||||||
onChange={setStatus}
|
prefix={<SearchOutlined style={{ color: designTokens.colors.primary.main }} />}
|
||||||
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"
|
|
||||||
style={{
|
style={{
|
||||||
height: '36px',
|
width: '280px',
|
||||||
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,
|
borderRadius: designTokens.borderRadius.medium,
|
||||||
border: `1px solid ${designTokens.colors.border.light}`,
|
border: `1px solid ${designTokens.colors.border.light}`,
|
||||||
|
transition: `all ${designTokens.transitions.fast}`,
|
||||||
}}
|
}}
|
||||||
onClick={handleReset}
|
value={keyword}
|
||||||
>
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
重置
|
allowClear
|
||||||
</Button>
|
/>
|
||||||
</Space>
|
</Form.Item>
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: designTokens.spacing.sm }}>
|
<Form.Item name="status" style={{ margin: 0 }}>
|
||||||
<Button
|
<Select
|
||||||
icon={<ReloadOutlined />}
|
value={status}
|
||||||
onClick={() => fetchDevices(1, pagination.pageSize, true)}
|
onChange={setStatus}
|
||||||
style={{
|
style={{ width: '140px', borderRadius: designTokens.borderRadius.medium }}
|
||||||
borderRadius: designTokens.borderRadius.medium,
|
suffixIcon={<ToolOutlined style={{ color: '#10b981' }} />}
|
||||||
border: `1px solid ${designTokens.colors.border.light}`,
|
>
|
||||||
height: '36px',
|
<Option value="all">所有状态</Option>
|
||||||
}}
|
<Option value="running">运行中</Option>
|
||||||
>
|
<Option value="maintenance">维护中</Option>
|
||||||
刷新
|
<Option value="offline">离线</Option>
|
||||||
</Button>
|
<Option value="fault">故障</Option>
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<Card style={cardStyle}>
|
<Card style={cardStyle}>
|
||||||
|
|||||||
@@ -151,10 +151,10 @@ const PowerGauge = ({ current, max }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RackCard = ({ rack, onEdit, onDelete, onView, selected, onSelect }) => {
|
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 powerUsage = (rack.currentPower / rack.maxPower) * 100;
|
||||||
const statusInfo = statusConfig[rack.status];
|
const statusInfo = statusConfig[rack.status];
|
||||||
const availableU = rack.height - deviceCount;
|
const availableU = rack.height - usedU;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -218,7 +218,7 @@ const RackCard = ({ rack, onEdit, onDelete, onView, selected, onSelect }) => {
|
|||||||
<div
|
<div
|
||||||
style={{ fontSize: '14px', fontWeight: '600', color: designTokens.colors.text.primary }}
|
style={{ fontSize: '14px', fontWeight: '600', color: designTokens.colors.text.primary }}
|
||||||
>
|
>
|
||||||
{rack.height}U / {deviceCount}
|
{rack.height}U / {usedU}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '12px', background: '#fafafa', borderRadius: '8px' }}>
|
<div style={{ padding: '12px', background: '#fafafa', borderRadius: '8px' }}>
|
||||||
@@ -602,27 +602,14 @@ function RackManagement() {
|
|||||||
title: '高度/已用U位',
|
title: '高度/已用U位',
|
||||||
key: 'heightUsage',
|
key: 'heightUsage',
|
||||||
render: (_, record) => {
|
render: (_, record) => {
|
||||||
const used = record.Devices?.length || 0;
|
const used = record.Devices?.reduce((sum, d) => sum + (d.height || 1), 0) || 0;
|
||||||
const percentage = (used / record.height) * 100;
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Text style={{ fontSize: '13px' }}>
|
||||||
<Text style={{ fontSize: '13px' }}>{record.height}U</Text>
|
{record.height}U / 已用 {used} U位
|
||||||
<Progress
|
</Text>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
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: '功率使用',
|
title: '功率使用',
|
||||||
|
|||||||
Reference in New Issue
Block a user