From ff543eba30839454d0e68e631e7ceb4925e69b0f Mon Sep 17 00:00:00 2001 From: zhang1106 <849185023@qq.com> Date: Thu, 26 Feb 2026 11:24:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=B5=84=E4=BA=A7=E7=9B=98=E7=82=B9):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=B5=84=E4=BA=A7=E7=9B=98=E7=82=B9=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/db.js | 2 +- backend/models/InventoryPlan.js | 104 ++ backend/models/InventoryRecord.js | 100 ++ backend/models/InventoryTask.js | 81 ++ backend/routes/inventory.js | 520 ++++++++ backend/server.js | 16 + frontend/src/App.jsx | 30 + frontend/src/pages/CategoryManagement.jsx | 5 +- frontend/src/pages/ConsumableLogs.jsx | 15 +- frontend/src/pages/InventoryManagement.jsx | 716 +++++++++++ frontend/src/pages/InventoryTaskExecution.jsx | 1044 +++++++++++++++++ 11 files changed, 2625 insertions(+), 8 deletions(-) create mode 100644 backend/models/InventoryPlan.js create mode 100644 backend/models/InventoryRecord.js create mode 100644 backend/models/InventoryTask.js create mode 100644 backend/routes/inventory.js create mode 100644 frontend/src/pages/InventoryManagement.jsx create mode 100644 frontend/src/pages/InventoryTaskExecution.jsx diff --git a/backend/db.js b/backend/db.js index 9fcb92b..ce1924e 100644 --- a/backend/db.js +++ b/backend/db.js @@ -32,7 +32,7 @@ if (DB_TYPE === 'mysql') { sequelize = new Sequelize({ dialect: 'sqlite', storage: process.env.DB_PATH || './idc_management.db', - logging: process.env.NODE_ENV === 'development' ? console.log : false, + logging: console.log, // SQLite 连接池配置 pool: { max: 5, diff --git a/backend/models/InventoryPlan.js b/backend/models/InventoryPlan.js new file mode 100644 index 0000000..dd08e29 --- /dev/null +++ b/backend/models/InventoryPlan.js @@ -0,0 +1,104 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); +const User = require('./User'); + +const InventoryPlan = sequelize.define('InventoryPlan', { + planId: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false, + unique: true + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + type: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'full', + comment: 'full:全面盘点, partial:局部盘点, sample:抽样盘点' + }, + description: { + type: DataTypes.TEXT, + allowNull: true + }, + status: { + type: DataTypes.STRING, + defaultValue: 'draft', + comment: 'draft:草稿, pending:待执行, in_progress:进行中, completed:已完成, cancelled:已取消' + }, + scheduledDate: { + type: DataTypes.DATE, + allowNull: true + }, + completedDate: { + type: DataTypes.DATE, + allowNull: true + }, + targetRooms: { + type: DataTypes.JSON, + defaultValue: [], + comment: '目标机房ID列表' + }, + targetRacks: { + type: DataTypes.JSON, + defaultValue: [], + comment: '目标机柜ID列表' + }, + totalDevices: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '盘点设备总数' + }, + checkedDevices: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '已盘点设备数' + }, + normalDevices: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '正常设备数' + }, + abnormalDevices: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '异常设备数' + }, + missedDevices: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '漏盘设备数' + }, + extraDevices: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '多出设备数' + }, + createdBy: { + type: DataTypes.STRING, + allowNull: true, + references: { + model: User, + key: 'userId' + } + }, + remark: { + type: DataTypes.TEXT, + allowNull: true + } +}, { + tableName: 'inventory_plans', + timestamps: true, + indexes: [ + { fields: ['status'] }, + { fields: ['scheduledDate'] }, + { fields: ['createdAt'] } + ] +}); + +InventoryPlan.belongsTo(User, { foreignKey: 'createdBy', as: 'Creator' }); +User.hasMany(InventoryPlan, { foreignKey: 'createdBy' }); + +module.exports = InventoryPlan; diff --git a/backend/models/InventoryRecord.js b/backend/models/InventoryRecord.js new file mode 100644 index 0000000..0fcc967 --- /dev/null +++ b/backend/models/InventoryRecord.js @@ -0,0 +1,100 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +const InventoryRecord = sequelize.define('InventoryRecord', { + recordId: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false, + unique: true + }, + taskId: { + type: DataTypes.STRING, + allowNull: false + }, + planId: { + type: DataTypes.STRING, + allowNull: false + }, + deviceId: { + type: DataTypes.STRING, + allowNull: false + }, + deviceName: { + type: DataTypes.STRING, + allowNull: true + }, + deviceType: { + type: DataTypes.STRING, + allowNull: true + }, + serialNumber: { + type: DataTypes.STRING, + allowNull: true, + comment: '系统记录的序列号' + }, + actualSerialNumber: { + type: DataTypes.STRING, + allowNull: true, + comment: '实际盘点序列号' + }, + rackId: { + type: DataTypes.STRING, + allowNull: true, + comment: '系统记录的机柜' + }, + actualRackId: { + type: DataTypes.STRING, + allowNull: true, + comment: '实际盘点机柜' + }, + position: { + type: DataTypes.INTEGER, + allowNull: true, + comment: '系统记录的位置' + }, + actualPosition: { + type: DataTypes.INTEGER, + allowNull: true, + comment: '实际盘点位置' + }, + status: { + type: DataTypes.STRING, + defaultValue: 'pending', + comment: 'pending:待盘点, normal:正常, abnormal:异常, missed:未盘点, not_found:未找到' + }, + abnormalType: { + type: DataTypes.STRING, + allowNull: true, + comment: 'serial_mismatch:序列号不符, position_mismatch:位置不符, device_missing:设备缺失, extra_device:多出设备' + }, + checkedBy: { + type: DataTypes.STRING, + allowNull: true + }, + checkedAt: { + type: DataTypes.DATE, + allowNull: true + }, + remark: { + type: DataTypes.TEXT, + allowNull: true + }, + photoUrl: { + type: DataTypes.STRING, + allowNull: true, + comment: '盘点照片' + } +}, { + tableName: 'inventory_records', + timestamps: true, + indexes: [ + { fields: ['taskId'] }, + { fields: ['planId'] }, + { fields: ['deviceId'] }, + { fields: ['status'] }, + { fields: ['checkedBy'] } + ] +}); + +module.exports = InventoryRecord; diff --git a/backend/models/InventoryTask.js b/backend/models/InventoryTask.js new file mode 100644 index 0000000..d20db7d --- /dev/null +++ b/backend/models/InventoryTask.js @@ -0,0 +1,81 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +const InventoryTask = sequelize.define('InventoryTask', { + taskId: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false, + unique: true + }, + planId: { + type: DataTypes.STRING, + allowNull: false + }, + targetType: { + type: DataTypes.STRING, + allowNull: false, + comment: 'room:机房, rack:机柜, device:设备' + }, + targetId: { + type: DataTypes.STRING, + allowNull: false, + comment: '目标ID(机房ID/机柜ID/设备ID)' + }, + targetName: { + type: DataTypes.STRING, + allowNull: true, + comment: '目标名称' + }, + status: { + type: DataTypes.STRING, + defaultValue: 'pending', + comment: 'pending:待执行, in_progress:进行中, completed:已完成, skipped:已跳过' + }, + totalDevices: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '设备总数' + }, + checkedDevices: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '已盘点设备数' + }, + normalDevices: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '正常设备数' + }, + abnormalDevices: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '异常设备数' + }, + assignedTo: { + type: DataTypes.STRING, + allowNull: true + }, + assignedAt: { + type: DataTypes.DATE, + allowNull: true + }, + completedAt: { + type: DataTypes.DATE, + allowNull: true + }, + remark: { + type: DataTypes.TEXT, + allowNull: true + } +}, { + tableName: 'inventory_tasks', + timestamps: true, + indexes: [ + { fields: ['planId'] }, + { fields: ['status'] }, + { fields: ['assignedTo'] } + ] +}); + +module.exports = InventoryTask; diff --git a/backend/routes/inventory.js b/backend/routes/inventory.js new file mode 100644 index 0000000..8ea1c13 --- /dev/null +++ b/backend/routes/inventory.js @@ -0,0 +1,520 @@ +const express = require('express'); +const router = express.Router(); +const { Op } = require('sequelize'); +const { sequelize } = require('../db'); +const InventoryPlan = require('../models/InventoryPlan'); +const InventoryTask = require('../models/InventoryTask'); +const InventoryRecord = require('../models/InventoryRecord'); +const Device = require('../models/Device'); +const Rack = require('../models/Rack'); +const Room = require('../models/Room'); +const User = require('../models/User'); +const { authMiddleware, authorize } = require('../middleware/auth'); + +InventoryTask.belongsTo(InventoryPlan, { foreignKey: 'planId', as: 'Plan' }); +InventoryPlan.hasMany(InventoryTask, { foreignKey: 'planId', as: 'Tasks' }); +InventoryTask.belongsTo(User, { foreignKey: 'assignedTo', as: 'Assignee' }); +InventoryRecord.belongsTo(InventoryTask, { foreignKey: 'taskId', as: 'Task' }); +InventoryTask.hasMany(InventoryRecord, { foreignKey: 'taskId', as: 'Records' }); +InventoryRecord.belongsTo(InventoryPlan, { foreignKey: 'planId', as: 'Plan' }); +InventoryPlan.hasMany(InventoryRecord, { foreignKey: 'planId', as: 'Records' }); +InventoryRecord.belongsTo(Device, { foreignKey: 'deviceId', as: 'Device' }); +InventoryRecord.belongsTo(User, { foreignKey: 'checkedBy', as: 'Checker' }); + +function generateId(prefix) { + return `${prefix}${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substr(2, 6).toUpperCase()}`; +} + +function generatePlanId() { + return `PLAN${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substr(2, 4).toUpperCase()}`; +} + +function generateTaskId() { + return `TASK${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substr(2, 4).toUpperCase()}`; +} + +function generateRecordId() { + return `REC${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substr(2, 6).toUpperCase()}`; +} + +router.use(authMiddleware); + +router.get('/plans', async (req, res) => { + try { + const { status, page = 1, pageSize = 10, keyword } = req.query; + const offset = (page - 1) * pageSize; + const where = {}; + + if (status) { + where.status = status; + } + + if (keyword) { + where[Op.or] = [ + { name: { [Op.like]: `%${keyword}%` } }, + { description: { [Op.like]: `%${keyword}%` } } + ]; + } + + const { count, rows } = await InventoryPlan.findAndCountAll({ + where, + include: [ + { model: require('../models/User'), as: 'Creator', attributes: ['userId', 'username', 'realName'] } + ], + order: [['createdAt', 'DESC']], + limit: parseInt(pageSize), + offset: parseInt(offset) + }); + + res.json({ + plans: rows, + total: count, + page: parseInt(page), + pageSize: parseInt(pageSize) + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/plans/:planId', async (req, res) => { + try { + console.log('=== GET /plans/:planId ===', req.params.planId); + + const plan = await InventoryPlan.findByPk(req.params.planId, { + include: [ + { model: require('../models/User'), as: 'Creator', attributes: ['userId', 'username', 'realName'] } + ] + }); + + if (!plan) { + console.log('Plan not found'); + return res.status(404).json({ error: '盘点计划不存在' }); + } + + const tasks = await InventoryTask.findAll({ + where: { planId: plan.planId }, + include: [ + { model: require('../models/User'), as: 'Assignee', attributes: ['userId', 'username', 'realName'] } + ], + order: [['createdAt', 'ASC']] + }); + + console.log('Found tasks:', tasks.length); + res.json({ plan, tasks }); + } catch (error) { + console.error('Error:', error); + res.status(500).json({ error: error.message }); + } +}); + +router.post('/plans', async (req, res) => { + try { + const { name, type, description, scheduledDate, targetRooms, targetRacks } = req.body; + + const plan = await InventoryPlan.create({ + planId: generatePlanId(), + name, + type: type || 'full', + description, + scheduledDate: scheduledDate ? new Date(scheduledDate) : null, + targetRooms: targetRooms || [], + targetRacks: targetRacks || [], + status: 'draft', + createdBy: req.user?.userId + }); + + res.status(201).json(plan); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.put('/plans/:planId', async (req, res) => { + try { + const plan = await InventoryPlan.findByPk(req.params.planId); + if (!plan) { + return res.status(404).json({ error: '盘点计划不存在' }); + } + + const { name, type, description, scheduledDate, targetRooms, targetRacks, status } = req.body; + + await plan.update({ + name: name || plan.name, + type: type || plan.type, + description: description !== undefined ? description : plan.description, + scheduledDate: scheduledDate ? new Date(scheduledDate) : plan.scheduledDate, + targetRooms: targetRooms || plan.targetRooms, + targetRacks: targetRacks || plan.targetRacks, + status: status || plan.status + }); + + res.json(plan); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.delete('/plans/:planId', async (req, res) => { + try { + const plan = await InventoryPlan.findByPk(req.params.planId); + if (!plan) { + return res.status(404).json({ error: '盘点计划不存在' }); + } + + await InventoryRecord.destroy({ where: { planId: plan.planId } }); + await InventoryTask.destroy({ where: { planId: plan.planId } }); + await plan.destroy(); + + res.json({ message: '盘点计划删除成功' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.post('/plans/:planId/start', async (req, res) => { + try { + const plan = await InventoryPlan.findByPk(req.params.planId); + + if (!plan) { + return res.status(404).json({ error: '盘点计划不存在' }); + } + + if (plan.status !== 'draft' && plan.status !== 'pending') { + return res.status(400).json({ error: '只有草稿或待执行状态的盘点计划可以启动' }); + } + + const targetRooms = plan.targetRooms || []; + const targetRacks = plan.targetRacks || []; + let allDevices = []; + + if (targetRacks.length > 0) { + allDevices = await Device.findAll({ + where: { rackId: { [Op.in]: targetRacks } } + }); + } else if (targetRooms.length > 0) { + const racksInRooms = await Rack.findAll({ + where: { roomId: { [Op.in]: targetRooms } }, + attributes: ['rackId'] + }); + const rackIds = racksInRooms.map(r => r.rackId); + allDevices = await Device.findAll({ + where: { rackId: { [Op.in]: rackIds } } + }); + } else { + allDevices = await Device.findAll(); + } + + const tasksToCreate = []; + const recordsToCreate = []; + + const taskId = generateTaskId(); + + tasksToCreate.push({ + taskId, + planId: plan.planId, + targetType: 'all', + targetId: 'all', + targetName: '全部设备', + status: 'pending', + totalDevices: allDevices.length + }); + + for (let i = 0; i < allDevices.length; i++) { + const device = allDevices[i]; + recordsToCreate.push({ + recordId: generateRecordId(), + taskId, + planId: plan.planId, + deviceId: device.deviceId, + deviceName: device.name, + deviceType: device.type, + serialNumber: device.serialNumber, + rackId: device.rackId, + position: device.position, + status: 'pending' + }); + } + + if (tasksToCreate.length > 0) { + await InventoryTask.bulkCreate(tasksToCreate, { individualHooks: false }); + } + + if (recordsToCreate.length > 0) { + const now = new Date().toISOString(); + const placeholders = recordsToCreate.map(r => + `('${r.recordId}', '${r.taskId}', '${r.planId}', '${r.deviceId}', '${r.deviceName}', '${r.deviceType}', '${r.serialNumber || ''}', '${r.rackId}', ${r.position}, 'pending', '${now}', '${now}')` + ).join(','); + + if (placeholders) { + await sequelize.query(` + INSERT INTO inventory_records (recordId, taskId, planId, deviceId, deviceName, deviceType, serialNumber, rackId, position, status, createdAt, updatedAt) + VALUES ${placeholders} + `); + } + } + + await plan.update({ + status: 'in_progress', + totalDevices: allDevices.length, + checkedDevices: 0, + normalDevices: 0, + abnormalDevices: 0, + missedDevices: allDevices.length + }); + + res.json({ message: '盘点任务已启动', taskCount: tasksToCreate.length, deviceCount: allDevices.length }); + } catch (error) { + console.error('启动盘点错误:', error); + res.status(500).json({ error: error.message }); + } +}); + +router.get('/tasks/:taskId', async (req, res) => { + try { + const task = await InventoryTask.findByPk(req.params.taskId, { + include: [ + { model: InventoryPlan, as: 'Plan', attributes: ['planId', 'name'] }, + { model: require('../models/User'), as: 'Assignee', attributes: ['userId', 'username', 'realName'] } + ] + }); + + if (!task) { + return res.status(404).json({ error: '盘点任务不存在' }); + } + + const records = await InventoryRecord.findAll({ + where: { taskId: task.taskId }, + include: [ + { model: Device, as: 'Device', attributes: ['deviceId', 'name', 'type', 'serialNumber', 'rackId', 'position'] }, + { model: require('../models/User'), as: 'Checker', attributes: ['userId', 'username', 'realName'] } + ] + }); + + const rackIds = [...new Set(records.map(r => r.rackId).filter(Boolean))]; + const racks = await Rack.findAll({ + where: { rackId: rackIds }, + include: [{ model: Room, as: 'Room' }] + }); + const rackMap = {}; + racks.forEach(r => { + rackMap[r.rackId] = r; + }); + + const enrichedRecords = records.map(record => { + const rackInfo = rackMap[record.rackId]; + const roomName = rackInfo?.Room?.name || ''; + const rackName = rackInfo?.name || record.rackId || ''; + const position = record.position || ''; + + return { + ...record.toJSON(), + displayLocation: roomName ? `${roomName} - ${rackName} - U${position}` : `${rackName} - U${position}` + }; + }); + + res.json({ task, records: enrichedRecords }); + } catch (error) { + console.error('获取盘点任务记录错误:', error); + res.status(500).json({ error: error.message }); + } +}); + +router.put('/tasks/:taskId', async (req, res) => { + try { + const task = await InventoryTask.findByPk(req.params.taskId); + if (!task) { + return res.status(404).json({ error: '盘点任务不存在' }); + } + + const { assignedTo, status } = req.body; + + if (assignedTo !== undefined) { + await task.update({ + assignedTo, + assignedAt: assignedTo ? new Date() : task.assignedAt + }); + } + + if (status) { + await task.update({ + status, + completedAt: status === 'completed' ? new Date() : null + }); + } + + res.json(task); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.post('/records/:recordId/check', async (req, res) => { + try { + const record = await InventoryRecord.findByPk(req.params.recordId, { + include: [{ model: Device, as: 'Device' }] + }); + + if (!record) { + return res.status(404).json({ error: '盘点记录不存在' }); + } + + const { actualSerialNumber, actualRackId, actualPosition, status, remark, photoUrl } = req.body; + + let abnormalType = null; + if (status === 'abnormal') { + if (actualSerialNumber && actualSerialNumber !== record.serialNumber) { + abnormalType = 'serial_mismatch'; + } else if (actualRackId && actualRackId !== record.rackId) { + abnormalType = 'position_mismatch'; + } else if (status === 'not_found') { + abnormalType = 'device_missing'; + } + } + + await record.update({ + actualSerialNumber: actualSerialNumber || null, + actualRackId: actualRackId || null, + actualPosition: actualPosition || null, + status: status || record.status, + abnormalType, + checkedBy: req.user?.userId, + checkedAt: new Date(), + remark: remark || null, + photoUrl: photoUrl || null + }); + + const task = await InventoryTask.findByPk(record.taskId); + const plan = await InventoryPlan.findByPk(record.planId); + + const taskRecords = await InventoryRecord.findAll({ where: { taskId: task.taskId } }); + const taskStats = { + totalDevices: taskRecords.length, + checkedDevices: taskRecords.filter(r => r.status !== 'pending').length, + normalDevices: taskRecords.filter(r => r.status === 'normal').length, + abnormalDevices: taskRecords.filter(r => r.status === 'abnormal').length + }; + + await task.update(taskStats); + + const planRecords = await InventoryRecord.findAll({ where: { planId: plan.planId } }); + const planStats = { + totalDevices: planRecords.length, + checkedDevices: planRecords.filter(r => r.status !== 'pending').length, + normalDevices: planRecords.filter(r => r.status === 'normal').length, + abnormalDevices: planRecords.filter(r => r.status === 'abnormal').length, + missedDevices: planRecords.filter(r => r.status === 'pending').length + }; + + await plan.update(planStats); + + res.json({ record, taskStats, planStats }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/records', async (req, res) => { + try { + const { planId, taskId, status, page = 1, pageSize = 20 } = req.query; + const offset = (page - 1) * pageSize; + const where = {}; + + if (planId) where.planId = planId; + if (taskId) where.taskId = taskId; + if (status) where.status = status; + + const { count, rows } = await InventoryRecord.findAndCountAll({ + where, + include: [ + { model: Device, as: 'Device', attributes: ['deviceId', 'name', 'type', 'serialNumber', 'rackId', 'position'] }, + { model: require('../models/User'), as: 'Checker', attributes: ['userId', 'username', 'realName'] } + ], + order: [['checkedAt', 'DESC'], ['createdAt', 'DESC']], + limit: parseInt(pageSize), + offset: parseInt(offset) + }); + + res.json({ + records: rows, + total: count, + page: parseInt(page), + pageSize: parseInt(pageSize) + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.post('/plans/:planId/complete', async (req, res) => { + try { + const plan = await InventoryPlan.findByPk(req.params.planId); + + if (!plan) { + return res.status(404).json({ error: '盘点计划不存在' }); + } + + await InventoryRecord.update( + { status: 'missed' }, + { where: { planId: plan.planId, status: 'pending' } } + ); + + const finalRecords = await InventoryRecord.findAll({ where: { planId: plan.planId } }); + + await plan.update({ + status: 'completed', + completedDate: new Date(), + checkedDevices: finalRecords.filter(r => r.status !== 'pending').length, + normalDevices: finalRecords.filter(r => r.status === 'normal').length, + abnormalDevices: finalRecords.filter(r => r.status === 'abnormal').length, + missedDevices: finalRecords.filter(r => r.status === 'missed').length + }); + + await InventoryTask.update( + { status: 'completed' }, + { where: { planId: plan.planId, status: { [Op.ne]: 'completed' } } } + ); + + res.json({ message: '盘点已完成', plan }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/stats/dashboard', async (req, res) => { + try { + const totalPlans = await InventoryPlan.count(); + const completedPlans = await InventoryPlan.count({ where: { status: 'completed' } }); + const inProgressPlans = await InventoryPlan.count({ where: { status: 'in_progress' } }); + + const totalRecords = await InventoryRecord.count(); + const normalRecords = await InventoryRecord.count({ where: { status: 'normal' } }); + const abnormalRecords = await InventoryRecord.count({ where: { status: 'abnormal' } }); + const pendingRecords = await InventoryRecord.count({ where: { status: 'pending' } }); + + const recentPlans = await InventoryPlan.findAll({ + limit: 5, + order: [['createdAt', 'DESC']], + include: [ + { model: require('../models/User'), as: 'Creator', attributes: ['userId', 'username', 'realName'] } + ] + }); + + res.json({ + totalPlans, + completedPlans, + inProgressPlans, + totalRecords, + normalRecords, + abnormalRecords, + pendingRecords, + completionRate: totalRecords > 0 ? ((normalRecords + abnormalRecords) / totalRecords * 100).toFixed(1) : 0, + abnormalRate: totalRecords > 0 ? (abnormalRecords / totalRecords * 100).toFixed(1) : 0, + recentPlans + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index abe5ca3..9a834d6 100644 --- a/backend/server.js +++ b/backend/server.js @@ -44,6 +44,20 @@ sequelize.authenticate() const SystemSetting = require('./models/SystemSetting'); return SystemSetting.sync(); }) + .then(() => { + // 同步盘点模型 + const InventoryPlan = require('./models/InventoryPlan'); + const InventoryTask = require('./models/InventoryTask'); + const InventoryRecord = require('./models/InventoryRecord'); + return Promise.all([ + InventoryPlan.sync(), + InventoryTask.sync(), + InventoryRecord.sync() + ]); + }) + .then(() => { + console.log('盘点模型同步完成'); + }) .then(() => { // 初始化系统设置默认值(关键:确保部署时数据正确初始化) console.log('开始初始化系统设置默认值...'); @@ -112,6 +126,7 @@ const systemSettingsRoutes = require('./routes/systemSettings'); const cableRoutes = require('./routes/cables'); const devicePortRoutes = require('./routes/devicePorts'); const networkCardRoutes = require('./routes/networkCards'); +const inventoryRoutes = require('./routes/inventory'); // 使用路由 app.use('/api/devices', deviceRoutes); @@ -132,6 +147,7 @@ app.use('/api/system-settings', systemSettingsRoutes); app.use('/api/cables', cableRoutes); app.use('/api/device-ports', devicePortRoutes); app.use('/api/network-cards', networkCardRoutes); +app.use('/api/inventory', inventoryRoutes); // 静态文件服务 app.use('/uploads', express.static('uploads')); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a2fc39c..95cf13e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -71,6 +71,8 @@ const TicketFieldManagement = lazy(() => import('./pages/TicketFieldManagement') const SystemSettings = lazy(() => import('./pages/SystemSettings')); const CableManagement = lazy(() => import('./pages/CableManagement')); const PortManagement = lazy(() => import('./pages/PortManagement')); +const InventoryManagement = lazy(() => import('./pages/InventoryManagement')); +const InventoryTaskExecution = lazy(() => import('./pages/InventoryTaskExecution')); const { Header, Content, Sider } = Layout; @@ -309,6 +311,18 @@ const AppLayout = ({ children }) => { }, ], }, + { + key: 'inventory-management', + icon: , + label: '资产盘点', + children: [ + { + key: 'inventory', + icon: , + label: 盘点计划, + }, + ], + }, { key: 'system-management', icon: , @@ -691,6 +705,22 @@ const ThemeConfig = () => { } /> + + + + } + /> + + + + } + /> - setKeyword(value)} + value={keyword} + onChange={e => setKeyword(e.target.value)} allowClear /> { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +const InventoryManagement = () => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const planIdParam = searchParams.get('planId'); + const [activeTab, setActiveTab] = useState(planIdParam ? 'execution' : 'list'); + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(false); + const [modalVisible, setModalVisible] = useState(false); + const [editingPlan, setEditingPlan] = useState(null); + const [rooms, setRooms] = useState([]); + const [racks, setRacks] = useState([]); + const [filteredRacks, setFilteredRacks] = useState([]); + const [selectedRooms, setSelectedRooms] = useState([]); + const [stats, setStats] = useState({ + totalPlans: 0, + completedPlans: 0, + inProgressPlans: 0, + }); + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 10, + total: 0, + }); + const [searchParamsObj, setSearchParamsObj] = useState({}); + const [form] = Form.useForm(); + + const fetchPlans = useCallback(async () => { + setLoading(true); + try { + const params = { + page: pagination.current, + pageSize: pagination.pageSize, + ...searchParamsObj, + }; + const res = await api.get('/inventory/plans', { params }); + setPlans(res.data.plans || []); + setPagination((prev) => ({ + ...prev, + total: res.data.total || 0, + })); + } catch (error) { + message.error('获取盘点计划失败'); + } finally { + setLoading(false); + } + }, [pagination.current, pagination.pageSize, searchParamsObj]); + + const fetchStats = async () => { + try { + const res = await api.get('/inventory/stats/dashboard'); + setStats({ + totalPlans: res.data.totalPlans || 0, + completedPlans: res.data.completedPlans || 0, + inProgressPlans: res.data.inProgressPlans || 0, + }); + } catch (error) { + console.error('获取统计失败', error); + } + }; + + const fetchRooms = async () => { + try { + const res = await api.get('/rooms', { params: { pageSize: 1000 } }); + setRooms(Array.isArray(res.data) ? res.data : (res.data.rooms || [])); + } catch (error) { + console.error('获取机房失败', error); + } + }; + + const fetchRacks = async () => { + try { + const res = await api.get('/racks', { params: { pageSize: 1000 } }); + const allRacks = Array.isArray(res.data) ? res.data : (res.data.racks || []); + setRacks(allRacks); + setFilteredRacks(allRacks); + } catch (error) { + console.error('获取机柜失败', error); + } + }; + + useEffect(() => { + fetchPlans(); + fetchStats(); + fetchRooms(); + fetchRacks(); + }, [fetchPlans]); + + useEffect(() => { + if (planIdParam) { + setActiveTab('execution'); + } + }, [planIdParam]); + + const handleAdd = () => { + setEditingPlan(null); + form.resetFields(); + setSelectedRooms([]); + setFilteredRacks(racks); + setModalVisible(true); + }; + + const handleEdit = (record) => { + setEditingPlan(record); + const targetRooms = record.targetRooms || []; + setSelectedRooms(targetRooms); + + if (targetRooms.length > 0) { + const filtered = racks.filter(rack => + targetRooms.includes(rack.roomId) || (rack.Room && targetRooms.includes(rack.Room.roomId)) + ); + setFilteredRacks(filtered); + } else { + setFilteredRacks(racks); + } + + form.setFieldsValue({ + name: record.name, + type: record.type, + description: record.description, + scheduledDate: record.scheduledDate ? dayjs(record.scheduledDate) : null, + targetRooms: targetRooms, + targetRacks: record.targetRacks || [], + }); + setModalVisible(true); + }; + + const handleDelete = async (planId) => { + try { + await api.delete(`/inventory/plans/${planId}`); + message.success('删除成功'); + fetchPlans(); + fetchStats(); + } catch (error) { + message.error('删除失败'); + } + }; + + const handleSubmit = async (values) => { + try { + const data = { + ...values, + scheduledDate: values.scheduledDate?.toISOString(), + targetRooms: values.targetRooms || [], + targetRacks: values.targetRacks || [], + }; + + if (editingPlan) { + await api.put(`/inventory/plans/${editingPlan.planId}`, data); + message.success('更新成功'); + } else { + await api.post('/inventory/plans', data); + message.success('创建成功'); + } + setModalVisible(false); + fetchPlans(); + fetchStats(); + } catch (error) { + message.error(error.response?.data?.error || '操作失败'); + } + }; + + const handleStart = async (plan) => { + try { + await api.post(`/inventory/plans/${plan.planId}/start`); + message.success('盘点任务已启动'); + fetchPlans(); + fetchStats(); + } catch (error) { + message.error(error.response?.data?.error || '启动失败'); + } + }; + + const handleComplete = async (plan) => { + try { + await api.post(`/inventory/plans/${plan.planId}/complete`); + message.success('盘点已完成'); + fetchPlans(); + fetchStats(); + } catch (error) { + message.error(error.response?.data?.error || '完成失败'); + } + }; + + const handleViewTasks = (plan) => { + navigate(`/inventory/execution?planId=${plan.planId}`); + }; + + const handleRoomsChange = (roomIds) => { + setSelectedRooms(roomIds || []); + if (!roomIds || roomIds.length === 0) { + setFilteredRacks(racks); + } else { + const filtered = racks.filter(rack => + roomIds.includes(rack.roomId) || (rack.Room && roomIds.includes(rack.Room.roomId)) + ); + setFilteredRacks(filtered); + } + }; + + const getStatusTag = (status) => { + const statusMap = { + draft: { color: 'default', text: '草稿', icon: }, + pending: { color: 'orange', text: '待执行', icon: }, + in_progress: { color: 'processing', text: '进行中', icon: }, + completed: { color: 'success', text: '已完成', icon: }, + cancelled: { color: 'error', text: '已取消', icon: }, + }; + const config = statusMap[status] || statusMap.draft; + return ( + + {config.text} + + ); + }; + + const getTypeTag = (type) => { + const typeMap = { + full: { color: 'blue', text: '全面盘点' }, + partial: { color: 'cyan', text: '局部盘点' }, + sample: { color: 'purple', text: '抽样盘点' }, + }; + const config = typeMap[type] || typeMap.full; + return {config.text}; + }; + + const getProgressPercent = (plan) => { + if (!plan.totalDevices || plan.totalDevices === 0) return 0; + return Math.round((plan.checkedDevices / plan.totalDevices) * 100); + }; + + const columns = [ + { + title: '盘点计划', + key: 'planInfo', + render: (_, record) => ( +
+
+ +
+
+
+ {record.name} +
+
+ {record.planId} +
+
+
+ ), + }, + { + title: '类型', + dataIndex: 'type', + key: 'type', + width: 100, + render: (type) => getTypeTag(type), + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 120, + render: (status) => getStatusTag(status), + }, + { + title: '盘点进度', + key: 'progress', + width: 180, + render: (_, record) => ( +
+
+ + 已盘: {record.checkedDevices || 0} / {record.totalDevices || 0} + + + {getProgressPercent(record)}% + +
+ +
+ ), + }, + { + title: '异常设备', + key: 'abnormal', + width: 100, + render: (_, record) => ( + record.abnormalDevices > 0 ? + {record.abnormalDevices} 异常 : + - + ), + }, + { + title: '创建人', + dataIndex: ['Creator', 'realName'], + key: 'creator', + width: 100, + render: (name) => name || '-', + }, + { + title: '创建时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 160, + render: (date) => (date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '-'), + }, + { + title: '操作', + key: 'action', + width: 200, + fixed: 'right', + render: (_, record) => ( + + + + + + + + `共 ${total} 条记录`, + onChange: (page, pageSize) => { + setPagination((prev) => ({ ...prev, current: page, pageSize })); + }, + }} + scroll={{ x: 1200 }} + locale={{ + emptyText: , + }} + /> + + + + + + + {editingPlan ? '编辑盘点计划' : '新建盘点计划'} + + } + open={modalVisible} + onCancel={() => setModalVisible(false)} + footer={null} + width={640} + centered + styles={{ + header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' }, + body: { padding: 24 }, + footer: { borderTop: '1px solid #f0f0f0', padding: '12px 24px' } + }} + > +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + ); +}; + +export default InventoryManagement; diff --git a/frontend/src/pages/InventoryTaskExecution.jsx b/frontend/src/pages/InventoryTaskExecution.jsx new file mode 100644 index 0000000..dce2b24 --- /dev/null +++ b/frontend/src/pages/InventoryTaskExecution.jsx @@ -0,0 +1,1044 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Table, + Button, + Modal, + Form, + Input, + Select, + message, + Card, + Space, + Tag, + Row, + Col, + Statistic, + Progress, + Descriptions, + Empty, + Badge, + Tooltip, + Tabs, +} from 'antd'; +import { + CheckCircleOutlined, + CloseCircleOutlined, + ExclamationCircleOutlined, + SearchOutlined, + ReloadOutlined, + InboxOutlined, + ScanOutlined, + CheckOutlined, + CloseOutlined, +} from '@ant-design/icons'; +import axios from 'axios'; +import dayjs from 'dayjs'; +import { useSearchParams } from 'react-router-dom'; +import { designTokens } from '../config/theme'; + +const api = axios.create({ + baseURL: '/api', +}); + +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +const InventoryTaskExecution = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const [plan, setPlan] = useState(null); + const [tasks, setTasks] = useState([]); + const [currentTask, setCurrentTask] = useState(null); + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(false); + const [checkModalVisible, setCheckModalVisible] = useState(false); + const [currentRecord, setCurrentRecord] = useState(null); + const [form] = Form.useForm(); + const [activeTab, setActiveTab] = useState('1'); + const [statusFilter, setStatusFilter] = useState(null); + const [searchKeyword, setSearchKeyword] = useState(''); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [batchLoading, setBatchLoading] = useState(false); + const [scanInput, setScanInput] = useState(''); + const [scanResult, setScanResult] = useState(null); + const [scanModalVisible, setScanModalVisible] = useState(false); + const [recordPagination, setRecordPagination] = useState({ current: 1, pageSize: 20, total: 0 }); + + const planId = searchParams.get('planId'); + + const fetchPlan = useCallback(async () => { + if (!planId) return; + setLoading(true); + try { + const res = await api.get(`/inventory/plans/${planId}`); + setPlan(res.data.plan); + setTasks(res.data.tasks || []); + } catch (error) { + message.error('获取盘点计划失败'); + } finally { + setLoading(false); + } + }, [planId]); + + const fetchTaskRecords = async (taskId) => { + try { + const res = await api.get(`/inventory/tasks/${taskId}`); + setCurrentTask(res.data.task); + setRecords(res.data.records || []); + setRecordPagination(prev => ({ ...prev, total: res.data.records?.length || 0, current: 1 })); + setSelectedRowKeys([]); + setActiveTab('2'); + } catch (error) { + message.error('获取盘点记录失败'); + } + }; + + const filteredRecords = records.filter(record => { + if (statusFilter && record.status !== statusFilter) return false; + if (searchKeyword) { + const keyword = searchKeyword.toLowerCase(); + return ( + (record.deviceName && record.deviceName.toLowerCase().includes(keyword)) || + (record.deviceId && record.deviceId.toLowerCase().includes(keyword)) || + (record.serialNumber && record.serialNumber.toLowerCase().includes(keyword)) + ); + } + return true; + }); + + const handleBatchCheck = async (status) => { + if (selectedRowKeys.length === 0) { + message.warning('请先选择要盘点的记录'); + return; + } + setBatchLoading(true); + try { + const promises = selectedRowKeys.map(recordId => + api.post(`/inventory/records/${recordId}/check`, { + status, + checkedAt: new Date().toISOString(), + }) + ); + await Promise.all(promises); + message.success(`已批量标记 ${selectedRowKeys.length} 条记录`); + if (currentTask) { + fetchTaskRecords(currentTask.taskId); + } + } catch (error) { + message.error('批量操作失败'); + } finally { + setBatchLoading(false); + setSelectedRowKeys([]); + } + }; + + const handleQuickCheckAll = async (status) => { + const pendingRecords = filteredRecords.filter(r => r.status === 'pending'); + if (pendingRecords.length === 0) { + message.info('没有待盘点的记录'); + return; + } + setBatchLoading(true); + try { + const promises = pendingRecords.map(record => + api.post(`/inventory/records/${record.recordId}/check`, { + status, + checkedAt: new Date().toISOString(), + }) + ); + await Promise.all(promises); + message.success(`已一键标记 ${pendingRecords.length} 条记录`); + if (currentTask) { + fetchTaskRecords(currentTask.taskId); + } + } catch (error) { + message.error('操作失败'); + } finally { + setBatchLoading(false); + } + }; + + const handleScan = async (sn) => { + if (!sn || sn.trim() === '') return; + + const trimmedSN = sn.trim().toUpperCase(); + const matchedRecord = records.find(r => + r.serialNumber && r.serialNumber.toUpperCase() === trimmedSN + ); + + if (!matchedRecord) { + setScanResult({ success: false, message: `未找到序列号为 "${sn}" 的设备`, sn: trimmedSN }); + setScanModalVisible(true); + return; + } + + if (matchedRecord.status !== 'pending') { + setScanResult({ + success: false, + message: `设备 "${matchedRecord.deviceName}" 已盘点`, + record: matchedRecord, + sn: trimmedSN + }); + setScanModalVisible(true); + return; + } + + try { + await api.post(`/inventory/records/${matchedRecord.recordId}/check`, { + status: 'normal', + actualSerialNumber: matchedRecord.serialNumber, + checkedAt: new Date().toISOString(), + }); + setScanResult({ + success: true, + message: `设备 "${matchedRecord.deviceName}" 盘点成功!`, + record: matchedRecord, + sn: trimmedSN + }); + setScanModalVisible(true); + if (currentTask) { + fetchTaskRecords(currentTask.taskId); + } + } catch (error) { + message.error('盘点失败'); + } + }; + + const handleScanInput = (e) => { + const value = e.target.value; + setScanInput(value); + if (value.includes('\n') || value.includes('\r')) { + const sn = value.replace(/[\r\n]/g, '').trim(); + if (sn) { + handleScan(sn); + setScanInput(''); + } + } + }; + + const handleScanKeyPress = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const sn = e.target.value.trim(); + if (sn) { + handleScan(sn); + setScanInput(''); + } + } + }; + + const handleScanClick = () => { + const sn = scanInput.trim(); + if (sn) { + handleScan(sn); + setScanInput(''); + } else { + message.warning('请输入序列号'); + } + }; + + useEffect(() => { + fetchPlan(); + }, [fetchPlan]); + + const handleCheck = (record) => { + setCurrentRecord(record); + form.setFieldsValue({ + actualSerialNumber: record.serialNumber, + actualRackId: record.rackId, + actualPosition: record.position, + status: record.status === 'pending' ? null : record.status, + remark: record.remark, + }); + setCheckModalVisible(true); + }; + + const handleQuickCheck = async (record, status) => { + try { + await api.post(`/inventory/records/${record.recordId}/check`, { + status, + checkedAt: new Date().toISOString(), + }); + message.success(status === 'normal' ? '标记正常' : '标记异常'); + if (currentTask) { + fetchTaskRecords(currentTask.taskId); + } + fetchPlan(); + } catch (error) { + message.error(error.response?.data?.error || '标记失败'); + } + }; + + const handleSubmitCheck = async (values) => { + if (!currentRecord) return; + try { + await api.post(`/inventory/records/${currentRecord.recordId}/check`, { + actualSerialNumber: values.actualSerialNumber, + actualRackId: values.actualRackId, + actualPosition: values.actualPosition, + status: values.status, + remark: values.remark, + }); + message.success('盘点提交成功'); + setCheckModalVisible(false); + if (currentTask) { + fetchTaskRecords(currentTask.taskId); + } + fetchPlan(); + } catch (error) { + message.error(error.response?.data?.error || '提交失败'); + } + }; + + const getStatusTag = (status) => { + const statusMap = { + pending: { color: 'default', text: '待盘点', icon: }, + normal: { color: 'success', text: '正常', icon: }, + abnormal: { color: 'error', text: '异常', icon: }, + missed: { color: 'warning', text: '漏盘', icon: }, + }; + const config = statusMap[status] || statusMap.pending; + return ( + + {config.text} + + ); + }; + + const getAbnormalTypeTag = (type) => { + const typeMap = { + serial_mismatch: '序列号不符', + position_mismatch: '位置不符', + device_missing: '设备缺失', + extra_device: '多出设备', + }; + return typeMap[type] || type; + }; + + const taskColumns = [ + { + title: '任务信息', + key: 'taskInfo', + render: (_, record) => ( +
+
{record.targetName}
+
+ {record.targetType === 'room' ? '机房' : record.targetType === 'rack' ? '机柜' : '全部设备'} +
+
+ ), + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + render: (status) => { + const statusMap = { + pending: , + in_progress: , + completed: , + skipped: , + }; + return statusMap[status] || status; + }, + }, + { + title: '设备进度', + key: 'progress', + width: 150, + render: (_, record) => ( + 0 ? Math.round((record.checkedDevices / record.totalDevices) * 100) : 0} + size="small" + format={() => `${record.checkedDevices}/${record.totalDevices}`} + /> + ), + }, + { + title: '异常', + dataIndex: 'abnormalDevices', + key: 'abnormalDevices', + width: 80, + render: (count) => (count > 0 ? {count} : '-'), + }, + { + title: '操作', + key: 'action', + width: 100, + render: (_, record) => ( + + ), + }, + ]; + + const recordColumns = [ + { + title: '设备信息', + key: 'deviceInfo', + width: 180, + render: (_, record) => ( +
+
{record.deviceName}
+
+ {record.deviceId} +
+
+ ), + }, + { + title: '类型', + dataIndex: 'deviceType', + key: 'deviceType', + width: 80, + render: (type) => { + const typeMap = { + server: '服务器', + switch: '交换机', + router: '路由器', + storage: '存储设备', + other: '其他', + }; + return typeMap[type] || type; + }, + }, + { + title: '系统序列号', + dataIndex: 'serialNumber', + key: 'serialNumber', + width: 140, + render: (sn) => {sn}, + }, + { + title: '实际序列号', + dataIndex: 'actualSerialNumber', + key: 'actualSerialNumber', + width: 140, + render: (sn, record) => ( +
+ {sn ? ( + {sn} + ) : ( + - + )} + {record.abnormalType === 'serial_mismatch' && ( + 不符 + )} +
+ ), + }, + { + title: '系统位置', + dataIndex: 'displayLocation', + key: 'displayLocation', + width: 180, + render: (_, record) => ( + {record.displayLocation || `${record.rackId} - U${record.position}`} + ), + }, + { + title: '实际位置', + key: 'actualPosition', + width: 100, + render: (_, record) => ( +
+ {record.actualRackId ? ( +
{record.actualRackId} - U{record.actualPosition}
+ ) : ( + - + )} + {record.abnormalType === 'position_mismatch' && ( + 不符 + )} +
+ ), + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 90, + render: (status) => getStatusTag(status), + }, + { + title: '盘点人', + dataIndex: ['Checker', 'realName'], + key: 'checker', + width: 90, + render: (name) => name || '-', + }, + { + title: '盘点时间', + dataIndex: 'checkedAt', + key: 'checkedAt', + width: 140, + render: (date) => (date ? dayjs(date).format('MM-DD HH:mm') : '-'), + }, + { + title: '操作', + key: 'action', + width: 120, + fixed: 'right', + render: (_, record) => ( + + {record.status === 'pending' ? ( + <> + + + + ), + }, + ]; + + const pageContainerStyle = { + padding: '24px', + background: designTokens.colors.bg, + minHeight: '100vh', + }; + + if (!planId) { + return ( +
+ + + +
+ ); + } + + return ( +
+ + +
+
+ +
+
{plan?.name || '加载中...'}
+
+ 计划ID: {planId} +
+
+
+ + + + + + + + + + + + {plan?.totalDevices > 0 && ( + 0 ? 'exception' : 'active'} + style={{ marginTop: 16 }} + /> + )} + + + + + ), + }, + { + key: '2', + label: ( + + 盘点记录 + {records.length > 0 && ( + + {filteredRecords.length}/{records.length} + + )} + + ), + children: ( +
+
+
+ + } + style={{ width: 250, height: 32 }} + className="统一输入框" + allowClear + value={searchKeyword} + onChange={(e) => { + setSearchKeyword(e.target.value); + setRecordPagination(prev => ({ ...prev, current: 1 })); + }} + /> +
+
+ + + +
+
+ + {records.length > 0 && ( + r.status !== 'pending').length) / records.length) * 100)} + status="active" + strokeColor={{ from: '#108ee9', to: '#87d068' }} + style={{ marginBottom: 16 }} + /> + )} + +
`共 ${total} 条`, + onChange: (page, pageSize) => { + setRecordPagination(prev => ({ ...prev, current: page, pageSize })); + } + }} + scroll={{ x: 1400 }} + loading={batchLoading} + locale={{ + emptyText: , + }} + /> + + ), + }, + ]} + /> + + + { + setScanModalVisible(false); + setScanInput(''); + }} + footer={null} + width={560} + centered + closeIcon={null} + styles={{ + header: { display: 'none' }, + body: { padding: 0 }, + content: { borderRadius: 16, overflow: 'hidden' } + }} + > + {/* 自定义 Header */} +
+
+
+ +
+ 扫码盘点 +
+
+ +
+
+
+ +
+ setScanInput(e.target.value)} + onKeyDown={handleScanKeyPress} + variant="outlined" + style={{ + fontSize: 16, + textAlign: 'center', + height: 40, + borderRadius: 8, + boxShadow: '0 2px 8px rgba(24, 144, 255, 0.1)' + }} + suffix={ + + } + autoFocus + /> +
+ 扫码枪扫描或手动输入序列号后按回车 / 点击盘点 +
+
+ + {scanResult && ( +
+
+ {scanResult.success ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ {scanResult.message} +
+ {scanResult.record && ( +
+ +
+
+ 设备ID + {scanResult.record.deviceId} +
+
+ 设备名称 + {scanResult.record.deviceName} +
+ + +
+ 序列号 + {scanResult.record.serialNumber} +
+
+ 当前位置 + {scanResult.record.displayLocation || `${scanResult.record.rackId} - U${scanResult.record.position}`} +
+ + + + )} + + )} + + {scanResult && ( +
+ + +
+ )} + + {!scanResult && ( +
+ r.status !== 'pending').length} + suffix={`/ ${records.length}`} + valueStyle={{ color: '#52c41a', fontWeight: 'bold' }} + /> + 0 ? Math.round((records.filter(r => r.status !== 'pending').length / records.length) * 100) : 0} + strokeColor={{ from: '#1890ff', to: '#52c41a' }} + style={{ marginTop: 12 }} + /> +
+ )} + + + + + setCheckModalVisible(false)} + footer={null} + width={600} + > + {currentRecord && ( +
+ + {currentRecord.deviceId} + {currentRecord.deviceName} + {currentRecord.serialNumber} + {currentRecord.displayLocation || `${currentRecord.rackId} - U${currentRecord.position}`} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} +
+ + ); +}; + +export default InventoryTaskExecution;