From f82b8202fee6218c148b12b803e93f3996a870d8 Mon Sep 17 00:00:00 2001 From: zhang1106 <849185023@qq.com> Date: Mon, 23 Mar 2026 14:07:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=AE=BE=E5=A4=87=E7=AE=A1=E7=90=86):=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E8=AE=BE=E5=A4=87=E5=AF=BC=E5=87=BA=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E5=AF=BC=E5=87=BA=E6=89=80?= =?UTF-8?q?=E6=9C=89=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor(字段管理): 优化字段选项编辑界面,使用可视化编辑器 refactor(工单字段管理): 重构选项编辑组件,提升用户体验 chore: 移除不再使用的设备字段选项迁移脚本 --- backend/routes/devices.js | 305 ++++++++++-------- backend/scripts/migrate-all.js | 99 ++---- .../src/components/device/ExportModal.jsx | 57 +--- frontend/src/pages/DeviceFieldManagement.jsx | 208 +++++++++++- frontend/src/pages/DeviceManagement.jsx | 10 +- frontend/src/pages/TicketFieldManagement.jsx | 204 +++++++++++- 6 files changed, 589 insertions(+), 294 deletions(-) diff --git a/backend/routes/devices.js b/backend/routes/devices.js index d296817..2e0fe11 100644 --- a/backend/routes/devices.js +++ b/backend/routes/devices.js @@ -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; \ No newline at end of file diff --git a/backend/scripts/migrate-all.js b/backend/scripts/migrate-all.js index 21a563c..e5be450 100644 --- a/backend/scripts/migrate-all.js +++ b/backend/scripts/migrate-all.js @@ -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(); diff --git a/frontend/src/components/device/ExportModal.jsx b/frontend/src/components/device/ExportModal.jsx index b6fb565..9a913bf 100644 --- a/frontend/src/components/device/ExportModal.jsx +++ b/frontend/src/components/device/ExportModal.jsx @@ -1,5 +1,5 @@ -import React, { useState, useMemo } from 'react'; -import { Modal, Form, Select, Checkbox, Button, message } from 'antd'; +import React, { useState } from 'react'; +import { Modal, Form, Select, Button } from 'antd'; import { ExportOutlined } from '@ant-design/icons'; import { designTokens } from '../../config/theme'; @@ -15,7 +15,6 @@ const modalHeaderStyle = { const ExportModal = ({ visible, - deviceFields, selectedDevices, currentPageDevices, allDevices, @@ -24,31 +23,14 @@ const ExportModal = ({ }) => { const [exportFormat, setExportFormat] = useState('csv'); const [exportScope, setExportScope] = useState('selected'); - const [exportFields, setExportFields] = useState([]); const [exportLoading, setExportLoading] = useState(false); - const visibleFields = useMemo(() => { - return deviceFields.filter((f) => f.visible && f.fieldName !== 'rackId'); - }, [deviceFields]); - - React.useEffect(() => { - if (visible) { - setExportFields(visibleFields.map((f) => f.fieldName)); - } - }, [visible, visibleFields]); - const handleExport = async () => { - if (exportFields.length === 0) { - message.warning('请至少选择一个导出字段'); - return; - } - setExportLoading(true); try { await onExport({ format: exportFormat, scope: exportScope, - fields: exportFields, }); onCancel(); } finally { @@ -121,7 +103,7 @@ const ExportModal = ({ }, body: { padding: '24px' }, }} - width={600} + width={500} >
@@ -137,39 +119,8 @@ const ExportModal = ({ - -
- {visibleFields.map((field) => ( -
- { - if (e.target.checked) { - setExportFields([...exportFields, field.fieldName]); - } else { - setExportFields(exportFields.filter((f) => f !== field.fieldName)); - } - }} - > - {field.displayName} - -
- ))} -
-
- 已选择{' '} - {selectedDevices.length} 个设备, - 将导出{' '} - {exportFields.length} 个字段 + 将导出设备的所有字段(包括自定义字段)
diff --git a/frontend/src/pages/DeviceFieldManagement.jsx b/frontend/src/pages/DeviceFieldManagement.jsx index aff89a7..a7d3c79 100644 --- a/frontend/src/pages/DeviceFieldManagement.jsx +++ b/frontend/src/pages/DeviceFieldManagement.jsx @@ -26,6 +26,8 @@ import { CalendarOutlined, FileTextOutlined, LockOutlined, + PlusCircleOutlined, + MinusCircleOutlined, } from '@ant-design/icons'; import axios from 'axios'; import { designTokens } from '../config/theme'; @@ -33,6 +35,161 @@ import CloseButton from '../components/CloseButton'; const { Option = Select.Option } = Select; +const OptionsEditor = ({ value = [], onChange }) => { + const handleAdd = () => { + onChange([...value, { value: '', label: '' }]); + }; + + const handleRemove = index => { + onChange(value.filter((_, i) => i !== index)); + }; + + const handleUpdate = (index, field, fieldValue) => { + const newOptions = value.map((opt, i) => + i === index ? { ...opt, [field]: fieldValue } : opt + ); + onChange(newOptions); + }; + + return ( +
+
+
+ + 选项配置 + + + (值用于提交,标签用于显示) + +
+ + {value.length === 0 ? ( +
+
+ 暂无选项 +
+ +
+ ) : ( +
+
+ 值(value) + 标签(label) +
+ {value.map((opt, index) => ( +
+
+ {index + 1} +
+ handleUpdate(index, 'value', e.target.value)} + style={{ width: '160px', borderRadius: '6px' }} + /> + handleUpdate(index, 'label', e.target.value)} + style={{ width: '160px', borderRadius: '6px' }} + /> + +
+ ))} +
+ )} + + {value.length > 0 && ( + + )} +
+ ); +}; + const pageContainerStyle = { minHeight: '100vh', background: designTokens.colors.background.secondary, @@ -176,6 +333,7 @@ function DeviceFieldManagement() { const [loading, setLoading] = useState(true); const [modalVisible, setModalVisible] = useState(false); const [editingField, setEditingField] = useState(null); + const [selectedFieldType, setSelectedFieldType] = useState('string'); const [form] = Form.useForm(); const [pagination, setPagination] = useState({ current: 1, @@ -208,11 +366,13 @@ function DeviceFieldManagement() { if (field) { const fieldData = { ...field, - options: field.options ? JSON.stringify(field.options, null, 2) : '', + options: field.options || [], }; + setSelectedFieldType(field.fieldType || 'string'); form.setFieldsValue(fieldData); } else { form.resetFields(); + setSelectedFieldType('string'); } setModalVisible(true); }; @@ -220,13 +380,21 @@ function DeviceFieldManagement() { const handleCancel = () => { setModalVisible(false); setEditingField(null); + setSelectedFieldType('string'); + }; + + const handleFieldTypeChange = value => { + setSelectedFieldType(value); + if (value !== 'select') { + form.setFieldsValue({ options: [] }); + } }; const handleSubmit = async values => { try { const fieldData = { ...values, - options: values.options ? JSON.parse(values.options) : null, + options: values.options && values.options.length > 0 ? values.options : null, }; if (editingField) { @@ -240,6 +408,7 @@ function DeviceFieldManagement() { setModalVisible(false); fetchFields(); setEditingField(null); + setSelectedFieldType('string'); } catch (error) { message.error(editingField ? '字段更新失败' : '字段创建失败'); console.error(editingField ? '字段更新失败:' : '字段创建失败:', error); @@ -474,7 +643,7 @@ function DeviceFieldManagement() { label={字段类型} rules={[{ required: true, message: '请选择字段类型' }]} > - {FIELD_TYPE_OPTIONS.map(opt => (