feat(ports): add device-grouped port list API and frontend implementation
1. 新增按设备分组的端口查询后端API,修复端口过多时部分设备不显示的分页问题 2. 重构前端端口管理页面,改为设备维度分页并移除旧的端口级分页逻辑 3. 统一端口名称自然排序逻辑,优化页面展示顺序 4. 更新部署文档中的容器目录路径 5. 升级项目所有package.json版本到2.1.1 6. 新增两个提案文档到gitignore
This commit is contained in:
@@ -119,3 +119,5 @@ docs/install-split-plan.md
|
||||
# Local-only references (本地参考文档)
|
||||
# ==============================================
|
||||
deploy/nginx-troubleshooting.md
|
||||
docs/trae-creativity-contest-proposal.html
|
||||
TRAE-创意提案-IDC设备资产管理系统.html
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "idc-backend",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.1",
|
||||
"description": "IDC设备管理系统后端",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const logger = require('../utils/logger').module('DevicePortsRoute');
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { Op } = require('sequelize');
|
||||
const { Op, Sequelize } = require('sequelize');
|
||||
const DevicePort = require('../models/DevicePort');
|
||||
const Device = require('../models/Device');
|
||||
const NetworkCard = require('../models/NetworkCard');
|
||||
@@ -489,6 +489,107 @@ router.get('/export/all', async (req, res) => {
|
||||
});
|
||||
|
||||
// 获取单个端口
|
||||
// 按设备分组返回端口,支持设备维度分页
|
||||
// 解决端口数超过 pageSize 时部分设备不显示的问题
|
||||
router.get('/grouped', async (req, res) => {
|
||||
try {
|
||||
const { deviceId, page = 1, pageSize = 10 } = req.query;
|
||||
const pageNum = Math.max(1, parseInt(page) || 1);
|
||||
const pageSizeNum = Math.max(1, parseInt(pageSize) || 10);
|
||||
const offset = (pageNum - 1) * pageSizeNum;
|
||||
|
||||
// 设备筛选条件
|
||||
const deviceWhere = {};
|
||||
if (deviceId) {
|
||||
deviceWhere.deviceId = deviceId;
|
||||
}
|
||||
|
||||
// 端口总数(不受设备分页影响,用于前端统计与导出)
|
||||
const portWhere = {};
|
||||
if (deviceId) {
|
||||
portWhere.deviceId = deviceId;
|
||||
}
|
||||
const totalPorts = await DevicePort.count({ where: portWhere });
|
||||
|
||||
// 查询有端口的设备(用 EXISTS 子查询,避免 hasMany JOIN 导致 limit 作用于端口行)
|
||||
// 若用 include+JOIN,limit 会作用于 JOIN 后的行数,端口多的设备会占满 limit
|
||||
const { count, rows } = await Device.findAndCountAll({
|
||||
attributes: ['deviceId', 'name', 'type', 'rackId'],
|
||||
where: {
|
||||
...deviceWhere,
|
||||
[Op.and]: [
|
||||
Sequelize.where(
|
||||
Sequelize.literal(
|
||||
`EXISTS (SELECT 1 FROM device_ports WHERE device_ports.deviceId = Device.deviceId)`
|
||||
),
|
||||
'=',
|
||||
1
|
||||
),
|
||||
],
|
||||
},
|
||||
order: [['name', 'ASC']],
|
||||
offset,
|
||||
limit: pageSizeNum,
|
||||
});
|
||||
|
||||
// 当前页设备ID列表
|
||||
const pagedDeviceIds = rows.map(d => d.deviceId);
|
||||
|
||||
if (pagedDeviceIds.length === 0) {
|
||||
return res.json({
|
||||
total: count,
|
||||
totalPorts,
|
||||
page: pageNum,
|
||||
pageSize: pageSizeNum,
|
||||
groups: [],
|
||||
});
|
||||
}
|
||||
|
||||
// 查询当前页设备的所有端口(含网卡关联)
|
||||
const ports = await DevicePort.findAll({
|
||||
where: { deviceId: { [Op.in]: pagedDeviceIds } },
|
||||
include: [
|
||||
{
|
||||
model: Device,
|
||||
as: 'device',
|
||||
attributes: ['deviceId', 'name', 'type', 'rackId'],
|
||||
},
|
||||
{
|
||||
model: NetworkCard,
|
||||
as: 'networkCard',
|
||||
attributes: ['nicId', 'name'],
|
||||
},
|
||||
],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
// 按设备分组,保持设备排序(与分页查询一致)
|
||||
const deviceMap = new Map();
|
||||
rows.forEach(d => {
|
||||
deviceMap.set(d.deviceId, { device: d, ports: [] });
|
||||
});
|
||||
ports.forEach(port => {
|
||||
const group = deviceMap.get(port.deviceId);
|
||||
if (group) {
|
||||
group.ports.push(port);
|
||||
}
|
||||
});
|
||||
|
||||
const groups = pagedDeviceIds.map(id => deviceMap.get(id));
|
||||
|
||||
res.json({
|
||||
total: count,
|
||||
totalPorts,
|
||||
page: pageNum,
|
||||
pageSize: pageSizeNum,
|
||||
groups,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('按设备分组获取端口失败', { error: error.message, stack: error.stack });
|
||||
res.status(500).json({ error: error.message, errorType: error.name });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:portId', async (req, res) => {
|
||||
try {
|
||||
const port = await DevicePort.findByPk(req.params.portId, {
|
||||
|
||||
@@ -93,7 +93,7 @@ const initDefaultSettings = async () => {
|
||||
// 关于页面
|
||||
{
|
||||
settingKey: 'app_version',
|
||||
settingValue: JSON.stringify('1.2.0'),
|
||||
settingValue: JSON.stringify(APP_VERSION),
|
||||
settingType: 'string',
|
||||
category: 'about',
|
||||
description: '应用版本',
|
||||
|
||||
@@ -200,7 +200,7 @@ EOF
|
||||
> **重要**:这个文件是后端 Node.js 应用读取的,配置数据库连接、JWT 等业务参数。不要与项目根目录的 `.env` 混淆。
|
||||
|
||||
```bash
|
||||
cd /opt/idc_assest
|
||||
cd /opt/idc_assest/backend
|
||||
|
||||
# 先生成 JWT 密钥(确保已执行 4.1 节的命令)
|
||||
# JWT_SECRET=$(openssl rand -base64 64)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "idc-frontend",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.1",
|
||||
"description": "IDC设备管理系统前端",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { designTokens } from '../../config/theme';
|
||||
import { useFetch } from '../../hooks/useSWR';
|
||||
import { version as appVersion } from '../../../package.json';
|
||||
|
||||
// 格式化运行时间
|
||||
const formatUptime = (seconds) => {
|
||||
@@ -38,7 +39,7 @@ const formatUptime = (seconds) => {
|
||||
const SystemInfo = ({ onRefresh, isRefreshing }) => {
|
||||
const { data: systemData, isLoading: isVersionLoading } = useFetch('/system-settings/system/info');
|
||||
|
||||
const version = systemData?.system?.version || '1.2.0';
|
||||
const version = systemData?.system?.version || appVersion;
|
||||
const formattedVersion = `v${version}`;
|
||||
const currentTime = new Date().toLocaleString('zh-CN');
|
||||
|
||||
|
||||
@@ -100,11 +100,34 @@ const animations = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 按端口名称中的数字段进行自然排序
|
||||
* @param {Array} ports - 端口数组
|
||||
* @returns {Array} 排序后的新数组
|
||||
*/
|
||||
function sortPortsByName(ports) {
|
||||
const extractNumbers = str => {
|
||||
const matches = str ? String(str).match(/\d+/g) : null;
|
||||
return matches ? matches.map(Number) : [];
|
||||
};
|
||||
return [...ports].sort((a, b) => {
|
||||
const numsA = extractNumbers(a.portName);
|
||||
const numsB = extractNumbers(b.portName);
|
||||
for (let i = 0; i < Math.min(numsA.length, numsB.length); i++) {
|
||||
if (numsA[i] !== numsB[i]) {
|
||||
return numsA[i] - numsB[i];
|
||||
}
|
||||
}
|
||||
return String(a.portName).localeCompare(String(b.portName));
|
||||
});
|
||||
}
|
||||
|
||||
function PortManagement() {
|
||||
const [ports, setPorts] = useState([]);
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [deviceSearching, setDeviceSearching] = useState(false);
|
||||
const [groupedPorts, setGroupedPorts] = useState({});
|
||||
// 按设备分组的端口列表(数组,每项 { device, ports })
|
||||
const [groupedPorts, setGroupedPorts] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
deviceId: '',
|
||||
@@ -167,17 +190,13 @@ function PortManagement() {
|
||||
const hasMoreRef = useRef(true);
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
// 设备卡片分页状态
|
||||
// 设备卡片分页状态(按设备维度分页)
|
||||
const [deviceCardPage, setDeviceCardPage] = useState(1);
|
||||
const deviceCardPageSize = 10;
|
||||
const [deviceCardTotal, setDeviceCardTotal] = useState(0);
|
||||
|
||||
// 分页和懒加载状态
|
||||
const [portPage, setPortPage] = useState(1);
|
||||
const [portPageSize, setPortPageSize] = useState(50);
|
||||
// 端口总数(所有设备端口总和,用于统计与导出弹窗)
|
||||
const [portTotal, setPortTotal] = useState(0);
|
||||
const [portLoading, setPortLoading] = useState(false);
|
||||
const [portLoadingMore, setPortLoadingMore] = useState(false);
|
||||
|
||||
// 服务器网卡卡片列表状态
|
||||
const [serverNicSearchText, setServerNicSearchText] = useState('');
|
||||
@@ -186,46 +205,42 @@ function PortManagement() {
|
||||
const [serverNicCardPage, setServerNicCardPage] = useState(1);
|
||||
const serverNicCardPageSize = 12;
|
||||
|
||||
// 按设备分组获取端口(设备维度分页,避免端口数过多时部分设备不显示)
|
||||
const fetchPorts = useCallback(
|
||||
async (page = 1, append = false) => {
|
||||
async (page = 1) => {
|
||||
const validPage = Number.isInteger(page) && page > 0 ? page : 1;
|
||||
const validPageSize = Number.isInteger(portPageSize) && portPageSize > 0 ? portPageSize : 50;
|
||||
try {
|
||||
if (append) {
|
||||
setPortLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const params = {
|
||||
page: validPage,
|
||||
pageSize: validPageSize,
|
||||
pageSize: deviceCardPageSize,
|
||||
};
|
||||
|
||||
if (filters.deviceId) params.deviceId = filters.deviceId;
|
||||
|
||||
const response = await api.get('/device-ports', { params });
|
||||
const portsData = response.ports || [];
|
||||
const total = response.total || 0;
|
||||
const filteredPorts = portsData;
|
||||
const response = await api.get('/device-ports/grouped', { params });
|
||||
const groups = response.groups || [];
|
||||
|
||||
if (append) {
|
||||
setPorts(prev => [...prev, ...filteredPorts]);
|
||||
} else {
|
||||
setPorts(filteredPorts);
|
||||
}
|
||||
setPortTotal(total);
|
||||
setPortPage(page);
|
||||
hasMoreRef.current = page * portPageSize < total;
|
||||
// 对每个设备的端口按名称自然排序,并展开为扁平数组(供统计、导出当前页使用)
|
||||
const flatPorts = [];
|
||||
const sortedGroups = groups.map(g => {
|
||||
const sortedPorts = sortPortsByName(g.ports || []);
|
||||
sortedPorts.forEach(p => flatPorts.push(p));
|
||||
return { device: g.device, ports: sortedPorts };
|
||||
});
|
||||
|
||||
setPorts(flatPorts);
|
||||
setGroupedPorts(sortedGroups);
|
||||
setDeviceCardTotal(response.total || 0);
|
||||
setPortTotal(response.totalPorts || 0);
|
||||
setDeviceCardPage(validPage);
|
||||
} catch (error) {
|
||||
message.error('获取端口列表失败');
|
||||
console.error('获取端口列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setPortLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[filters, portPageSize]
|
||||
[filters, deviceCardPageSize]
|
||||
);
|
||||
|
||||
const fetchDevices = useCallback(async (keyword = '') => {
|
||||
@@ -255,104 +270,21 @@ function PortManagement() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPorts(1, false);
|
||||
fetchPorts(1);
|
||||
fetchDevices();
|
||||
}, [fetchPorts, fetchDevices]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (!hasMoreRef.current || isLoadingRef.current || portLoadingMore) return;
|
||||
isLoadingRef.current = true;
|
||||
const nextPage = portPage + 1;
|
||||
fetchPorts(nextPage, true).then(() => {
|
||||
isLoadingRef.current = false;
|
||||
});
|
||||
}, [portPage, portLoadingMore, fetchPorts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadMoreRef.current) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting && hasMoreRef.current && !portLoadingMore) {
|
||||
handleLoadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: '100px' }
|
||||
);
|
||||
|
||||
observer.observe(loadMoreRef.current);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [handleLoadMore, portLoadingMore]);
|
||||
|
||||
const handlePageSizeChange = useCallback(
|
||||
(newPage, newPageSize) => {
|
||||
setPortPageSize(newPageSize);
|
||||
setPortPage(1);
|
||||
fetchPorts(1, false);
|
||||
},
|
||||
[fetchPorts]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const grouped = {};
|
||||
ports.forEach(port => {
|
||||
const deviceId = port.deviceId;
|
||||
if (!grouped[deviceId]) {
|
||||
grouped[deviceId] = {
|
||||
device: port.device || devices.find(d => d.deviceId === deviceId),
|
||||
ports: [],
|
||||
};
|
||||
}
|
||||
grouped[deviceId].ports.push(port);
|
||||
});
|
||||
|
||||
// 对每个设备的端口按名称升序排序
|
||||
Object.keys(grouped).forEach(deviceId => {
|
||||
grouped[deviceId].ports.sort((a, b) => {
|
||||
const extractNumbers = str => {
|
||||
const matches = str.match(/\d+/g);
|
||||
return matches ? matches.map(Number) : [];
|
||||
};
|
||||
const numsA = extractNumbers(a.portName);
|
||||
const numsB = extractNumbers(b.portName);
|
||||
for (let i = 0; i < Math.min(numsA.length, numsB.length); i++) {
|
||||
if (numsA[i] !== numsB[i]) {
|
||||
return numsA[i] - numsB[i];
|
||||
}
|
||||
}
|
||||
return a.portName.localeCompare(b.portName);
|
||||
});
|
||||
});
|
||||
|
||||
setGroupedPorts(grouped);
|
||||
|
||||
const deviceIds = Object.keys(grouped);
|
||||
const newTotal = deviceIds.length;
|
||||
setDeviceCardTotal(newTotal);
|
||||
if (deviceCardPage > Math.ceil(newTotal / deviceCardPageSize)) {
|
||||
setDeviceCardPage(1);
|
||||
}
|
||||
setExpandedKeys([]);
|
||||
}, [ports, devices, deviceCardPage, deviceCardPageSize]);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
setPortPage(1);
|
||||
setDeviceCardPage(1);
|
||||
hasMoreRef.current = true;
|
||||
fetchPorts(1, false);
|
||||
fetchPorts(1);
|
||||
}, [fetchPorts]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setFilters({
|
||||
deviceId: '',
|
||||
});
|
||||
setPortPage(1);
|
||||
setDeviceCardPage(1);
|
||||
hasMoreRef.current = true;
|
||||
fetchPorts(1, false);
|
||||
fetchPorts(1);
|
||||
}, [fetchPorts]);
|
||||
|
||||
const handleAdd = () => {
|
||||
@@ -547,7 +479,7 @@ function PortManagement() {
|
||||
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
||||
});
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
fetchPorts(1, false);
|
||||
fetchPorts(1);
|
||||
};
|
||||
|
||||
const handleEdit = port => {
|
||||
@@ -579,7 +511,7 @@ function PortManagement() {
|
||||
content: '删除成功',
|
||||
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
||||
});
|
||||
fetchPorts(1, false);
|
||||
fetchPorts(1);
|
||||
} catch (error) {
|
||||
const errorMsg = error.response?.data?.error || '';
|
||||
if (errorMsg.includes('关联的接线记录')) {
|
||||
@@ -615,7 +547,7 @@ function PortManagement() {
|
||||
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
||||
});
|
||||
setSelectedRowKeys([]);
|
||||
fetchPorts(1, false);
|
||||
fetchPorts(1);
|
||||
} catch (error) {
|
||||
const errorMsg = error.response?.data?.error || '';
|
||||
if (errorMsg.includes('关联的接线记录')) {
|
||||
@@ -861,7 +793,7 @@ function PortManagement() {
|
||||
setParseResult(null);
|
||||
setPreviewPorts([]);
|
||||
setShowPreview(false);
|
||||
fetchPorts(1, false);
|
||||
fetchPorts(1);
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || editingPort ? '更新失败' : '创建失败');
|
||||
console.error('提交失败:', error);
|
||||
@@ -1289,7 +1221,7 @@ function PortManagement() {
|
||||
});
|
||||
}
|
||||
|
||||
fetchPorts(1, false);
|
||||
fetchPorts(1);
|
||||
setImportModalVisible(false);
|
||||
setImportPreview([]);
|
||||
} catch (error) {
|
||||
@@ -1871,7 +1803,7 @@ function PortManagement() {
|
||||
<div style={{ padding: '40px' }}>
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
</div>
|
||||
) : Object.keys(groupedPorts).length === 0 ? (
|
||||
) : groupedPorts.length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -1912,22 +1844,18 @@ function PortManagement() {
|
||||
</motion.div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{Object.entries(groupedPorts)
|
||||
.slice(
|
||||
(deviceCardPage - 1) * deviceCardPageSize,
|
||||
deviceCardPage * deviceCardPageSize
|
||||
)
|
||||
.map(([deviceId, data], index) => {
|
||||
{groupedPorts.map((data, index) => {
|
||||
const device = data.device;
|
||||
const deviceId = device?.deviceId;
|
||||
const devicePorts = data.ports || [];
|
||||
const freeCount = devicePorts.filter(p => p.status === 'free').length;
|
||||
const occupiedCount = devicePorts.filter(p => p.status === 'occupied').length;
|
||||
const faultCount = devicePorts.filter(p => p.status === 'fault').length;
|
||||
const isExpanded = expandedKeys.includes(deviceId);
|
||||
const isExpanded = deviceId ? expandedKeys.includes(deviceId) : false;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={deviceId}
|
||||
key={deviceId || index}
|
||||
style={{
|
||||
borderRadius: designTokens.borderRadius.lg,
|
||||
border: `1px solid ${designTokens.colors.neutral[200]}`,
|
||||
@@ -2140,7 +2068,7 @@ function PortManagement() {
|
||||
onChange: setSelectedRowKeys,
|
||||
}}
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
defaultPageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showTotal: total => `共 ${total} 个端口`,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
@@ -2164,8 +2092,8 @@ function PortManagement() {
|
||||
pageSize={deviceCardPageSize}
|
||||
total={deviceCardTotal}
|
||||
onChange={page => {
|
||||
setDeviceCardPage(page);
|
||||
setExpandedKeys([]);
|
||||
fetchPorts(page);
|
||||
}}
|
||||
showSizeChanger={false}
|
||||
showTotal={(total, range) =>
|
||||
@@ -2174,14 +2102,9 @@ function PortManagement() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasMoreRef.current && (
|
||||
<div ref={loadMoreRef} style={{ textAlign: 'center', padding: '20px' }}>
|
||||
{portLoadingMore && <Spin tip="加载更多端口..." />}
|
||||
</div>
|
||||
)}
|
||||
{!hasMoreRef.current && ports.length > 0 && (
|
||||
{ports.length > 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '16px', color: '#999' }}>
|
||||
已加载全部 {portTotal} 个端口
|
||||
共 {deviceCardTotal} 个设备,{portTotal} 个端口
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "idc-device-management",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.1",
|
||||
"description": "IDC设备管理系统",
|
||||
"scripts": {
|
||||
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
|
||||
|
||||
Reference in New Issue
Block a user