feat(设备管理): 重构设备导出功能,支持导出所有字段
refactor(字段管理): 优化字段选项编辑界面,使用可视化编辑器 refactor(工单字段管理): 重构选项编辑组件,提升用户体验 chore: 移除不再使用的设备字段选项迁移脚本
This commit is contained in:
+175
-130
@@ -1581,6 +1581,181 @@ router.put('/batch-move', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 增强导出设备数据(支持所有字段和自定义字段)
|
||||
router.get('/enhanced-export', async (req, res) => {
|
||||
try {
|
||||
const { deviceIds, format = 'csv' } = req.query;
|
||||
|
||||
// 从数据库读取所有字段配置(不过滤 visible,以导出所有信息)
|
||||
const allFields = await DeviceField.findAll({
|
||||
order: [['order', 'ASC']]
|
||||
});
|
||||
|
||||
// 构建字段映射表
|
||||
const fieldMap = {};
|
||||
const fieldLabels = {};
|
||||
allFields.forEach(field => {
|
||||
fieldMap[field.fieldName] = field;
|
||||
fieldLabels[field.fieldName] = field.displayName;
|
||||
});
|
||||
|
||||
// 构建查询条件
|
||||
const where = {};
|
||||
if (deviceIds) {
|
||||
const ids = Array.isArray(deviceIds) ? deviceIds : [deviceIds];
|
||||
where.deviceId = { [Op.in]: ids };
|
||||
}
|
||||
|
||||
// 查询设备数据
|
||||
const devices = await Device.findAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: Rack,
|
||||
include: [{ model: Room }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (devices.length === 0) {
|
||||
return res.status(404).json({ error: '未找到指定的设备' });
|
||||
}
|
||||
|
||||
// 状态和类型映射
|
||||
const statusMap = {
|
||||
running: '运行中',
|
||||
maintenance: '维护中',
|
||||
offline: '离线',
|
||||
fault: '故障'
|
||||
};
|
||||
const typeMap = {
|
||||
server: '服务器',
|
||||
switch: '交换机',
|
||||
router: '路由器',
|
||||
storage: '存储设备',
|
||||
other: '其他设备'
|
||||
};
|
||||
|
||||
// 准备导出数据 - 遍历所有设备
|
||||
const exportData = devices.map(device => {
|
||||
const data = {};
|
||||
|
||||
// 首先处理关联字段(机房)- 如果 DeviceField 中没有配置 roomName,也导出机房信息
|
||||
const hasRoomField = allFields.some(f => f.fieldName === 'roomName');
|
||||
if (!hasRoomField) {
|
||||
data['所在机房'] = device.Rack?.Room?.name || '';
|
||||
}
|
||||
|
||||
// 遍历所有字段配置动态获取值
|
||||
allFields.forEach(field => {
|
||||
const fieldName = field.fieldName;
|
||||
const label = field.displayName;
|
||||
|
||||
// 首先检查 device 表的直字段
|
||||
if (device[fieldName] !== undefined && device[fieldName] !== null) {
|
||||
if (fieldName === 'rackId') {
|
||||
data[label] = device.Rack?.name || '';
|
||||
} else if (fieldName === 'roomName') {
|
||||
data[label] = device.Rack?.Room?.name || '';
|
||||
} else if (fieldName === 'status') {
|
||||
data[label] = statusMap[device.status] || device.status || '';
|
||||
} else if (fieldName === 'type') {
|
||||
data[label] = typeMap[device.type] || device.type || '';
|
||||
} else if (fieldName === 'purchaseDate' || fieldName === 'warrantyExpiry') {
|
||||
data[label] = device[fieldName] ? new Date(device[fieldName]).toLocaleDateString('zh-CN') : '';
|
||||
} else {
|
||||
data[label] = device[fieldName];
|
||||
}
|
||||
} else if (device.customFields && typeof device.customFields === 'object' && device.customFields[fieldName] !== undefined) {
|
||||
data[label] = device.customFields[fieldName];
|
||||
} else {
|
||||
// 设备表中没有该字段且 customFields 中也没有,设为空字符串
|
||||
data[label] = '';
|
||||
}
|
||||
});
|
||||
|
||||
// 展开 customFields 中额外的自定义字段(不在 DeviceField 配置中的)
|
||||
if (device.customFields && typeof device.customFields === 'object') {
|
||||
Object.entries(device.customFields).forEach(([key, value]) => {
|
||||
if (!fieldMap[key]) {
|
||||
data[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
// CSV 导出
|
||||
if (format === 'csv') {
|
||||
// 构建完整的 header 列表(基于所有字段配置 + customFields 中的额外字段)
|
||||
const headerSet = new Set();
|
||||
|
||||
// 添加机房字段(如果存在)
|
||||
const hasRoomField = allFields.some(f => f.fieldName === 'roomName');
|
||||
if (!hasRoomField) {
|
||||
headerSet.add('所在机房');
|
||||
}
|
||||
|
||||
// 添加所有 DeviceField 配置的字段
|
||||
allFields.forEach(field => {
|
||||
headerSet.add(field.displayName);
|
||||
});
|
||||
|
||||
// 收集所有设备 customFields 中的额外字段键
|
||||
devices.forEach(device => {
|
||||
if (device.customFields && typeof device.customFields === 'object') {
|
||||
Object.keys(device.customFields).forEach(key => {
|
||||
if (!fieldMap[key]) {
|
||||
headerSet.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const headers = Array.from(headerSet).map(key => ({ id: key, title: key }));
|
||||
|
||||
if (headers.length === 0) {
|
||||
return res.status(400).json({ error: '没有可导出的字段' });
|
||||
}
|
||||
|
||||
const csvWriter = createObjectCsvWriter({
|
||||
path: path.join(__dirname, '../temp/enhanced_export.csv'),
|
||||
header: headers,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
|
||||
if (!fs.existsSync(path.join(__dirname, '../temp'))) {
|
||||
fs.mkdirSync(path.join(__dirname, '../temp'));
|
||||
}
|
||||
|
||||
await csvWriter.writeRecords(exportData);
|
||||
|
||||
const csvContent = fs.readFileSync(path.join(__dirname, '../temp/enhanced_export.csv'), 'utf8');
|
||||
const gbkContent = iconv.encode(csvContent, 'gbk');
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=devices.csv');
|
||||
res.send(gbkContent);
|
||||
|
||||
fs.unlinkSync(path.join(__dirname, '../temp/enhanced_export.csv'));
|
||||
} else {
|
||||
// JSON 导出
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=devices.json');
|
||||
res.json({
|
||||
exportTime: new Date().toISOString(),
|
||||
totalCount: devices.length,
|
||||
fields: Object.values(fieldLabels),
|
||||
devices: exportData
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('增强导出失败:', error);
|
||||
res.status(500).json({ error: '增强导出失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取单个设备
|
||||
router.get('/:deviceId', async (req, res) => {
|
||||
try {
|
||||
@@ -2044,134 +2219,4 @@ router.delete('/:deviceId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 增强导出设备数据(支持自定义字段)
|
||||
router.get('/enhanced-export', async (req, res) => {
|
||||
try {
|
||||
const { deviceIds, format = 'csv', fields, fieldLabels } = req.query;
|
||||
|
||||
// 解析字段列表
|
||||
let selectedFields = [];
|
||||
try {
|
||||
selectedFields = fields ? JSON.parse(fields) : [];
|
||||
} catch (e) {
|
||||
selectedFields = [];
|
||||
}
|
||||
|
||||
// 解析字段标签
|
||||
let fieldLabelMap = {};
|
||||
try {
|
||||
fieldLabelMap = fieldLabels ? JSON.parse(fieldLabels) : {};
|
||||
} catch (e) {
|
||||
fieldLabelMap = {};
|
||||
}
|
||||
|
||||
// 构建查询条件
|
||||
const where = {};
|
||||
if (deviceIds) {
|
||||
const ids = Array.isArray(deviceIds) ? deviceIds : [deviceIds];
|
||||
where.deviceId = { [Op.in]: ids };
|
||||
}
|
||||
|
||||
// 查询设备数据
|
||||
const devices = await Device.findAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: Rack,
|
||||
include: [
|
||||
{ model: Room }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (devices.length === 0) {
|
||||
return res.status(404).json({ error: '未找到指定的设备' });
|
||||
}
|
||||
|
||||
// 准备导出数据
|
||||
const exportData = devices.map(device => {
|
||||
const data = {};
|
||||
|
||||
selectedFields.forEach(fieldName => {
|
||||
// 映射字段名到中文标签
|
||||
const label = fieldLabelMap[fieldName] || fieldName;
|
||||
|
||||
// 根据字段名获取值
|
||||
if (fieldName === 'rackName') {
|
||||
data[label] = device.Rack?.name || '';
|
||||
} else if (fieldName === 'roomName') {
|
||||
data[label] = device.Rack?.Room?.name || '';
|
||||
} else if (fieldName === 'status') {
|
||||
const statusMap = {
|
||||
running: '运行中',
|
||||
maintenance: '维护中',
|
||||
offline: '离线',
|
||||
fault: '故障'
|
||||
};
|
||||
data[label] = statusMap[device.status] || device.status;
|
||||
} else if (fieldName === 'type') {
|
||||
const typeMap = {
|
||||
server: '服务器',
|
||||
switch: '交换机',
|
||||
router: '路由器',
|
||||
storage: '存储设备',
|
||||
other: '其他设备'
|
||||
};
|
||||
data[label] = typeMap[device.type] || device.type;
|
||||
} else if (fieldName === 'purchaseDate' || fieldName === 'warrantyExpiry') {
|
||||
data[label] = device[fieldName] ? new Date(device[fieldName]).toLocaleDateString('zh-CN') : '';
|
||||
} else if (fieldName === 'customFields' && device.customFields) {
|
||||
// 如果选择导出自定义字段,展开为单独的列
|
||||
Object.entries(device.customFields).forEach(([key, value]) => {
|
||||
data[key] = value;
|
||||
});
|
||||
} else if (device[fieldName] !== undefined) {
|
||||
data[label] = device[fieldName];
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
if (format === 'json') {
|
||||
// JSON格式导出
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=devices.json');
|
||||
res.json({
|
||||
exportTime: new Date().toISOString(),
|
||||
totalCount: devices.length,
|
||||
devices: exportData
|
||||
});
|
||||
} else {
|
||||
// CSV格式导出
|
||||
const csvWriter = createObjectCsvWriter({
|
||||
path: path.join(__dirname, '../temp/enhanced_export.csv'),
|
||||
header: Object.keys(exportData[0] || {}).map(key => ({ id: key, title: key })),
|
||||
encoding: 'utf8'
|
||||
});
|
||||
|
||||
// 确保temp目录存在
|
||||
if (!fs.existsSync(path.join(__dirname, '../temp'))) {
|
||||
fs.mkdirSync(path.join(__dirname, '../temp'));
|
||||
}
|
||||
|
||||
await csvWriter.writeRecords(exportData);
|
||||
|
||||
const csvContent = fs.readFileSync(path.join(__dirname, '../temp/enhanced_export.csv'), 'utf8');
|
||||
const gbkContent = iconv.encode(csvContent, 'gbk');
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=devices.csv');
|
||||
|
||||
res.send(gbkContent);
|
||||
|
||||
fs.unlinkSync(path.join(__dirname, '../temp/enhanced_export.csv'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('增强导出失败:', error);
|
||||
res.status(500).json({ error: '增强导出失败' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -94,11 +94,6 @@ const migrations = [
|
||||
name: '设备字段系统标记',
|
||||
description: '为 deviceFields 表添加 isSystem 字段,标记系统字段不可删除',
|
||||
migrate: migrateDeviceFieldsIsSystem
|
||||
},
|
||||
{
|
||||
name: '设备字段Options配置',
|
||||
description: '确保 deviceFields 表的 type 和 status 字段有正确的 options 配置',
|
||||
migrate: migrateDeviceFieldsOptions
|
||||
}
|
||||
];
|
||||
|
||||
@@ -154,7 +149,7 @@ async function runMigrations() {
|
||||
|
||||
async function getTableColumns(tableName) {
|
||||
const dialect = sequelize.getDialect();
|
||||
|
||||
|
||||
if (dialect === 'sqlite') {
|
||||
const tableInfo = await sequelize.query(
|
||||
`PRAGMA table_info(${tableName})`,
|
||||
@@ -172,7 +167,7 @@ async function getTableColumns(tableName) {
|
||||
|
||||
async function tableExists(tableName) {
|
||||
const dialect = sequelize.getDialect();
|
||||
|
||||
|
||||
if (dialect === 'sqlite') {
|
||||
const tables = await sequelize.query(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
||||
@@ -190,10 +185,10 @@ async function tableExists(tableName) {
|
||||
|
||||
async function addColumnIfNotExists(tableName, columnName, columnDef) {
|
||||
const columns = await getTableColumns(tableName);
|
||||
|
||||
|
||||
if (!columns.includes(columnName)) {
|
||||
const dialect = sequelize.getDialect();
|
||||
const sql = dialect === 'sqlite'
|
||||
const sql = dialect === 'sqlite'
|
||||
? `ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnDef}`
|
||||
: `ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnDef}`;
|
||||
await sequelize.query(sql);
|
||||
@@ -281,7 +276,7 @@ async function migrateConsumableLogDecouple() {
|
||||
|
||||
async function removeConsumableLogFK() {
|
||||
const dialect = sequelize.getDialect();
|
||||
|
||||
|
||||
if (dialect === 'sqlite') {
|
||||
const fks = await sequelize.query(
|
||||
`PRAGMA foreign_key_list(consumable_logs);`,
|
||||
@@ -416,7 +411,7 @@ async function migrateConsumableLogArchive() {
|
||||
|
||||
async function migrateSnList() {
|
||||
const tables = ['consumables', 'consumable_records', 'consumable_logs'];
|
||||
|
||||
|
||||
for (const table of tables) {
|
||||
if (await tableExists(table)) {
|
||||
const columnDef = dbDialect === 'sqlite' ? "TEXT DEFAULT '[]'" : "JSON";
|
||||
@@ -434,7 +429,7 @@ async function migrateDeviceModelField() {
|
||||
}
|
||||
|
||||
const dialect = sequelize.getDialect();
|
||||
|
||||
|
||||
if (dialect === 'mysql') {
|
||||
await sequelize.query(
|
||||
'ALTER TABLE devices MODIFY COLUMN model VARCHAR(255) NULL'
|
||||
@@ -446,7 +441,7 @@ async function migrateDeviceModelField() {
|
||||
console.log(' model_old 字段已存在,跳过迁移');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await sequelize.query('ALTER TABLE devices RENAME COLUMN model TO model_old');
|
||||
await sequelize.query('ALTER TABLE devices ADD COLUMN model VARCHAR(255)');
|
||||
await sequelize.query('UPDATE devices SET model = model_old');
|
||||
@@ -457,14 +452,14 @@ async function migrateDeviceModelField() {
|
||||
|
||||
async function migrateDeviceFieldsConfig() {
|
||||
const DeviceField = require('../models/DeviceField');
|
||||
|
||||
|
||||
const updates = [
|
||||
{ fieldName: 'model', required: false },
|
||||
{ fieldName: 'powerConsumption', required: true },
|
||||
{ fieldName: 'purchaseDate', required: false },
|
||||
{ fieldName: 'warrantyExpiry', required: false },
|
||||
];
|
||||
|
||||
|
||||
for (const update of updates) {
|
||||
const field = await DeviceField.findOne({ where: { fieldName: update.fieldName } });
|
||||
if (field && field.required !== update.required) {
|
||||
@@ -480,7 +475,7 @@ async function migrateDeviceFieldsConfig() {
|
||||
|
||||
async function migrateDeviceFieldsNullable() {
|
||||
const dialect = sequelize.getDialect();
|
||||
|
||||
|
||||
if (dialect === 'mysql') {
|
||||
const alterCommands = [
|
||||
"ALTER TABLE devices MODIFY COLUMN name VARCHAR(255) NULL",
|
||||
@@ -493,7 +488,7 @@ async function migrateDeviceFieldsNullable() {
|
||||
"ALTER TABLE devices MODIFY COLUMN powerConsumption FLOAT NULL",
|
||||
"ALTER TABLE devices MODIFY COLUMN customFields JSON NULL"
|
||||
];
|
||||
|
||||
|
||||
for (const sql of alterCommands) {
|
||||
try {
|
||||
await sequelize.query(sql);
|
||||
@@ -504,21 +499,21 @@ async function migrateDeviceFieldsNullable() {
|
||||
}
|
||||
}
|
||||
console.log(' devices 表字段已改为可空');
|
||||
|
||||
|
||||
} else if (dialect === 'sqlite') {
|
||||
const columns = await getTableColumns('devices');
|
||||
const hasNullableFlag = columns.includes('_nullable_migration_done');
|
||||
|
||||
|
||||
if (hasNullableFlag) {
|
||||
console.log(' 已完成可空迁移,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await sequelize.query('PRAGMA foreign_keys = OFF');
|
||||
|
||||
|
||||
try {
|
||||
await sequelize.query('DROP TABLE IF EXISTS devices_new');
|
||||
|
||||
|
||||
await sequelize.query(`
|
||||
CREATE TABLE devices_new (
|
||||
deviceId VARCHAR(255) PRIMARY KEY NOT NULL UNIQUE,
|
||||
@@ -541,28 +536,28 @@ async function migrateDeviceFieldsNullable() {
|
||||
_nullable_migration_done INTEGER DEFAULT 1
|
||||
)
|
||||
`);
|
||||
|
||||
|
||||
await sequelize.query(`
|
||||
INSERT INTO devices_new (
|
||||
deviceId, name, type, model, serialNumber, rackId, position, height,
|
||||
powerConsumption, status, purchaseDate, warrantyExpiry, ipAddress,
|
||||
description, customFields, createdAt, updatedAt
|
||||
)
|
||||
SELECT
|
||||
SELECT
|
||||
deviceId, name, type, model, serialNumber, rackId, position, height,
|
||||
powerConsumption, status, purchaseDate, warrantyExpiry, ipAddress,
|
||||
description, customFields, createdAt, updatedAt
|
||||
FROM devices
|
||||
`);
|
||||
|
||||
|
||||
await sequelize.query('DROP TABLE devices');
|
||||
await sequelize.query('ALTER TABLE devices_new RENAME TO devices');
|
||||
|
||||
|
||||
await sequelize.query('CREATE INDEX IF NOT EXISTS idx_devices_status ON devices(status)');
|
||||
await sequelize.query('CREATE INDEX IF NOT EXISTS idx_devices_type ON devices(type)');
|
||||
await sequelize.query('CREATE INDEX IF NOT EXISTS idx_devices_rackId ON devices(rackId)');
|
||||
await sequelize.query('CREATE INDEX IF NOT EXISTS idx_devices_name ON devices(name)');
|
||||
|
||||
|
||||
console.log(' devices 表字段已改为可空');
|
||||
} finally {
|
||||
await sequelize.query('PRAGMA foreign_keys = ON');
|
||||
@@ -637,56 +632,6 @@ async function migrateDeviceFieldsIsSystem() {
|
||||
console.log(' 设备字段系统标记迁移完成');
|
||||
}
|
||||
|
||||
async function migrateDeviceFieldsOptions() {
|
||||
const DeviceField = require('../models/DeviceField');
|
||||
|
||||
if (!(await tableExists('deviceFields'))) {
|
||||
console.log(' deviceFields 表不存在,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceTypeOptions = [
|
||||
{ value: 'server', label: '服务器' },
|
||||
{ value: 'switch', label: '交换机' },
|
||||
{ value: 'router', label: '路由器' },
|
||||
{ value: 'storage', label: '存储设备' },
|
||||
{ value: 'other', label: '其他设备' }
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'running', label: '运行中' },
|
||||
{ value: 'maintenance', label: '维护中' },
|
||||
{ value: 'offline', label: '离线' },
|
||||
{ value: 'fault', label: '故障' }
|
||||
];
|
||||
|
||||
const typeField = await DeviceField.findOne({ where: { fieldName: 'type' } });
|
||||
if (typeField) {
|
||||
if (!typeField.options || typeField.options.length === 0) {
|
||||
await typeField.update({ options: deviceTypeOptions });
|
||||
console.log(' 已更新 type 字段的 options');
|
||||
} else {
|
||||
console.log(' type 字段 options 已存在,跳过');
|
||||
}
|
||||
} else {
|
||||
console.log(' type 字段不存在,跳过');
|
||||
}
|
||||
|
||||
const statusField = await DeviceField.findOne({ where: { fieldName: 'status' } });
|
||||
if (statusField) {
|
||||
if (!statusField.options || statusField.options.length === 0) {
|
||||
await statusField.update({ options: statusOptions });
|
||||
console.log(' 已更新 status 字段的 options');
|
||||
} else {
|
||||
console.log(' status 字段 options 已存在,跳过');
|
||||
}
|
||||
} else {
|
||||
console.log(' status 字段不存在,跳过');
|
||||
}
|
||||
|
||||
console.log(' 设备字段 Options 配置迁移完成');
|
||||
}
|
||||
|
||||
async function migrateIdleDeviceAndBusiness() {
|
||||
const queryInterface = sequelize.getQueryInterface();
|
||||
const dialect = sequelize.getDialect();
|
||||
|
||||
Reference in New Issue
Block a user