feat(设备管理): 重构设备导出功能,支持导出所有字段

refactor(字段管理): 优化字段选项编辑界面,使用可视化编辑器

refactor(工单字段管理): 重构选项编辑组件,提升用户体验

chore: 移除不再使用的设备字段选项迁移脚本
This commit is contained in:
zhang1106
2026-03-23 14:07:40 +08:00
parent f51c39284b
commit f82b8202fe
6 changed files with 589 additions and 294 deletions
+175 -130
View File
@@ -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) => { router.get('/:deviceId', async (req, res) => {
try { 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; module.exports = router;
+22 -77
View File
@@ -94,11 +94,6 @@ const migrations = [
name: '设备字段系统标记', name: '设备字段系统标记',
description: '为 deviceFields 表添加 isSystem 字段,标记系统字段不可删除', description: '为 deviceFields 表添加 isSystem 字段,标记系统字段不可删除',
migrate: migrateDeviceFieldsIsSystem migrate: migrateDeviceFieldsIsSystem
},
{
name: '设备字段Options配置',
description: '确保 deviceFields 表的 type 和 status 字段有正确的 options 配置',
migrate: migrateDeviceFieldsOptions
} }
]; ];
@@ -154,7 +149,7 @@ async function runMigrations() {
async function getTableColumns(tableName) { async function getTableColumns(tableName) {
const dialect = sequelize.getDialect(); const dialect = sequelize.getDialect();
if (dialect === 'sqlite') { if (dialect === 'sqlite') {
const tableInfo = await sequelize.query( const tableInfo = await sequelize.query(
`PRAGMA table_info(${tableName})`, `PRAGMA table_info(${tableName})`,
@@ -172,7 +167,7 @@ async function getTableColumns(tableName) {
async function tableExists(tableName) { async function tableExists(tableName) {
const dialect = sequelize.getDialect(); const dialect = sequelize.getDialect();
if (dialect === 'sqlite') { if (dialect === 'sqlite') {
const tables = await sequelize.query( const tables = await sequelize.query(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
@@ -190,10 +185,10 @@ async function tableExists(tableName) {
async function addColumnIfNotExists(tableName, columnName, columnDef) { async function addColumnIfNotExists(tableName, columnName, columnDef) {
const columns = await getTableColumns(tableName); const columns = await getTableColumns(tableName);
if (!columns.includes(columnName)) { if (!columns.includes(columnName)) {
const dialect = sequelize.getDialect(); 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}`
: `ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnDef}`; : `ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnDef}`;
await sequelize.query(sql); await sequelize.query(sql);
@@ -281,7 +276,7 @@ async function migrateConsumableLogDecouple() {
async function removeConsumableLogFK() { async function removeConsumableLogFK() {
const dialect = sequelize.getDialect(); const dialect = sequelize.getDialect();
if (dialect === 'sqlite') { if (dialect === 'sqlite') {
const fks = await sequelize.query( const fks = await sequelize.query(
`PRAGMA foreign_key_list(consumable_logs);`, `PRAGMA foreign_key_list(consumable_logs);`,
@@ -416,7 +411,7 @@ async function migrateConsumableLogArchive() {
async function migrateSnList() { async function migrateSnList() {
const tables = ['consumables', 'consumable_records', 'consumable_logs']; const tables = ['consumables', 'consumable_records', 'consumable_logs'];
for (const table of tables) { for (const table of tables) {
if (await tableExists(table)) { if (await tableExists(table)) {
const columnDef = dbDialect === 'sqlite' ? "TEXT DEFAULT '[]'" : "JSON"; const columnDef = dbDialect === 'sqlite' ? "TEXT DEFAULT '[]'" : "JSON";
@@ -434,7 +429,7 @@ async function migrateDeviceModelField() {
} }
const dialect = sequelize.getDialect(); const dialect = sequelize.getDialect();
if (dialect === 'mysql') { if (dialect === 'mysql') {
await sequelize.query( await sequelize.query(
'ALTER TABLE devices MODIFY COLUMN model VARCHAR(255) NULL' 'ALTER TABLE devices MODIFY COLUMN model VARCHAR(255) NULL'
@@ -446,7 +441,7 @@ async function migrateDeviceModelField() {
console.log(' model_old 字段已存在,跳过迁移'); console.log(' model_old 字段已存在,跳过迁移');
return; return;
} }
await sequelize.query('ALTER TABLE devices RENAME COLUMN model TO model_old'); 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('ALTER TABLE devices ADD COLUMN model VARCHAR(255)');
await sequelize.query('UPDATE devices SET model = model_old'); await sequelize.query('UPDATE devices SET model = model_old');
@@ -457,14 +452,14 @@ async function migrateDeviceModelField() {
async function migrateDeviceFieldsConfig() { async function migrateDeviceFieldsConfig() {
const DeviceField = require('../models/DeviceField'); const DeviceField = require('../models/DeviceField');
const updates = [ const updates = [
{ fieldName: 'model', required: false }, { fieldName: 'model', required: false },
{ fieldName: 'powerConsumption', required: true }, { fieldName: 'powerConsumption', required: true },
{ fieldName: 'purchaseDate', required: false }, { fieldName: 'purchaseDate', required: false },
{ fieldName: 'warrantyExpiry', required: false }, { fieldName: 'warrantyExpiry', required: false },
]; ];
for (const update of updates) { for (const update of updates) {
const field = await DeviceField.findOne({ where: { fieldName: update.fieldName } }); const field = await DeviceField.findOne({ where: { fieldName: update.fieldName } });
if (field && field.required !== update.required) { if (field && field.required !== update.required) {
@@ -480,7 +475,7 @@ async function migrateDeviceFieldsConfig() {
async function migrateDeviceFieldsNullable() { async function migrateDeviceFieldsNullable() {
const dialect = sequelize.getDialect(); const dialect = sequelize.getDialect();
if (dialect === 'mysql') { if (dialect === 'mysql') {
const alterCommands = [ const alterCommands = [
"ALTER TABLE devices MODIFY COLUMN name VARCHAR(255) NULL", "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 powerConsumption FLOAT NULL",
"ALTER TABLE devices MODIFY COLUMN customFields JSON NULL" "ALTER TABLE devices MODIFY COLUMN customFields JSON NULL"
]; ];
for (const sql of alterCommands) { for (const sql of alterCommands) {
try { try {
await sequelize.query(sql); await sequelize.query(sql);
@@ -504,21 +499,21 @@ async function migrateDeviceFieldsNullable() {
} }
} }
console.log(' devices 表字段已改为可空'); console.log(' devices 表字段已改为可空');
} else if (dialect === 'sqlite') { } else if (dialect === 'sqlite') {
const columns = await getTableColumns('devices'); const columns = await getTableColumns('devices');
const hasNullableFlag = columns.includes('_nullable_migration_done'); const hasNullableFlag = columns.includes('_nullable_migration_done');
if (hasNullableFlag) { if (hasNullableFlag) {
console.log(' 已完成可空迁移,跳过'); console.log(' 已完成可空迁移,跳过');
return; return;
} }
await sequelize.query('PRAGMA foreign_keys = OFF'); await sequelize.query('PRAGMA foreign_keys = OFF');
try { try {
await sequelize.query('DROP TABLE IF EXISTS devices_new'); await sequelize.query('DROP TABLE IF EXISTS devices_new');
await sequelize.query(` await sequelize.query(`
CREATE TABLE devices_new ( CREATE TABLE devices_new (
deviceId VARCHAR(255) PRIMARY KEY NOT NULL UNIQUE, deviceId VARCHAR(255) PRIMARY KEY NOT NULL UNIQUE,
@@ -541,28 +536,28 @@ async function migrateDeviceFieldsNullable() {
_nullable_migration_done INTEGER DEFAULT 1 _nullable_migration_done INTEGER DEFAULT 1
) )
`); `);
await sequelize.query(` await sequelize.query(`
INSERT INTO devices_new ( INSERT INTO devices_new (
deviceId, name, type, model, serialNumber, rackId, position, height, deviceId, name, type, model, serialNumber, rackId, position, height,
powerConsumption, status, purchaseDate, warrantyExpiry, ipAddress, powerConsumption, status, purchaseDate, warrantyExpiry, ipAddress,
description, customFields, createdAt, updatedAt description, customFields, createdAt, updatedAt
) )
SELECT SELECT
deviceId, name, type, model, serialNumber, rackId, position, height, deviceId, name, type, model, serialNumber, rackId, position, height,
powerConsumption, status, purchaseDate, warrantyExpiry, ipAddress, powerConsumption, status, purchaseDate, warrantyExpiry, ipAddress,
description, customFields, createdAt, updatedAt description, customFields, createdAt, updatedAt
FROM devices FROM devices
`); `);
await sequelize.query('DROP TABLE devices'); await sequelize.query('DROP TABLE devices');
await sequelize.query('ALTER TABLE devices_new RENAME TO 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_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_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_rackId ON devices(rackId)');
await sequelize.query('CREATE INDEX IF NOT EXISTS idx_devices_name ON devices(name)'); await sequelize.query('CREATE INDEX IF NOT EXISTS idx_devices_name ON devices(name)');
console.log(' devices 表字段已改为可空'); console.log(' devices 表字段已改为可空');
} finally { } finally {
await sequelize.query('PRAGMA foreign_keys = ON'); await sequelize.query('PRAGMA foreign_keys = ON');
@@ -637,56 +632,6 @@ async function migrateDeviceFieldsIsSystem() {
console.log(' 设备字段系统标记迁移完成'); 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() { async function migrateIdleDeviceAndBusiness() {
const queryInterface = sequelize.getQueryInterface(); const queryInterface = sequelize.getQueryInterface();
const dialect = sequelize.getDialect(); const dialect = sequelize.getDialect();
+4 -53
View File
@@ -1,5 +1,5 @@
import React, { useState, useMemo } from 'react'; import React, { useState } from 'react';
import { Modal, Form, Select, Checkbox, Button, message } from 'antd'; import { Modal, Form, Select, Button } from 'antd';
import { ExportOutlined } from '@ant-design/icons'; import { ExportOutlined } from '@ant-design/icons';
import { designTokens } from '../../config/theme'; import { designTokens } from '../../config/theme';
@@ -15,7 +15,6 @@ const modalHeaderStyle = {
const ExportModal = ({ const ExportModal = ({
visible, visible,
deviceFields,
selectedDevices, selectedDevices,
currentPageDevices, currentPageDevices,
allDevices, allDevices,
@@ -24,31 +23,14 @@ const ExportModal = ({
}) => { }) => {
const [exportFormat, setExportFormat] = useState('csv'); const [exportFormat, setExportFormat] = useState('csv');
const [exportScope, setExportScope] = useState('selected'); const [exportScope, setExportScope] = useState('selected');
const [exportFields, setExportFields] = useState([]);
const [exportLoading, setExportLoading] = useState(false); 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 () => { const handleExport = async () => {
if (exportFields.length === 0) {
message.warning('请至少选择一个导出字段');
return;
}
setExportLoading(true); setExportLoading(true);
try { try {
await onExport({ await onExport({
format: exportFormat, format: exportFormat,
scope: exportScope, scope: exportScope,
fields: exportFields,
}); });
onCancel(); onCancel();
} finally { } finally {
@@ -121,7 +103,7 @@ const ExportModal = ({
}, },
body: { padding: '24px' }, body: { padding: '24px' },
}} }}
width={600} width={500}
> >
<Form layout="vertical"> <Form layout="vertical">
<Form.Item label="导出格式"> <Form.Item label="导出格式">
@@ -137,39 +119,8 @@ const ExportModal = ({
<Option value="all">全部设备 ({allDevices.length} )</Option> <Option value="all">全部设备 ({allDevices.length} )</Option>
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item label="选择导出字段">
<div
style={{
maxHeight: '300px',
overflow: 'auto',
border: '1px solid #f0f0f0',
borderRadius: '8px',
padding: '12px',
}}
>
{visibleFields.map((field) => (
<div key={field.fieldName} style={{ marginBottom: '8px' }}>
<Checkbox
checked={exportFields.includes(field.fieldName)}
onChange={(e) => {
if (e.target.checked) {
setExportFields([...exportFields, field.fieldName]);
} else {
setExportFields(exportFields.filter((f) => f !== field.fieldName));
}
}}
>
{field.displayName}
</Checkbox>
</div>
))}
</div>
</Form.Item>
<div style={{ color: '#666', fontSize: '13px' }}> <div style={{ color: '#666', fontSize: '13px' }}>
已选择{' '} 将导出设备的所有字段包括自定义字段
<span style={{ color: '#1890ff', fontWeight: 600 }}>{selectedDevices.length}</span> 个设备
将导出{' '}
<span style={{ color: '#52c41a', fontWeight: 600 }}>{exportFields.length}</span> 个字段
</div> </div>
</Form> </Form>
</Modal> </Modal>
+194 -14
View File
@@ -26,6 +26,8 @@ import {
CalendarOutlined, CalendarOutlined,
FileTextOutlined, FileTextOutlined,
LockOutlined, LockOutlined,
PlusCircleOutlined,
MinusCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import axios from 'axios'; import axios from 'axios';
import { designTokens } from '../config/theme'; import { designTokens } from '../config/theme';
@@ -33,6 +35,161 @@ import CloseButton from '../components/CloseButton';
const { Option = Select.Option } = Select; 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 (
<div style={{
border: '1px solid #e8e8e8',
borderRadius: '12px',
padding: '20px',
background: 'linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '16px',
gap: '8px',
}}>
<div style={{
width: '4px',
height: '16px',
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: '2px',
}}/>
<span style={{ color: '#333', fontSize: '14px', fontWeight: '600' }}>
选项配置
</span>
<span style={{ color: '#999', fontSize: '12px' }}>
值用于提交标签用于显示
</span>
</div>
{value.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '24px',
background: '#fff',
borderRadius: '8px',
border: '1px dashed #d9d9d9',
}}>
<div style={{ color: '#bbb', fontSize: '14px', marginBottom: '12px' }}>
暂无选项
</div>
<Button
type="primary"
icon={<PlusCircleOutlined />}
onClick={handleAdd}
style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
borderRadius: '6px',
height: '36px',
}}
>
添加第一个选项
</Button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '16px' }}>
<div style={{
display: 'flex',
gap: '12px',
padding: '0 4px',
marginBottom: '4px',
}}>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>value</span>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>标签label</span>
</div>
{value.map((opt, index) => (
<div
key={index}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px',
background: '#fff',
borderRadius: '8px',
border: '1px solid #e8e8e8',
transition: 'all 0.2s ease',
}}
>
<div style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea20 0%, #764ba220 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#667eea',
fontSize: '12px',
fontWeight: '600',
flexShrink: 0,
}}>
{index + 1}
</div>
<Input
placeholder="值"
value={opt.value}
onChange={e => handleUpdate(index, 'value', e.target.value)}
style={{ width: '160px', borderRadius: '6px' }}
/>
<Input
placeholder="标签"
value={opt.label}
onChange={e => handleUpdate(index, 'label', e.target.value)}
style={{ width: '160px', borderRadius: '6px' }}
/>
<Button
type="text"
danger
icon={<MinusCircleOutlined />}
onClick={() => handleRemove(index)}
style={{ flexShrink: 0 }}
>
删除
</Button>
</div>
))}
</div>
)}
{value.length > 0 && (
<Button
type="dashed"
icon={<PlusCircleOutlined />}
onClick={handleAdd}
style={{
width: '100%',
height: '40px',
borderRadius: '8px',
borderColor: '#d9d9d9',
color: '#666',
}}
>
添加选项
</Button>
)}
</div>
);
};
const pageContainerStyle = { const pageContainerStyle = {
minHeight: '100vh', minHeight: '100vh',
background: designTokens.colors.background.secondary, background: designTokens.colors.background.secondary,
@@ -176,6 +333,7 @@ function DeviceFieldManagement() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [editingField, setEditingField] = useState(null); const [editingField, setEditingField] = useState(null);
const [selectedFieldType, setSelectedFieldType] = useState('string');
const [form] = Form.useForm(); const [form] = Form.useForm();
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
current: 1, current: 1,
@@ -208,11 +366,13 @@ function DeviceFieldManagement() {
if (field) { if (field) {
const fieldData = { const fieldData = {
...field, ...field,
options: field.options ? JSON.stringify(field.options, null, 2) : '', options: field.options || [],
}; };
setSelectedFieldType(field.fieldType || 'string');
form.setFieldsValue(fieldData); form.setFieldsValue(fieldData);
} else { } else {
form.resetFields(); form.resetFields();
setSelectedFieldType('string');
} }
setModalVisible(true); setModalVisible(true);
}; };
@@ -220,13 +380,21 @@ function DeviceFieldManagement() {
const handleCancel = () => { const handleCancel = () => {
setModalVisible(false); setModalVisible(false);
setEditingField(null); setEditingField(null);
setSelectedFieldType('string');
};
const handleFieldTypeChange = value => {
setSelectedFieldType(value);
if (value !== 'select') {
form.setFieldsValue({ options: [] });
}
}; };
const handleSubmit = async values => { const handleSubmit = async values => {
try { try {
const fieldData = { const fieldData = {
...values, ...values,
options: values.options ? JSON.parse(values.options) : null, options: values.options && values.options.length > 0 ? values.options : null,
}; };
if (editingField) { if (editingField) {
@@ -240,6 +408,7 @@ function DeviceFieldManagement() {
setModalVisible(false); setModalVisible(false);
fetchFields(); fetchFields();
setEditingField(null); setEditingField(null);
setSelectedFieldType('string');
} catch (error) { } catch (error) {
message.error(editingField ? '字段更新失败' : '字段创建失败'); message.error(editingField ? '字段更新失败' : '字段创建失败');
console.error(editingField ? '字段更新失败:' : '字段创建失败:', error); console.error(editingField ? '字段更新失败:' : '字段创建失败:', error);
@@ -474,7 +643,7 @@ function DeviceFieldManagement() {
label={<span style={formLabelStyle}>字段类型</span>} label={<span style={formLabelStyle}>字段类型</span>}
rules={[{ required: true, message: '请选择字段类型' }]} rules={[{ required: true, message: '请选择字段类型' }]}
> >
<Select placeholder="请选择字段类型"> <Select placeholder="请选择字段类型" onChange={handleFieldTypeChange}>
{FIELD_TYPE_OPTIONS.map(opt => ( {FIELD_TYPE_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}> <Option key={opt.value} value={opt.value}>
{opt.label} {opt.label}
@@ -511,17 +680,28 @@ function DeviceFieldManagement() {
<InputNumber placeholder="请输入显示顺序" min={0} style={{ width: '100%' }} /> <InputNumber placeholder="请输入显示顺序" min={0} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item {selectedFieldType === 'select' ? (
name="options" <Form.Item
label={<span style={formLabelStyle}>选项配置JSON格式</span>} name="options"
tooltip="格式示例:[{value: 'option1', label: '选项1'}],仅下拉选择类型需要配置" label={<span style={formLabelStyle}>选项配置</span>}
> tooltip="为下拉选择类型添加选项,值(value)用于提交数据,标签(label)用于显示"
<Input.TextArea >
rows={3} <OptionsEditor />
placeholder="请输入JSON格式的选项配置,使用单引号" </Form.Item>
style={textAreaStyle} ) : (
/> <Form.Item
</Form.Item> name="options"
label={<span style={formLabelStyle}>选项配置</span>}
tooltip="仅下拉选择类型需要配置选项"
>
<Input.TextArea
rows={2}
placeholder="仅下拉选择类型需要配置,此处不可编辑"
disabled
style={{ background: '#f5f5f5' }}
/>
</Form.Item>
)}
<Form.Item style={formActionsStyle}> <Form.Item style={formActionsStyle}>
<Space> <Space>
+1 -9
View File
@@ -478,12 +478,7 @@ function DeviceManagement() {
setExportModalVisible(true); setExportModalVisible(true);
}; };
const handleEnhancedExport = async ({ format, scope, fields }) => { const handleEnhancedExport = async ({ format, scope }) => {
const fieldLabels = {};
deviceFields.forEach((field) => {
fieldLabels[field.fieldName] = field.displayName;
});
let deviceIds = []; let deviceIds = [];
if (scope === 'selected') { if (scope === 'selected') {
deviceIds = selectedDevices; deviceIds = selectedDevices;
@@ -501,8 +496,6 @@ function DeviceManagement() {
const params = new URLSearchParams(); const params = new URLSearchParams();
deviceIds.forEach((id) => params.append('deviceIds', id)); deviceIds.forEach((id) => params.append('deviceIds', id));
params.append('format', format); params.append('format', format);
params.append('fields', JSON.stringify(fields));
params.append('fieldLabels', JSON.stringify(fieldLabels));
const response = await axios.get(`/api/devices/enhanced-export?${params.toString()}`, { const response = await axios.get(`/api/devices/enhanced-export?${params.toString()}`, {
responseType: 'blob', responseType: 'blob',
@@ -1221,7 +1214,6 @@ function DeviceManagement() {
<ExportModal <ExportModal
visible={exportModalVisible} visible={exportModalVisible}
deviceFields={deviceFields}
selectedDevices={selectedDevices} selectedDevices={selectedDevices}
currentPageDevices={currentPageDevices} currentPageDevices={currentPageDevices}
allDevices={allDevices} allDevices={allDevices}
+193 -11
View File
@@ -12,17 +12,173 @@ import {
InputNumber, InputNumber,
Switch, Switch,
} from 'antd'; } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { PlusOutlined, EditOutlined, DeleteOutlined, PlusCircleOutlined, MinusCircleOutlined } from '@ant-design/icons';
import axios from 'axios'; import axios from 'axios';
import CloseButton from '../components/CloseButton'; import CloseButton from '../components/CloseButton';
const { Option } = Select; const { 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 (
<div style={{
border: '1px solid #e8e8e8',
borderRadius: '12px',
padding: '20px',
background: 'linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '16px',
gap: '8px',
}}>
<div style={{
width: '4px',
height: '16px',
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: '2px',
}}/>
<span style={{ color: '#333', fontSize: '14px', fontWeight: '600' }}>
选项配置
</span>
<span style={{ color: '#999', fontSize: '12px' }}>
值用于提交标签用于显示
</span>
</div>
{value.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '24px',
background: '#fff',
borderRadius: '8px',
border: '1px dashed #d9d9d9',
}}>
<div style={{ color: '#bbb', fontSize: '14px', marginBottom: '12px' }}>
暂无选项
</div>
<Button
type="primary"
icon={<PlusCircleOutlined />}
onClick={handleAdd}
style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
borderRadius: '6px',
height: '36px',
}}
>
添加第一个选项
</Button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '16px' }}>
<div style={{
display: 'flex',
gap: '12px',
padding: '0 4px',
marginBottom: '4px',
}}>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>value</span>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>标签label</span>
</div>
{value.map((opt, index) => (
<div
key={index}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px',
background: '#fff',
borderRadius: '8px',
border: '1px solid #e8e8e8',
transition: 'all 0.2s ease',
}}
>
<div style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea20 0%, #764ba220 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#667eea',
fontSize: '12px',
fontWeight: '600',
flexShrink: 0,
}}>
{index + 1}
</div>
<Input
placeholder="值"
value={opt.value}
onChange={e => handleUpdate(index, 'value', e.target.value)}
style={{ width: '160px', borderRadius: '6px' }}
/>
<Input
placeholder="标签"
value={opt.label}
onChange={e => handleUpdate(index, 'label', e.target.value)}
style={{ width: '160px', borderRadius: '6px' }}
/>
<Button
type="text"
danger
icon={<MinusCircleOutlined />}
onClick={() => handleRemove(index)}
style={{ flexShrink: 0 }}
>
删除
</Button>
</div>
))}
</div>
)}
{value.length > 0 && (
<Button
type="dashed"
icon={<PlusCircleOutlined />}
onClick={handleAdd}
style={{
width: '100%',
height: '40px',
borderRadius: '8px',
borderColor: '#d9d9d9',
color: '#666',
}}
>
添加选项
</Button>
)}
</div>
);
};
function TicketFieldManagement() { function TicketFieldManagement() {
const [fields, setFields] = useState([]); const [fields, setFields] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [editingField, setEditingField] = useState(null); const [editingField, setEditingField] = useState(null);
const [selectedFieldType, setSelectedFieldType] = useState('string');
const [form] = Form.useForm(); const [form] = Form.useForm();
const fetchFields = async () => { const fetchFields = async () => {
@@ -47,11 +203,13 @@ function TicketFieldManagement() {
if (field) { if (field) {
const fieldData = { const fieldData = {
...field, ...field,
options: field.options ? JSON.stringify(field.options, null, 2) : '', options: field.options || [],
}; };
setSelectedFieldType(field.fieldType || 'string');
form.setFieldsValue(fieldData); form.setFieldsValue(fieldData);
} else { } else {
form.resetFields(); form.resetFields();
setSelectedFieldType('string');
} }
setModalVisible(true); setModalVisible(true);
}; };
@@ -59,13 +217,21 @@ function TicketFieldManagement() {
const handleCancel = () => { const handleCancel = () => {
setModalVisible(false); setModalVisible(false);
setEditingField(null); setEditingField(null);
setSelectedFieldType('string');
};
const handleFieldTypeChange = value => {
setSelectedFieldType(value);
if (value !== 'select') {
form.setFieldsValue({ options: [] });
}
}; };
const handleSubmit = async values => { const handleSubmit = async values => {
try { try {
const fieldData = { const fieldData = {
...values, ...values,
options: values.options ? JSON.parse(values.options || '[]') : null, options: values.options && values.options.length > 0 ? values.options : null,
}; };
if (editingField) { if (editingField) {
@@ -79,6 +245,7 @@ function TicketFieldManagement() {
setModalVisible(false); setModalVisible(false);
fetchFields(); fetchFields();
setEditingField(null); setEditingField(null);
setSelectedFieldType('string');
} catch (error) { } catch (error) {
message.error(editingField ? '字段更新失败' : '字段创建失败'); message.error(editingField ? '字段更新失败' : '字段创建失败');
console.error(error); console.error(error);
@@ -226,7 +393,7 @@ function TicketFieldManagement() {
label="字段类型" label="字段类型"
rules={[{ required: true, message: '请选择字段类型' }]} rules={[{ required: true, message: '请选择字段类型' }]}
> >
<Select placeholder="请选择字段类型"> <Select placeholder="请选择字段类型" onChange={handleFieldTypeChange}>
<Option value="string">文本</Option> <Option value="string">文本</Option>
<Option value="number">数字</Option> <Option value="number">数字</Option>
<Option value="boolean">布尔值</Option> <Option value="boolean">布尔值</Option>
@@ -254,13 +421,28 @@ function TicketFieldManagement() {
<InputNumber placeholder="请输入显示顺序" min={0} style={{ width: '100%' }} /> <InputNumber placeholder="请输入显示顺序" min={0} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item {selectedFieldType === 'select' ? (
name="options" <Form.Item
label="选项配置(仅下拉选择类型,JSON格式)" name="options"
tooltip="格式示例:[{value: 'option1', label: '选项1'}]" label="选项配置"
> tooltip="为下拉选择类型添加选项,值(value)用于提交数据,标签(label)用于显示"
<Input.TextArea rows={3} placeholder='[{"value": "option1", "label": "选项1"}]' /> >
</Form.Item> <OptionsEditor />
</Form.Item>
) : (
<Form.Item
name="options"
label="选项配置"
tooltip="仅下拉选择类型需要配置选项"
>
<Input.TextArea
rows={2}
placeholder="仅下拉选择类型需要配置,此处不可编辑"
disabled
style={{ background: '#f5f5f5' }}
/>
</Form.Item>
)}
<Form.Item style={{ textAlign: 'right' }}> <Form.Item style={{ textAlign: 'right' }}>
<Space> <Space>