From 1b898814ed8147d75ae220be82ccef28439775f6 Mon Sep 17 00:00:00 2001
From: zhang1106 <849185023@qq.com>
Date: Tue, 24 Mar 2026 14:54:39 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=A9=BA=E9=97=B2?=
=?UTF-8?q?=E8=AE=BE=E5=A4=87=E7=8A=B6=E6=80=81=E6=94=AF=E6=8C=81=E5=B9=B6?=
=?UTF-8?q?=E4=BC=98=E5=8C=96=E8=80=97=E6=9D=90=E5=AF=BC=E5=85=A5=E5=8A=9F?=
=?UTF-8?q?=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
refactor(设备管理): 重构状态筛选和高级搜索界面
perf(机柜管理): 优化U位计算方式为基于设备高度
fix(耗材管理): 修复导入时库存计算不准确的问题
style(耗材统计): 调整表格样式和实时刷新功能
docs: 更新设备状态映射包含空闲状态
---
backend/initDeviceFields.js | 15 +-
backend/routes/consumables.js | 97 +-
backend/routes/devices.js | 1 +
backend/routes/idleDevices.js | 2 +
backend/routes/racks.js | 2 +-
backend/validation/deviceSchema.js | 2 +-
.../src/components/DeviceDetailDrawer.jsx | 15 +-
.../components/device/DeviceDetailModal.jsx | 2 +-
.../constants/deviceManagementConstants.js | 3 +
frontend/src/pages/ConsumableManagement.jsx | 1102 ++++++++++++++---
frontend/src/pages/ConsumableStatistics.jsx | 239 +++-
frontend/src/pages/DeviceManagement.jsx | 332 +++--
frontend/src/pages/RackManagement.jsx | 29 +-
13 files changed, 1385 insertions(+), 456 deletions(-)
diff --git a/backend/initDeviceFields.js b/backend/initDeviceFields.js
index 1a18640..91a5beb 100644
--- a/backend/initDeviceFields.js
+++ b/backend/initDeviceFields.js
@@ -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}`);
}
diff --git a/backend/routes/consumables.js b/backend/routes/consumables.js
index f497f40..fbd50de 100644
--- a/backend/routes/consumables.js
+++ b/backend/routes/consumables.js
@@ -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,
diff --git a/backend/routes/devices.js b/backend/routes/devices.js
index 2e0fe11..7fdd146 100644
--- a/backend/routes/devices.js
+++ b/backend/routes/devices.js
@@ -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 });
diff --git a/backend/routes/idleDevices.js b/backend/routes/idleDevices.js
index 60764e2..d4e397c 100644
--- a/backend/routes/idleDevices.js
+++ b/backend/routes/idleDevices.js
@@ -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 || `批量转入`
},
diff --git a/backend/routes/racks.js b/backend/routes/racks.js
index 3190140..de124fc 100644
--- a/backend/routes/racks.js
+++ b/backend/routes/racks.js
@@ -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']
});
// 将设备信息关联到对应的机柜
diff --git a/backend/validation/deviceSchema.js b/backend/validation/deviceSchema.js
index 2dd96d9..578c79c 100644
--- a/backend/validation/deviceSchema.js
+++ b/backend/validation/deviceSchema.js
@@ -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({
diff --git a/frontend/src/components/DeviceDetailDrawer.jsx b/frontend/src/components/DeviceDetailDrawer.jsx
index c4b58a0..d7ae24f 100644
--- a/frontend/src/components/DeviceDetailDrawer.jsx
+++ b/frontend/src/components/DeviceDetailDrawer.jsx
@@ -469,12 +469,15 @@ function DeviceDetailDrawer({
{customFieldEntries.length > 0 && (
- {customFieldEntries.map(([key, value]) => (
-
- {key}
- {String(value)}
-
- ))}
+ {customFieldEntries.map(([key, value]) => {
+ const fieldLabel = tooltipFields?.[key]?.label || key;
+ return (
+
+ {fieldLabel}
+ {String(value)}
+
+ );
+ })}
)}
diff --git a/frontend/src/components/device/DeviceDetailModal.jsx b/frontend/src/components/device/DeviceDetailModal.jsx
index a56c7b6..73754f5 100644
--- a/frontend/src/components/device/DeviceDetailModal.jsx
+++ b/frontend/src/components/device/DeviceDetailModal.jsx
@@ -163,7 +163,7 @@ const DeviceDetailModal = ({
功率
- {device.power ? `${device.power}W` : '-'}
+ {device.powerConsumption ? `${device.powerConsumption}W` : '-'}
状态
diff --git a/frontend/src/constants/deviceManagementConstants.js b/frontend/src/constants/deviceManagementConstants.js
index 81b88c6..2cfebc2 100644
--- a/frontend/src/constants/deviceManagementConstants.js
+++ b/frontend/src/constants/deviceManagementConstants.js
@@ -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' },
};
// 设备类型映射
diff --git a/frontend/src/pages/ConsumableManagement.jsx b/frontend/src/pages/ConsumableManagement.jsx
index 6af3817..2a08c3b 100644
--- a/frontend/src/pages/ConsumableManagement.jsx
+++ b/frontend/src/pages/ConsumableManagement.jsx
@@ -16,6 +16,7 @@ import {
Upload,
Progress,
Checkbox,
+ Radio,
Row,
Col,
Badge,
@@ -25,6 +26,7 @@ import {
Skeleton,
Alert,
Typography,
+ Divider,
} from 'antd';
import {
PlusOutlined,
@@ -35,6 +37,7 @@ import {
ImportOutlined,
UploadOutlined,
FileExcelOutlined,
+ FileTextOutlined,
ShoppingOutlined,
FilterOutlined,
ClearOutlined,
@@ -45,8 +48,12 @@ import {
ArrowDownOutlined,
ScanOutlined,
BarcodeOutlined,
+ DownloadOutlined,
+ WarningOutlined,
+ InfoCircleOutlined,
} from '@ant-design/icons';
import axios from 'axios';
+import * as XLSX from 'xlsx';
import { motion, AnimatePresence } from 'framer-motion';
import { designTokens } from '../config/theme';
import CloseButton from '../components/CloseButton';
@@ -113,6 +120,9 @@ function ConsumableManagement() {
const [importProgress, setImportProgress] = useState(0);
const [importPhase, setImportPhase] = useState('');
const [importResult, setImportResult] = useState(null);
+ const [importMode, setImportMode] = useState('create');
+ const [importValidationErrors, setImportValidationErrors] = useState([]);
+ const [importStep, setImportStep] = useState('upload');
const [stockModalVisible, setStockModalVisible] = useState(false);
const [stockRecord, setStockRecord] = useState(null);
const [stockType, setStockType] = useState('in');
@@ -352,55 +362,98 @@ function ConsumableManagement() {
}
};
- const parseCSV = text => {
- const lines = text.trim().split('\n');
- if (lines.length < 2) return [];
-
- const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
- const data = [];
-
- for (let i = 1; i < lines.length; i++) {
- const line = lines[i].trim();
- if (!line) continue;
-
- let values = [];
- let inQuotes = false;
- let current = '';
-
- for (let j = 0; j < line.length; j++) {
- const char = line[j];
- if (char === '"') {
- inQuotes = !inQuotes;
- } else if (char === ',' && !inQuotes) {
- values.push(current.trim().replace(/^"|"$/g, ''));
- current = '';
- } else {
- current += char;
- }
- }
- values.push(current.trim().replace(/^"|"$/g, ''));
-
- const row = {};
- headers.forEach((header, idx) => {
- row[header] = values[idx] || '';
- });
- data.push(row);
- }
-
- return data;
- };
-
- const handleFileChange = info => {
- const file = info.fileList[info.fileList.length - 1];
- if (file && file.originFileObj) {
+ const parseFile = file => {
+ return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => {
- const text = e.target.result;
- const parsedData = parseCSV(text);
- setImportPreview(parsedData.slice(0, 10));
- setImportFile(file.originFileObj);
+ try {
+ const data = new Uint8Array(e.target.result);
+ const workbook = XLSX.read(data, { type: 'array' });
+ const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
+ const jsonData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 });
+
+ if (jsonData.length < 2) {
+ resolve([]);
+ return;
+ }
+
+ const headers = jsonData[0].map(h => String(h || '').trim());
+ const result = [];
+
+ for (let i = 1; i < jsonData.length; i++) {
+ const row = jsonData[i];
+ const obj = {};
+ headers.forEach((header, idx) => {
+ obj[header] = row[idx] !== undefined ? String(row[idx] || '').trim() : '';
+ });
+ if (Object.values(obj).some(v => v)) {
+ result.push(obj);
+ }
+ }
+
+ resolve(result);
+ } catch (error) {
+ reject(error);
+ }
};
- reader.readAsText(file.originFileObj);
+ reader.onerror = () => reject(new Error('文件读取失败'));
+ reader.readAsArrayBuffer(file);
+ });
+ };
+
+ const validateImportData = (data, validCategories) => {
+ const errors = [];
+ const validCategoryNames = validCategories.map(c => c.name);
+ data.forEach((item, index) => {
+ const rowNum = index + 1;
+ const name = item['名称'] || item.name;
+ const category = item['分类'] || item.category;
+
+ if (!name) {
+ errors.push({ row: rowNum, field: '名称', message: '名称为必填项' });
+ }
+ if (!category) {
+ errors.push({ row: rowNum, field: '分类', message: '分类为必填项' });
+ } else if (validCategoryNames.length > 0 && !validCategoryNames.includes(category)) {
+ errors.push({ row: rowNum, field: '分类', message: `分类"${category}"不存在,请使用系统已有的分类` });
+ }
+ });
+ return errors;
+ };
+
+ const handleFileChange = async info => {
+ const file = info.fileList[info.fileList.length - 1];
+ if (file && file.originFileObj) {
+ try {
+ setImporting(true);
+ setImportPhase('正在解析文件...');
+
+ const parsedData = await parseFile(file.originFileObj);
+
+ if (parsedData.length === 0) {
+ message.warning('文件中没有有效数据');
+ setImporting(false);
+ return;
+ }
+
+ const validationErrors = validateImportData(parsedData, categories);
+ setImportValidationErrors(validationErrors);
+ setImportPreview(parsedData);
+ setImportFile(file.originFileObj);
+ setImportStep('preview');
+
+ if (validationErrors.length > 0) {
+ message.warning(`数据校验发现 ${validationErrors.length} 个问题,请检查预览`);
+ } else {
+ message.success(`成功解析 ${parsedData.length} 条数据`);
+ }
+ } catch (error) {
+ message.error('文件解析失败,请确保文件格式正确');
+ console.error('文件解析失败:', error);
+ } finally {
+ setImporting(false);
+ setImportPhase('');
+ }
}
};
@@ -408,6 +461,10 @@ function ConsumableManagement() {
setImportPreview([]);
setImportFile(null);
setImportModalVisible(true);
+ setImportStep('upload');
+ setImportMode('create');
+ setImportValidationErrors([]);
+ setImportResult(null);
};
const handleImportCancel = () => {
@@ -417,102 +474,108 @@ function ConsumableManagement() {
setImportProgress(0);
setImportPhase('');
setImportResult(null);
+ setImportStep('upload');
+ setImportValidationErrors([]);
};
const handleImport = async () => {
- if (!importFile) {
- message.warning('请先选择文件');
+ if (!importFile || importPreview.length === 0) {
+ message.warning('请先选择并解析文件');
return;
}
setImporting(true);
- setImportProgress(0);
- setImportPhase('正在读取文件...');
+ setImportProgress(10);
+ setImportPhase('准备导入数据...');
setImportResult(null);
try {
- const reader = new FileReader();
- reader.onload = async e => {
- const text = e.target.result;
- setImportProgress(10);
+ setImportProgress(30);
+ setImportPhase('正在提交到服务器...');
- setTimeout(() => {
- setImportProgress(20);
- setImportPhase('正在解析CSV数据...');
- }, 100);
+ const response = await axios.post('/api/consumables/import', {
+ items: importPreview,
+ mode: importMode
+ });
- const items = parseCSV(text);
- const totalItems = items.length;
+ setImportProgress(70);
+ setImportPhase('处理导入结果...');
- setTimeout(() => {
- setImportProgress(30);
- setImportPhase(`共解析 ${totalItems} 条记录,准备提交...`);
- }, 200);
-
- setTimeout(() => {
- setImportProgress(40);
- setImportPhase('正在连接服务器...');
- }, 300);
-
- const response = await axios.post('/api/consumables/import', { items });
-
- setTimeout(() => {
- setImportProgress(60);
- setImportPhase('正在处理服务器响应...');
- }, 100);
-
- setTimeout(() => {
- setImportProgress(80);
- setImportPhase('正在更新本地数据...');
- }, 200);
-
- const results = response.data.results;
-
- setTimeout(() => {
- setImportResult(results);
- setImportProgress(100);
- setImportPhase('导入完成');
- setImporting(false);
-
- if (results.failed > 0) {
- message.warning(`导入完成,成功 ${results.success} 条,失败 ${results.failed} 条`);
- } else {
- message.success({
- content: response.data.message || `成功导入 ${results.success} 条记录`,
- icon: ,
- });
- }
- fetchConsumables();
- }, 300);
- };
- reader.onerror = () => {
- setImporting(false);
- setImportProgress(0);
- setImportPhase('文件读取失败');
- message.error('文件读取失败');
- };
- reader.readAsText(importFile);
+ const results = response.data.results;
+ setImportResult(results);
+ setImportStep('result');
+ setImportProgress(100);
+ setImportPhase('导入完成');
+
+ if (results.failed > 0) {
+ message.warning(response.data.message);
+ } else {
+ message.success({
+ content: response.data.message,
+ icon: ,
+ });
+ }
+
+ fetchConsumables();
} catch (error) {
- setImporting(false);
- setImportProgress(0);
- setImportPhase('导入失败');
message.error('导入失败,请检查网络连接或服务器状态');
console.error('导入耗材失败:', error);
+ } finally {
+ setImporting(false);
}
};
const downloadTemplate = () => {
- const template =
- '耗材ID,名称,分类,单位,当前库存,最小库存,最大库存,单价,供应商,存放位置,描述,状态\n,测试耗材,办公用品,个,100,10,500,5.00,XX公司,A柜-01层,测试数据,active';
- const blob = new Blob([template], { type: 'text/csv;charset=utf-8;' });
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = '耗材导入模板.csv';
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- window.URL.revokeObjectURL(url);
+ const template = [
+ {
+ '耗材ID': '',
+ '名称': '示例耗材-网络模块',
+ '分类': '光模块',
+ '单位': '个',
+ '当前库存': 100,
+ '最小库存': 10,
+ '最大库存': 500,
+ '单价': 5.00,
+ '供应商': 'XX公司',
+ '存放位置': 'A柜-01层',
+ '描述': '测试数据',
+ 'SN序列号': 'SN001,SN002,SN003',
+ '状态': 'active'
+ }
+ ];
+
+ const ws = XLSX.utils.json_to_sheet(template);
+ const wb = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(wb, ws, '耗材导入模板');
+ XLSX.writeFile(wb, '耗材导入模板.xlsx');
+
+ message.success('模板下载成功');
+ };
+
+ const downloadFailedRecords = () => {
+ if (!importResult || !importResult.details) return;
+
+ const failedRecords = importResult.details
+ .filter(d => d.status === 'failed')
+ .map(d => {
+ const original = importPreview[d.row - 1] || {};
+ return {
+ '行号': d.row,
+ '错误原因': d.error,
+ ...original
+ };
+ });
+
+ if (failedRecords.length === 0) {
+ message.info('没有失败记录');
+ return;
+ }
+
+ const ws = XLSX.utils.json_to_sheet(failedRecords);
+ const wb = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(wb, ws, '失败记录');
+ XLSX.writeFile(wb, '耗材导入失败记录.xlsx');
+ message.success('失败记录下载成功');
};
const showStockModal = useCallback(
@@ -904,11 +967,14 @@ function ConsumableManagement() {
);
const previewColumns = [
- { title: '名称', dataIndex: '名称', key: 'name', width: 120 },
+ { title: '行号', key: 'row', width: 60, render: (_, __, index) => index + 1 },
+ { title: '耗材ID', dataIndex: '耗材ID', key: 'consumableId', width: 120 },
+ { title: '名称', dataIndex: '名称', key: 'name', width: 150 },
{ title: '分类', dataIndex: '分类', key: 'category', width: 100 },
- { title: '单位', dataIndex: '单位', key: 'unit', width: 80 },
+ { title: '单位', dataIndex: '单位', key: 'unit', width: 70 },
{ title: '当前库存', dataIndex: '当前库存', key: 'currentStock', width: 90 },
- { title: '单价', dataIndex: '单价', key: 'unitPrice', width: 80 },
+ { title: '供应商', dataIndex: '供应商', key: 'supplier', width: 120 },
+ { title: 'SN序列号', dataIndex: 'SN序列号', key: 'snList', width: 150, ellipsis: true },
];
return (
@@ -1578,7 +1644,10 @@ function ConsumableManagement() {
size="small"
danger
icon={}
- onClick={() => setSnList([])}
+ onClick={() => {
+ setSnList([]);
+ form.setFieldsValue({ currentStock: 0 });
+ }}
disabled={snList.length === 0}
style={{ borderRadius: designTokens.borderRadius.sm }}
>
@@ -1619,7 +1688,9 @@ function ConsumableManagement() {
.map(s => s.trim())
.filter(s => s && !snList.includes(s));
if (newSns.length > 0) {
- setSnList([...snList, ...newSns]);
+ const updatedSnList = [...snList, ...newSns];
+ setSnList(updatedSnList);
+ form.setFieldsValue({ currentStock: updatedSnList.length });
setSnInputValue('');
message.success(`成功添加 ${newSns.length} 个SN`);
} else {
@@ -1672,7 +1743,11 @@ function ConsumableManagement() {
>
setSnList(snList.filter((_, i) => i !== index))}
+ onClose={() => {
+ const newSnList = snList.filter((_, i) => i !== index);
+ setSnList(newSnList);
+ form.setFieldsValue({ currentStock: newSnList.length });
+ }}
color="blue"
style={{
padding: '4px 8px',
@@ -1739,104 +1814,694 @@ function ConsumableManagement() {
- {/* 导入耗材弹窗 */}
+ {/* 导入耗材弹窗 - 全新UI/UX设计 */}
+
-
+
+
+
+
批量导入耗材
+
支持 Excel/CSV 格式批量导入
-
批量导入耗材
}
open={importModalVisible}
closeIcon={}
onCancel={handleImportCancel}
footer={null}
- width={700}
+ width={920}
+ bodyStyle={{ padding: '24px' }}
+ style={{ top: 40 }}
>
-
-
+ {/* 步骤指示器 */}
+
+ {[
+ { key: 'upload', label: '上传文件', icon: UploadOutlined },
+ { key: 'preview', label: '预览确认', icon: FileTextOutlined },
+ { key: 'result', label: '完成', icon: CheckCircleOutlined },
+ ].map((step, index) => {
+ const isActive = importStep === step.key;
+ const isPast = ['upload', 'preview', 'result'].indexOf(importStep) > index;
+ const StepIcon = step.icon;
-
-
- }
- onClick={downloadTemplate}
- style={{ borderRadius: designTokens.borderRadius.sm }}
- >
- 下载模板
-
-
- 请下载模板后填写数据再导入
-
-
-
+ return (
+
+
+
+
+
+
+ {step.label}
+
+
+ {index < 2 && (
+
+ )}
+
+ );
+ })}
+
-
false}
- onChange={handleFileChange}
- >
- }
- style={{ borderRadius: designTokens.borderRadius.sm }}
- >
- 选择CSV文件
-
-
-
- {importPreview.length > 0 && (
+
+ {/* 步骤1: 上传文件 */}
+ {importStep === 'upload' && (
-
- 数据预览(前10条)
+ {/* 模板下载区域 */}
+
+
+
+
+
+ 下载导入模板
+
+
+ 先下载标准模板,填写数据后再上传,支持 .xlsx、.xls、.csv 格式
+
+
+
}
+ onClick={downloadTemplate}
+ size="large"
+ style={{
+ background: `linear-gradient(135deg, ${designTokens.colors.success.main} 0%, ${designTokens.colors.primary.main} 100%)`,
+ border: 'none',
+ borderRadius: '10px',
+ height: '44px',
+ paddingInline: '24px',
+ boxShadow: `0 4px 12px ${designTokens.colors.success.main}40`,
+ }}
+ >
+ 下载模板
+
+
+
+
+
+ {/* 字段说明 */}
+
+
+
+
+
+
模板字段说明
+
必填
+
可选
+
+
+
+ {[
+ { field: '耗材ID', desc: '留空自动生成;填写后可识别并更新现有耗材', required: false, icon: '🔑' },
+ { field: '名称', desc: '耗材名称,必填项', required: true, icon: '📝' },
+ { field: '分类', desc: '耗材分类,如"光模块"或"光纤跳线",必填', required: true, icon: '📂' },
+ { field: '单位', desc: '计量单位,如"个"、"根"、"箱",默认"个"', required: false, icon: '📏' },
+ { field: '当前库存', desc: '当前库存数量,数字类型', required: false, icon: '📦' },
+ { field: '最小库存', desc: '安全库存阈值,低于此值会触发预警', required: false, icon: '⚠️' },
+ { field: '最大库存', desc: '最大库存限制,0表示无限制', required: false, icon: '📈' },
+ { field: '单价', desc: '耗材单价,数字类型', required: false, icon: '💰' },
+ { field: '供应商', desc: '耗材供应商名称', required: false, icon: '🏭' },
+ { field: '存放位置', desc: '仓库内存放位置,如"A柜-01层"', required: false, icon: '📍' },
+ { field: '描述', desc: '耗材的详细描述或备注', required: false, icon: '📄' },
+ { field: 'SN序列号', desc: '多个SN用逗号分隔,如"SN001,SN002"', required: false, icon: '🏷️' },
+ { field: '状态', desc: '"active"启用,"inactive"停用,默认启用', required: false, icon: '✅' },
+ ].map((item, idx) => (
+
+
+ {item.icon}
+
+
+
+
+ {item.field}
+
+ {item.required && (
+
+ 必填
+
+ )}
+
+
+ {item.desc}
+
+
+
+ ))}
+
+
-
index}
- pagination={false}
- size="small"
- scroll={{ x: 500 }}
- style={{ borderRadius: designTokens.borderRadius.md }}
- />
+
+ {/* 拖拽上传区域 */}
+ false}
+ onChange={handleFileChange}
+ showUploadList={false}
+ style={{
+ borderRadius: '16px',
+ overflow: 'hidden',
+ }}
+ >
+
+
+
+
+
+ 点击或拖拽文件到此处上传
+
+
+ 支持 .xlsx、.xls、.csv 格式,文件大小不超过 10MB
+
+
+ {['.xlsx', '.xls', '.csv'].map(type => (
+
+ {type}
+
+ ))}
+
+
+
)}
+ {/* 步骤2: 预览确认 */}
+ {importStep === 'preview' && (
+
+ {/* 数据统计卡片 */}
+
+
+
{importPreview.length}
+
待导入记录
+
+
0
+ ? `linear-gradient(135deg, ${designTokens.colors.warning.main} 0%, ${designTokens.colors.error.main} 100%)`
+ : `linear-gradient(135deg, ${designTokens.colors.success.main} 0%, #52c41a 100%)`,
+ borderRadius: '12px',
+ padding: '16px 20px',
+ color: '#fff',
+ }}>
+
{importValidationErrors.length}
+
数据问题
+
+
+
+ {/* 错误提示 */}
+ {importValidationErrors.length > 0 && (
+
+ {importValidationErrors.slice(0, 5).map((err, idx) => (
+
+ 行{err.row}
+ {err.field && `[${err.field}]`} {err.message}
+
+ ))}
+ {importValidationErrors.length > 5 && (
+
+ ...还有 {importValidationErrors.length - 5} 个问题
+
+ )}
+
+ }
+ type="warning"
+ showIcon
+ icon={}
+ style={{
+ marginBottom: '20px',
+ borderRadius: '12px',
+ border: 'none',
+ background: `${designTokens.colors.warning.main}15`,
+ }}
+ />
+ )}
+
+ {/* 导入模式选择 */}
+
+
+ setImportMode(e.target.value)}
+ style={{ width: '100%' }}
+ >
+
+
+
+ 仅新增模式
+
+ 跳过已存在的耗材(根据耗材ID判断),仅创建新耗材
+
+
+
+
+
+ 更新模式
+
+ 如果耗材ID已存在则更新现有记录,不存在则创建新耗材
+
+
+
+
+
+
+
+ {/* 数据预览表格 */}
+
+ 数据预览
+
+ {importPreview.length} 条
+
+
+ }
+ extra={
+ }
+ onClick={() => setImportStep('upload')}
+ style={{ borderRadius: '8px' }}
+ >
+ 重新选择
+
+ }
+ style={{
+ borderRadius: '12px',
+ border: `1px solid ${designTokens.colors.neutral[200]}`,
+ }}
+ bodyStyle={{ padding: 0 }}
+ >
+ index}
+ pagination={{ pageSize: 5, showSizeChanger: false }}
+ size="small"
+ scroll={{ x: 900 }}
+ style={{ borderRadius: '12px' }}
+ />
+
+
+ )}
+
+ {/* 步骤3: 完成 */}
+ {importStep === 'result' && importResult && (
+
+ {/* 结果状态 */}
+
+
+ {importResult.failed === 0 ? (
+
+ ) : (
+
+ )}
+
+
+ {importResult.failed === 0 ? '导入成功!' : '导入完成(部分失败)'}
+
+
+ {importResult.success > 0 && `成功新增 ${importResult.success} 条,`}
+ {importResult.updated > 0 && `更新 ${importResult.updated} 条,`}
+ {importResult.skipped > 0 && `跳过 ${importResult.skipped} 条,`}
+ {importResult.failed > 0 && `失败 ${importResult.failed} 条`}
+
+
+
+ {/* 统计卡片 */}
+
+ {[
+ { label: '新增成功', value: importResult.success, color: designTokens.colors.success.main, bg: `${designTokens.colors.success.main}15` },
+ { label: '更新成功', value: importResult.updated, color: designTokens.colors.primary.main, bg: `${designTokens.colors.primary.main}15` },
+ { label: '跳过', value: importResult.skipped, color: designTokens.colors.neutral[500], bg: designTokens.colors.neutral[100] },
+ { label: '失败', value: importResult.failed, color: designTokens.colors.error.main, bg: `${designTokens.colors.error.main}15` },
+ ].map((stat, idx) => (
+
+
+
+
+ {stat.value}
+
+
+ {stat.label}
+
+
+
+
+ ))}
+
+
+ {/* 失败记录 */}
+ {importResult.failed > 0 && (
+
+
+
+ 失败记录
+
+ {importResult.failed} 条
+
+
+ }
+ extra={
+ }
+ onClick={downloadFailedRecords}
+ style={{ borderRadius: '8px' }}
+ >
+ 导出失败记录
+
+ }
+ style={{
+ borderRadius: '12px',
+ border: `1px solid ${designTokens.colors.error.main}30`,
+ }}
+ bodyStyle={{ padding: 0 }}
+ >
+
+ {importResult.details
+ .filter(d => d.status === 'failed')
+ .map((detail, idx) => (
+
d.status === 'failed').length - 1
+ ? `1px solid ${designTokens.colors.neutral[100]}`
+ : 'none',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '12px',
+ }}
+ >
+
+
+ {detail.row}
+
+
+
+ {detail.error}
+
+
+ ))}
+
+
+
+ )}
+
+ )}
+
+ {/* 导入中状态 */}
{importing && (
+
+
+
+
+ 正在导入数据...
+
-
+
{importPhase}
-
+
)}
+
-
-
-
+ {/* 底部按钮 */}
+ {!importing && importStep !== 'result' && (
+
+
+ {importStep === 'preview' && (
-
+ )}
-
+ )}
{/* 入库/出库弹窗 */}
@@ -2104,7 +2790,11 @@ function ConsumableManagement() {
setSnList(prev => prev.filter((_, i) => i !== index))}
+ onClose={() => {
+ const newSnList = snList.filter((_, i) => i !== index);
+ setSnList(newSnList);
+ form.setFieldsValue({ currentStock: newSnList.length });
+ }}
color="blue"
style={{ marginBottom: '4px' }}
>
diff --git a/frontend/src/pages/ConsumableStatistics.jsx b/frontend/src/pages/ConsumableStatistics.jsx
index 2c66df2..634245f 100644
--- a/frontend/src/pages/ConsumableStatistics.jsx
+++ b/frontend/src/pages/ConsumableStatistics.jsx
@@ -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) => (
-
+
-
-
+
+
{text}
-
- {record.specification || '-'}
+
+ {record.specification || record.category || '-'}
-
+
),
},
{
- title: '当前库存',
- dataIndex: 'currentStock',
- key: 'currentStock',
+ title: '库存状态',
+ key: 'stockStatus',
+ width: '40%',
align: 'center',
- width: 100,
- render: (currentStock, record) => (
-
- {currentStock} {record.unit}
-
- ),
- },
- {
- title: '安全库存',
- dataIndex: 'minStock',
- key: 'minStock',
- align: 'center',
- width: 100,
- render: (minStock, record) => (
-
{minStock} {record.unit}
- ),
+ render: (_, record) => {
+ const current = record.currentStock || 0;
+ const min = record.minStock || 0;
+ const isLow = current < min;
+ return (
+
+
+ {current}
+
+ /
+
+ {min}
+
+
+ {record.unit || '个'}
+
+
+ );
+ },
},
{
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
未设置;
+ return
未设置;
}
-
+
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 (
-
-
-
+
);
},
},
@@ -998,6 +1085,28 @@ const ConsumableStatistics = () => {
+ {lastUpdateTime && (
+
+ {isAutoRefreshing ? (
+
+ ) : (
+ ●
+ )}
+ {isAutoRefreshing ? '刷新中...' : `更新于 ${dayjs(lastUpdateTime).format('HH:mm:ss')}`}
+
+ )}
+
+
+ 实时
+
+
+
}
onClick={handleExport}
@@ -1011,9 +1120,9 @@ const ConsumableStatistics = () => {
}
+ icon={}
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: (
diff --git a/frontend/src/pages/DeviceManagement.jsx b/frontend/src/pages/DeviceManagement.jsx
index 96b1202..8d9d1d2 100644
--- a/frontend/src/pages/DeviceManagement.jsx
+++ b/frontend/src/pages/DeviceManagement.jsx
@@ -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"
>
-
- }
- 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)}
- />
-
+
+
+
+ 筛选
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ value={keyword}
+ onChange={(e) => setKeyword(e.target.value)}
+ allowClear
+ />
+
-
- }
- onClick={() => fetchDevices(1, pagination.pageSize, true)}
- style={{
- borderRadius: designTokens.borderRadius.medium,
- border: `1px solid ${designTokens.colors.border.light}`,
- height: '36px',
- }}
- >
- 刷新
-
-
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
:
}
+ 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 ? '▲' : '▼'}
+
+
+
+
+ }
+ htmlType="submit"
+ loading={searching}
+ >
+ 搜索
+
+
+
+ }
+ onClick={() => fetchDevices(1, pagination.pageSize, true)}
+ style={{
+ borderRadius: designTokens.borderRadius.medium,
+ border: `1px solid ${designTokens.colors.border.light}`,
+ height: '36px',
+ }}
+ >
+ 刷新
+
+
+
+
+ {advancedSearchVisible && (
+
+
+
+ 位置筛选
+
+
+
+
+
+
+
+ }
+ >
+
+ {racks
+ .filter((rack) => roomId === 'all' || rack.roomId === roomId)
+ .map((rack) => (
+
+ ))}
+
+
+
+ )}
+
diff --git a/frontend/src/pages/RackManagement.jsx b/frontend/src/pages/RackManagement.jsx
index b668d4d..1f39068 100644
--- a/frontend/src/pages/RackManagement.jsx
+++ b/frontend/src/pages/RackManagement.jsx
@@ -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 (
{
- {rack.height}U / {deviceCount}
+ {rack.height}U / {usedU}
@@ -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 (
-
-
{record.height}U
-
+
+ {record.height}U / 已用 {used} U位
+
);
},
- 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: '功率使用',