refactor: 移除调试日志并优化代码结构
feat(components): 新增设备管理相关组件和仪表盘组件 feat(hooks): 添加自定义hooks用于API调用和数据管理 style: 优化滚动条样式和模态框布局 chore: 清理无用脚本和调试文件 docs: 更新组件导出文件
This commit is contained in:
+5
-1
@@ -40,4 +40,8 @@ lerna-debug.log*
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
*.temp
|
||||
|
||||
# Backup files (generated by maintenance scripts)
|
||||
backend/backups/
|
||||
*.backup.json
|
||||
@@ -1,33 +0,0 @@
|
||||
const { sequelize } = require('./db');
|
||||
const User = require('./models/User');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await sequelize.authenticate();
|
||||
console.log('数据库连接成功!\n');
|
||||
|
||||
const users = await User.findAll({
|
||||
attributes: ['userId', 'username', 'password', 'email', 'status', 'createdAt']
|
||||
});
|
||||
|
||||
console.log('用户列表:');
|
||||
console.log('============');
|
||||
|
||||
for (const user of users) {
|
||||
console.log('用户名:', user.username);
|
||||
console.log('密码(加密后):', user.password);
|
||||
console.log('状态:', user.status);
|
||||
console.log('创建时间:', user.createdAt);
|
||||
console.log('------------');
|
||||
}
|
||||
|
||||
console.log('\n' + users.length + ' 个用户');
|
||||
|
||||
await sequelize.close();
|
||||
} catch (error) {
|
||||
console.error('错误:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,99 +0,0 @@
|
||||
const { sequelize } = require('./db');
|
||||
|
||||
async function fixMaxStock() {
|
||||
try {
|
||||
console.log('开始修复 maxStock 字段...');
|
||||
|
||||
// 检查当前表结构
|
||||
const [results] = await sequelize.query(
|
||||
"PRAGMA table_info(consumables);"
|
||||
);
|
||||
|
||||
console.log('当前表结构:');
|
||||
results.forEach(col => {
|
||||
console.log(` ${col.name}: ${col.type} ${col.notnull ? 'NOT NULL' : 'NULL'} default=${col.dflt_value}`);
|
||||
});
|
||||
|
||||
const maxStockCol = results.find(c => c.name === 'maxStock');
|
||||
if (maxStockCol) {
|
||||
console.log('\n当前 maxStock 字段:', maxStockCol);
|
||||
|
||||
// SQLite 不支持直接修改列,需要创建新表
|
||||
console.log('\n需要重建表结构...');
|
||||
|
||||
// 1. 创建新表
|
||||
await sequelize.query(`
|
||||
CREATE TABLE consumables_new (
|
||||
consumableId VARCHAR(255) PRIMARY KEY NOT NULL UNIQUE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
category VARCHAR(255) NOT NULL,
|
||||
unit VARCHAR(255) NOT NULL DEFAULT '个',
|
||||
currentStock INTEGER NOT NULL DEFAULT 0,
|
||||
minStock INTEGER NOT NULL DEFAULT 10,
|
||||
maxStock INTEGER NOT NULL DEFAULT 0,
|
||||
unitPrice DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
supplier VARCHAR(255),
|
||||
location VARCHAR(255),
|
||||
description TEXT,
|
||||
status VARCHAR(255) DEFAULT 'active',
|
||||
version INTEGER NOT NULL DEFAULT 0,
|
||||
createdAt DATETIME,
|
||||
updatedAt DATETIME
|
||||
)
|
||||
`);
|
||||
|
||||
// 2. 复制数据(将 null 转换为 0)
|
||||
await sequelize.query(`
|
||||
INSERT INTO consumables_new
|
||||
SELECT
|
||||
consumableId,
|
||||
name,
|
||||
category,
|
||||
unit,
|
||||
currentStock,
|
||||
minStock,
|
||||
COALESCE(maxStock, 0) as maxStock,
|
||||
unitPrice,
|
||||
supplier,
|
||||
location,
|
||||
description,
|
||||
status,
|
||||
version,
|
||||
createdAt,
|
||||
updatedAt
|
||||
FROM consumables
|
||||
`);
|
||||
|
||||
// 3. 删除旧表
|
||||
await sequelize.query('DROP TABLE consumables');
|
||||
|
||||
// 4. 重命名新表
|
||||
await sequelize.query('ALTER TABLE consumables_new RENAME TO consumables');
|
||||
|
||||
// 5. 创建索引
|
||||
await sequelize.query('CREATE INDEX consumables_category ON consumables(category)');
|
||||
await sequelize.query('CREATE INDEX consumables_status ON consumables(status)');
|
||||
await sequelize.query('CREATE INDEX consumables_category_status ON consumables(category, status)');
|
||||
await sequelize.query('CREATE INDEX consumables_updatedAt ON consumables(updatedAt)');
|
||||
|
||||
console.log('\n表结构修复完成!');
|
||||
|
||||
// 验证新表结构
|
||||
const [newResults] = await sequelize.query(
|
||||
"PRAGMA table_info(consumables);"
|
||||
);
|
||||
console.log('\n新表结构:');
|
||||
newResults.forEach(col => {
|
||||
console.log(` ${col.name}: ${col.type} ${col.notnull ? 'NOT NULL' : 'NULL'} default=${col.dflt_value}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n修复完成!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('修复失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fixMaxStock();
|
||||
@@ -1,125 +0,0 @@
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { sequelize } = require('./db');
|
||||
const User = require('./models/User');
|
||||
const Role = require('./models/Role');
|
||||
const UserRole = require('./models/UserRole');
|
||||
|
||||
async function fixAdminUser() {
|
||||
try {
|
||||
await sequelize.authenticate();
|
||||
console.log('数据库连接成功!\n');
|
||||
|
||||
const adminUser = await User.findOne({
|
||||
where: { username: 'admin' }
|
||||
});
|
||||
|
||||
if (!adminUser) {
|
||||
console.log('未找到 admin 用户');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('当前 admin 用户信息:');
|
||||
console.log('用户名:', adminUser.username);
|
||||
console.log('邮箱:', adminUser.email);
|
||||
console.log('状态:', adminUser.status);
|
||||
console.log('');
|
||||
|
||||
const userRoles = await UserRole.findAll({
|
||||
where: { UserId: adminUser.userId },
|
||||
include: [{ model: Role }]
|
||||
});
|
||||
|
||||
console.log('当前用户角色:');
|
||||
if (userRoles.length === 0) {
|
||||
console.log(' - 无角色分配');
|
||||
} else {
|
||||
userRoles.forEach(ur => {
|
||||
console.log(` - ${ur.Role.roleName} (${ur.Role.roleCode})`);
|
||||
});
|
||||
}
|
||||
console.log('');
|
||||
|
||||
let adminRole = await Role.findOne({
|
||||
where: { roleCode: 'admin' }
|
||||
});
|
||||
|
||||
if (!adminRole) {
|
||||
console.log('正在创建管理员角色...');
|
||||
adminRole = await Role.create({
|
||||
roleId: 'role_admin',
|
||||
roleName: '管理员',
|
||||
roleCode: 'admin',
|
||||
description: '系统管理员,拥有所有权限',
|
||||
status: 'active',
|
||||
permissions: []
|
||||
});
|
||||
console.log('管理员角色创建成功!');
|
||||
} else {
|
||||
console.log('管理员角色已存在');
|
||||
}
|
||||
|
||||
const existingUserRole = await UserRole.findOne({
|
||||
where: {
|
||||
UserId: adminUser.userId,
|
||||
RoleId: adminRole.roleId
|
||||
}
|
||||
});
|
||||
|
||||
if (!existingUserRole) {
|
||||
console.log('正在为 admin 用户分配管理员角色...');
|
||||
await UserRole.create({
|
||||
UserId: adminUser.userId,
|
||||
RoleId: adminRole.roleId
|
||||
});
|
||||
console.log('角色分配成功!');
|
||||
} else {
|
||||
console.log('管理员角色已分配给该用户');
|
||||
}
|
||||
console.log('');
|
||||
|
||||
const newPassword = 'admin123';
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const hashedPassword = await bcrypt.hash(newPassword, salt);
|
||||
|
||||
await adminUser.update({
|
||||
password: hashedPassword
|
||||
});
|
||||
|
||||
console.log('密码已重置为: admin123');
|
||||
console.log('');
|
||||
|
||||
const updatedUser = await User.findOne({
|
||||
where: { username: 'admin' },
|
||||
include: [{ model: Role }]
|
||||
});
|
||||
|
||||
console.log('修复后的 admin 用户信息:');
|
||||
console.log('用户名:', updatedUser.username);
|
||||
console.log('邮箱:', updatedUser.email);
|
||||
console.log('状态:', updatedUser.status);
|
||||
console.log('角色:',
|
||||
updatedUser.Roles && updatedUser.Roles.length > 0
|
||||
? updatedUser.Roles.map(r => r.roleName).join(', ')
|
||||
: '无角色'
|
||||
);
|
||||
console.log('');
|
||||
|
||||
const testResult = bcrypt.compareSync(newPassword, updatedUser.password);
|
||||
console.log('密码验证测试:');
|
||||
console.log(` 输入密码: "${newPassword}"`);
|
||||
console.log(` 验证结果: ${testResult ? '✓ 成功' : '✗ 失败'}`);
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('修复完成!现在可以使用以下信息登录:');
|
||||
console.log(' 用户名: admin');
|
||||
console.log(' 密码: admin123');
|
||||
console.log('========================================');
|
||||
|
||||
} catch (error) {
|
||||
console.error('修复过程中发生错误:', error.message);
|
||||
} finally {
|
||||
await sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
fixAdminUser();
|
||||
@@ -1,133 +0,0 @@
|
||||
const { sequelize } = require('./db');
|
||||
const Role = require('./models/Role');
|
||||
const User = require('./models/User');
|
||||
const UserRole = require('./models/UserRole');
|
||||
|
||||
async function fixForeignKeys() {
|
||||
try {
|
||||
console.log('开始修复外键约束...');
|
||||
|
||||
await sequelize.query('PRAGMA foreign_keys = OFF');
|
||||
|
||||
await sequelize.query('DROP TABLE IF EXISTS user_roles');
|
||||
await sequelize.query('DROP TABLE IF EXISTS users');
|
||||
await sequelize.query('DROP TABLE IF EXISTS roles');
|
||||
|
||||
console.log('已删除现有表,正在重新创建...');
|
||||
|
||||
await sequelize.query(`
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
roleId TEXT PRIMARY KEY,
|
||||
roleName TEXT NOT NULL,
|
||||
roleCode TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
permissions TEXT DEFAULT '[]',
|
||||
sort INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'active',
|
||||
createdAt DATETIME,
|
||||
updatedAt DATETIME
|
||||
)
|
||||
`);
|
||||
|
||||
await sequelize.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
userId TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
realName TEXT,
|
||||
avatar TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
lastLoginTime DATETIME,
|
||||
lastLoginIp TEXT,
|
||||
loginCount INTEGER DEFAULT 0,
|
||||
remark TEXT,
|
||||
createdAt DATETIME,
|
||||
updatedAt DATETIME
|
||||
)
|
||||
`);
|
||||
|
||||
await sequelize.query(`
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
UserId TEXT NOT NULL,
|
||||
RoleId TEXT NOT NULL,
|
||||
createdAt DATETIME,
|
||||
updatedAt DATETIME,
|
||||
FOREIGN KEY (UserId) REFERENCES users(userId) ON DELETE CASCADE,
|
||||
FOREIGN KEY (RoleId) REFERENCES roles(roleId) ON DELETE CASCADE,
|
||||
UNIQUE(UserId, RoleId)
|
||||
)
|
||||
`);
|
||||
|
||||
await sequelize.query('PRAGMA foreign_keys = ON');
|
||||
|
||||
console.log('外键约束修复完成!');
|
||||
console.log('正在重新初始化角色数据...');
|
||||
|
||||
const bcrypt = require('bcryptjs');
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
await Role.bulkCreate([
|
||||
{
|
||||
roleId: 'role_admin',
|
||||
roleName: '管理员',
|
||||
roleCode: 'admin',
|
||||
description: '系统管理员,拥有所有权限',
|
||||
permissions: '[]',
|
||||
sort: 1,
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
roleId: 'role_operator',
|
||||
roleName: '操作员',
|
||||
roleCode: 'operator',
|
||||
description: '设备操作员,可进行设备操作',
|
||||
permissions: '[]',
|
||||
sort: 2,
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
roleId: 'role_viewer',
|
||||
roleName: '访客',
|
||||
roleCode: 'viewer',
|
||||
description: '只读权限,只能查看数据',
|
||||
permissions: '[]',
|
||||
sort: 3,
|
||||
status: 'active'
|
||||
}
|
||||
]);
|
||||
|
||||
console.log('角色数据初始化完成!');
|
||||
|
||||
const hashedPassword = await bcrypt.hash('admin123', SALT_ROUNDS);
|
||||
|
||||
const adminUser = await User.create({
|
||||
userId: 'user_admin',
|
||||
username: 'admin',
|
||||
password: hashedPassword,
|
||||
email: 'admin@example.com',
|
||||
realName: '系统管理员',
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
await UserRole.create({
|
||||
UserId: adminUser.userId,
|
||||
RoleId: 'role_admin'
|
||||
});
|
||||
|
||||
console.log('管理员账户创建完成!');
|
||||
console.log('用户名: admin');
|
||||
console.log('密码: admin123');
|
||||
|
||||
console.log('\n修复完成!请重启后端服务。');
|
||||
|
||||
} catch (error) {
|
||||
console.error('修复外键约束失败:', error);
|
||||
} finally {
|
||||
await sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
fixForeignKeys();
|
||||
@@ -1,35 +0,0 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, 'idc_management.db');
|
||||
|
||||
const db = new sqlite3.Database(dbPath, (err) => {
|
||||
if (err) {
|
||||
console.error('无法打开数据库:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('开始修复数据库表结构...');
|
||||
|
||||
db.serialize(() => {
|
||||
db.run(`ALTER TABLE consumables ADD COLUMN location TEXT;`, function(err) {
|
||||
if (err) {
|
||||
if (err.message.includes('duplicate column name: location')) {
|
||||
console.log('location 列已存在,无需添加');
|
||||
} else {
|
||||
console.error('添加 location 列失败:', err.message);
|
||||
}
|
||||
} else {
|
||||
console.log('成功添加 location 列到 consumables 表');
|
||||
}
|
||||
|
||||
db.close((closeErr) => {
|
||||
if (closeErr) {
|
||||
console.error('关闭数据库失败:', closeErr);
|
||||
} else {
|
||||
console.log('数据库修复完成');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = process.env.DB_PATH || path.join(__dirname, 'idc_management.db');
|
||||
|
||||
const db = new sqlite3.Database(dbPath, (err) => {
|
||||
if (err) {
|
||||
console.error('无法连接到数据库:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('已连接到数据库:', dbPath);
|
||||
});
|
||||
|
||||
const columnsToAdd = [
|
||||
{ table: 'pending_devices', column: 'height', type: 'INTEGER', defaultValue: 1 },
|
||||
{ table: 'pending_devices', column: 'powerConsumption', type: 'FLOAT', defaultValue: 0 },
|
||||
{ table: 'pending_devices', column: 'brand', type: 'VARCHAR(255)', defaultValue: null },
|
||||
{ table: 'pending_devices', column: 'purchaseDate', type: 'DATE', defaultValue: null },
|
||||
{ table: 'pending_devices', column: 'warrantyExpiry', type: 'DATE', defaultValue: null },
|
||||
];
|
||||
|
||||
db.serialize(() => {
|
||||
columnsToAdd.forEach(({ table, column, type, defaultValue }) => {
|
||||
db.all(`PRAGMA table_info(${table})`, (err, rows) => {
|
||||
if (err) {
|
||||
console.error(`获取表 ${table} 信息失败:`, err);
|
||||
return;
|
||||
}
|
||||
|
||||
const columnExists = rows && rows.some(row => row.name === column);
|
||||
|
||||
if (!columnExists) {
|
||||
const defaultClause = defaultValue !== null ? ` DEFAULT ${typeof defaultValue === 'string' ? `'${defaultValue}'` : defaultValue}` : '';
|
||||
const sql = `ALTER TABLE ${table} ADD COLUMN ${column} ${type}${defaultClause}`;
|
||||
|
||||
db.run(sql, (err) => {
|
||||
if (err) {
|
||||
console.error(`添加列 ${table}.${column} 失败:`, err.message);
|
||||
} else {
|
||||
console.log(`成功添加列 ${table}.${column}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log(`列 ${table}.${column} 已存在,跳过`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
db.close((err) => {
|
||||
if (err) {
|
||||
console.error('关闭数据库失败:', err);
|
||||
} else {
|
||||
console.log('数据库迁移完成,连接已关闭');
|
||||
}
|
||||
});
|
||||
}, 2000);
|
||||
@@ -1,51 +0,0 @@
|
||||
const { sequelize } = require('./db');
|
||||
const FaultCategory = require('./models/FaultCategory');
|
||||
|
||||
async function recreateFaultCategoryTable() {
|
||||
try {
|
||||
console.log('开始重建故障分类表...');
|
||||
|
||||
await sequelize.authenticate();
|
||||
console.log('数据库连接成功');
|
||||
|
||||
// 删除旧表并创建新表
|
||||
await sequelize.query('DROP TABLE IF EXISTS fault_categories');
|
||||
console.log('旧表已删除');
|
||||
|
||||
await FaultCategory.sync();
|
||||
console.log('新表已创建');
|
||||
|
||||
// 初始化默认分类
|
||||
const defaultCategories = [
|
||||
{ name: '系统故障', description: '操作系统、应用程序等系统软件的故障问题', priority: 1, defaultPriority: 'high', expectedDuration: 120, isSystem: true, isActive: true },
|
||||
{ name: '硬件故障', description: '物理设备、服务器、存储等硬件设备的故障问题', priority: 2, defaultPriority: 'high', expectedDuration: 180, isSystem: true, isActive: true },
|
||||
{ name: '网络故障', description: '网络连接、交换机、路由器等网络相关故障', priority: 3, defaultPriority: 'high', expectedDuration: 120, isSystem: true, isActive: true },
|
||||
{ name: '软件故障', description: '应用程序错误、软件兼容性等问题', priority: 4, defaultPriority: 'medium', expectedDuration: 90, isSystem: true, isActive: true },
|
||||
{ name: '安全事件', description: '安全漏洞、入侵检测、权限异常等安全问题', priority: 5, defaultPriority: 'urgent', expectedDuration: 60, isSystem: true, isActive: true },
|
||||
{ name: '性能问题', description: '系统响应慢、资源利用率高等性能问题', priority: 6, defaultPriority: 'medium', expectedDuration: 120, isSystem: true, isActive: true },
|
||||
{ name: '配置变更', description: '系统配置、软件配置等变更需求', priority: 7, defaultPriority: 'low', expectedDuration: 60, isSystem: true, isActive: true },
|
||||
{ name: '例行维护', description: '定期维护、巡检、更新等计划性工作', priority: 8, defaultPriority: 'low', expectedDuration: 120, isSystem: true, isActive: true },
|
||||
{ name: '数据问题', description: '数据错误、数据丢失、数据同步等数据相关问题', priority: 9, defaultPriority: 'high', expectedDuration: 150, isSystem: true, isActive: true },
|
||||
{ name: '其他问题', description: '无法归类的其他问题', priority: 99, defaultPriority: 'medium', expectedDuration: 90, isSystem: true, isActive: true }
|
||||
];
|
||||
|
||||
for (const cat of defaultCategories) {
|
||||
const categoryId = `CAT${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substr(2, 4).toUpperCase()}`;
|
||||
await FaultCategory.create({
|
||||
categoryId,
|
||||
...cat,
|
||||
solutions: [],
|
||||
metadata: {}
|
||||
});
|
||||
}
|
||||
console.log('默认分类已初始化');
|
||||
|
||||
console.log('故障分类表重建完成!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
recreateFaultCategoryTable();
|
||||
@@ -55,12 +55,10 @@ router.get('/', async (req, res) => {
|
||||
router.post('/', async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
console.log('接收到的数据:', JSON.stringify(req.body, null, 2));
|
||||
const consumableData = {
|
||||
...req.body,
|
||||
consumableId: req.body.consumableId || `CON${Date.now()}`
|
||||
};
|
||||
console.log('处理后的数据:', JSON.stringify(consumableData, null, 2));
|
||||
const consumable = await Consumable.create(consumableData, { transaction });
|
||||
|
||||
await ConsumableLog.create({
|
||||
|
||||
@@ -81,8 +81,6 @@ router.get('/plans', async (req, res) => {
|
||||
|
||||
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'] }
|
||||
@@ -90,7 +88,6 @@ router.get('/plans/:planId', async (req, res) => {
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
console.log('Plan not found');
|
||||
return res.status(404).json({ error: '盘点计划不存在' });
|
||||
}
|
||||
|
||||
@@ -102,10 +99,8 @@ router.get('/plans/:planId', async (req, res) => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
+123
-132
@@ -13,136 +13,131 @@ app.use(express.json());
|
||||
app.use(fileUpload({ limits: { fileSize: FILE_UPLOAD.MAX_FILE_SIZE } }));
|
||||
app.use('/temp', express.static('temp'));
|
||||
|
||||
// 数据库连接已从db.js导入
|
||||
async function syncDatabase() {
|
||||
await sequelize.authenticate();
|
||||
console.log('数据库连接成功');
|
||||
|
||||
// 测试数据库连接并同步表结构
|
||||
sequelize.authenticate()
|
||||
.then(() => {
|
||||
console.log('数据库连接成功');
|
||||
// 自动同步表结构,不再强制删除表
|
||||
return sequelize.sync({
|
||||
force: false, // 改为false,避免每次启动都删除表结构
|
||||
alter: false // SQLite不支持复杂的表结构修改
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
console.log('数据库表结构同步完成');
|
||||
// 同步完成后初始化设备字段
|
||||
return require('./initDeviceFields')();
|
||||
})
|
||||
.then(() => {
|
||||
// 初始化工单字段
|
||||
return require('./initTicketFields')();
|
||||
})
|
||||
.then(() => {
|
||||
// 初始化工单模型关联
|
||||
return require('./models/ticketIndex').initializeModels();
|
||||
})
|
||||
.then(() => {
|
||||
// 同步系统设置模型
|
||||
const SystemSetting = require('./models/SystemSetting');
|
||||
return SystemSetting.sync();
|
||||
})
|
||||
.then(() => {
|
||||
// 同步耗材模型
|
||||
const Consumable = require('./models/Consumable');
|
||||
const ConsumableLog = require('./models/ConsumableLog');
|
||||
const ConsumableCategory = require('./models/ConsumableCategory');
|
||||
const ConsumableRecord = require('./models/ConsumableRecord');
|
||||
const ConsumableLogArchive = require('./models/ConsumableLogArchive');
|
||||
return Promise.all([
|
||||
Consumable.sync({ alter: true }),
|
||||
ConsumableLog.sync(),
|
||||
ConsumableCategory.sync(),
|
||||
ConsumableRecord.sync(),
|
||||
ConsumableLogArchive.sync()
|
||||
]);
|
||||
})
|
||||
.then(() => {
|
||||
console.log('耗材模型同步完成');
|
||||
})
|
||||
.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(() => {
|
||||
// 同步耗材相关模型
|
||||
const Consumable = require('./models/Consumable');
|
||||
const ConsumableLog = require('./models/ConsumableLog');
|
||||
const ConsumableCategory = require('./models/ConsumableCategory');
|
||||
const ConsumableRecord = require('./models/ConsumableRecord');
|
||||
const ConsumableLogArchive = require('./models/ConsumableLogArchive');
|
||||
return Promise.all([
|
||||
Consumable.sync({ alter: true }),
|
||||
ConsumableLog.sync(),
|
||||
ConsumableCategory.sync(),
|
||||
ConsumableRecord.sync(),
|
||||
ConsumableLogArchive.sync()
|
||||
]);
|
||||
})
|
||||
.then(() => {
|
||||
console.log('耗材模型同步完成');
|
||||
})
|
||||
.then(() => {
|
||||
// 初始化系统设置默认值(关键:确保部署时数据正确初始化)
|
||||
console.log('开始初始化系统设置默认值...');
|
||||
const { initDefaultSettings } = require('./routes/systemSettings');
|
||||
return initDefaultSettings();
|
||||
})
|
||||
.then(() => {
|
||||
console.log('系统设置初始化完成');
|
||||
})
|
||||
.then(() => {
|
||||
// 初始化故障分类数据
|
||||
console.log('开始初始化故障分类...');
|
||||
const FaultCategory = require('./models/FaultCategory');
|
||||
const defaultCategories = [
|
||||
{ name: '系统故障', description: '操作系统、应用程序等系统软件的故障问题', priority: 1, defaultPriority: 'high' },
|
||||
{ name: '硬件故障', description: '物理设备、服务器、存储等硬件设备的故障问题', priority: 2, defaultPriority: 'high' },
|
||||
{ name: '网络故障', description: '网络连接、交换机、路由器等网络相关故障', priority: 3, defaultPriority: 'high' },
|
||||
{ name: '软件故障', description: '应用程序错误、软件兼容性等问题', priority: 4, defaultPriority: 'medium' },
|
||||
{ name: '安全事件', description: '安全漏洞、入侵检测、权限异常等安全问题', priority: 5, defaultPriority: 'urgent' },
|
||||
{ name: '性能问题', description: '系统响应慢、资源利用率高等性能问题', priority: 6, defaultPriority: 'medium' },
|
||||
{ name: '配置变更', description: '系统配置、软件配置等变更需求', priority: 7, defaultPriority: 'low' },
|
||||
{ name: '例行维护', description: '定期维护、巡检、更新等计划性工作', priority: 8, defaultPriority: 'low' },
|
||||
{ name: '数据问题', description: '数据错误、数据丢失、数据同步等数据相关问题', priority: 9, defaultPriority: 'high' },
|
||||
{ name: '其他问题', description: '无法归类的其他问题', priority: 99, defaultPriority: 'medium' }
|
||||
];
|
||||
await sequelize.sync({
|
||||
force: false,
|
||||
alter: false
|
||||
});
|
||||
console.log('数据库表结构同步完成');
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
defaultCategories.map(async (cat) => {
|
||||
const existing = await FaultCategory.findOne({ where: { name: cat.name } });
|
||||
if (!existing) {
|
||||
const categoryId = `CAT${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substr(2, 4).toUpperCase()}`;
|
||||
await FaultCategory.create({
|
||||
categoryId,
|
||||
...cat,
|
||||
expectedDuration: 120,
|
||||
solutions: [],
|
||||
isSystem: true,
|
||||
isActive: true
|
||||
});
|
||||
console.log(`创建故障分类: ${cat.name}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
console.log('故障分类初始化完成');
|
||||
})
|
||||
.catch(err => console.error('数据库操作失败:', err));
|
||||
async function initDeviceFields() {
|
||||
await require('./initDeviceFields')();
|
||||
console.log('设备字段初始化完成');
|
||||
}
|
||||
|
||||
async function initTicketFields() {
|
||||
await require('./initTicketFields')();
|
||||
console.log('工单字段初始化完成');
|
||||
}
|
||||
|
||||
async function initTicketModels() {
|
||||
await require('./models/ticketIndex').initializeModels();
|
||||
console.log('工单模型关联初始化完成');
|
||||
}
|
||||
|
||||
async function syncSystemSettings() {
|
||||
const SystemSetting = require('./models/SystemSetting');
|
||||
await SystemSetting.sync();
|
||||
console.log('系统设置模型同步完成');
|
||||
}
|
||||
|
||||
async function syncConsumableModels() {
|
||||
const Consumable = require('./models/Consumable');
|
||||
const ConsumableLog = require('./models/ConsumableLog');
|
||||
const ConsumableCategory = require('./models/ConsumableCategory');
|
||||
const ConsumableRecord = require('./models/ConsumableRecord');
|
||||
const ConsumableLogArchive = require('./models/ConsumableLogArchive');
|
||||
|
||||
await Promise.all([
|
||||
Consumable.sync({ alter: true }),
|
||||
ConsumableLog.sync(),
|
||||
ConsumableCategory.sync(),
|
||||
ConsumableRecord.sync(),
|
||||
ConsumableLogArchive.sync()
|
||||
]);
|
||||
console.log('耗材模型同步完成');
|
||||
}
|
||||
|
||||
async function syncInventoryModels() {
|
||||
const InventoryPlan = require('./models/InventoryPlan');
|
||||
const InventoryTask = require('./models/InventoryTask');
|
||||
const InventoryRecord = require('./models/InventoryRecord');
|
||||
|
||||
await Promise.all([
|
||||
InventoryPlan.sync(),
|
||||
InventoryTask.sync(),
|
||||
InventoryRecord.sync()
|
||||
]);
|
||||
console.log('盘点模型同步完成');
|
||||
}
|
||||
|
||||
async function initDefaultSystemSettings() {
|
||||
console.log('开始初始化系统设置默认值...');
|
||||
const { initDefaultSettings } = require('./routes/systemSettings');
|
||||
await initDefaultSettings();
|
||||
console.log('系统设置初始化完成');
|
||||
}
|
||||
|
||||
async function initFaultCategories() {
|
||||
console.log('开始初始化故障分类...');
|
||||
const FaultCategory = require('./models/FaultCategory');
|
||||
|
||||
const defaultCategories = [
|
||||
{ name: '系统故障', description: '操作系统、应用程序等系统软件的故障问题', priority: 1, defaultPriority: 'high' },
|
||||
{ name: '硬件故障', description: '物理设备、服务器、存储等硬件设备的故障问题', priority: 2, defaultPriority: 'high' },
|
||||
{ name: '网络故障', description: '网络连接、交换机、路由器等网络相关故障', priority: 3, defaultPriority: 'high' },
|
||||
{ name: '软件故障', description: '应用程序错误、软件兼容性等问题', priority: 4, defaultPriority: 'medium' },
|
||||
{ name: '安全事件', description: '安全漏洞、入侵检测、权限异常等安全问题', priority: 5, defaultPriority: 'urgent' },
|
||||
{ name: '性能问题', description: '系统响应慢、资源利用率高等性能问题', priority: 6, defaultPriority: 'medium' },
|
||||
{ name: '配置变更', description: '系统配置、软件配置等变更需求', priority: 7, defaultPriority: 'low' },
|
||||
{ name: '例行维护', description: '定期维护、巡检、更新等计划性工作', priority: 8, defaultPriority: 'low' },
|
||||
{ name: '数据问题', description: '数据错误、数据丢失、数据同步等数据相关问题', priority: 9, defaultPriority: 'high' },
|
||||
{ name: '其他问题', description: '无法归类的其他问题', priority: 99, defaultPriority: 'medium' }
|
||||
];
|
||||
|
||||
for (const cat of defaultCategories) {
|
||||
const existing = await FaultCategory.findOne({ where: { name: cat.name } });
|
||||
if (!existing) {
|
||||
const categoryId = `CAT${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substr(2, 4).toUpperCase()}`;
|
||||
await FaultCategory.create({
|
||||
categoryId,
|
||||
...cat,
|
||||
expectedDuration: 120,
|
||||
solutions: [],
|
||||
isSystem: true,
|
||||
isActive: true
|
||||
});
|
||||
console.log(`创建故障分类: ${cat.name}`);
|
||||
}
|
||||
}
|
||||
console.log('故障分类初始化完成');
|
||||
}
|
||||
|
||||
async function initializeApp() {
|
||||
try {
|
||||
await syncDatabase();
|
||||
await initDeviceFields();
|
||||
await initTicketFields();
|
||||
await initTicketModels();
|
||||
await syncSystemSettings();
|
||||
await syncConsumableModels();
|
||||
await syncInventoryModels();
|
||||
await initDefaultSystemSettings();
|
||||
await initFaultCategories();
|
||||
|
||||
console.log('所有初始化完成,服务器准备就绪');
|
||||
} catch (error) {
|
||||
console.error('初始化失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
initializeApp();
|
||||
|
||||
// 导入路由
|
||||
const deviceRoutes = require('./routes/devices');
|
||||
const rackRoutes = require('./routes/racks');
|
||||
const roomRoutes = require('./routes/rooms');
|
||||
@@ -163,7 +158,6 @@ const devicePortRoutes = require('./routes/devicePorts');
|
||||
const networkCardRoutes = require('./routes/networkCards');
|
||||
const inventoryRoutes = require('./routes/inventory');
|
||||
|
||||
// 使用路由
|
||||
app.use('/api/devices', deviceRoutes);
|
||||
app.use('/api/racks', rackRoutes);
|
||||
app.use('/api/rooms', roomRoutes);
|
||||
@@ -184,15 +178,12 @@ app.use('/api/device-ports', devicePortRoutes);
|
||||
app.use('/api/network-cards', networkCardRoutes);
|
||||
app.use('/api/inventory', inventoryRoutes);
|
||||
|
||||
// 静态文件服务
|
||||
app.use('/uploads', express.static('uploads'));
|
||||
|
||||
// 健康检查
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', message: 'IDC设备管理系统后端服务正常运行' });
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
app.listen(PORT, () => {
|
||||
console.log(`服务器运行在 http://localhost:${PORT}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
const fs = require('fs');
|
||||
|
||||
const content = fs.readFileSync('e:/IDC/jigui/frontend/src/pages/DeviceManagement.jsx', 'utf8');
|
||||
|
||||
const startMarker = '// 可调整列宽的表头组件';
|
||||
const endMarker = '// 防抖 Hook';
|
||||
|
||||
const startIndex = content.indexOf(startMarker);
|
||||
const endIndex = content.indexOf(endMarker, startIndex);
|
||||
|
||||
console.log('startMarker index:', startIndex);
|
||||
console.log('endMarker index:', endIndex);
|
||||
|
||||
if (startIndex >= 0 && endIndex >= 0) {
|
||||
console.log('Content to remove:');
|
||||
console.log(content.substring(startIndex, endIndex + endMarker.length).substring(0, 500));
|
||||
}
|
||||
+40
-163
@@ -50,6 +50,7 @@ import { ConfigProvider, useConfig } from './context/ConfigContext';
|
||||
import { Scene3DProvider } from './context/Scene3DContext';
|
||||
import { useDesignTokens } from './hooks/useDesignTokens';
|
||||
import useIdleTimeout from './hooks/useIdleTimeout';
|
||||
import { SWRConfig, swrConfig } from './hooks/useSWR';
|
||||
import axios from 'axios';
|
||||
import { Spin } from 'antd';
|
||||
|
||||
@@ -561,55 +562,50 @@ const AppLayout = ({ children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const routeConfig = [
|
||||
{ path: '/', component: Dashboard },
|
||||
{ path: '/devices', component: DeviceManagement },
|
||||
{ path: '/racks', component: RackManagement },
|
||||
{ path: '/rooms', component: RoomManagement },
|
||||
{ path: '/fields', component: DeviceFieldManagement },
|
||||
{ path: '/consumables', component: ConsumableManagement },
|
||||
{ path: '/consumables-categories', component: CategoryManagement },
|
||||
{ path: '/consumables-stats', component: ConsumableStatistics },
|
||||
{ path: '/consumables-logs', component: ConsumableLogs },
|
||||
{ path: '/users', component: UserManagement },
|
||||
{ path: '/tickets', component: TicketManagement },
|
||||
{ path: '/ticket-categories', component: TicketCategoryManagement },
|
||||
{ path: '/ticket-statistics', component: TicketStatistics },
|
||||
{ path: '/ticket-fields', component: TicketFieldManagement },
|
||||
{ path: '/settings', component: SystemSettings },
|
||||
{ path: '/cables', component: CableManagement },
|
||||
{ path: '/inventory', component: InventoryManagement },
|
||||
{ path: '/inventory/execution', component: InventoryTaskExecution },
|
||||
{ path: '/pending-devices', component: PendingDeviceManagement },
|
||||
{ path: '/ports', component: PortManagement },
|
||||
];
|
||||
|
||||
const ThemeConfig = () => {
|
||||
const designTokens = useDesignTokens();
|
||||
|
||||
return (
|
||||
<AntdConfigProvider theme={{ token: designTokens }}>
|
||||
<Router>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Routes>
|
||||
<SWRConfig value={swrConfig}>
|
||||
<Router>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Dashboard />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/devices"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<DeviceManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/racks"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<RackManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/rooms"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<RoomManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/fields"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<DeviceFieldManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
{routeConfig.map(({ path, component: Component }) => (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Component />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Route
|
||||
path="/visualization-3d"
|
||||
element={
|
||||
@@ -620,130 +616,11 @@ const ThemeConfig = () => {
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/consumables"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<ConsumableManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/consumables-categories"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<CategoryManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/consumables-stats"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<ConsumableStatistics />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/consumables-logs"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<ConsumableLogs />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<UserManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tickets"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<TicketManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ticket-categories"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<TicketCategoryManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ticket-statistics"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<TicketStatistics />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ticket-fields"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<TicketFieldManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<SystemSettings />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/cables"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<CableManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/inventory"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<InventoryManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/inventory/execution"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<InventoryTaskExecution />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/pending-devices"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<PendingDeviceManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ports"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<PortManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Router>
|
||||
</SWRConfig>
|
||||
</AntdConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,13 +45,8 @@ api.interceptors.response.use(
|
||||
|
||||
if (status === 401) {
|
||||
const currentPath = window.location.pathname;
|
||||
console.log('[API] 401 error, current path:', currentPath);
|
||||
|
||||
if (!currentPath.startsWith('/login')) {
|
||||
const savedToken = localStorage.getItem('token');
|
||||
if (savedToken) {
|
||||
console.log('[API] Token exists but got 401, might be expired');
|
||||
}
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
|
||||
@@ -116,7 +116,6 @@ const CableCreateModal = ({ visible, onClose, onSuccess, sourceDevice }) => {
|
||||
targetDeviceId: values.sourceDeviceId,
|
||||
targetPort: values.sourcePort,
|
||||
};
|
||||
console.log('Swapped source/target to ensure Switch is Source');
|
||||
}
|
||||
|
||||
await axios.post('/api/cables', payload);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
const AnimatedCounter = ({ value, duration = 1500 }) => {
|
||||
const [displayValue, setDisplayValue] = useState(0);
|
||||
const animationRef = useRef(null);
|
||||
const startTimeRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const animate = (currentTime) => {
|
||||
if (!startTimeRef.current) {
|
||||
startTimeRef.current = currentTime;
|
||||
}
|
||||
|
||||
const elapsed = currentTime - startTimeRef.current;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
||||
const currentValue = Math.floor(easeOutQuart * value);
|
||||
|
||||
setDisplayValue(currentValue);
|
||||
|
||||
if (progress < 1) {
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [value, duration]);
|
||||
|
||||
return <span>{displayValue}</span>;
|
||||
};
|
||||
|
||||
export default React.memo(AnimatedCounter);
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { designTokens } from '../../config/theme';
|
||||
|
||||
const CircularProgress = ({ percentage, size = 120, strokeWidth = 10, color, label }) => {
|
||||
const circumference = 2 * Math.PI * ((size - strokeWidth) / 2);
|
||||
const offset = circumference - (percentage / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: size, height: size }}>
|
||||
<svg width={size} height={size} style={{ transform: 'rotate(-90deg)' }}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={(size - strokeWidth) / 2}
|
||||
fill="none"
|
||||
stroke="#f0f0f0"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={(size - strokeWidth) / 2}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
style={{
|
||||
transition: 'stroke-dashoffset 1s ease-out',
|
||||
filter: `drop-shadow(0 0 6px ${color}40)`,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: '700',
|
||||
color: designTokens.colors.text.primary,
|
||||
}}
|
||||
>
|
||||
{percentage}%
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: designTokens.colors.text.secondary }}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(CircularProgress);
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { designTokens } from '../../config/theme';
|
||||
|
||||
const DeviceTrendChart = ({ data }) => {
|
||||
const maxValue = Math.max(...data.map((d) => d.value));
|
||||
const chartHeight = 120;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
height: chartHeight,
|
||||
gap: '8px',
|
||||
padding: '0 8px',
|
||||
}}
|
||||
>
|
||||
{data.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '40px',
|
||||
height: `${(item.value / maxValue) * chartHeight}px`,
|
||||
borderRadius: '4px 4px 0 0',
|
||||
background: `linear-gradient(180deg, ${item.color} 0%, ${item.color}80 100%)`,
|
||||
transition: `height ${designTokens.transitions.slow}`,
|
||||
boxShadow: `0 -2px 8px ${item.color}30`,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.7rem',
|
||||
color: designTokens.colors.text.tertiary,
|
||||
marginTop: '4px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(DeviceTrendChart);
|
||||
@@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
CloudServerOutlined,
|
||||
DatabaseOutlined,
|
||||
WarningOutlined,
|
||||
BarChartOutlined,
|
||||
AppstoreOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { designTokens } from '../../config/theme';
|
||||
|
||||
const NAV_BUTTONS_DATA = [
|
||||
{
|
||||
key: 'devices',
|
||||
icon: CloudServerOutlined,
|
||||
text: '设备管理',
|
||||
path: '/devices',
|
||||
color: designTokens.colors.primary.main,
|
||||
},
|
||||
{
|
||||
key: 'racks',
|
||||
icon: DatabaseOutlined,
|
||||
text: '资源规划',
|
||||
path: '/racks',
|
||||
color: '#722ed1',
|
||||
},
|
||||
{
|
||||
key: 'faults',
|
||||
icon: WarningOutlined,
|
||||
text: '故障监控',
|
||||
path: '/faults',
|
||||
color: designTokens.colors.warning.main,
|
||||
},
|
||||
{
|
||||
key: 'tickets',
|
||||
icon: BarChartOutlined,
|
||||
text: '工单管理',
|
||||
path: '/tickets',
|
||||
color: '#13c2c2',
|
||||
},
|
||||
{
|
||||
key: 'consumables',
|
||||
icon: AppstoreOutlined,
|
||||
text: '耗材管理',
|
||||
path: '/consumables',
|
||||
color: '#fa8c16',
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
icon: SettingOutlined,
|
||||
text: '系统配置',
|
||||
path: '/settings',
|
||||
color: designTokens.colors.success.main,
|
||||
},
|
||||
];
|
||||
|
||||
const createNavButtonStyle = (color, isHovered) => ({
|
||||
height: 'auto',
|
||||
padding: 'clamp(16px, 4vw, 24px) clamp(12px, 3vw, 20px)',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
border: `2px solid ${isHovered ? color : '#f0f0f0'}`,
|
||||
background: '#fff',
|
||||
transition: `all ${designTokens.transitions.normal}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 'clamp(8px, 2vw, 12px)',
|
||||
cursor: 'pointer',
|
||||
boxShadow: isHovered ? designTokens.shadows.large : designTokens.shadows.small,
|
||||
transform: isHovered ? 'translateY(-4px)' : 'none',
|
||||
minWidth: 0,
|
||||
});
|
||||
|
||||
const createNavIconContainer = (color) => ({
|
||||
width: 'clamp(44px, 10vw, 60px)',
|
||||
height: 'clamp(44px, 10vw, 60px)',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: `linear-gradient(135deg, ${color}20 0%, ${color}10 100%)`,
|
||||
fontSize: 'clamp(20px, 5vw, 28px)',
|
||||
transition: `all ${designTokens.transitions.normal}`,
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
const navTextStyle = {
|
||||
fontSize: 'clamp(0.75rem, 2vw, 0.9rem)',
|
||||
fontWeight: '600',
|
||||
color: designTokens.colors.text.primary,
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
};
|
||||
|
||||
const NavigationGrid = ({ hoveredCard, onHover }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="nav-grid"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
|
||||
gap: 'clamp(8px, 2vw, 16px)',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
{NAV_BUTTONS_DATA.map(({ key, icon: Icon, text, color }) => {
|
||||
const isHovered = hoveredCard === `nav-${key}`;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="nav-button"
|
||||
style={{
|
||||
...createNavButtonStyle(color, isHovered),
|
||||
animationDelay: `${NAV_BUTTONS_DATA.findIndex((b) => b.key === key) * 0.1}s`,
|
||||
}}
|
||||
onMouseEnter={() => onHover(`nav-${key}`)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
onClick={() => navigate(`/${key}`)}
|
||||
>
|
||||
<div className="nav-icon" style={createNavIconContainer(color)}>
|
||||
<Icon style={{ color, fontSize: 'clamp(20px, 5vw, 28px)' }} />
|
||||
</div>
|
||||
<span className="nav-text" style={navTextStyle}>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(NavigationGrid);
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import { designTokens } from '../../config/theme';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const PowerGauge = ({ value, maxValue }) => {
|
||||
const percentage = Math.min((value / maxValue) * 100, 100);
|
||||
const getColor = () => {
|
||||
if (percentage >= 80) return designTokens.colors.error.main;
|
||||
if (percentage >= 60) return designTokens.colors.warning.main;
|
||||
return designTokens.colors.success.main;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<Text style={{ fontSize: '0.85rem', color: designTokens.colors.text.secondary }}>
|
||||
功率使用率
|
||||
</Text>
|
||||
<Text style={{ fontSize: '0.85rem', fontWeight: '600', color: getColor() }}>
|
||||
{percentage.toFixed(1)}%
|
||||
</Text>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: '8px',
|
||||
borderRadius: '4px',
|
||||
background: '#f0f0f0',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
borderRadius: '4px',
|
||||
background: `linear-gradient(90deg, ${getColor()}, ${getColor()}80)`,
|
||||
width: `${percentage}%`,
|
||||
transition: 'width 0.8s ease-out',
|
||||
boxShadow: `0 0 8px ${getColor()}40`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: '4px',
|
||||
fontSize: '0.75rem',
|
||||
color: designTokens.colors.text.tertiary,
|
||||
}}
|
||||
>
|
||||
<span>{value}W</span>
|
||||
<span>{maxValue}W</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(PowerGauge);
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { Card, Typography } from 'antd';
|
||||
import { LineChartOutlined, SafetyOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { designTokens } from '../../config/theme';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const quickStatItemStyle = {
|
||||
background: 'linear-gradient(135deg, #fff 0%, #fafafa 100%)',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
border: '1px solid #f0f0f0',
|
||||
boxShadow: designTokens.shadows.small,
|
||||
};
|
||||
|
||||
const QuickStats = ({ onlineRate, powerUsage }) => {
|
||||
const quickStats = [
|
||||
{
|
||||
icon: LineChartOutlined,
|
||||
label: '在线率',
|
||||
value: `${onlineRate}%`,
|
||||
color: designTokens.colors.success.main,
|
||||
},
|
||||
{
|
||||
icon: SafetyOutlined,
|
||||
label: '安全等级',
|
||||
value: 'A级',
|
||||
color: designTokens.colors.primary.main,
|
||||
},
|
||||
{
|
||||
icon: ThunderboltOutlined,
|
||||
label: '功率使用',
|
||||
value: `${powerUsage}W`,
|
||||
color: designTokens.colors.warning.main,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
||||
gap: '16px',
|
||||
marginBottom: '0',
|
||||
animation: 'fadeInUp 0.6s ease-out 0.5s backwards',
|
||||
}}
|
||||
>
|
||||
{quickStats.map((stat, index) => (
|
||||
<div key={index} style={quickStatItemStyle}>
|
||||
<div
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
background: `linear-gradient(135deg, ${stat.color}20 0%, ${stat.color}10 100%)`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '24px',
|
||||
color: stat.color,
|
||||
boxShadow: `0 4px 12px ${stat.color}20`,
|
||||
}}
|
||||
>
|
||||
<stat.icon />
|
||||
</div>
|
||||
<div>
|
||||
<Text style={{ color: designTokens.colors.text.secondary, fontSize: '0.85rem' }}>
|
||||
{stat.label}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '1.2rem',
|
||||
fontWeight: '700',
|
||||
color: designTokens.colors.text.primary,
|
||||
}}
|
||||
>
|
||||
{stat.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(QuickStats);
|
||||
@@ -0,0 +1,176 @@
|
||||
import React from 'react';
|
||||
import { Card, Col, Tag, Spin } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
||||
import { designTokens } from '../../config/theme';
|
||||
import AnimatedCounter from './AnimatedCounter';
|
||||
|
||||
const createStatCardStyle = (color) => ({
|
||||
borderRadius: designTokens.borderRadius.large,
|
||||
border: 'none',
|
||||
boxShadow: designTokens.shadows.medium,
|
||||
background: '#fff',
|
||||
transition: `all ${designTokens.transitions.normal}`,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
height: '100%',
|
||||
animation: 'fadeInUp 0.6s ease-out backwards',
|
||||
borderLeft: `4px solid ${color}`,
|
||||
});
|
||||
|
||||
const createStatIconContainer = (color) => ({
|
||||
width: 'clamp(40px, 8vw, 64px)',
|
||||
height: 'clamp(40px, 8vw, 64px)',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 'clamp(20px, 4vw, 32px)',
|
||||
transition: `all ${designTokens.transitions.normal}`,
|
||||
flexShrink: 0,
|
||||
background: `linear-gradient(135deg, ${color}20 0%, ${color}10 100%)`,
|
||||
});
|
||||
|
||||
const StatCard = ({
|
||||
config,
|
||||
stats,
|
||||
loading,
|
||||
animatedKey,
|
||||
hoveredCard,
|
||||
onHover,
|
||||
}) => {
|
||||
const {
|
||||
icon: Icon,
|
||||
color,
|
||||
statKey,
|
||||
title,
|
||||
trend,
|
||||
tagColor,
|
||||
customStatus,
|
||||
xs,
|
||||
sm,
|
||||
lg,
|
||||
xl,
|
||||
delay,
|
||||
} = config;
|
||||
|
||||
const colProps = { xs, sm, lg, xl };
|
||||
const cardStyle = {
|
||||
...createStatCardStyle(color),
|
||||
...(hoveredCard === statKey
|
||||
? { transform: 'translateY(-6px)', boxShadow: designTokens.shadows.xl }
|
||||
: {}),
|
||||
animationDelay: `${delay * 0.1}s`,
|
||||
};
|
||||
|
||||
return (
|
||||
<Col key={statKey} {...colProps}>
|
||||
<Card
|
||||
style={cardStyle}
|
||||
onMouseEnter={() => onHover(statKey)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
styles={{ body: { padding: 'clamp(16px, 3vw, 24px)' } }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'clamp(0.75rem, 2vw, 0.9rem)',
|
||||
fontWeight: '600',
|
||||
color: designTokens.colors.text.secondary,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<div style={createStatIconContainer(color)}>
|
||||
<Icon style={{ color }} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="stat-value"
|
||||
style={{
|
||||
fontSize: 'clamp(1.6rem, 4vw, 2.2rem)',
|
||||
fontWeight: '700',
|
||||
color: color,
|
||||
lineHeight: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<AnimatedCounter key={`${animatedKey}-${statKey}`} value={stats[statKey]} />
|
||||
)}
|
||||
</div>
|
||||
{customStatus ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 'clamp(0.7rem, 1.8vw, 0.85rem)',
|
||||
color: designTokens.colors.success.main,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
background: designTokens.colors.success.main,
|
||||
borderRadius: '50%',
|
||||
marginRight: '6px',
|
||||
boxShadow: '0 0 6px rgba(82, 196, 26, 0.5)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span>{statKey === 'totalRacks' ? '正常运行中' : '全部在线'}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 'clamp(0.7rem, 1.8vw, 0.875rem)',
|
||||
fontWeight: '500',
|
||||
color: trend > 0 ? designTokens.colors.success.main : designTokens.colors.error.main,
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
{trend > 0 ? (
|
||||
<ArrowUpOutlined style={{ fontSize: '0.75rem' }} />
|
||||
) : (
|
||||
<ArrowDownOutlined style={{ fontSize: '0.75rem' }} />
|
||||
)}
|
||||
<span>{Math.abs(trend)}%</span>
|
||||
<Tag
|
||||
color={tagColor}
|
||||
style={{
|
||||
fontSize: 'clamp(0.6rem, 1.5vw, 0.75rem)',
|
||||
borderRadius: '4px',
|
||||
margin: 0,
|
||||
padding: '0 4px',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
环比
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(StatCard);
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import { designTokens } from '../../config/theme';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const StatusLegend = () => {
|
||||
const legends = [
|
||||
{ color: designTokens.colors.success.main, label: '运行中', percent: 60 },
|
||||
{ color: designTokens.colors.warning.main, label: '维护中', percent: 20 },
|
||||
{ color: designTokens.colors.error.main, label: '故障', percent: 10 },
|
||||
{ color: designTokens.colors.primary.main, label: '离线', percent: 10 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
{legends.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 0',
|
||||
borderBottom: index < legends.length - 1 ? '1px solid #f5f5f5' : 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '3px',
|
||||
background: item.color,
|
||||
boxShadow: `0 0 6px ${item.color}40`,
|
||||
}}
|
||||
/>
|
||||
<Text style={{ fontSize: '0.85rem', color: designTokens.colors.text.secondary }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</div>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: '600',
|
||||
color: designTokens.colors.text.primary,
|
||||
}}
|
||||
>
|
||||
{item.percent}%
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(StatusLegend);
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import { designTokens } from '../../config/theme';
|
||||
|
||||
const systemInfoStyle = {
|
||||
background: 'linear-gradient(135deg, #f0f7ff 0%, #e6f7ff 100%)',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
padding: '20px',
|
||||
border: '1px solid #91d5ff',
|
||||
};
|
||||
|
||||
const SystemInfo = ({ onRefresh, isRefreshing }) => {
|
||||
return (
|
||||
<div style={{ animation: 'fadeInUp 0.6s ease-out 0.5s backwards' }}>
|
||||
<div style={systemInfoStyle}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
margin: '0',
|
||||
fontSize: '0.9rem',
|
||||
color: designTokens.colors.text.primary,
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
<strong>系统版本:</strong> v1.0.0
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
margin: '4px 0 0 0',
|
||||
fontSize: '0.85rem',
|
||||
color: designTokens.colors.text.secondary,
|
||||
}}
|
||||
>
|
||||
<strong>最后更新:</strong>
|
||||
{new Date().toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined spin={isRefreshing} />}
|
||||
size="small"
|
||||
onClick={onRefresh}
|
||||
loading={isRefreshing}
|
||||
style={{
|
||||
background: designTokens.colors.primary.gradient,
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(24, 144, 255, 0.3)',
|
||||
}}
|
||||
>
|
||||
刷新数据
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(SystemInfo);
|
||||
@@ -0,0 +1,9 @@
|
||||
export { default as AnimatedCounter } from './AnimatedCounter';
|
||||
export { default as CircularProgress } from './CircularProgress';
|
||||
export { default as PowerGauge } from './PowerGauge';
|
||||
export { default as DeviceTrendChart } from './DeviceTrendChart';
|
||||
export { default as StatusLegend } from './StatusLegend';
|
||||
export { default as StatCard } from './StatCard';
|
||||
export { default as NavigationGrid } from './NavigationGrid';
|
||||
export { default as QuickStats } from './QuickStats';
|
||||
export { default as SystemInfo } from './SystemInfo';
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { Modal, Form, Select, Button } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import { designTokens } from '../../config/theme';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const modalHeaderStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const secondaryActionStyle = {
|
||||
height: '40px',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
border: `1px solid ${designTokens.colors.border.light}`,
|
||||
fontWeight: '500',
|
||||
};
|
||||
|
||||
const BatchStatusModal = ({
|
||||
visible,
|
||||
selectedCount,
|
||||
loading,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await onSubmit(values.status);
|
||||
} catch (error) {
|
||||
if (!error.errorFields) {
|
||||
console.error('批量状态变更失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
|
||||
<ReloadOutlined style={{ color: '#52c41a' }} />
|
||||
批量状态变更
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onCancel} style={secondaryActionStyle}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={handleSubmit}
|
||||
style={{
|
||||
height: '40px',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
background: designTokens.colors.primary.gradient,
|
||||
border: 'none',
|
||||
color: '#ffffff',
|
||||
boxShadow: designTokens.shadows.small,
|
||||
fontWeight: '500',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
确定
|
||||
</Button>,
|
||||
]}
|
||||
destroyOnHidden
|
||||
styles={{
|
||||
header: {
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
padding: '16px 24px',
|
||||
position: 'relative',
|
||||
},
|
||||
body: { padding: '24px' },
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="选择新状态"
|
||||
rules={[{ required: true, message: '请选择设备状态' }]}
|
||||
>
|
||||
<Select placeholder="请选择设备状态" style={{ width: '100%' }}>
|
||||
<Option value="running">运行中</Option>
|
||||
<Option value="maintenance">维护中</Option>
|
||||
<Option value="offline">离线</Option>
|
||||
<Option value="fault">故障</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<div style={{ color: '#666', fontSize: '13px' }}>
|
||||
已选择{' '}
|
||||
<span style={{ color: '#1890ff', fontWeight: 600 }}>{selectedCount}</span> 个设备
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(BatchStatusModal);
|
||||
@@ -0,0 +1,256 @@
|
||||
import React from 'react';
|
||||
import { Modal, Button, Card, Row, Col, Tag } from 'antd';
|
||||
import { AppstoreOutlined } from '@ant-design/icons';
|
||||
import { designTokens } from '../../config/theme';
|
||||
import { getStatusConfig, getTypeLabel, getDeviceTypeIcon } from '../../utils/deviceUtils.jsx';
|
||||
|
||||
const modalHeaderStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const secondaryActionStyle = {
|
||||
height: '40px',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
border: `1px solid ${designTokens.colors.border.light}`,
|
||||
fontWeight: '500',
|
||||
};
|
||||
|
||||
const DeviceDetailModal = ({
|
||||
visible,
|
||||
device,
|
||||
deviceFields,
|
||||
onClose,
|
||||
onEdit,
|
||||
onViewTickets,
|
||||
onCreateTicket,
|
||||
}) => {
|
||||
if (!device) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
|
||||
<AppstoreOutlined style={{ color: '#667eea' }} />
|
||||
设备详情
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={[
|
||||
<Button key="close" onClick={onClose} style={secondaryActionStyle}>
|
||||
关闭
|
||||
</Button>,
|
||||
<Button key="viewTickets" onClick={() => onViewTickets(device)} style={secondaryActionStyle}>
|
||||
查看工单
|
||||
</Button>,
|
||||
<Button key="createTicket" onClick={() => onCreateTicket(device)} style={secondaryActionStyle}>
|
||||
创建工单
|
||||
</Button>,
|
||||
<Button
|
||||
key="edit"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onEdit(device);
|
||||
}}
|
||||
style={{
|
||||
height: '40px',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
background: designTokens.colors.primary.gradient,
|
||||
border: 'none',
|
||||
color: '#ffffff',
|
||||
boxShadow: designTokens.shadows.small,
|
||||
fontWeight: '500',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>,
|
||||
]}
|
||||
width={700}
|
||||
destroyOnHidden
|
||||
styles={{
|
||||
header: {
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
padding: '16px 24px',
|
||||
position: 'relative',
|
||||
},
|
||||
body: { padding: '0', overflow: 'auto' },
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
padding: '24px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{getDeviceTypeIcon(device.type)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: 600, marginBottom: '8px' }}>
|
||||
{device.name}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', opacity: 0.9 }}>
|
||||
<span>{getTypeLabel(device.type)}</span>
|
||||
<span>|</span>
|
||||
<span>{device.deviceId}</span>
|
||||
<span>|</span>
|
||||
<Tag
|
||||
color={device.status ? getStatusConfig(device.status).badgeColor : 'default'}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{device.status ? getStatusConfig(device.status).text : '-'}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<Card
|
||||
size="small"
|
||||
title={<span style={{ fontWeight: 600 }}>基本信息</span>}
|
||||
style={{ marginBottom: '16px', borderRadius: '8px' }}
|
||||
>
|
||||
<Row gutter={[24, 16]}>
|
||||
<Col span={8}>
|
||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>设备型号</div>
|
||||
<div style={{ fontWeight: 500 }}>{device.model || '-'}</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>序列号</div>
|
||||
<div style={{ fontWeight: 500 }}>{device.serialNumber || '-'}</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>IP地址</div>
|
||||
<div style={{ fontWeight: 500 }}>{device.ipAddress || '-'}</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>所在机房</div>
|
||||
<div style={{ fontWeight: 500 }}>{device.Rack?.Room?.name || '-'}</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>所在机柜</div>
|
||||
<div style={{ fontWeight: 500 }}>{device.Rack?.name || '-'}</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>位置(U)</div>
|
||||
<div style={{ fontWeight: 500 }}>U{device.position || '-'}</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>高度</div>
|
||||
<div style={{ fontWeight: 500 }}>{device.height ? `${device.height}U` : '-'}</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>功率</div>
|
||||
<div style={{ fontWeight: 500 }}>{device.power ? `${device.power}W` : '-'}</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>状态</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: device.status ? getStatusConfig(device.status).color : '#666',
|
||||
}}
|
||||
>
|
||||
{device.status ? getStatusConfig(device.status).text : '-'}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title={<span style={{ fontWeight: 600 }}>维保信息</span>}
|
||||
style={{ marginBottom: '16px', borderRadius: '8px' }}
|
||||
>
|
||||
<Row gutter={[24, 16]}>
|
||||
<Col span={12}>
|
||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>购买日期</div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{device.purchaseDate
|
||||
? new Date(device.purchaseDate).toLocaleDateString('zh-CN')
|
||||
: '-'}
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>保修到期</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight:
|
||||
device.warrantyExpiry && new Date(device.warrantyExpiry) < new Date()
|
||||
? 600
|
||||
: 500,
|
||||
color:
|
||||
device.warrantyExpiry && new Date(device.warrantyExpiry) < new Date()
|
||||
? '#d93025'
|
||||
: '#333',
|
||||
}}
|
||||
>
|
||||
{device.warrantyExpiry
|
||||
? new Date(device.warrantyExpiry).toLocaleDateString('zh-CN')
|
||||
: '-'}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{device.description && (
|
||||
<Card
|
||||
size="small"
|
||||
title={<span style={{ fontWeight: 600 }}>描述</span>}
|
||||
style={{ marginBottom: '16px', borderRadius: '8px' }}
|
||||
>
|
||||
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>{device.description}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{device.customFields && Object.keys(device.customFields).length > 0 && (
|
||||
<Card
|
||||
size="small"
|
||||
title={<span style={{ fontWeight: 600 }}>自定义字段</span>}
|
||||
style={{ borderRadius: '8px' }}
|
||||
>
|
||||
<Row gutter={[24, 16]}>
|
||||
{Object.entries(device.customFields).map(([key, value]) => {
|
||||
const fieldConfig = deviceFields.find((f) => f.fieldName === key);
|
||||
const displayName = fieldConfig?.displayName || key;
|
||||
return (
|
||||
<Col span={8} key={key}>
|
||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>
|
||||
{displayName}
|
||||
</div>
|
||||
<div style={{ fontWeight: 500 }}>{String(value)}</div>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(DeviceDetailModal);
|
||||
@@ -0,0 +1,361 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, Select, InputNumber, DatePicker, Switch, Row, Col, Button, Space } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DatabaseOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { designTokens } from '../../config/theme';
|
||||
import { getFormInitialValues, prepareDeviceFormData } from '../../utils/deviceUtils.jsx';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const modalHeaderStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const inputStyle = {
|
||||
borderRadius: '8px',
|
||||
transition: 'all 0.3s ease',
|
||||
};
|
||||
|
||||
const DeviceFormModal = ({
|
||||
visible,
|
||||
editingDevice,
|
||||
deviceFields,
|
||||
racks,
|
||||
rooms,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [selectedRoomId, setSelectedRoomId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (editingDevice) {
|
||||
const initialValues = getFormInitialValues(editingDevice, racks);
|
||||
if (initialValues.purchaseDate) {
|
||||
initialValues.purchaseDate = dayjs(initialValues.purchaseDate);
|
||||
}
|
||||
if (initialValues.warrantyExpiry) {
|
||||
initialValues.warrantyExpiry = dayjs(initialValues.warrantyExpiry);
|
||||
}
|
||||
form.setFieldsValue(initialValues);
|
||||
if (editingDevice.rackId) {
|
||||
const rack = racks.find((r) => r.rackId === editingDevice.rackId);
|
||||
if (rack) {
|
||||
setSelectedRoomId(rack.roomId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
form.resetFields();
|
||||
setSelectedRoomId(null);
|
||||
}
|
||||
}
|
||||
}, [visible, editingDevice, racks, form]);
|
||||
|
||||
const handleSubmit = (values) => {
|
||||
const deviceData = prepareDeviceFormData(values, !!editingDevice);
|
||||
onSubmit(deviceData);
|
||||
};
|
||||
|
||||
const handleRoomChange = (value) => {
|
||||
setSelectedRoomId(value);
|
||||
form.setFieldValue('rackId', undefined);
|
||||
};
|
||||
|
||||
const renderFieldControl = (field) => {
|
||||
switch (field.fieldType) {
|
||||
case 'number':
|
||||
return (
|
||||
<InputNumber
|
||||
placeholder={`请输入${field.displayName}`}
|
||||
min={0}
|
||||
style={{ width: '100%', ...inputStyle }}
|
||||
className="form-input-enhanced"
|
||||
/>
|
||||
);
|
||||
case 'boolean':
|
||||
return <Switch />;
|
||||
case 'date':
|
||||
return (
|
||||
<DatePicker
|
||||
style={{ width: '100%', ...inputStyle }}
|
||||
placeholder={`请选择${field.displayName}`}
|
||||
className="form-input-enhanced"
|
||||
/>
|
||||
);
|
||||
case 'textarea':
|
||||
return (
|
||||
<Input.TextArea
|
||||
placeholder={`请输入${field.displayName}`}
|
||||
rows={3}
|
||||
style={inputStyle}
|
||||
className="form-input-enhanced"
|
||||
/>
|
||||
);
|
||||
case 'select':
|
||||
return (
|
||||
<Select
|
||||
placeholder={`请选择${field.displayName}`}
|
||||
style={inputStyle}
|
||||
className="form-input-enhanced"
|
||||
>
|
||||
{field.options &&
|
||||
field.options.map((option) => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
placeholder={`请输入${field.displayName}`}
|
||||
style={inputStyle}
|
||||
className="form-input-enhanced"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFields = deviceFields.filter(
|
||||
(field) => field.fieldName !== 'deviceId' && field.fieldName !== 'rackId'
|
||||
);
|
||||
|
||||
const formItems = [];
|
||||
filteredFields.forEach((field) => {
|
||||
if (field.fieldName === 'serialNumber') {
|
||||
formItems.push(
|
||||
<React.Fragment key={field.fieldName}>
|
||||
<Col span={12} key={`${field.fieldName}-col`}>
|
||||
<Form.Item
|
||||
name={field.fieldName}
|
||||
label={
|
||||
<span>
|
||||
{field.displayName}
|
||||
{field.required && (
|
||||
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
rules={
|
||||
field.required ? [{ required: true, message: `请输入${field.displayName}` }] : []
|
||||
}
|
||||
>
|
||||
{renderFieldControl(field)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24} key="room-rack-section">
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #f0f5ff 0%, #e6f7ff 100%)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
marginBottom: '16px',
|
||||
border: '2px solid #d6e4ff',
|
||||
boxShadow: '0 2px 8px rgba(24, 144, 255, 0.1)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#1890ff',
|
||||
marginBottom: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<DatabaseOutlined style={{ marginRight: '8px' }} />
|
||||
设备位置选择
|
||||
</div>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="roomId"
|
||||
label={
|
||||
<span>
|
||||
机房
|
||||
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
|
||||
</span>
|
||||
}
|
||||
rules={[{ required: true, message: '请选择机房' }]}
|
||||
style={{ marginBottom: '0' }}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择机房"
|
||||
style={{ borderRadius: '8px' }}
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
onChange={handleRoomChange}
|
||||
>
|
||||
{rooms.map((room) => (
|
||||
<Option key={room.roomId} value={room.roomId}>
|
||||
{room.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="rackId"
|
||||
label={
|
||||
<span>
|
||||
机柜
|
||||
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
|
||||
</span>
|
||||
}
|
||||
rules={[{ required: true, message: '请选择机柜' }]}
|
||||
style={{ marginBottom: '0' }}
|
||||
>
|
||||
<Select
|
||||
placeholder={selectedRoomId ? '请选择机柜' : '请先选择机房'}
|
||||
style={{ borderRadius: '8px' }}
|
||||
disabled={!selectedRoomId}
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
>
|
||||
{(selectedRoomId ? racks.filter((rack) => rack.roomId === selectedRoomId) : []).map(
|
||||
(rack) => (
|
||||
<Option key={rack.rackId} value={rack.rackId}>
|
||||
{rack.name} ({rack.rackId})
|
||||
</Option>
|
||||
)
|
||||
)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</Col>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else if (field.fieldType === 'textarea') {
|
||||
formItems.push(
|
||||
<Col span={24} key={field.fieldName}>
|
||||
<Form.Item
|
||||
name={field.fieldName}
|
||||
label={
|
||||
<span>
|
||||
{field.displayName}
|
||||
{field.required && (
|
||||
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
rules={
|
||||
field.required ? [{ required: true, message: `请输入${field.displayName}` }] : []
|
||||
}
|
||||
>
|
||||
{renderFieldControl(field)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
);
|
||||
} else {
|
||||
formItems.push(
|
||||
<Col span={12} key={field.fieldName}>
|
||||
<Form.Item
|
||||
name={field.fieldName}
|
||||
label={
|
||||
<span>
|
||||
{field.displayName}
|
||||
{field.required && (
|
||||
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
rules={
|
||||
field.required ? [{ required: true, message: `请输入${field.displayName}` }] : []
|
||||
}
|
||||
>
|
||||
{renderFieldControl(field)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
|
||||
{editingDevice ? (
|
||||
<EditOutlined style={{ color: '#667eea' }} />
|
||||
) : (
|
||||
<PlusOutlined style={{ color: '#667eea' }} />
|
||||
)}
|
||||
{editingDevice ? '编辑设备' : '添加设备'}
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
width={700}
|
||||
style={{ borderRadius: '16px' }}
|
||||
styles={{
|
||||
header: {
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
padding: '16px 24px',
|
||||
position: 'relative',
|
||||
},
|
||||
body: { padding: '24px' },
|
||||
}}
|
||||
className="device-modal"
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Row gutter={16}>{formItems}</Row>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '12px',
|
||||
marginTop: '32px',
|
||||
paddingTop: '24px',
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
height: '40px',
|
||||
borderRadius: '8px',
|
||||
padding: '0 24px',
|
||||
fontWeight: '500',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
style={{
|
||||
height: '40px',
|
||||
borderRadius: '8px',
|
||||
background: designTokens.colors.primary.gradient,
|
||||
border: 'none',
|
||||
color: '#ffffff',
|
||||
boxShadow: designTokens.shadows.small,
|
||||
fontWeight: '500',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 32px',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(DeviceFormModal);
|
||||
@@ -0,0 +1,179 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Modal, Form, Select, Checkbox, Button, message } from 'antd';
|
||||
import { ExportOutlined } from '@ant-design/icons';
|
||||
import { designTokens } from '../../config/theme';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const modalHeaderStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const ExportModal = ({
|
||||
visible,
|
||||
deviceFields,
|
||||
selectedDevices,
|
||||
currentPageDevices,
|
||||
allDevices,
|
||||
onExport,
|
||||
onCancel,
|
||||
}) => {
|
||||
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 {
|
||||
setExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getScopeLabel = () => {
|
||||
switch (exportScope) {
|
||||
case 'selected':
|
||||
return `选择的行 (${selectedDevices.length} 个)`;
|
||||
case 'currentPage':
|
||||
return `当前页 (${currentPageDevices.length} 个)`;
|
||||
case 'all':
|
||||
return `全部设备 (${allDevices.length} 个)`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
|
||||
<ExportOutlined style={{ color: '#fa8c16' }} />
|
||||
导出设备数据
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
height: '40px',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
border: `1px solid ${designTokens.colors.border.light}`,
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
loading={exportLoading}
|
||||
onClick={handleExport}
|
||||
style={{
|
||||
height: '40px',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
background: designTokens.colors.primary.gradient,
|
||||
border: 'none',
|
||||
color: '#ffffff',
|
||||
boxShadow: designTokens.shadows.small,
|
||||
fontWeight: '500',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
导出
|
||||
</Button>,
|
||||
]}
|
||||
destroyOnHidden
|
||||
styles={{
|
||||
header: {
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
padding: '16px 24px',
|
||||
position: 'relative',
|
||||
},
|
||||
body: { padding: '24px' },
|
||||
}}
|
||||
width={600}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="导出格式">
|
||||
<Select value={exportFormat} onChange={setExportFormat} style={{ width: '100%' }}>
|
||||
<Option value="csv">CSV 格式</Option>
|
||||
<Option value="json">JSON 格式</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="导出范围">
|
||||
<Select value={exportScope} onChange={setExportScope} style={{ width: '100%' }}>
|
||||
<Option value="selected">{getScopeLabel()}</Option>
|
||||
<Option value="currentPage">当前页 ({currentPageDevices.length} 个)</Option>
|
||||
<Option value="all">全部设备 ({allDevices.length} 个)</Option>
|
||||
</Select>
|
||||
</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' }}>
|
||||
已选择{' '}
|
||||
<span style={{ color: '#1890ff', fontWeight: 600 }}>{selectedDevices.length}</span> 个设备,
|
||||
将导出{' '}
|
||||
<span style={{ color: '#52c41a', fontWeight: 600 }}>{exportFields.length}</span> 个字段
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ExportModal);
|
||||
@@ -0,0 +1,150 @@
|
||||
import React from 'react';
|
||||
import { Modal, Form, Switch, Button, Space, message } from 'antd';
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
import { designTokens } from '../../config/theme';
|
||||
|
||||
const modalHeaderStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const secondaryActionStyle = {
|
||||
height: '40px',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
border: `1px solid ${designTokens.colors.border.light}`,
|
||||
fontWeight: '500',
|
||||
};
|
||||
|
||||
const FieldConfigModal = ({
|
||||
visible,
|
||||
deviceFields,
|
||||
defaultDeviceFields,
|
||||
onSave,
|
||||
onReset,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const getInitialValues = () => {
|
||||
return deviceFields.reduce(
|
||||
(acc, field) => ({
|
||||
...acc,
|
||||
[`visible_${field.fieldName}`]: field.visible,
|
||||
[`required_${field.fieldName}`]: field.required,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
const updatedFields = deviceFields.map((field) => ({
|
||||
fieldId: field.fieldId,
|
||||
fieldName: field.fieldName,
|
||||
displayName: field.displayName,
|
||||
visible: values[`visible_${field.fieldName}`] ?? field.visible,
|
||||
required: values[`required_${field.fieldName}`] ?? field.required,
|
||||
}));
|
||||
|
||||
await onSave(updatedFields);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
onReset(defaultDeviceFields);
|
||||
message.success('字段配置已重置为默认值');
|
||||
};
|
||||
|
||||
const filteredFields = deviceFields.filter((field) => field.fieldName !== 'deviceId');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
|
||||
<SettingOutlined style={{ color: '#667eea' }} />
|
||||
字段配置
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
width={600}
|
||||
styles={{
|
||||
header: {
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
padding: '16px 24px',
|
||||
position: 'relative',
|
||||
},
|
||||
body: { padding: '24px' },
|
||||
}}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
initialValues={getInitialValues()}
|
||||
>
|
||||
<div style={{ maxHeight: 400, overflowY: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #f0f0f0' }}>
|
||||
<th style={{ padding: '8px', textAlign: 'left', width: '40%' }}>字段名称</th>
|
||||
<th style={{ padding: '8px', textAlign: 'center', width: '30%' }}>可见</th>
|
||||
<th style={{ padding: '8px', textAlign: 'center', width: '30%' }}>必填</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredFields.map((field) => (
|
||||
<tr key={field.fieldName} style={{ borderBottom: '1px solid #f0f0f0' }}>
|
||||
<td style={{ padding: '8px' }}>{field.displayName}</td>
|
||||
<td style={{ padding: '8px', textAlign: 'center' }}>
|
||||
<Form.Item name={`visible_${field.fieldName}`} valuePropName="checked" noStyle>
|
||||
<Switch size="small" />
|
||||
</Form.Item>
|
||||
</td>
|
||||
<td style={{ padding: '8px', textAlign: 'center' }}>
|
||||
<Form.Item name={`required_${field.fieldName}`} valuePropName="checked" noStyle>
|
||||
<Switch size="small" />
|
||||
</Form.Item>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Form.Item style={{ textAlign: 'right', marginTop: '20px' }}>
|
||||
<Space>
|
||||
<Button onClick={onCancel} style={secondaryActionStyle}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleReset} style={secondaryActionStyle}>
|
||||
重置默认
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
style={{
|
||||
height: '40px',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
background: designTokens.colors.primary.gradient,
|
||||
border: 'none',
|
||||
color: '#ffffff',
|
||||
boxShadow: designTokens.shadows.small,
|
||||
fontWeight: '500',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(FieldConfigModal);
|
||||
@@ -0,0 +1,385 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Upload, Button, Progress, message } from 'antd';
|
||||
import { UploadOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import { designTokens } from '../../config/theme';
|
||||
|
||||
const modalHeaderStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const ImportModal = ({
|
||||
visible,
|
||||
deviceFields,
|
||||
onImport,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [importProgress, setImportProgress] = useState(0);
|
||||
const [importPhase, setImportPhase] = useState('');
|
||||
const [importResult, setImportResult] = useState(null);
|
||||
|
||||
const handleImport = async (file) => {
|
||||
try {
|
||||
setIsImporting(true);
|
||||
setImportProgress(0);
|
||||
setImportPhase('正在上传文件...');
|
||||
setImportResult(null);
|
||||
|
||||
await onImport(file, {
|
||||
onProgress: (progress, phase) => {
|
||||
setImportProgress(progress);
|
||||
setImportPhase(phase);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
setImportResult(result);
|
||||
setImportProgress(100);
|
||||
setImportPhase('导入完成');
|
||||
setIsImporting(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
setImportResult({
|
||||
success: false,
|
||||
statistics: {
|
||||
total: 0,
|
||||
success: 0,
|
||||
failed: 1,
|
||||
errors: [{ row: 0, error: error.message || '导入失败' }],
|
||||
},
|
||||
});
|
||||
setIsImporting(false);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
setIsImporting(false);
|
||||
setImportProgress(0);
|
||||
message.error('导入失败');
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setImportProgress(0);
|
||||
setImportPhase('');
|
||||
setImportResult(null);
|
||||
setIsImporting(false);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const requiredFields = deviceFields.filter((f) => f.visible && f.required);
|
||||
const optionalFields = deviceFields.filter((f) => f.visible && !f.required);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
|
||||
<UploadOutlined style={{ color: '#667eea' }} />
|
||||
导入设备
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
width={650}
|
||||
destroyOnHidden
|
||||
styles={{
|
||||
header: {
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
padding: '16px 24px',
|
||||
position: 'relative',
|
||||
},
|
||||
body: { padding: '24px' },
|
||||
}}
|
||||
>
|
||||
{!isImporting && !importResult ? (
|
||||
<div>
|
||||
<p style={{ color: '#666', marginBottom: '8px' }}>请上传CSV格式的设备数据文件</p>
|
||||
<p style={{ color: '#999', fontSize: '12px', marginBottom: '20px' }}>
|
||||
支持的编码格式:GBK
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '20px',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(180deg, #fafafa 0%, #ffffff 100%)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#333' }}>
|
||||
CSV文件格式要求:
|
||||
</p>
|
||||
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
|
||||
{requiredFields.length > 0 && (
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<span style={{ color: '#d93025', fontWeight: '500' }}>必填字段:</span>
|
||||
<span style={{ color: '#666', fontSize: '13px' }}>
|
||||
{requiredFields.map((f) => f.displayName).join('、')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{optionalFields.length > 0 && (
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<span style={{ color: '#666', fontWeight: '500' }}>可选字段:</span>
|
||||
<span style={{ color: '#666', fontSize: '13px' }}>
|
||||
{optionalFields.map((f) => f.displayName).join('、')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ul
|
||||
style={{
|
||||
paddingLeft: '20px',
|
||||
marginBottom: '10px',
|
||||
color: '#666',
|
||||
fontSize: '13px',
|
||||
marginTop: '12px',
|
||||
}}
|
||||
>
|
||||
<li>
|
||||
设备类型:server(服务器)、switch(交换机)、router(路由器)、storage(存储设备)、other(其他)
|
||||
</li>
|
||||
<li>状态值:running(运行中)、maintenance(维护中)、offline(离线)、fault(故障)</li>
|
||||
<li>日期格式:YYYY-MM-DD (例如:2023-01-01)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<a href="/api/devices/import-template" download="设备导入模板.csv">
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
style={{
|
||||
height: '36px',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
border: `1px solid ${designTokens.colors.border.light}`,
|
||||
}}
|
||||
>
|
||||
下载导入模板
|
||||
</Button>
|
||||
</a>
|
||||
<span style={{ color: '#999', fontSize: '12px', marginLeft: '10px' }}>
|
||||
包含示例数据的CSV模板文件(根据当前字段配置生成)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Upload
|
||||
name="csvFile"
|
||||
accept=".csv"
|
||||
showUploadList={false}
|
||||
beforeUpload={handleImport}
|
||||
maxCount={1}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UploadOutlined />}
|
||||
block
|
||||
style={{
|
||||
height: '40px',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
background: designTokens.colors.primary.gradient,
|
||||
border: 'none',
|
||||
color: '#ffffff',
|
||||
boxShadow: designTokens.shadows.small,
|
||||
fontWeight: '500',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
选择CSV文件
|
||||
</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
) : isImporting ? (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: '16px',
|
||||
color: '#fff',
|
||||
fontSize: '20px',
|
||||
}}
|
||||
>
|
||||
<UploadOutlined spin />
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 4px 0',
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
正在导入设备数据
|
||||
</p>
|
||||
<p style={{ margin: 0, color: '#667eea', fontSize: '14px' }}>{importPhase}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Progress
|
||||
percent={importProgress}
|
||||
status="active"
|
||||
strokeColor={{ '0%': '#667eea', '100%': '#764ba2' }}
|
||||
format={() => `${importProgress}%`}
|
||||
/>
|
||||
</div>
|
||||
) : importResult?.statistics ? (
|
||||
<div>
|
||||
<p style={{ marginBottom: '10px', fontWeight: '600' }}>导入完成:</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: '12px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700' }}>
|
||||
{importResult.statistics.total || 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.9 }}>总记录数</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700' }}>
|
||||
{importResult.statistics.success || 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.9 }}>成功</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: 'linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%)',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700' }}>
|
||||
{importResult.statistics.failed || 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.9 }}>失败</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importResult.statistics?.errors?.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '20px',
|
||||
maxHeight: 400,
|
||||
overflowY: 'auto',
|
||||
border: '1px solid #ffcccc',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#fff7f7',
|
||||
}}
|
||||
>
|
||||
<h4 style={{ color: '#d93025', marginBottom: '12px', fontWeight: '600' }}>
|
||||
失败记录详情:
|
||||
</h4>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#ffeeee' }}>
|
||||
<th
|
||||
style={{
|
||||
border: '1px solid #ffcccc',
|
||||
padding: '8px',
|
||||
textAlign: 'left',
|
||||
width: '80px',
|
||||
}}
|
||||
>
|
||||
行号
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
border: '1px solid #ffcccc',
|
||||
padding: '8px',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
失败原因
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{importResult.statistics.errors.map((item, index) => (
|
||||
<tr key={index} style={{ borderBottom: '1px solid #ffcccc' }}>
|
||||
<td
|
||||
style={{
|
||||
border: '1px solid #ffcccc',
|
||||
padding: '8px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{item.row || index + 1}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
border: '1px solid #ffcccc',
|
||||
padding: '8px',
|
||||
color: '#d93025',
|
||||
}}
|
||||
>
|
||||
{item.error || '未知错误'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
marginTop: '20px',
|
||||
height: '40px',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
background: designTokens.colors.primary.gradient,
|
||||
border: 'none',
|
||||
color: '#ffffff',
|
||||
boxShadow: designTokens.shadows.small,
|
||||
fontWeight: '500',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ImportModal);
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
|
||||
const resizableTitleStyles = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
paddingRight: '8px',
|
||||
},
|
||||
text: {
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
resizeHandle: {
|
||||
width: '10px',
|
||||
height: '100%',
|
||||
cursor: 'col-resize',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'transparent',
|
||||
transition: 'background-color 0.2s',
|
||||
},
|
||||
};
|
||||
|
||||
const ResizableTitle = (props) => {
|
||||
const { children, onResize, width, ...restProps } = props;
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
if (!onResize) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const th = e.currentTarget.closest('th');
|
||||
if (!th) return;
|
||||
|
||||
const startWidth = th.offsetWidth;
|
||||
const startX = e.clientX;
|
||||
|
||||
const handleMouseMove = (moveEvent) => {
|
||||
const diff = moveEvent.clientX - startX;
|
||||
const newWidth = Math.max(50, startWidth + diff);
|
||||
onResize(newWidth);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<th {...restProps} style={{ position: 'relative' }}>
|
||||
<div style={resizableTitleStyles.container}>
|
||||
<span style={resizableTitleStyles.text}>{children}</span>
|
||||
{onResize && (
|
||||
<div onMouseDown={handleMouseDown} style={resizableTitleStyles.resizeHandle} />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ResizableTitle);
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as ResizableTitle } from './ResizableTitle';
|
||||
export { default as DeviceDetailModal } from './DeviceDetailModal';
|
||||
export { default as DeviceFormModal } from './DeviceFormModal';
|
||||
export { default as ImportModal } from './ImportModal';
|
||||
export { default as ExportModal } from './ExportModal';
|
||||
export { default as FieldConfigModal } from './FieldConfigModal';
|
||||
export { default as BatchStatusModal } from './BatchStatusModal';
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useSWR, useFetch, useFetchList } from './useSWR';
|
||||
|
||||
export const useDevices = (params = {}) => {
|
||||
const queryString = new URLSearchParams(
|
||||
Object.entries(params).filter(([_, v]) => v !== undefined && v !== '')
|
||||
).toString();
|
||||
const key = queryString ? `/devices?${queryString}` : '/devices';
|
||||
|
||||
return useFetchList(key);
|
||||
};
|
||||
|
||||
export const useDevice = deviceId => {
|
||||
return useFetch(deviceId ? `/devices/${deviceId}` : null);
|
||||
};
|
||||
|
||||
export const useRacks = (params = {}) => {
|
||||
const queryString = new URLSearchParams(
|
||||
Object.entries(params).filter(([_, v]) => v !== undefined && v !== '')
|
||||
).toString();
|
||||
const key = queryString ? `/racks?${queryString}` : '/racks';
|
||||
|
||||
return useFetchList(key);
|
||||
};
|
||||
|
||||
export const useRack = rackId => {
|
||||
return useFetch(rackId ? `/racks/${rackId}` : null);
|
||||
};
|
||||
|
||||
export const useRooms = (params = {}) => {
|
||||
const queryString = new URLSearchParams(
|
||||
Object.entries(params).filter(([_, v]) => v !== undefined && v !== '')
|
||||
).toString();
|
||||
const key = queryString ? `/rooms?${queryString}` : '/rooms';
|
||||
|
||||
return useFetchList(key);
|
||||
};
|
||||
|
||||
export const useRoom = roomId => {
|
||||
return useFetch(roomId ? `/rooms/${roomId}` : null);
|
||||
};
|
||||
|
||||
export const useUsers = (params = {}) => {
|
||||
const queryString = new URLSearchParams(
|
||||
Object.entries(params).filter(([_, v]) => v !== undefined && v !== '')
|
||||
).toString();
|
||||
const key = queryString ? `/users?${queryString}` : '/users';
|
||||
|
||||
return useFetchList(key);
|
||||
};
|
||||
|
||||
export const useUser = userId => {
|
||||
return useFetch(userId ? `/users/${userId}` : null);
|
||||
};
|
||||
|
||||
export const useTickets = (params = {}) => {
|
||||
const queryString = new URLSearchParams(
|
||||
Object.entries(params).filter(([_, v]) => v !== undefined && v !== '')
|
||||
).toString();
|
||||
const key = queryString ? `/tickets?${queryString}` : '/tickets';
|
||||
|
||||
return useFetchList(key);
|
||||
};
|
||||
|
||||
export const useTicket = ticketId => {
|
||||
return useFetch(ticketId ? `/tickets/${ticketId}` : null);
|
||||
};
|
||||
|
||||
export const useDeviceFields = () => {
|
||||
return useFetchList('/device-fields');
|
||||
};
|
||||
|
||||
export const useConsumables = (params = {}) => {
|
||||
const queryString = new URLSearchParams(
|
||||
Object.entries(params).filter(([_, v]) => v !== undefined && v !== '')
|
||||
).toString();
|
||||
const key = queryString ? `/consumables?${queryString}` : '/consumables';
|
||||
|
||||
return useFetchList(key);
|
||||
};
|
||||
|
||||
export const useConsumableCategories = () => {
|
||||
return useFetchList('/consumable-categories');
|
||||
};
|
||||
|
||||
export const useCables = (params = {}) => {
|
||||
const queryString = new URLSearchParams(
|
||||
Object.entries(params).filter(([_, v]) => v !== undefined && v !== '')
|
||||
).toString();
|
||||
const key = queryString ? `/cables?${queryString}` : '/cables';
|
||||
|
||||
return useFetchList(key);
|
||||
};
|
||||
|
||||
export const usePorts = (params = {}) => {
|
||||
const queryString = new URLSearchParams(
|
||||
Object.entries(params).filter(([_, v]) => v !== undefined && v !== '')
|
||||
).toString();
|
||||
const key = queryString ? `/device-ports?${queryString}` : '/device-ports';
|
||||
|
||||
return useFetchList(key);
|
||||
};
|
||||
|
||||
export const useInventoryPlans = (params = {}) => {
|
||||
const queryString = new URLSearchParams(
|
||||
Object.entries(params).filter(([_, v]) => v !== undefined && v !== '')
|
||||
).toString();
|
||||
const key = queryString ? `/inventory/plans?${queryString}` : '/inventory/plans';
|
||||
|
||||
return useFetchList(key);
|
||||
};
|
||||
|
||||
export const useSystemSettings = () => {
|
||||
return useFetch('/system-settings');
|
||||
};
|
||||
|
||||
export const useDashboardStats = () => {
|
||||
return useFetch('/devices/stats');
|
||||
};
|
||||
|
||||
export const useMutate = () => {
|
||||
const { mutate } = useSWR();
|
||||
return mutate;
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import useSWR, { SWRConfig } from 'swr';
|
||||
import api from '../api';
|
||||
|
||||
const fetcher = url => api.get(url).then(res => res);
|
||||
|
||||
export const swrConfig = {
|
||||
fetcher,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: true,
|
||||
shouldRetryOnError: false,
|
||||
dedupingInterval: 5000,
|
||||
errorRetryCount: 2,
|
||||
};
|
||||
|
||||
export const useSWRConfig = () => {
|
||||
return {
|
||||
mutate: useSWR().mutate,
|
||||
};
|
||||
};
|
||||
|
||||
export const useFetch = (key, options = {}) => {
|
||||
const { data, error, isLoading, isValidating, mutate } = useSWR(key, options);
|
||||
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
isValidating,
|
||||
mutate,
|
||||
isError: !!error,
|
||||
};
|
||||
};
|
||||
|
||||
export const useFetchList = (key, options = {}) => {
|
||||
const { data, error, isLoading, mutate } = useSWR(key, {
|
||||
...options,
|
||||
onSuccess: data => {
|
||||
if (options.onSuccess) {
|
||||
options.onSuccess(data);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
list: data?.list || data?.devices || data?.racks || data?.rooms || data || [],
|
||||
total: data?.total || data?.count || 0,
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
};
|
||||
};
|
||||
|
||||
export { SWRConfig, useSWR };
|
||||
+23
-1
@@ -592,7 +592,29 @@ body {
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 24px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.custom-scroll-container {
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
.custom-scroll-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.custom-scroll-container::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.custom-scroll-container::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.custom-scroll-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
|
||||
@@ -536,7 +536,6 @@ function CableManagement() {
|
||||
|
||||
if (errors.length > 0) {
|
||||
message.warning(`发现 ${errors.length} 条数据错误,已跳过`);
|
||||
console.log('导入错误:', errors);
|
||||
}
|
||||
|
||||
return validatedData;
|
||||
|
||||
@@ -1256,8 +1256,16 @@ function ConsumableManagement() {
|
||||
footer={null}
|
||||
width={900}
|
||||
style={{ top: 20 }}
|
||||
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: '65vh',
|
||||
overflowY: 'auto',
|
||||
padding: '24px',
|
||||
paddingRight: '16px',
|
||||
}}
|
||||
className="custom-scroll-container"
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
{/* 基本信息 */}
|
||||
<div style={{
|
||||
@@ -1778,6 +1786,7 @@ function ConsumableManagement() {
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 导入耗材弹窗 */}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+207
-1898
File diff suppressed because it is too large
Load Diff
@@ -509,7 +509,6 @@ function PortManagement() {
|
||||
|
||||
if (errors.length > 0) {
|
||||
message.warning(`发现 ${errors.length} 条数据错误,已跳过`);
|
||||
console.log('导入错误:', errors);
|
||||
}
|
||||
|
||||
return validatedData;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CloudServerOutlined,
|
||||
SafetyOutlined,
|
||||
DatabaseOutlined,
|
||||
AppstoreOutlined,
|
||||
SwapOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
export const STATUS_MAP = {
|
||||
running: { text: '运行中', color: '#52c41a', badgeColor: 'green' },
|
||||
maintenance: { text: '维护中', color: '#faad14', badgeColor: 'orange' },
|
||||
offline: { text: '离线', color: '#8c8c8c', badgeColor: 'default' },
|
||||
fault: { text: '故障', color: '#ff4d4f', badgeColor: 'red' },
|
||||
};
|
||||
|
||||
export const TYPE_MAP = {
|
||||
server: '服务器',
|
||||
switch: '交换机',
|
||||
router: '路由器',
|
||||
storage: '存储设备',
|
||||
other: '其他设备',
|
||||
};
|
||||
|
||||
export const getStatusConfig = (status) => {
|
||||
return STATUS_MAP[status] || { text: status, color: 'black', badgeColor: 'default' };
|
||||
};
|
||||
|
||||
export const getTypeLabel = (type) => {
|
||||
return TYPE_MAP[type] || type;
|
||||
};
|
||||
|
||||
export const getDeviceTypeIcon = (type) => {
|
||||
const iconMap = {
|
||||
server: <CloudServerOutlined style={{ color: '#1890ff' }} />,
|
||||
switch: <SwapOutlined style={{ color: '#52c41a' }} />,
|
||||
router: <SafetyOutlined style={{ color: '#faad14' }} />,
|
||||
storage: <DatabaseOutlined style={{ color: '#722ed1' }} />,
|
||||
other: <AppstoreOutlined style={{ color: '#8c8c8c' }} />,
|
||||
};
|
||||
return iconMap[type] || <AppstoreOutlined style={{ color: '#8c8c8c' }} />;
|
||||
};
|
||||
|
||||
export const formatDate = (date, fieldName) => {
|
||||
if (!date) return '';
|
||||
|
||||
const dateObj = new Date(date);
|
||||
const formattedDate = dateObj.toLocaleDateString('zh-CN');
|
||||
|
||||
if (fieldName === 'warrantyExpiry') {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
dateObj.setHours(0, 0, 0, 0);
|
||||
|
||||
if (dateObj < today) {
|
||||
return <span style={{ color: '#d93025', fontWeight: 'bold' }}>{formattedDate}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
export const FIXED_FIELDS = [
|
||||
'deviceId',
|
||||
'name',
|
||||
'type',
|
||||
'model',
|
||||
'serialNumber',
|
||||
'rackId',
|
||||
'position',
|
||||
'height',
|
||||
'powerConsumption',
|
||||
'status',
|
||||
'purchaseDate',
|
||||
'warrantyExpiry',
|
||||
'ipAddress',
|
||||
'description',
|
||||
];
|
||||
|
||||
export const SYSTEM_FIELDS = ['createdAt', 'updatedAt', 'Rack', 'Room', 'customFields'];
|
||||
|
||||
export const processDeviceData = (device) => {
|
||||
const deviceWithFields = { ...device };
|
||||
if (device.customFields && typeof device.customFields === 'object') {
|
||||
Object.entries(device.customFields).forEach(([fieldName, value]) => {
|
||||
deviceWithFields[fieldName] = value;
|
||||
});
|
||||
}
|
||||
return deviceWithFields;
|
||||
};
|
||||
|
||||
export const prepareDeviceFormData = (values, isEditing) => {
|
||||
const deviceData = {
|
||||
...values,
|
||||
purchaseDate: values.purchaseDate ? values.purchaseDate.format('YYYY-MM-DD') : null,
|
||||
warrantyExpiry: values.warrantyExpiry ? values.warrantyExpiry.format('YYYY-MM-DD') : null,
|
||||
customFields: {},
|
||||
};
|
||||
|
||||
Object.keys(deviceData).forEach((key) => {
|
||||
if (!FIXED_FIELDS.includes(key) && key !== 'customFields' && key !== 'roomId') {
|
||||
deviceData.customFields[key] = deviceData[key];
|
||||
delete deviceData[key];
|
||||
}
|
||||
});
|
||||
|
||||
delete deviceData.roomId;
|
||||
|
||||
return deviceData;
|
||||
};
|
||||
|
||||
export const getFormInitialValues = (device, racks) => {
|
||||
if (!device) return {};
|
||||
|
||||
const deviceData = { ...device };
|
||||
const cleanDeviceData = {};
|
||||
|
||||
FIXED_FIELDS.forEach((field) => {
|
||||
if (deviceData[field] !== undefined) {
|
||||
cleanDeviceData[field] = deviceData[field];
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(deviceData).forEach(([key, value]) => {
|
||||
if (
|
||||
!FIXED_FIELDS.includes(key) &&
|
||||
!SYSTEM_FIELDS.includes(key) &&
|
||||
key !== 'deviceId' &&
|
||||
typeof value !== 'object' &&
|
||||
value !== null
|
||||
) {
|
||||
cleanDeviceData[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
if (deviceData.customFields && typeof deviceData.customFields === 'object') {
|
||||
Object.entries(deviceData.customFields).forEach(([key, value]) => {
|
||||
cleanDeviceData[key] = value;
|
||||
});
|
||||
}
|
||||
|
||||
if (device.rackId) {
|
||||
const rack = racks.find((r) => r.rackId === device.rackId);
|
||||
if (rack) {
|
||||
cleanDeviceData.roomId = rack.roomId;
|
||||
}
|
||||
}
|
||||
|
||||
return cleanDeviceData;
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
const fs = require('fs');
|
||||
|
||||
const content = fs.readFileSync('e:/IDC/jigui/frontend/src/pages/DeviceManagement.jsx', 'utf8');
|
||||
|
||||
const startMarker = '];\n\n// 可调整列宽的表头组件';
|
||||
const endMarker = '\n\n// 防抖 Hook';
|
||||
|
||||
const startIndex = content.indexOf(startMarker);
|
||||
const endIndex = content.indexOf(endMarker, startIndex);
|
||||
|
||||
if (startIndex >= 0 && endIndex >= 0) {
|
||||
const newContent = content.substring(0, startIndex + 3) + '\n\n// 防抖 Hook' + content.substring(endIndex + endMarker.length);
|
||||
fs.writeFileSync('e:/IDC/jigui/frontend/src/pages/DeviceManagement.jsx', newContent, 'utf8');
|
||||
console.log('Removed ResizeableTitle component');
|
||||
} else {
|
||||
console.log('Could not find markers');
|
||||
console.log('startMarker found:', startIndex >= 0);
|
||||
console.log('endMarker found:', endIndex >= 0);
|
||||
}
|
||||
Reference in New Issue
Block a user