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; - - - - - 请下载模板后填写数据再导入 - - - + return ( + +
+
+ +
+ + {step.label} + +
+ {index < 2 && ( +
+ )} + + ); + })} +
- false} - onChange={handleFileChange} - > - - - - {importPreview.length > 0 && ( +
+ {/* 步骤1: 上传文件 */} + {importStep === 'upload' && ( -
- 数据预览(前10条) + {/* 模板下载区域 */} +
+
+
+
+ + 下载导入模板 +
+ + 先下载标准模板,填写数据后再上传,支持 .xlsx、.xls、.csv 格式 + +
+ +
+ + + + {/* 字段说明 */} +
+
+
+ +
+ 模板字段说明 + 必填 + 可选 +
+ +
+ {[ + { 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={ + + } + 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={ + + } + 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 ( - -
- - {rate}% -
-
+
+ + + {rate}% + +
); }, }, @@ -998,6 +1085,28 @@ const ConsumableStatistics = () => {
+ {lastUpdateTime && ( +
+ {isAutoRefreshing ? ( + + ) : ( + + )} + {isAutoRefreshing ? '刷新中...' : `更新于 ${dayjs(lastUpdateTime).format('HH:mm:ss')}`} +
+ )} + +
+ 实时 + +
+
- -
- - + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + allowClear + /> + -
- -
+ + + + + + + + + + +
+ + + + + +
+
+ + {advancedSearchVisible && ( +
+
+ + 位置筛选 +
+ + + + + + + + +
+ )} + 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 - = 90 ? designTokens.colors.error.main : designTokens.colors.success.main - } - trailColor="#f0f0f0" - style={{ marginTop: '4px', marginBottom: 0 }} - /> - - 已用 {used} 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: '功率使用',