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:
zhang1106
2026-06-17 10:21:19 +08:00
parent e57df45e48
commit 755712bdb6
9 changed files with 174 additions and 147 deletions
+2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "idc-backend",
"version": "2.1.0",
"version": "2.1.1",
"description": "IDC设备管理系统后端",
"scripts": {
"start": "node server.js",
+102 -1
View File
@@ -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+JOINlimit 会作用于 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, {
+1 -1
View File
@@ -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: '应用版本',
+1 -1
View File
@@ -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 -1
View File
@@ -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');
+63 -140
View File
@@ -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);
}
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
View File
@@ -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\"",