feat(资产盘点): 新增资产盘点功能模块
This commit is contained in:
+1
-1
@@ -32,7 +32,7 @@ if (DB_TYPE === 'mysql') {
|
|||||||
sequelize = new Sequelize({
|
sequelize = new Sequelize({
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
storage: process.env.DB_PATH || './idc_management.db',
|
storage: process.env.DB_PATH || './idc_management.db',
|
||||||
logging: process.env.NODE_ENV === 'development' ? console.log : false,
|
logging: console.log,
|
||||||
// SQLite 连接池配置
|
// SQLite 连接池配置
|
||||||
pool: {
|
pool: {
|
||||||
max: 5,
|
max: 5,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -44,6 +44,20 @@ sequelize.authenticate()
|
|||||||
const SystemSetting = require('./models/SystemSetting');
|
const SystemSetting = require('./models/SystemSetting');
|
||||||
return SystemSetting.sync();
|
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(() => {
|
.then(() => {
|
||||||
// 初始化系统设置默认值(关键:确保部署时数据正确初始化)
|
// 初始化系统设置默认值(关键:确保部署时数据正确初始化)
|
||||||
console.log('开始初始化系统设置默认值...');
|
console.log('开始初始化系统设置默认值...');
|
||||||
@@ -112,6 +126,7 @@ const systemSettingsRoutes = require('./routes/systemSettings');
|
|||||||
const cableRoutes = require('./routes/cables');
|
const cableRoutes = require('./routes/cables');
|
||||||
const devicePortRoutes = require('./routes/devicePorts');
|
const devicePortRoutes = require('./routes/devicePorts');
|
||||||
const networkCardRoutes = require('./routes/networkCards');
|
const networkCardRoutes = require('./routes/networkCards');
|
||||||
|
const inventoryRoutes = require('./routes/inventory');
|
||||||
|
|
||||||
// 使用路由
|
// 使用路由
|
||||||
app.use('/api/devices', deviceRoutes);
|
app.use('/api/devices', deviceRoutes);
|
||||||
@@ -132,6 +147,7 @@ app.use('/api/system-settings', systemSettingsRoutes);
|
|||||||
app.use('/api/cables', cableRoutes);
|
app.use('/api/cables', cableRoutes);
|
||||||
app.use('/api/device-ports', devicePortRoutes);
|
app.use('/api/device-ports', devicePortRoutes);
|
||||||
app.use('/api/network-cards', networkCardRoutes);
|
app.use('/api/network-cards', networkCardRoutes);
|
||||||
|
app.use('/api/inventory', inventoryRoutes);
|
||||||
|
|
||||||
// 静态文件服务
|
// 静态文件服务
|
||||||
app.use('/uploads', express.static('uploads'));
|
app.use('/uploads', express.static('uploads'));
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ const TicketFieldManagement = lazy(() => import('./pages/TicketFieldManagement')
|
|||||||
const SystemSettings = lazy(() => import('./pages/SystemSettings'));
|
const SystemSettings = lazy(() => import('./pages/SystemSettings'));
|
||||||
const CableManagement = lazy(() => import('./pages/CableManagement'));
|
const CableManagement = lazy(() => import('./pages/CableManagement'));
|
||||||
const PortManagement = lazy(() => import('./pages/PortManagement'));
|
const PortManagement = lazy(() => import('./pages/PortManagement'));
|
||||||
|
const InventoryManagement = lazy(() => import('./pages/InventoryManagement'));
|
||||||
|
const InventoryTaskExecution = lazy(() => import('./pages/InventoryTaskExecution'));
|
||||||
|
|
||||||
const { Header, Content, Sider } = Layout;
|
const { Header, Content, Sider } = Layout;
|
||||||
|
|
||||||
@@ -309,6 +311,18 @@ const AppLayout = ({ children }) => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'inventory-management',
|
||||||
|
icon: <InboxOutlined style={{ fontSize: '18px' }} />,
|
||||||
|
label: '资产盘点',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'inventory',
|
||||||
|
icon: <InboxOutlined style={{ fontSize: '16px' }} />,
|
||||||
|
label: <Link to="/inventory">盘点计划</Link>,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'system-management',
|
key: 'system-management',
|
||||||
icon: <UserOutlined style={{ fontSize: '18px' }} />,
|
icon: <UserOutlined style={{ fontSize: '18px' }} />,
|
||||||
@@ -691,6 +705,22 @@ const ThemeConfig = () => {
|
|||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/inventory"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<InventoryManagement />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/inventory/execution"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<InventoryTaskExecution />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/ports"
|
path="/ports"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -182,10 +182,11 @@ function CategoryManagement() {
|
|||||||
>
|
>
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
<Space>
|
<Space>
|
||||||
<Input.Search
|
<Input
|
||||||
placeholder="搜索分类名称、描述"
|
placeholder="搜索分类名称、描述"
|
||||||
style={{ width: 300 }}
|
style={{ width: 300 }}
|
||||||
onSearch={value => setKeyword(value)}
|
value={keyword}
|
||||||
|
onChange={e => setKeyword(e.target.value)}
|
||||||
allowClear
|
allowClear
|
||||||
/>
|
/>
|
||||||
<Select value={status} onChange={setStatus} style={{ width: 120 }}>
|
<Select value={status} onChange={setStatus} style={{ width: 120 }}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useDebounce } from '../hooks/useDebounce';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
Card,
|
Card,
|
||||||
@@ -49,6 +50,7 @@ function ConsumableLogs() {
|
|||||||
consumableId: '',
|
consumableId: '',
|
||||||
dateRange: null,
|
dateRange: null,
|
||||||
});
|
});
|
||||||
|
const debouncedConsumableId = useDebounce(filters.consumableId, 300);
|
||||||
const [importModalVisible, setImportModalVisible] = useState(false);
|
const [importModalVisible, setImportModalVisible] = useState(false);
|
||||||
const [importType, setImportType] = useState('excel');
|
const [importType, setImportType] = useState('excel');
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
@@ -94,12 +96,14 @@ function ConsumableLogs() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchLogs(1, pagination.pageSize, filters);
|
fetchLogs(1, pagination.pageSize, {
|
||||||
}, [filters.operationType, filters.consumableId, filters.dateRange]);
|
...filters,
|
||||||
|
consumableId: debouncedConsumableId,
|
||||||
|
});
|
||||||
|
}, [debouncedConsumableId, filters.operationType, filters.dateRange]);
|
||||||
|
|
||||||
const handleFilterChange = (key, value) => {
|
const handleFilterChange = (key, value) => {
|
||||||
setFilters(prev => ({ ...prev, [key]: value }));
|
setFilters(prev => ({ ...prev, [key]: value }));
|
||||||
fetchLogs(1, pagination.pageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getOperationTag = type => {
|
const getOperationTag = type => {
|
||||||
@@ -528,11 +532,12 @@ function ConsumableLogs() {
|
|||||||
>
|
>
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Input.Search
|
<Input
|
||||||
placeholder="搜索耗材ID"
|
placeholder="搜索耗材ID"
|
||||||
style={{ width: 200 }}
|
style={{ width: 200 }}
|
||||||
allowClear
|
allowClear
|
||||||
onSearch={value => handleFilterChange('consumableId', value)}
|
value={filters.consumableId}
|
||||||
|
onChange={e => handleFilterChange('consumableId', e.target.value)}
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -0,0 +1,716 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
DatePicker,
|
||||||
|
message,
|
||||||
|
Card,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Statistic,
|
||||||
|
Popconfirm,
|
||||||
|
Tooltip,
|
||||||
|
Empty,
|
||||||
|
Progress,
|
||||||
|
Tabs,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
FileSearchOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
CheckSquareOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import axios from 'axios';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { designTokens } from '../config/theme';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
const { TabPane } = Tabs;
|
||||||
|
|
||||||
|
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 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: <FileSearchOutlined /> },
|
||||||
|
pending: { color: 'orange', text: '待执行', icon: <ClockCircleOutlined /> },
|
||||||
|
in_progress: { color: 'processing', text: '进行中', icon: <SyncOutlined spin /> },
|
||||||
|
completed: { color: 'success', text: '已完成', icon: <CheckSquareOutlined /> },
|
||||||
|
cancelled: { color: 'error', text: '已取消', icon: <DeleteOutlined /> },
|
||||||
|
};
|
||||||
|
const config = statusMap[status] || statusMap.draft;
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
color={config.color}
|
||||||
|
icon={config.icon}
|
||||||
|
style={{ borderRadius: 6, padding: '2px 8px' }}
|
||||||
|
>
|
||||||
|
{config.text}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeTag = (type) => {
|
||||||
|
const typeMap = {
|
||||||
|
full: { color: 'blue', text: '全面盘点' },
|
||||||
|
partial: { color: 'cyan', text: '局部盘点' },
|
||||||
|
sample: { color: 'purple', text: '抽样盘点' },
|
||||||
|
};
|
||||||
|
const config = typeMap[type] || typeMap.full;
|
||||||
|
return <Tag color={config.color}>{config.text}</Tag>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '44px',
|
||||||
|
height: '44px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
background: 'linear-gradient(135deg, #1890ff15 0%, #1890ff08 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InboxOutlined style={{ fontSize: '22px', color: designTokens.colors.primary.main }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, color: designTokens.colors.text.primary }}>
|
||||||
|
{record.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: designTokens.colors.text.tertiary }}>
|
||||||
|
{record.planId}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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) => (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, color: '#8c8c8c' }}>
|
||||||
|
已盘: {record.checkedDevices || 0} / {record.totalDevices || 0}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: '#52c41a', fontWeight: 600 }}>
|
||||||
|
{getProgressPercent(record)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={getProgressPercent(record)}
|
||||||
|
size="small"
|
||||||
|
strokeColor={{
|
||||||
|
'0%': '#108ee9',
|
||||||
|
'100%': '#52c41a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '异常设备',
|
||||||
|
key: 'abnormal',
|
||||||
|
width: 100,
|
||||||
|
render: (_, record) => (
|
||||||
|
record.abnormalDevices > 0 ?
|
||||||
|
<Tag color="error">{record.abnormalDevices} 异常</Tag> :
|
||||||
|
<span style={{ color: '#8c8c8c' }}>-</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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) => (
|
||||||
|
<Space>
|
||||||
|
<Tooltip title="查看任务">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<InfoCircleOutlined />}
|
||||||
|
onClick={() => handleViewTasks(record)}
|
||||||
|
style={{ color: designTokens.colors.text.secondary }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
{record.status === 'draft' || record.status === 'pending' ? (
|
||||||
|
<Tooltip title="启动盘点">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
onClick={() => handleStart(record)}
|
||||||
|
style={{ color: '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
{record.status === 'in_progress' ? (
|
||||||
|
<>
|
||||||
|
<Tooltip title="继续盘点">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
onClick={() => handleViewTasks(record)}
|
||||||
|
style={{ color: '#1890ff' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="完成盘点">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<CheckCircleOutlined />}
|
||||||
|
onClick={() => handleComplete(record)}
|
||||||
|
style={{ color: '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<Tooltip title="编辑">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => handleEdit(record)}
|
||||||
|
style={{ color: designTokens.colors.primary.main }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除此盘点计划吗?"
|
||||||
|
onConfirm={() => handleDelete(record.planId)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<Button type="text" icon={<DeleteOutlined />} danger />
|
||||||
|
</Tooltip>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
title: '总计划',
|
||||||
|
value: stats.totalPlans,
|
||||||
|
icon: <InboxOutlined />,
|
||||||
|
gradient: 'linear-gradient(135deg, #1890ff 0%, #096dd9 100%)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '已完成',
|
||||||
|
value: stats.completedPlans,
|
||||||
|
icon: <CheckCircleOutlined />,
|
||||||
|
gradient: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '进行中',
|
||||||
|
value: stats.inProgressPlans,
|
||||||
|
icon: <SyncOutlined spin />,
|
||||||
|
gradient: 'linear-gradient(135deg, #faad14 0%, #d48806 100%)',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, background: designTokens.colors.bg, minHeight: '100vh' }}>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{statCards.map((stat, index) => (
|
||||||
|
<Col xs={24} sm={8} key={index}>
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
style={{
|
||||||
|
borderRadius: 16,
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||||
|
background: stat.gradient,
|
||||||
|
}}
|
||||||
|
bodyStyle={{ padding: 20 }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.9)', fontSize: 14, marginBottom: 8 }}>
|
||||||
|
{stat.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#fff', fontSize: 28, fontWeight: 'bold' }}>
|
||||||
|
{stat.value || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 24,
|
||||||
|
color: '#fff'
|
||||||
|
}}>
|
||||||
|
{stat.icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
style={{ borderRadius: 16, boxShadow: '0 2px 8px rgba(0,0,0,0.06)' }}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<TabPane tab="盘点计划列表" key="list">
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索计划名称"
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
style={{ width: 240, borderRadius: 8 }}
|
||||||
|
onChange={(e) => setSearchParamsObj({ keyword: e.target.value })}
|
||||||
|
onPressEnter={() => {
|
||||||
|
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||||
|
fetchPlans();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="选择状态"
|
||||||
|
style={{ width: 140, borderRadius: 8 }}
|
||||||
|
allowClear
|
||||||
|
onChange={(value) => {
|
||||||
|
setSearchParamsObj((prev) => ({ ...prev, status: value }));
|
||||||
|
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Option value="draft">草稿</Select.Option>
|
||||||
|
<Select.Option value="pending">待执行</Select.Option>
|
||||||
|
<Select.Option value="in_progress">进行中</Select.Option>
|
||||||
|
<Select.Option value="completed">已完成</Select.Option>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => { setSearchParamsObj({}); fetchPlans(); fetchStats(); }}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleAdd}
|
||||||
|
style={{ borderRadius: 8, height: 40 }}
|
||||||
|
>
|
||||||
|
新建盘点计划
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={plans}
|
||||||
|
loading={loading}
|
||||||
|
rowKey="planId"
|
||||||
|
pagination={{
|
||||||
|
...pagination,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条记录`,
|
||||||
|
onChange: (page, pageSize) => {
|
||||||
|
setPagination((prev) => ({ ...prev, current: page, pageSize }));
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1200 }}
|
||||||
|
locale={{
|
||||||
|
emptyText: <Empty description="暂无盘点计划" image={Empty.PRESENTED_IMAGE_SIMPLE} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<InboxOutlined style={{ fontSize: 18, color: '#1890ff' }} />
|
||||||
|
<span>{editingPlan ? '编辑盘点计划' : '新建盘点计划'}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
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' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={16}>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="计划名称"
|
||||||
|
rules={[{ required: true, message: '请输入计划名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入计划名称" style={{ borderRadius: 8 }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item
|
||||||
|
name="type"
|
||||||
|
label="盘点类型"
|
||||||
|
rules={[{ required: true, message: '请选择盘点类型' }]}
|
||||||
|
initialValue="full"
|
||||||
|
>
|
||||||
|
<Select style={{ borderRadius: 8 }}>
|
||||||
|
<Select.Option value="full">全面盘点</Select.Option>
|
||||||
|
<Select.Option value="partial">局部盘点</Select.Option>
|
||||||
|
<Select.Option value="sample">抽样盘点</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="description"
|
||||||
|
label="描述"
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={2} placeholder="请输入描述" style={{ borderRadius: 8 }} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="scheduledDate"
|
||||||
|
label="计划日期"
|
||||||
|
>
|
||||||
|
<DatePicker style={{ width: '100%', borderRadius: 8 }} placeholder="选择计划日期" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="targetRooms"
|
||||||
|
label="目标机房"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="选择目标机房(不选则为全部)"
|
||||||
|
allowClear
|
||||||
|
onChange={handleRoomsChange}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
{rooms.map((room) => (
|
||||||
|
<Select.Option key={room.roomId} value={room.roomId}>
|
||||||
|
{room.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="targetRacks"
|
||||||
|
label="目标机柜"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="选择目标机柜(不选则为全部)"
|
||||||
|
allowClear
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
{filteredRacks.map((rack) => (
|
||||||
|
<Select.Option key={rack.rackId} value={rack.rackId}>
|
||||||
|
{rack.name} ({rack.Room?.name || ''})
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'right', marginTop: 16 }}>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => setModalVisible(false)} style={{ borderRadius: 8 }}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" htmlType="submit" style={{ borderRadius: 8 }}>
|
||||||
|
{editingPlan ? '更新' : '创建'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InventoryManagement;
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user