From 1d815b812ed7318b05c7b43119e009ffaf52ad94 Mon Sep 17 00:00:00 2001 From: zhangbing <849185023@qq.com> Date: Sun, 29 Mar 2026 10:06:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E5=92=8C=E6=89=B9=E9=87=8F=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor(前端): 重构认证和存储逻辑使用secureStorage feat(前端): 添加安全存储工具和日志工具 feat(前端): 增强批量导入功能支持服务器和交换机设备 fix(后端): 改进设备端口和网卡批量导入接口 docs: 更新导入模板和操作说明 --- .gitignore | 14 +- backend/idc_management.db-journal | Bin 12824 -> 0 bytes backend/routes/devicePorts.js | 17 +- backend/routes/networkCards.js | 35 +- backend/temp/.gitkeep | 2 + backend/uploads/.gitkeep | 2 + frontend/package-lock.json | 710 ++++++++++++++++++ frontend/package.json | 2 + frontend/src/api/index.js | 7 +- frontend/src/components/BatchImportModal.jsx | 37 +- .../src/components/NetworkCardImportModal.jsx | 45 +- frontend/src/context/AuthContext.jsx | 49 +- frontend/src/hooks/useIdleTimeout.js | 5 +- frontend/src/pages/BackupManagement.jsx | 3 +- frontend/src/pages/InventoryManagement.jsx | 14 +- frontend/src/pages/InventoryTaskExecution.jsx | 14 +- .../src/pages/PendingDeviceManagement.jsx | 14 +- frontend/src/pages/PortManagement.jsx | 419 ++++++++--- frontend/src/pages/TicketManagement.jsx | 9 +- frontend/src/utils/common.js | 6 +- frontend/src/utils/logger.js | 25 + frontend/src/utils/secureStorage.js | 111 +++ 22 files changed, 1326 insertions(+), 214 deletions(-) delete mode 100644 backend/idc_management.db-journal create mode 100644 backend/temp/.gitkeep create mode 100644 backend/uploads/.gitkeep create mode 100644 frontend/src/utils/logger.js create mode 100644 frontend/src/utils/secureStorage.js diff --git a/.gitignore b/.gitignore index 7f2f261..be37d58 100644 --- a/.gitignore +++ b/.gitignore @@ -38,14 +38,20 @@ lerna-debug.log* *.db *.sqlite -# Temporary files -*.tmp -*.temp - # Backup files (generated by maintenance scripts) backend/backups/ *.backup.json +# Uploads directory (user-uploaded files) +backend/uploads/* +!backend/uploads/.gitkeep + +# Temporary files +backend/temp/* +!backend/temp/.gitkeep +*.tmp +*.temp + #数据文件 *.xlsx *.csv diff --git a/backend/idc_management.db-journal b/backend/idc_management.db-journal deleted file mode 100644 index 93f17c8be2f1d21917dd741f518587c87fc79f76..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12824 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 { const portData = ports[i]; try { - if (!portData.portId || !portData.deviceId || !portData.portName) { + if (!portData.portId || !(portData.deviceId || portData.deviceSn) || !portData.portName) { throw new Error('缺少必填字段'); } - const device = await Device.findByPk(portData.deviceId, { transaction }); - if (!device) { - throw new Error(`设备 ${portData.deviceId} 不存在`); + let device; + if (portData.deviceSn) { + device = await Device.findOne({ where: { serialNumber: portData.deviceSn }, transaction }); + if (!device) { + throw new Error(`设备SN ${portData.deviceSn} 不存在`); + } + portData.deviceId = device.deviceId; + } else { + device = await Device.findByPk(portData.deviceId, { transaction }); + if (!device) { + throw new Error(`设备ID ${portData.deviceId} 不存在`); + } } const isServer = device.type && device.type.toLowerCase().includes('server'); diff --git a/backend/routes/networkCards.js b/backend/routes/networkCards.js index a6377ac..7e3d599 100644 --- a/backend/routes/networkCards.js +++ b/backend/routes/networkCards.js @@ -172,14 +172,22 @@ router.get('/:nicId/ports', async (req, res) => { router.get('/find', async (req, res) => { try { - const { deviceId, name } = req.query; + const { deviceId, deviceSn, name } = req.query; - if (!deviceId || !name) { - return res.status(400).json({ error: '缺少设备ID或网卡名称' }); + if ((!deviceId && !deviceSn) || !name) { + return res.status(400).json({ error: '缺少设备ID/SN或网卡名称' }); + } + + let device; + if (deviceSn) { + device = await Device.findOne({ where: { serialNumber: deviceSn } }); + if (!device) { + return res.json({ nicId: null }); + } } const networkCard = await NetworkCard.findOne({ - where: { deviceId, name }, + where: { deviceId: device ? device.deviceId : deviceId, name }, }); if (!networkCard) { @@ -265,13 +273,22 @@ router.post('/batch', async (req, res) => { const cardData = networkCards[i]; try { - if (!cardData.deviceId || !cardData.name) { - throw new Error('缺少必填字段:设备ID和网卡名称'); + if (!(cardData.deviceId || cardData.deviceSn) || !cardData.name) { + throw new Error('缺少必填字段:设备ID/SN和网卡名称'); } - const device = await Device.findByPk(cardData.deviceId, { transaction }); - if (!device) { - throw new Error(`设备 ${cardData.deviceId} 不存在`); + let device; + if (cardData.deviceSn) { + device = await Device.findOne({ where: { serialNumber: cardData.deviceSn }, transaction }); + if (!device) { + throw new Error(`设备SN ${cardData.deviceSn} 不存在`); + } + cardData.deviceId = device.deviceId; + } else { + device = await Device.findByPk(cardData.deviceId, { transaction }); + if (!device) { + throw new Error(`设备ID ${cardData.deviceId} 不存在`); + } } const isServer = device.type && device.type.toLowerCase().includes('server'); diff --git a/backend/temp/.gitkeep b/backend/temp/.gitkeep new file mode 100644 index 0000000..cbf233b --- /dev/null +++ b/backend/temp/.gitkeep @@ -0,0 +1,2 @@ +# This file ensures the temp directory is tracked by Git +# All temporary files in this directory are ignored diff --git a/backend/uploads/.gitkeep b/backend/uploads/.gitkeep new file mode 100644 index 0000000..58da402 --- /dev/null +++ b/backend/uploads/.gitkeep @@ -0,0 +1,2 @@ +# This file ensures the uploads directory is tracked by Git +# All user-uploaded files in this directory are ignored diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2cbb06c..8f2976e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,11 +15,13 @@ "axios": "^1.13.6", "dayjs": "^1.11.19", "framer-motion": "^12.34.0", + "html2canvas": "^1.4.1", "papaparse": "^5.5.3", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.15.0", "react-transition-group": "^4.4.5", + "reactflow": "^11.11.4", "styled-components": "^6.3.9", "swr": "^2.4.0", "three": "^0.183.2", @@ -1806,6 +1808,276 @@ } } }, + "node_modules/@reactflow/background": { + "version": "11.3.14", + "resolved": "https://registry.npmmirror.com/@reactflow/background/-/background-11.3.14.tgz", + "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/background/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@reactflow/controls": { + "version": "11.2.14", + "resolved": "https://registry.npmmirror.com/@reactflow/controls/-/controls-11.2.14.tgz", + "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/controls/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@reactflow/core": { + "version": "11.11.4", + "resolved": "https://registry.npmmirror.com/@reactflow/core/-/core-11.11.4.tgz", + "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==", + "license": "MIT", + "dependencies": { + "@types/d3": "^7.4.0", + "@types/d3-drag": "^3.0.1", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/core/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@reactflow/minimap": { + "version": "11.7.14", + "resolved": "https://registry.npmmirror.com/@reactflow/minimap/-/minimap-11.7.14.tgz", + "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/minimap/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@reactflow/node-resizer": { + "version": "2.2.14", + "resolved": "https://registry.npmmirror.com/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", + "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.4", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-resizer/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@reactflow/node-toolbar": { + "version": "1.3.14", + "resolved": "https://registry.npmmirror.com/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", + "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-toolbar/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@remix-run/router": { "version": "1.23.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", @@ -2296,6 +2568,259 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmmirror.com/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmmirror.com/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmmirror.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2316,6 +2841,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3034,6 +3565,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3304,6 +3844,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmmirror.com/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -3456,6 +4002,15 @@ "node": ">=4" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-to-react-native": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", @@ -3509,6 +4064,112 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", @@ -4792,6 +5453,19 @@ "node": ">=18" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -7017,6 +7691,24 @@ } } }, + "node_modules/reactflow": { + "version": "11.11.4", + "resolved": "https://registry.npmmirror.com/reactflow/-/reactflow-11.11.4.tgz", + "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==", + "license": "MIT", + "dependencies": { + "@reactflow/background": "11.3.14", + "@reactflow/controls": "11.2.14", + "@reactflow/core": "11.11.4", + "@reactflow/minimap": "11.7.14", + "@reactflow/node-resizer": "2.2.14", + "@reactflow/node-toolbar": "1.3.14" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -7828,6 +8520,15 @@ "node": ">=10" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/three": { "version": "0.183.2", "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", @@ -8397,6 +9098,15 @@ "node": ">= 4" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "4.5.14", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", diff --git a/frontend/package.json b/frontend/package.json index 975f50e..b8bb655 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,11 +20,13 @@ "axios": "^1.13.6", "dayjs": "^1.11.19", "framer-motion": "^12.34.0", + "html2canvas": "^1.4.1", "papaparse": "^5.5.3", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.15.0", "react-transition-group": "^4.4.5", + "reactflow": "^11.11.4", "styled-components": "^6.3.9", "swr": "^2.4.0", "three": "^0.183.2", diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index bb5d5ce..4457231 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1,5 +1,6 @@ import axios from 'axios'; import { API_CONFIG } from '../config/api'; +import secureStorage, { TOKEN_KEY } from '../utils/secureStorage'; const api = axios.create({ baseURL: API_CONFIG.baseURL, @@ -11,7 +12,7 @@ const api = axios.create({ api.interceptors.request.use( config => { - const token = localStorage.getItem('token'); + const token = secureStorage.get(TOKEN_KEY); if (token) { config.headers.Authorization = `Bearer ${token}`; } @@ -47,8 +48,8 @@ api.interceptors.response.use( const currentPath = window.location.pathname; if (!currentPath.startsWith('/login')) { - localStorage.removeItem('token'); - localStorage.removeItem('user'); + secureStorage.remove(TOKEN_KEY); + secureStorage.remove('user'); window.location.href = '/login'; } } diff --git a/frontend/src/components/BatchImportModal.jsx b/frontend/src/components/BatchImportModal.jsx index c35aab2..7baa050 100644 --- a/frontend/src/components/BatchImportModal.jsx +++ b/frontend/src/components/BatchImportModal.jsx @@ -2,14 +2,14 @@ import React from 'react'; import { Modal, Button, Typography, Divider } from 'antd'; import { ImportOutlined, - CloudServerOutlined, - ApiOutlined, InfoCircleOutlined, + CloudServerOutlined as ServerOutlined, + SwapOutlined as SwitcherOutlined, } from '@ant-design/icons'; import { designTokens } from '../config/theme'; import CloseButton from './CloseButton'; -const { Text, Paragraph } = Typography; +const { Paragraph } = Typography; function BatchImportModal({ visible, onClose, onImportNetworkCard, onImportPort }) { return ( @@ -49,7 +49,7 @@ function BatchImportModal({ visible, onClose, onImportNetworkCard, onImportPort > @@ -127,7 +127,7 @@ function BatchImportModal({ visible, onClose, onImportNetworkCard, onImportPort marginBottom: '8px', }} > - 交换机与服务器导入说明 + 导入流程说明
- 交换机端口:可直接批量导入端口,无需先导入网卡 + 服务器批量导入:通过一个模板文件同时导入网卡和端口数据。 + 系统会先导入网卡数据,再导入端口数据,自动关联网卡。
-
- 服务器端口:必须先在"网卡管理"中添加网卡,才能导入端口。 - 服务器的网卡和端口是层级关系:设备 → 网卡 → 端口 +
+ 交换机设备:可直接批量导入端口,无需先导入网卡 +
+
+ 操作步骤: +
+
+
1. 点击对应设备类型的按钮
+
2. 下载导入模板并填写数据
+
3. 上传模板文件并开始导入
+
4. 查看导入结果
diff --git a/frontend/src/components/NetworkCardImportModal.jsx b/frontend/src/components/NetworkCardImportModal.jsx index 60ee48d..366bc25 100644 --- a/frontend/src/components/NetworkCardImportModal.jsx +++ b/frontend/src/components/NetworkCardImportModal.jsx @@ -115,13 +115,13 @@ function NetworkCardImportModal({ visible, onClose, onSuccess }) { const rowNum = index + 2; const errors = []; - if (!row['设备ID']) { + if (!row['设备SN']) { errors.push({ row: rowNum, - field: '设备ID', - value: row['设备ID'] || '(空)', + field: '设备SN', + value: row['设备SN'] || '(空)', error: '缺少必填字段', - suggestion: '请填写设备ID,格式如:DEV001', + suggestion: '请填写设备SN,格式如:SNAA0001', }); } @@ -169,7 +169,7 @@ function NetworkCardImportModal({ visible, onClose, onSuccess }) { try { const networkCardsData = importPreview.map((row, index) => ({ nicId: generateUniqueNicId(), - deviceId: row['设备ID'], + deviceSn: row['设备SN'], name: row['网卡名称'], slotNumber: row['插槽编号'] ? parseInt(row['插槽编号']) : null, model: row['网卡型号'] || null, @@ -208,11 +208,28 @@ function NetworkCardImportModal({ visible, onClose, onSuccess }) { } if (failed > 0) { msgContent += `失败 ${failed} 个`; - console.error('导入错误:', errors); + Modal.error({ + title: '导入失败详情', + content: ( +
+ + + ), + width: 600, + }); } - if (failed > 0 && success === 0 && skipped === 0 && updated === 0) { - message.error(`导入失败!${msgContent}`); + if (failed > 0) { + message.warning(`导入完成,但有 ${failed} 个失败。${msgContent}`); } else { message.success({ content: `导入完成!${msgContent}`, @@ -239,7 +256,7 @@ function NetworkCardImportModal({ visible, onClose, onSuccess }) { const handleDownloadTemplate = () => { const templateData = [ { - 设备ID: 'DEV001', + 设备SN: 'SN-20230001', 网卡名称: '网卡1', 插槽编号: '1', 网卡型号: 'Intel X710', @@ -247,7 +264,7 @@ function NetworkCardImportModal({ visible, onClose, onSuccess }) { 描述: '主网卡', }, { - 设备ID: 'DEV001', + 设备SN: 'SN-20230001', 网卡名称: '网卡2', 插槽编号: '2', 网卡型号: 'Intel X710', @@ -342,7 +359,7 @@ function NetworkCardImportModal({ visible, onClose, onSuccess }) {
前置条件:请先在 设备管理 - 中添加目标服务器,确保设备ID已存在 + 中添加目标服务器,确保设备SN已存在
操作步骤: @@ -351,7 +368,7 @@ function NetworkCardImportModal({ visible, onClose, onSuccess }) {
1. 点击「下载模板」获取标准Excel/CSV文件
2. 按模板格式填写网卡信息, - 设备ID和 + 设备SN网卡名称为必填项
3. 点击上传区域选择文件,或直接拖拽文件到上传区域
@@ -363,7 +380,7 @@ function NetworkCardImportModal({ visible, onClose, onSuccess }) {
- • 设备ID(必填):服务器的唯一标识,如DEV001 + • 设备SN(必填):服务器的唯一序列号,如SN-20230001
网卡名称(必填):网卡的名称或标识,如eth0、网卡1 @@ -537,7 +554,7 @@ function NetworkCardImportModal({ visible, onClose, onSuccess }) {
{ export const AuthProvider = ({ children }) => { console.log('[AuthContext] Initializing...'); - const savedToken = localStorage.getItem('token'); - const savedUser = localStorage.getItem('user'); + const savedToken = secureStorage.get(TOKEN_KEY); + const savedUser = secureStorage.get(USER_KEY); console.log('[AuthContext] Saved token:', savedToken ? 'exists' : 'null'); console.log('[AuthContext] Saved user:', savedUser ? 'exists' : 'null'); - const [user, setUser] = useState(() => { - try { - if (savedUser) { - return JSON.parse(savedUser); - } - } catch (e) { - console.error('[AuthContext] Parse user error:', e); - } - return null; - }); - + const [user, setUser] = useState(() => savedUser || null); const [token, setToken] = useState(() => savedToken); const [loading, setLoading] = useState(false); const [initialized, setInitialized] = useState(false); useEffect(() => { - const currentToken = localStorage.getItem('token'); - const currentUser = localStorage.getItem('user'); + const currentToken = secureStorage.get(TOKEN_KEY); + const currentUser = secureStorage.get(USER_KEY); if (currentToken && currentToken === token) { - if (currentUser) { - try { - const parsedUser = JSON.parse(currentUser); - if (JSON.stringify(parsedUser) !== JSON.stringify(user)) { - setUser(parsedUser); - } - } catch (e) { - console.error('[AuthContext] Parse user error:', e); - } + if (currentUser && JSON.stringify(currentUser) !== JSON.stringify(user)) { + setUser(currentUser); } setInitialized(true); } else if (!currentToken) { @@ -62,7 +46,7 @@ export const AuthProvider = ({ children }) => { }, []); const fetchProfile = useCallback(async () => { - const currentToken = localStorage.getItem('token'); + const currentToken = secureStorage.get(TOKEN_KEY); if (!currentToken) { setLoading(false); return; @@ -72,6 +56,7 @@ export const AuthProvider = ({ children }) => { const response = await authAPI.getProfile(); if (response.success) { setUser(response.data.user); + secureStorage.set(USER_KEY, response.data.user); } } catch (error) { console.error('获取用户信息失败:', error); @@ -85,8 +70,8 @@ export const AuthProvider = ({ children }) => { const response = await authAPI.login({ username, password }); if (response.success) { const { token: newToken, user: userData } = response.data; - localStorage.setItem('token', newToken); - localStorage.setItem('user', JSON.stringify(userData)); + secureStorage.set(TOKEN_KEY, newToken); + secureStorage.set(USER_KEY, userData); setToken(newToken); setUser(userData); return { success: true }; @@ -103,8 +88,8 @@ export const AuthProvider = ({ children }) => { if (response.success) { const { token: newToken, user: newUser, isFirstUser, pendingApproval } = response.data; if (newToken) { - localStorage.setItem('token', newToken); - localStorage.setItem('user', JSON.stringify(newUser)); + secureStorage.set(TOKEN_KEY, newToken); + secureStorage.set(USER_KEY, newUser); setToken(newToken); setUser(newUser); } @@ -117,8 +102,8 @@ export const AuthProvider = ({ children }) => { }; const logout = useCallback(() => { - localStorage.removeItem('token'); - localStorage.removeItem('user'); + secureStorage.remove(TOKEN_KEY); + secureStorage.remove(USER_KEY); setToken(null); setUser(null); }, []); @@ -126,7 +111,7 @@ export const AuthProvider = ({ children }) => { const updateUser = newUserData => { const updatedUser = { ...user, ...newUserData }; setUser(updatedUser); - localStorage.setItem('user', JSON.stringify(updatedUser)); + secureStorage.set(USER_KEY, updatedUser); }; const hasPermission = permission => { diff --git a/frontend/src/hooks/useIdleTimeout.js b/frontend/src/hooks/useIdleTimeout.js index 7249043..8997b01 100644 --- a/frontend/src/hooks/useIdleTimeout.js +++ b/frontend/src/hooks/useIdleTimeout.js @@ -1,6 +1,7 @@ import { useEffect, useRef, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { message, Modal } from 'antd'; +import secureStorage, { TOKEN_KEY, USER_KEY } from '../utils/secureStorage'; /** * 用户空闲超时检测 Hook @@ -43,8 +44,8 @@ const useIdleTimeout = ({ clearTimers(); // 清除登录状态 - localStorage.removeItem('token'); - localStorage.removeItem('user'); + secureStorage.remove(TOKEN_KEY); + secureStorage.remove(USER_KEY); // 执行自定义登出回调 if (onLogout) { diff --git a/frontend/src/pages/BackupManagement.jsx b/frontend/src/pages/BackupManagement.jsx index 2214c14..c9f3af5 100644 --- a/frontend/src/pages/BackupManagement.jsx +++ b/frontend/src/pages/BackupManagement.jsx @@ -45,6 +45,7 @@ import { import api, { backupAPI } from '../api'; import CloseButton from '../components/CloseButton'; import { useNavigate } from 'react-router-dom'; +import secureStorage, { TOKEN_KEY } from '../utils/secureStorage'; const { Title, Text } = Typography; @@ -605,7 +606,7 @@ const BackupManagement = () => { setRestoreProgress(0); setRestoreStatus('正在初始化...'); - const token = localStorage.getItem('token'); + const token = secureStorage.get(TOKEN_KEY); const options = { overwriteExisting: true, skipFiles: false, diff --git a/frontend/src/pages/InventoryManagement.jsx b/frontend/src/pages/InventoryManagement.jsx index 371a1fd..af6edf9 100644 --- a/frontend/src/pages/InventoryManagement.jsx +++ b/frontend/src/pages/InventoryManagement.jsx @@ -35,7 +35,7 @@ import { CheckSquareOutlined, SyncOutlined, } from '@ant-design/icons'; -import axios from 'axios'; +import api from '../api'; import dayjs from 'dayjs'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { designTokens } from '../config/theme'; @@ -44,18 +44,6 @@ import CloseButton from '../components/CloseButton'; const { RangePicker } = DatePicker; const { TabPane } = Tabs; -const api = axios.create({ - baseURL: '/api', -}); - -api.interceptors.request.use(config => { - const token = localStorage.getItem('token'); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; -}); - const InventoryManagement = () => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); diff --git a/frontend/src/pages/InventoryTaskExecution.jsx b/frontend/src/pages/InventoryTaskExecution.jsx index fe9b760..5ae23df 100644 --- a/frontend/src/pages/InventoryTaskExecution.jsx +++ b/frontend/src/pages/InventoryTaskExecution.jsx @@ -34,24 +34,12 @@ import { CloseOutlined, PlusOutlined, } from '@ant-design/icons'; -import axios from 'axios'; +import api from '../api'; import dayjs from 'dayjs'; import { useSearchParams, useNavigate } from 'react-router-dom'; import { designTokens } from '../config/theme'; import CloseButton from '../components/CloseButton'; -const api = axios.create({ - baseURL: '/api', -}); - -api.interceptors.request.use(config => { - const token = localStorage.getItem('token'); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; -}); - const InventoryTaskExecution = () => { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); diff --git a/frontend/src/pages/PendingDeviceManagement.jsx b/frontend/src/pages/PendingDeviceManagement.jsx index 112666d..98caefd 100644 --- a/frontend/src/pages/PendingDeviceManagement.jsx +++ b/frontend/src/pages/PendingDeviceManagement.jsx @@ -32,24 +32,12 @@ import { CloudUploadOutlined, CloudServerOutlined, } from '@ant-design/icons'; -import axios from 'axios'; +import api from '../api'; import dayjs from 'dayjs'; import { useNavigate } from 'react-router-dom'; import { designTokens } from '../config/theme'; import CloseButton from '../components/CloseButton'; -const api = axios.create({ - baseURL: '/api', -}); - -api.interceptors.request.use(config => { - const token = localStorage.getItem('token'); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; -}); - const PendingDeviceManagement = () => { const navigate = useNavigate(); const [pendingDevices, setPendingDevices] = useState([]); diff --git a/frontend/src/pages/PortManagement.jsx b/frontend/src/pages/PortManagement.jsx index 62c2a1a..5095d62 100644 --- a/frontend/src/pages/PortManagement.jsx +++ b/frontend/src/pages/PortManagement.jsx @@ -135,6 +135,7 @@ function PortManagement() { const [batchImportModalVisible, setBatchImportModalVisible] = useState(false); const [portAddGuideModalVisible, setPortAddGuideModalVisible] = useState(false); const [portExportModalVisible, setPortExportModalVisible] = useState(false); + const [importDeviceType, setImportDeviceType] = useState(null); // 展开的设备 const [expandedKeys, setExpandedKeys] = useState([]); @@ -707,7 +708,7 @@ function PortManagement() { } }; - const handleImport = () => { + const handleImport = (deviceType = 'all') => { setImportModalVisible(true); setImportPreview([]); setImportProgress({ current: 0, total: 0 }); @@ -720,12 +721,24 @@ function PortManagement() { try { const data = e.target.result; let parsedData = []; + let networkCardData = []; if (file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) { const workbook = XLSX.read(data, { type: 'binary' }); - const sheetName = workbook.SheetNames[0]; - const worksheet = workbook.Sheets[sheetName]; - parsedData = XLSX.utils.sheet_to_json(worksheet); + + if (importDeviceType === 'server' && workbook.SheetNames.length >= 2) { + // 服务器设备:解析两张表格 + const networkCardSheet = workbook.Sheets[workbook.SheetNames[0]]; + const portSheet = workbook.Sheets[workbook.SheetNames[1]]; + + networkCardData = XLSX.utils.sheet_to_json(networkCardSheet); + parsedData = XLSX.utils.sheet_to_json(portSheet); + } else { + // 其他设备:只解析第一张表格 + const sheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[sheetName]; + parsedData = XLSX.utils.sheet_to_json(worksheet); + } } else if (file.name.endsWith('.csv')) { parsedData = Papa.parse(data, { header: true, @@ -741,13 +754,81 @@ function PortManagement() { return; } - const validatedResult = await validateImportData(parsedData); + // 保存网卡数据,供后续导入使用 + if (networkCardData.length > 0) { + // 验证网卡数据 + const validatedNicData = []; + const nicErrors = []; + + for (let i = 0; i < networkCardData.length; i++) { + const row = networkCardData[i]; + const rowNum = i + 2; + const errors = []; + + if (!row['设备SN']) { + errors.push({ + row: rowNum, + field: '设备SN', + value: row['设备SN'] || '(空)', + error: '缺少必填字段', + suggestion: '请填写设备SN,格式如:SNAA0001', + }); + } + + if (!row['网卡名称']) { + errors.push({ + row: rowNum, + field: '网卡名称', + value: row['网卡名称'] || '(空)', + error: '缺少必填字段', + suggestion: '请填写网卡名称,格式如:网卡1、eth0', + }); + } + + if (row['插槽编号']) { + const validSlotValues = ['LOM', 'OCP', '1', '2', '3', '4']; + const slotValue = row['插槽编号'].toString().trim(); + if (!validSlotValues.includes(slotValue) && !/^\d+$/.test(slotValue)) { + errors.push({ + row: rowNum, + field: '插槽编号', + value: row['插槽编号'], + error: '插槽编号格式错误', + suggestion: '插槽编号必须为数字或预选项(LOM、OCP、1-4)', + }); + } + } + + if (errors.length > 0) { + nicErrors.push(...errors.map(err => ({ ...err, originalRow: row }))); + } else { + validatedNicData.push(row); + } + } + + if (nicErrors.length > 0) { + message.warning({ + content: `发现 ${nicErrors.length} 个网卡数据错误,请查看错误详情并修正后重新导入`, + duration: 5, + }); + setImportErrors(nicErrors); + return; + } + + sessionStorage.setItem('networkCardImportData', JSON.stringify(validatedNicData)); + } + + // 验证并处理数据 + // 对于服务器设备,跳过网卡存在性验证,因为网卡还没有被导入 + const validatedResult = await validateImportData(parsedData, importDeviceType === 'server'); console.log('文件解析结果:', { 总行数: parsedData.length, 有效数据: validatedResult.validData.length, 错误数据: validatedResult.errors.length, 第一行数据: parsedData[0], + 网卡数据行数: networkCardData.length, }); + setImportPreview(validatedResult.validData); setImportErrors(validatedResult.errors); setImportProgress({ current: 0, total: validatedResult.validData.length }); @@ -765,13 +846,13 @@ function PortManagement() { } }; - const validateImportData = async data => { + const validateImportData = async (data, skipNicValidation = false) => { const validData = []; const allErrors = []; for (let i = 0; i < data.length; i++) { const row = data[i]; - const result = await validatePortRow(row, i); + const result = await validatePortRow(row, i, skipNicValidation); if (result.valid) { validData.push(row); @@ -790,17 +871,17 @@ function PortManagement() { return { validData, errors: allErrors }; }; - const validatePortRow = async (row, index) => { + const validatePortRow = async (row, index, skipNicValidation = false) => { const rowNum = index + 2; const errors = []; - if (!row['设备ID']) { + if (!row['设备SN']) { errors.push({ row: rowNum, - field: '设备ID', - value: row['设备ID'] || '(空)', + field: '设备SN', + value: row['设备SN'] || '(空)', error: '缺少必填字段', - suggestion: '请填写设备ID,格式如:DEV001', + suggestion: '请填写设备SN,格式如:SNAA0001', }); } @@ -814,15 +895,15 @@ function PortManagement() { }); } - if (row['设备ID']) { - const device = devices.find(d => d.deviceId === row['设备ID']); + if (row['设备SN']) { + const device = devices.find(d => d.serialNumber === row['设备SN']); if (!device) { errors.push({ row: rowNum, - field: '设备ID', - value: row['设备ID'], + field: '设备SN', + value: row['设备SN'], error: '设备不存在', - suggestion: `设备ID "${row['设备ID']}" 未在系统中注册,请先在设备管理中添加该设备`, + suggestion: `设备SN "${row['设备SN']}" 未在系统中注册,请先在设备管理中添加该设备`, }); } } @@ -884,10 +965,10 @@ function PortManagement() { } } - if (row['网卡名称'] && row['设备ID']) { + if (!skipNicValidation && row['网卡名称'] && row['设备SN']) { try { const nicResponse = await api.get('/network-cards/find', { - params: { deviceId: row['设备ID'], name: row['网卡名称'] }, + params: { deviceSn: row['设备SN'], name: row['网卡名称'] }, }); row._nicId = nicResponse.nicId || null; } catch (error) { @@ -895,8 +976,8 @@ function PortManagement() { } } - if (row['设备ID']) { - const device = devices.find(d => d.deviceId === row['设备ID']); + if (row['设备SN']) { + const device = devices.find(d => d.serialNumber === row['设备SN']) || devices.find(d => d.sn === row['设备SN']); if (device) { const isServer = device.type && device.type.toLowerCase().includes('server'); if (isServer) { @@ -908,7 +989,7 @@ function PortManagement() { error: '服务器端口必须关联网卡', suggestion: `服务器 "${device.name}" 的端口必须关联到网卡,请先在网卡管理中添加网卡,或在导入模板中填写网卡名称`, }); - } else if (row['网卡名称'] && !row._nicId) { + } else if (!skipNicValidation && row['网卡名称'] && !row._nicId) { errors.push({ row: rowNum, field: '网卡名称', @@ -940,6 +1021,44 @@ function PortManagement() { let currentProgress = 0; try { + // 检查是否有网卡数据需要导入 + let networkCardImportResult = null; + const networkCardDataStr = sessionStorage.getItem('networkCardImportData'); + + if (networkCardDataStr) { + const networkCardData = JSON.parse(networkCardDataStr); + if (networkCardData.length > 0) { + // 先导入网卡数据 + console.log('开始导入网卡数据:', networkCardData.length, '条'); + + // 生成网卡ID + const networkCardsData = networkCardData.map((card, index) => ({ + nicId: `NIC-${Date.now()}-${index}`, + deviceSn: card['设备SN'], + name: card['网卡名称'], + slotNumber: card['插槽编号'] ? parseInt(card['插槽编号']) : null, + model: card['网卡型号'] || null, + manufacturer: card['制造商'] || null, + description: card['描述'] || null, + })); + + const nicResponse = await api.post('/network-cards/batch', { + networkCards: networkCardsData, + skipExisting: true, + updateExisting: false, + }); + + networkCardImportResult = nicResponse; + console.log('网卡导入结果:', nicResponse); + + // 重新获取设备列表,确保包含最新数据 + await fetchDevices(); + } + + // 清除保存的网卡数据 + sessionStorage.removeItem('networkCardImportData'); + } + const statusMap = { 空闲: 'free', 占用: 'occupied', @@ -948,8 +1067,9 @@ function PortManagement() { const portsData = importPreview.map((row, index) => ({ portId: generateUniquePortId(), - deviceId: row['设备ID'], + deviceSn: row['设备SN'], nicId: row._nicId || null, + '网卡名称': row['网卡名称'], portName: row['端口名称'], portType: row['端口类型'], portSpeed: row['端口速率'], @@ -977,17 +1097,26 @@ function PortManagement() { setImportProgress({ current: importPreview.length, total: importPreview.length }); let msgContent = ''; + if (networkCardImportResult) { + const { success: nicSuccess, failed: nicFailed } = networkCardImportResult; + if (nicSuccess > 0) { + msgContent += `网卡导入成功 ${nicSuccess} 个,`; + } + if (nicFailed > 0) { + msgContent += `网卡导入失败 ${nicFailed} 个,`; + } + } if (updated > 0) { - msgContent += `更新 ${updated} 个,`; + msgContent += `端口更新 ${updated} 个,`; } if (skipped > 0) { - msgContent += `跳过 ${skipped} 个,`; + msgContent += `端口跳过 ${skipped} 个,`; } if (success > 0) { - msgContent += `新增 ${success - updated} 个,`; + msgContent += `端口新增 ${success - updated} 个,`; } if (failed > 0) { - msgContent += `失败 ${failed} 个`; + msgContent += `端口失败 ${failed} 个`; console.error('导入错误:', errors); } @@ -1010,27 +1139,107 @@ function PortManagement() { } finally { clearInterval(progressInterval); setImporting(false); + // 确保清除保存的网卡数据 + sessionStorage.removeItem('networkCardImportData'); } }; const handleDownloadTemplate = () => { - const templateData = [ - { - 设备ID: 'DEV001', - 端口名称: 'eth0/1', - 网卡名称: '', - 端口类型: 'RJ45', - 端口速率: '1G', - 状态: '空闲', - 'VLAN ID': '100', - 描述: '示例端口', - }, - ]; - - const worksheet = XLSX.utils.json_to_sheet(templateData); + let fileName = '端口导入模板.xlsx'; const workbook = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(workbook, worksheet, '端口数据'); - XLSX.writeFile(workbook, '端口导入模板.xlsx'); + + if (importDeviceType === 'server') { + // 网卡数据表格 + const networkCardTemplateData = [ + { + '设备SN': 'SN-20230001', + '网卡名称': '网卡1', + '插槽编号': 'LOM', + '网卡型号': 'Intel X710', + '制造商': 'Intel', + '描述': '板载网卡', + }, + { + '设备SN': 'SN-20230001', + '网卡名称': '网卡2', + '插槽编号': 'OCP', + '网卡型号': 'Intel X710', + '制造商': 'Intel', + '描述': 'OCP网卡', + }, + { + '设备SN': 'SN-20230001', + '网卡名称': '网卡3', + '插槽编号': '1', + '网卡型号': 'Intel X710', + '制造商': 'Intel', + '描述': 'PCIe插槽1', + }, + ]; + + // 端口数据表格 + const portTemplateData = [ + { + '设备SN': 'SN-20230001', + '网卡名称': '网卡1', + '端口名称': 'eth0/1', + '端口类型': 'RJ45', + '端口速率': '1G', + '状态': '空闲', + 'VLAN ID': '100', + '描述': '服务器端口', + }, + { + '设备SN': 'SN-20230001', + '网卡名称': '网卡2', + '端口名称': 'eth0/2', + '端口类型': 'RJ45', + '端口速率': '1G', + '状态': '空闲', + 'VLAN ID': '200', + '描述': '服务器端口', + }, + ]; + + const networkCardWorksheet = XLSX.utils.json_to_sheet(networkCardTemplateData); + const portWorksheet = XLSX.utils.json_to_sheet(portTemplateData); + + XLSX.utils.book_append_sheet(workbook, networkCardWorksheet, '网卡数据'); + XLSX.utils.book_append_sheet(workbook, portWorksheet, '端口数据'); + fileName = '服务器设备导入模板.xlsx'; + } else if (importDeviceType === 'switch') { + const templateData = [ + { + '设备SN': 'SW-20230001', + '端口名称': 'GigabitEthernet0/0/1', + '端口类型': 'RJ45', + '端口速率': '1G', + '状态': '空闲', + 'VLAN ID': '100', + '描述': '交换机端口', + }, + ]; + const worksheet = XLSX.utils.json_to_sheet(templateData); + XLSX.utils.book_append_sheet(workbook, worksheet, '端口数据'); + fileName = '交换机端口导入模板.xlsx'; + } else { + const templateData = [ + { + '设备SN': 'DEV-20230001', + '端口名称': 'eth0/1', + '网卡名称': '', + '端口类型': 'RJ45', + '端口速率': '1G', + '状态': '空闲', + 'VLAN ID': '100', + '描述': '示例端口', + }, + ]; + const worksheet = XLSX.utils.json_to_sheet(templateData); + XLSX.utils.book_append_sheet(workbook, worksheet, '端口数据'); + } + + XLSX.writeFile(workbook, fileName); }; const handleExport = async ({ scope, format }) => { @@ -2412,7 +2621,9 @@ function PortManagement() { > - 批量导入端口 + + {importDeviceType === 'server' ? '服务器批量导入' : '交换机批量导入'} + } open={importModalVisible} @@ -2459,61 +2670,78 @@ function PortManagement() { >
-
- 适用范围:批量导入端口适用于 - 所有设备类型 - (交换机、服务器、路由器、存储等) -
-
- 前置条件: -
-
-
- • 交换机端口:可直接导入,无需前置操作 -
-
- • 服务器端口:必须先在 - 网卡管理 - 中添加网卡,服务器端口与网卡是 - 层级关系:设备 → 网卡 - → 端口 -
-
操作步骤:
-
1. 点击「下载模板」获取标准Excel/CSV文件
+
1. 点击「下载模板」获取{importDeviceType === 'server' ? '服务器批量导入' : '交换机批量导入'}模板
- 2. 按模板格式填写端口信息, - 设备ID和 - 端口名称为必填项 + 2. 按模板格式填写数据:
-
- 3. - 服务器端口可在「网卡名称」列填写对应的网卡名称(如eth0),不填则归入「未分组端口」 +
+ {importDeviceType === 'server' ? ( + <> +
+ - 第一张表格(网卡数据):填写服务器的网卡信息 +
+
+ - 第二张表格(端口数据):填写网卡对应的端口信息 +
+ + ) : ( +
+ - 端口数据:填写交换机的端口信息 +
+ )}
-
4. 点击上传区域选择文件,或直接拖拽文件到上传区域
-
5. 系统自动校验数据,可预览前10条数据及错误详情
-
6. 选择导入策略(跳过/更新已存在),点击「开始导入」
+
3. 点击上传区域选择文件,或直接拖拽文件到上传区域
+
4. 系统自动校验数据,可预览前10条数据及错误详情
+
5. 选择导入策略(跳过/更新已存在),点击「开始导入」
+ {importDeviceType === 'server' && ( + <> +
+ 网卡数据字段说明: +
+
+
+ • 设备SN(必填):服务器的唯一序列号,如SN-20230001 +
+
+ • 网卡名称(必填):网卡的名称或标识,如网卡1、eth0 +
+
+ • 插槽编号(选填):网卡所在的插槽位置,可选择预选项(LOM、OCP、1-4)或填写其他数字 +
+
+ • 网卡型号(选填):如Intel X710、BCM57414 +
+
+ • 制造商(选填):如Intel、Mellanox +
+
+ • 描述(选填):备注信息 +
+
+ + )}
- 字段说明: + 端口数据字段说明:
- • 设备ID(必填):设备的唯一标识,如DEV001 + • 设备SN(必填):{importDeviceType === 'server' ? '服务器' : '交换机'}的唯一序列号,如{importDeviceType === 'server' ? 'SN-20230001' : 'SW-20230001'}
+ {importDeviceType === 'server' && ( +
+ • 网卡名称(必填):端口所属的网卡名称,必须与网卡数据中的网卡名称对应 +
+ )}
- • 端口名称 - (必填):端口的名称或标识,如eth0/1、GigabitEthernet0/0/1 -
-
- • 网卡名称 - (选填):服务器端口所属的网卡名称,如eth0;交换机端口留空 + • 端口名称(必填):端口的名称或标识,如{importDeviceType === 'server' ? 'eth0/1' : 'GigabitEthernet0/0/1'}
端口类型(选填):如RJ45、LC、SC、FC、SFP+、QSFP28等 @@ -2535,9 +2763,15 @@ function PortManagement() { 注意事项:
+ {importDeviceType === 'server' && ( +
• 同一设备下网卡名称不可重复
+ )}
• 同一设备下端口名称不可重复
• 批量导入支持最多50000条记录
• 状态字段请使用:空闲/占用/故障,勿使用其他值
+ {importDeviceType === 'server' && ( +
• 端口数据的网卡名称必须与网卡数据中的网卡名称对应
+ )}
} @@ -2697,7 +2931,7 @@ function PortManagement() {
setBatchImportModalVisible(false)} - onImportNetworkCard={() => setNetworkCardImportModalVisible(true)} + onImportNetworkCard={() => { + setImportDeviceType('server'); + handleImport(); + }} onImportPort={() => { + setImportDeviceType('switch'); handleImport(); }} /> @@ -3509,12 +3747,21 @@ function PortManagement() { setNetworkCardImportModalVisible(false)} - onSuccess={() => { + onSuccess={async () => { message.success({ content: '网卡导入成功', icon: , }); setRefreshTrigger(prev => prev + 1); + // 重新获取设备列表,确保包含最新数据 + await fetchDevices(); + // 服务器设备导入网卡后自动进入端口导入 + if (importDeviceType === 'server') { + setTimeout(() => { + setNetworkCardImportModalVisible(false); + handleImport(); + }, 1000); + } }} /> diff --git a/frontend/src/pages/TicketManagement.jsx b/frontend/src/pages/TicketManagement.jsx index 5ea81ea..36fc5bd 100644 --- a/frontend/src/pages/TicketManagement.jsx +++ b/frontend/src/pages/TicketManagement.jsx @@ -47,6 +47,7 @@ import CloseButton from '../components/CloseButton'; import dayjs from 'dayjs'; import { useSearchParams } from 'react-router-dom'; import { debounce, getUserFromStorage } from '../utils/common'; +import secureStorage, { TICKET_COLUMNS_KEY } from '../utils/secureStorage'; const { Option } = Select; const { RangePicker } = DatePicker; @@ -715,7 +716,7 @@ function TicketManagement() { message.success('工单更新成功'); } else { const user = getUserFromStorage(); - ticketData.reporterId = user.userId || localStorage.getItem('userId') || 'USER001'; + ticketData.reporterId = user.userId || 'USER001'; ticketData.reporterName = user.username || '系统用户'; await axios.post('/api/tickets', ticketData); message.success('工单创建成功'); @@ -767,7 +768,7 @@ function TicketManagement() { const user = getUserFromStorage(); await axios.put(`/api/tickets/${selectedTicket.ticketId}/process`, { ...values, - operatorId: localStorage.getItem('userId'), + operatorId: user.userId, operatorName: user.username, }); message.success('工单处理完成'); @@ -787,7 +788,7 @@ function TicketManagement() { const user = getUserFromStorage(); await axios.put(`/api/tickets/${ticketId}/status`, { status: newStatus, - operatorId: localStorage.getItem('userId'), + operatorId: user.userId, operatorName: user.username, }); message.success('状态更新成功'); @@ -1128,7 +1129,7 @@ function TicketManagement() { scroll={{ x: 1200 }} columnsState={{ onChange: ({ visibleColumns }) => { - localStorage.setItem('ticketVisibleColumns', JSON.stringify(visibleColumns)); + secureStorage.set(TICKET_COLUMNS_KEY, visibleColumns); }, }} /> diff --git a/frontend/src/utils/common.js b/frontend/src/utils/common.js index 8c7a45f..c4be54d 100644 --- a/frontend/src/utils/common.js +++ b/frontend/src/utils/common.js @@ -1,3 +1,5 @@ +import secureStorage, { USER_KEY } from './secureStorage'; + export const debounce = (func, wait) => { let timeout; return function executedFunction(...args) { @@ -23,8 +25,8 @@ export const throttle = (func, limit) => { export const getUserFromStorage = () => { try { - const userStr = localStorage.getItem('user'); - return userStr ? JSON.parse(userStr) : {}; + const user = secureStorage.get(USER_KEY); + return user || {}; } catch (e) { return {}; } diff --git a/frontend/src/utils/logger.js b/frontend/src/utils/logger.js new file mode 100644 index 0000000..7de42aa --- /dev/null +++ b/frontend/src/utils/logger.js @@ -0,0 +1,25 @@ +const isDev = import.meta.env.DEV; + +const noop = () => {}; + +export const logger = { + log: isDev ? console.log.bind(console) : noop, + warn: isDev ? console.warn.bind(console) : noop, + error: console.error.bind(console), + info: isDev ? console.info.bind(console) : noop, + debug: isDev ? console.debug.bind(console) : noop, + + group: isDev ? console.group.bind(console) : noop, + groupEnd: isDev ? console.groupEnd.bind(console) : noop, + groupCollapsed: isDev ? console.groupCollapsed.bind(console) : noop, + + table: isDev ? console.table.bind(console) : noop, + trace: isDev ? console.trace.bind(console) : noop, + time: isDev ? console.time.bind(console) : noop, + timeEnd: isDev ? console.timeEnd.bind(console) : noop, + timeLog: isDev ? console.timeLog.bind(console) : noop, + + clear: isDev ? console.clear.bind(console) : noop, +}; + +export default logger; diff --git a/frontend/src/utils/secureStorage.js b/frontend/src/utils/secureStorage.js new file mode 100644 index 0000000..9f2e0cc --- /dev/null +++ b/frontend/src/utils/secureStorage.js @@ -0,0 +1,111 @@ +const STORAGE_PREFIX = 'idc_'; +const ENCRYPTION_ENABLED = import.meta.env.PROD; + +const simpleEncrypt = (text) => { + if (!ENCRYPTION_ENABLED) return text; + + const key = import.meta.env.VITE_STORAGE_KEY || 'idc_default_secret_key_2024'; + let result = ''; + for (let i = 0; i < text.length; i++) { + result += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length)); + } + return btoa(encodeURIComponent(result)); +}; + +const simpleDecrypt = (encrypted) => { + if (!ENCRYPTION_ENABLED) return encrypted; + + try { + const key = import.meta.env.VITE_STORAGE_KEY || 'idc_default_secret_key_2024'; + const decoded = decodeURIComponent(atob(encrypted)); + let result = ''; + for (let i = 0; i < decoded.length; i++) { + result += String.fromCharCode(decoded.charCodeAt(i) ^ key.charCodeAt(i % key.length)); + } + return result; + } catch { + return null; + } +}; + +export const secureStorage = { + set: (key, value, options = {}) => { + try { + const storageKey = `${STORAGE_PREFIX}${key}`; + const data = { + value, + timestamp: Date.now(), + expiry: options.expiry || null, + }; + const serialized = JSON.stringify(data); + const encrypted = simpleEncrypt(serialized); + localStorage.setItem(storageKey, encrypted); + return true; + } catch (error) { + console.error('[SecureStorage] Set error:', error); + return false; + } + }, + + get: (key) => { + try { + const storageKey = `${STORAGE_PREFIX}${key}`; + const encrypted = localStorage.getItem(storageKey); + + if (!encrypted) return null; + + const decrypted = simpleDecrypt(encrypted); + if (!decrypted) { + localStorage.removeItem(storageKey); + return null; + } + + const data = JSON.parse(decrypted); + + if (data.expiry && Date.now() > data.expiry) { + localStorage.removeItem(storageKey); + return null; + } + + return data.value; + } catch (error) { + console.error('[SecureStorage] Get error:', error); + return null; + } + }, + + remove: (key) => { + try { + const storageKey = `${STORAGE_PREFIX}${key}`; + localStorage.removeItem(storageKey); + return true; + } catch (error) { + console.error('[SecureStorage] Remove error:', error); + return false; + } + }, + + clear: () => { + try { + const keys = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(STORAGE_PREFIX)) { + keys.push(key); + } + } + keys.forEach((key) => localStorage.removeItem(key)); + return true; + } catch (error) { + console.error('[SecureStorage] Clear error:', error); + return false; + } + }, +}; + +export const TOKEN_KEY = 'token'; +export const USER_KEY = 'user'; +export const USER_ID_KEY = 'userId'; +export const TICKET_COLUMNS_KEY = 'ticketVisibleColumns'; + +export default secureStorage;