深浅模式
ArkTS 拓扑图实现
更新: 6/12/2026 字数: 0 字 时长: 0 分钟
在网络管理类应用里,拓扑图的价值不是“画几条线”这么简单,而是把项目、网关、交换机、端口、AP、摄像头、离线设备这些信息组织成一张用户能看懂、能操作、能追踪链路关系的图。
这篇文章介绍一种基于 ArkTS 做拓扑图的实现方式。整体思路并不复杂:先把接口返回的树形数据整理成统一节点,再用递归布局算法计算坐标,最后用 ArkTS 的 Stack、Column、Row、Image 等组件把节点和连线渲染出来。
最终效果
初始聚合态

图中可以看到:网关在最上方,下面连接交换机,交换机下面挂载 AP 和 IPC。多个同类终端会被聚合显示,例如 AP x3、IPC x2。
AP 聚合展开

点击 AP x3 后,聚合节点展开成 3 个 AP 子节点。交换机下方出现收起按钮,用来恢复聚合状态。
IPC 聚合展开

点击 IPC x2 后,摄像头聚合节点展开成 2 个 IPC 子节点。这里的 IPC 就是摄像头。
整体设计
这套拓扑图可以拆成四层来看:
- 数据层:把接口数据整理成统一节点。
- 图构建层:处理离线设备、端口下联设备、同类设备聚合。
- 布局层:根据父子关系计算节点坐标和连线。
- 渲染与交互层:用 ArkTS 组件画节点、画线、处理点击、拖拽和缩放。
整体流程如下:
mermaid
flowchart TD
A[进入拓扑页] --> B[触发拓扑探测]
B --> C[拉取拓扑树]
B --> D[拉取 MAC 表]
C --> E[转换为统一节点]
D --> E
E --> F[补充端口布局和端口状态]
F --> G[生成聚合节点和隐藏子节点]
G --> H[计算节点坐标和连线]
H --> I[ArkTS 组件渲染]
I --> J[点击、展开、收起、缩放、拖拽]第一步:定义统一节点结构
后端返回的数据可能有网关、交换机、端口、下联设备、离线设备等多种形态。为了让布局和渲染逻辑更简单,需要先整理出统一的节点结构。
核心字段可以设计成这样:
ts
interface TopologyNode {
id: string;
parentId?: string;
type: string;
sn?: string;
mac?: string;
name?: string;
product?: string;
mgmt_ip?: string;
gw_ip?: string;
offline?: boolean;
isoff?: boolean;
// 是否为 AP x3、IPC x2 这种聚合节点
isCompose?: boolean;
// 聚合节点展开前,真实子节点默认隐藏
isshowChild?: boolean;
// 端口信息,主要给交换机使用
ports?: TopologyPortInfo[];
// 扩展字段:端口布局、端口实时状态、接入口、聚合信息等
extra?: TopologyNodeExtra;
}这个结构里最关键的是 id 和 parentId。只要每个节点都有唯一 ID,并且知道自己的父节点是谁,就可以把任意拓扑关系交给后面的布局算法处理。
第二步:加载拓扑数据
进入拓扑页时,一般不是直接画图,而是先触发一次拓扑探测,再拉取最新结果。
核心流程可以写成这样:
ts
async function loadTopology(projectId: string): Promise<void> {
if (projectId.length === 0) {
showError('缺少项目ID');
return;
}
// 1. 通知后端开始拓扑探测
const detectRes = await detectTopology({ proj_id: projectId });
// 2. 后端可能需要一点时间扫描网络,这里按接口返回的 delay 等待
const delay = detectRes.code === 1 && detectRes.data?.delay
? detectRes.data.delay
: 1000;
await wait(delay);
// 3. 并行拉取拓扑树和 MAC 表
const [topologyRes, macTableRes] = await Promise.all([
getTopologyList({ proj_id: projectId }),
getTopologyMacTables({ proj_id: projectId }),
]);
const rawList = Array.isArray(topologyRes.data) ? topologyRes.data : [];
const macTableData = Array.isArray(macTableRes.data) ? macTableRes.data : [];
// 4. 构建可渲染的拓扑图数据
const graphData = await buildTopologyGraphData({
rawList,
macTableData,
deviceList,
projectName,
});
// 5. 生成真正给画布使用的节点
displayNodes = prepareTopologyDisplayNodes(graphData.nodes);
}这里有两个数据来源:
- 拓扑树:告诉我们网关、交换机和已知设备的父子结构。
- MAC 表:补充交换机端口下的离线设备和历史下联终端。
只有把两者合起来,拓扑图才比较完整。
第三步:把树形数据拍平成节点数组
后端常见返回形式是树形结构:节点下面有 devices,设备下面还可能继续挂载 devices。
布局算法不直接处理这种嵌套树,而是先把它拍平成数组:
ts
function convertToFlatTree(rawNodes: RawNode[], prefix: string = ''): TopologyNode[] {
const result: TopologyNode[] = [];
let rootIndex = 1;
let childIndex = 1;
function walk(node: RawNode, parentId?: string): void {
const id = parentId
? `${parentId}_${childIndex++}`
: `${prefix}id${rootIndex++}`;
result.push({
id,
parentId,
type: node.type || 'switch',
sn: node.sn,
mac: node.mac,
name: node.name,
product: node.product,
mgmt_ip: node.mgmt_ip,
gw_ip: node.gw_ip,
offline: node.offline,
ports: node.ports,
});
(node.devices || []).forEach((child) => {
walk(child, id);
});
}
rawNodes.forEach((node) => walk(node));
return result;
}这样做的好处是:后面的布局、点击、查找、更新,都可以围绕一个数组完成,不需要在嵌套树里反复递归搜索。
第四步:合并离线设备
拓扑树通常更擅长描述在线设备,离线终端则需要从 MAC 表里补。
处理方法是:
- 从 MAC 表里拆出离线交换机和离线终端。
- 离线终端按
switch_mac找到对应交换机。 - 再按
switch_port挂到交换机对应端口。 - 给离线节点打上
isoff或offline标记。
示例代码:
ts
function attachOfflineDevices(
switches: RawNode[],
offlineTerminals: PortDevice[],
): RawNode[] {
switches.forEach((sw) => {
sw.ports = sw.ports || [];
const terminals = offlineTerminals.filter((dev) => {
return dev.switch_mac === sw.sys_mac;
});
terminals.forEach((dev) => {
dev.isoff = true;
const portIndex = (dev.switch_port || 1) - 1;
const port = sw.ports![portIndex];
if (!port) {
return;
}
port.devs = port.devs || [];
port.devs.push(dev);
});
(sw.devices || []).forEach((child) => {
attachOfflineDevices([child], offlineTerminals);
});
});
return switches;
}离线节点进入图里之后,渲染层只需要判断 offline || isoff,就可以统一做灰色连线、灰色图标、禁止跳转等处理。
第五步:展开交换机端口下联设备
交换机本身是一个节点,但它的端口下面还有 AP、IPC、CPE、网关等终端。为了让这些终端出现在图上,需要把端口里的 devs 转成拓扑节点。
核心思路:
ts
function expandDownstreamDevices(nodes: TopologyNode[], hiddenNodes: TopologyNode[]): TopologyNode[] {
const appendNodes: TopologyNode[] = [];
nodes.forEach((switchNode) => {
if (!switchNode.ports || switchNode.ports.length === 0) {
return;
}
const downstreamDevices: PortDevice[] = [];
switchNode.ports
// 上行口不作为下联终端展示
.filter((port) => port.port !== switchNode.gw_port)
.forEach((port) => {
(port.devs || []).forEach((dev) => {
dev.isoff = !!dev.offline_time || dev.isoff === true;
dev.videoPort = port.port;
});
downstreamDevices.push(...(port.devs || []));
});
appendNodes.push(
...buildTerminalNodes(switchNode, downstreamDevices, hiddenNodes),
);
});
return [...nodes, ...appendNodes];
}这里要注意两个细节:
videoPort会记录终端接入的是交换机哪个端口,渲染时可以显示端口号。- 上行口要过滤掉,否则上级设备也可能被误当成下联终端画出来。
第六步:AP、IPC 等同类设备聚合
如果交换机下面挂了很多同类型终端,全部铺开会很乱。更好的方式是按类型聚合。
比如:
- 3 台 AP 变成
AP x3。 - 2 台 IPC 变成
IPC x2。
实现方式是按 ${company}/${type} 分组。
ts
function buildGroupedNodes(
parent: TopologyNode,
devices: PortDevice[],
hiddenNodes: TopologyNode[],
): TopologyNode[] {
const result: TopologyNode[] = [];
const groups = new Map<string, PortDevice[]>();
devices.forEach((dev) => {
const key = `${dev.company}/${dev.type}`;
const list = groups.get(key) || [];
list.push(dev);
groups.set(key, list);
});
groups.forEach((list, typeKey) => {
if (list.length === 1) {
result.push(createTerminalNode(parent, list[0], typeKey));
return;
}
const composeId = `${parent.id}_compose_${typeKey}`;
result.push({
id: composeId,
parentId: parent.id,
type: typeKey,
isCompose: true,
name: `${getTypeLabel(typeKey)} x${list.length}`,
extra: {
type: typeKey,
num: list.length,
videoPort: list[0].videoPort,
},
});
list.forEach((dev, index) => {
hiddenNodes.push({
...createTerminalNode(parent, dev, typeKey),
id: `${composeId}_${index}`,
parentId: composeId,
isshowChild: true,
});
});
});
return result;
}这个设计里有两个数组:
nodes:当前画布可见节点。hiddenNodes:聚合节点展开后才显示的真实子节点。
聚合节点本身是一个普通节点,只是多了 isCompose: true。点击它时,不跳详情,而是展开。
第七步:补充交换机端口布局
如果只画一个交换机图标,用户不知道哪个端口在线、哪个端口是上行、哪个端口接了下联设备。所以交换机节点需要补充端口布局和实时端口状态。
常见做法是根据交换机设备 ID 请求两个接口:
- 端口布局:每个端口的位置、类型、电口/光口。
- 端口状态:在线、速率、PoE、环路、禁用等。
整理后的数据放到 node.extra:
ts
async function enrichSwitchPortLayouts(nodes: TopologyNode[], deviceList: DeviceItem[]) {
const switchNodes = nodes.filter((node) => node.type === 'switch' && !!node.sn);
await Promise.all(switchNodes.map(async (node) => {
const device = deviceList.find((item) => item.sn === node.sn);
if (!device) {
return;
}
const [layoutRes, statusRes] = await Promise.all([
getDevicePortLayout({ dev_id: device.id }),
node.offline ? Promise.resolve(null) : getDeviceRemotePortInfo({ dev_id: device.id }),
]);
node.extra = {
type: 'switch',
ip: node.mgmt_ip || node.gw_ip || '',
layouts: layoutRes.data.layouts,
StatusPorts: statusRes?.data?.ports || [],
gw_port: node.gw_port,
parent_port: node.parent_port,
product: node.product,
};
}));
}端口渲染时再根据 layouts 和 StatusPorts 选择图标。
ts
function getSwitchPortImageUrl(node: TopologyNode, port: PortLayoutItem): string {
const status = findStatusPort(node, port.port);
const isOffline = !!(node.offline || node.extra?.isOff);
const isFiber = port.type && port.type !== 'rj45';
if (isOffline) {
return isFiber ? 'fiber-offline.png' : 'rj45-offline.png';
}
if (status?.actual?.loop_stats === 'blocked') {
return 'loop-blocked.png';
}
if (status?.actual?.watt > 0) {
return 'poe-online.png';
}
if (isFiber) {
return 'fiber-online.png';
}
return 'rj45-online.png';
}真实项目里图标可以来自本地资源,也可以来自 CDN。算法本身只关心“当前端口应该用哪种状态图”。
第八步:计算拓扑布局
有了节点和父子关系后,就可以计算坐标。
这里采用的是自上而下的树布局,也就是 Top-Bottom 布局:父节点在上,子节点在下。
核心算法是递归布局子树:
ts
function computeTopologyLayout(nodes: TopologyNode[]): LayoutResult {
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
const positions: LayoutPosition[] = [];
function getChildren(parentId: string): TopologyNode[] {
return nodes
.filter((node) => node.parentId === parentId)
.sort((a, b) => a.id.localeCompare(b.id));
}
function layoutSubtree(nodeId: string, startX: number, y: number): number {
const node = nodeMap.get(nodeId)!;
const size = getNodeSize(node);
const children = getChildren(nodeId);
if (children.length === 0) {
positions.push({ id: nodeId, node, x: startX, y, width: size.width, height: size.height });
return size.width;
}
let cursorX = startX;
const childY = y + size.height + 90;
children.forEach((child, index) => {
const childWidth = layoutSubtree(child.id, cursorX, childY);
cursorX += childWidth + (children.length > 3 ? 36 : 24);
});
const childPositions = positions.filter((item) => {
return children.some((child) => child.id === item.id);
});
const left = Math.min(...childPositions.map((item) => item.x));
const right = Math.max(...childPositions.map((item) => item.x + item.width));
const centerX = (left + right) / 2;
const nodeX = centerX - size.width / 2;
positions.push({ id: nodeId, node, x: nodeX, y, width: size.width, height: size.height });
return Math.max(right, nodeX + size.width) - Math.min(left, nodeX);
}
const root = nodes.find((node) => !node.parentId) || nodes[0];
layoutSubtree(root.id, 32, 32);
return {
positions,
edges: buildBusEdges(positions, nodes),
canvasWidth: getMaxRight(positions) + 32,
canvasHeight: getMaxBottom(positions) + 32,
};
}这个算法的关键思想是:先布局子节点,再把父节点居中放到所有子节点的上方。这样不需要复杂图布局库,也能得到稳定、可控的树形拓扑。
第九步:生成总线式连线
拓扑图的连线不建议直接父节点到子节点一条斜线。网络拓扑更适合用“竖线 + 横线 + 竖线”的总线式连线。
多个子节点时,可以这样画:
ts
function buildBusEdges(positions: LayoutPosition[], nodes: TopologyNode[]): Edge[] {
const edges: Edge[] = [];
const posMap = new Map(positions.map((pos) => [pos.id, pos]));
const childrenByParent = groupChildrenByParent(nodes, positions);
childrenByParent.forEach((children, parentId) => {
const parent = posMap.get(parentId);
if (!parent) {
return;
}
const parentCenterX = parent.x + parent.width / 2;
const parentBottomY = parent.y + parent.height;
const busY = parentBottomY + 45;
const childCenters = children.map((child) => child.x + child.width / 2);
const leftX = Math.min(...childCenters, parentCenterX);
const rightX = Math.max(...childCenters, parentCenterX);
// 父节点到底部总线
edges.push({ fromX: parentCenterX, fromY: parentBottomY, toX: parentCenterX, toY: busY });
// 水平总线
edges.push({ fromX: leftX, fromY: busY, toX: rightX, toY: busY });
// 总线到每个子节点
children.forEach((child) => {
const childCenterX = child.x + child.width / 2;
edges.push({ fromX: childCenterX, fromY: busY, toX: childCenterX, toY: child.y });
});
});
return edges;
}连线颜色可以根据链路类型决定:
ts
function getEdgeColor(edge: Edge): number {
if (edge.linkType === 'offline') {
return 0xafabab;
}
if (edge.linkType === 'fiber') {
return 0xee822f;
}
return 0x00b0f0;
}这样就可以做到:
- 普通网线:蓝色。
- 光纤:橙色。
- 离线链路:灰色。
- 无线链路:虚线。
第十步:用 ArkTS 渲染画布
ArkTS 里可以用 Stack 实现一个自由画布:连线和节点都绝对定位到同一个容器中。
简化后的结构如下:
ts
@Component
struct TopologyCanvas {
@Prop nodes: TopologyNode[] = [];
@State zoomScale: number = 1;
@State panOffsetX: number = 0;
@State panOffsetY: number = 0;
build() {
Stack({ alignContent: Alignment.TopStart }) {
if (this.nodes.length === 0) {
Text('暂无拓扑数据')
} else {
this.buildTopologyView(computeTopologyLayout(this.nodes))
}
}
.width('100%')
.height('100%')
}
}画布内容分两层:
ts
@Builder
buildTopologyContent(layout: LayoutResult) {
Stack({ alignContent: Alignment.TopStart }) {
// 第一层:连线
ForEach(layout.edges, (edge: Edge) => {
this.buildEdgeSegment(edge);
})
// 第二层:节点
ForEach(layout.positions, (item: LayoutPosition) => {
TopologyNodeCard({ node: item.node })
.width(item.width)
.height(item.height)
.position({ x: item.x, y: item.y })
})
}
.width(layout.canvasWidth)
.height(layout.canvasHeight)
}连线可以用 Column 和 Row 画出来:
ts
@Builder
buildEdgeSegment(edge: Edge) {
if (edge.fromX === edge.toX) {
Column()
.width(2)
.height(Math.abs(edge.toY - edge.fromY))
.backgroundColor(this.getEdgeColor(edge))
.position({
x: edge.fromX - 1,
y: Math.min(edge.fromY, edge.toY),
})
} else if (edge.fromY === edge.toY) {
Row()
.width(Math.abs(edge.toX - edge.fromX))
.height(2)
.backgroundColor(this.getEdgeColor(edge))
.position({
x: Math.min(edge.fromX, edge.toX),
y: edge.fromY - 1,
})
}
}如果要画虚线,可以把背景色设为透明,再加虚线边框。
第十一步:渲染不同类型节点
节点渲染可以统一交给一个 TopologyNodeCard 组件,然后按类型分发。
ts
@Component
struct TopologyNodeCard {
@Prop node: TopologyNode;
build() {
Column() {
if (this.node.type === 'header') {
this.buildGatewayNode();
} else if (this.node.type === 'switch' && this.hasPortPanel()) {
this.buildSwitchNode();
} else if (this.node.type === 'switch') {
this.buildSimpleSwitchNode();
} else if (this.isApOrIpc()) {
this.buildTerminalIconNode();
} else {
this.buildGenericNode();
}
}
}
}AP 和 IPC 节点适合图标化展示:
ts
@Builder
buildTerminalIconNode() {
Column({ space: 4 }) {
if (this.node.extra?.videoPort !== undefined) {
Text(`${this.node.extra.videoPort}`)
.fontSize(8)
.fontColor(0x70737d)
}
Image(this.getTerminalIcon())
.width(52)
.height(44)
.objectFit(ImageFit.Contain)
.opacity(this.isOffline() ? 0.45 : 1)
Text(this.getDisplayName())
.fontSize(10)
.fontWeight(FontWeight.Medium)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.node.isCompose) {
Text(`点击展开 ${this.node.extra?.num || 0} 台`)
.fontSize(10)
.fontColor(0x3766f4)
} else if (this.getIpText() !== '--') {
Text(this.getIpText())
.fontSize(10)
.fontColor(0x70737d)
}
}
.width(90)
.alignItems(HorizontalAlign.Center)
}交换机节点则需要机箱图和端口面板:
ts
@Builder
buildSwitchNode() {
Column({ space: 2 }) {
Stack({ alignContent: Alignment.TopStart }) {
Image(this.switchChassisImage)
.width(205)
.height(this.hasTwoPortRows() ? 43 : 34)
.objectFit(ImageFit.Fill)
TopologyPortPanel({ node: this.node })
.width(205)
.padding({ left: 34, right: 8, top: 16 })
}
Text(this.getDisplayName())
.fontSize(10)
.fontWeight(FontWeight.Medium)
.textAlign(TextAlign.Center)
}
.width(205)
.alignItems(HorizontalAlign.Center)
}第十二步:实现拖拽和缩放
拓扑图通常会超过屏幕大小,所以画布需要支持拖拽和平移。
拖拽可以用 PanGesture:
ts
PanGesture({ fingers: 1 })
.onActionStart(() => {
this.panStartX = this.panOffsetX;
this.panStartY = this.panOffsetY;
})
.onActionUpdate((event: GestureEvent) => {
this.panOffsetX = this.panStartX + event.offsetX;
this.panOffsetY = this.panStartY + event.offsetY;
})缩放可以用 PinchGesture:
ts
PinchGesture({ fingers: 2 })
.onActionStart((event: GestureEvent) => {
this.pinchStartScale = this.zoomScale;
this.anchorX = (event.pinchCenterX - this.panOffsetX) / this.zoomScale;
this.anchorY = (event.pinchCenterY - this.panOffsetY) / this.zoomScale;
})
.onActionUpdate((event: GestureEvent) => {
const nextScale = clamp(this.pinchStartScale * event.scale, 0.5, 2);
this.panOffsetX = event.pinchCenterX - this.anchorX * nextScale;
this.panOffsetY = event.pinchCenterY - this.anchorY * nextScale;
this.zoomScale = nextScale;
})画布内容应用平移和缩放:
ts
Column() {
this.buildTopologyContent(layout)
}
.translate({ x: this.panOffsetX, y: this.panOffsetY })
.scale({ x: this.zoomScale, y: this.zoomScale, centerX: 0, centerY: 0 })为了体验更好,还可以加一个“复位”按钮,把 zoomScale 和偏移量重置。
第十三步:实现点击交互
拓扑节点点击逻辑一般按优先级处理。
ts
function handleTopologyNodeClick(node: TopologyNode): void {
if (node.type === 'header') {
return;
}
if (node.isCompose) {
expandComposeNode(node.id);
return;
}
if (isOffline(node)) {
showToast('设备已离线');
return;
}
const typeCode = getTypeCode(node.type);
if (typeCode === '0' || typeCode === '1' || typeCode === '4') {
showToast('暂未对接');
return;
}
if (typeCode === '5' || typeCode === '9' || typeCode === '10') {
openApDetail(node);
return;
}
if (node.type === 'switch') {
openSwitchDetail(node);
return;
}
showToast('暂不支持跳转该设备');
}这里真正要注意的是顺序:
- 聚合节点先展开。
- 离线节点不跳转。
- IPC 是摄像头,目前没有详情页,提示
暂未对接。 - AP 和交换机进入各自详情页。
也就是说,IPC x2 这种聚合节点被点击时,第一反应是展开成两个 IPC;展开后的单个 IPC 被点击时,才提示 暂未对接。
第十四步:展开和收起聚合节点
展开聚合节点时,需要把占位聚合节点替换成真实子节点。
ts
function expandComposeNode(composeId: string): void {
const composeNode = nodes.find((item) => item.id === composeId);
if (!composeNode) {
return;
}
const children = hiddenNodes
.filter((item) => item.parentId === composeId)
.map((item) => ({
...item,
parentId: composeNode.parentId,
isCompose: false,
isshowChild: false,
extra: {
...item.extra,
composeParentId: composeNode.id,
composeParentName: composeNode.name,
composeParentType: composeNode.type,
composeParentNum: composeNode.extra?.num,
},
}));
nodes = nodes
.filter((item) => item.id !== composeId)
.concat(children);
markParentSwitchCanCollapse(composeNode.parentId);
refreshCanvas();
}收起时则反过来:找到带 composeParentId 的展开子节点,删除它们,再恢复一个聚合节点。
ts
function collapseComposeChildren(switchId: string): void {
const expandedChildren = nodes.filter((node) => {
return node.parentId === switchId && !!node.extra?.composeParentId;
});
const composeNodes = rebuildComposeNodes(expandedChildren, switchId);
const expandedIds = expandedChildren.map((item) => item.id);
nodes = nodes
.filter((item) => !expandedIds.includes(item.id))
.concat(composeNodes);
markParentSwitchCollapsed(switchId);
refreshCanvas();
}需要注意的实现细节
1. 数据节点和展示节点要分开
拓扑内部可以保留完整节点数据,但画布展示时要过滤隐藏节点。
ts
function prepareDisplayNodes(nodes: TopologyNode[]): TopologyNode[] {
return nodes.filter((node) => !node.isshowChild);
}否则聚合节点和真实子节点会同时出现在画布上。
2. 节点尺寸必须和真实 UI 对齐
布局算法用的是 getNodeSize(node) 返回的宽高。如果这里和真实组件尺寸不一致,连线就可能穿过文字或图标。
ts
function getNodeSize(node: TopologyNode): Size {
if (node.type === 'switch') {
return { width: 205, height: 90 };
}
if (isApOrIpc(node)) {
return { width: 90, height: node.isCompose ? 96 : 84 };
}
return { width: 110, height: 78 };
}3. 画线层不要挡住点击
连线只是视觉元素,不应该拦截触摸事件。
ts
Column()
.width(2)
.height(120)
.backgroundColor(0x00b0f0)
.hitTestBehavior(HitTestMode.None)4. 类型判断要集中
AP、IPC 这样的类型不要散落在各处判断。可以封装成函数:
ts
function isIpcType(typeCode: string): boolean {
return typeCode === '0' || typeCode === '1' || typeCode === '4';
}
function isApType(typeCode: string): boolean {
return typeCode === '5' || typeCode === '9' || typeCode === '10';
}这样后面新增类型时,只需要改一处。
5. 不支持的设备不要默认跳详情
拓扑里经常会出现很多不同厂商、不同类型的终端。没有对应详情页时,应该明确提示,而不是默认跳到某个已有详情页。
ts
function openDeviceDetail(device: DeviceItem): void {
if (device.type === 'AP') {
openApDetail(device);
return;
}
if (device.type === 'switch') {
openSwitchDetail(device);
return;
}
showToast('暂未对接');
}这能避免摄像头、NVR、未知终端误进交换机详情页。
小结
这个拓扑图真正麻烦的地方,不是某一个 UI 组件,而是一整套数据到视图的转换流程:
- 把后端拓扑树和 MAC 表合并成统一节点。
- 把交换机端口下联设备转换成终端节点。
- 把多个同类终端聚合成
AP x3、IPC x2这样的节点。 - 用递归树布局计算每个节点的位置。
- 用总线式连线表达父子关系。
- 用 ArkTS 组件渲染节点、端口、图标、连线。
- 通过点击逻辑实现聚合展开、收起和设备详情跳转。
按照这个思路实现后,拓扑图既能清楚展示网络层级,又能保持移动端页面的可读性。设备多的时候先聚合,用户需要查看细节时再展开;支持详情页的设备可以进入详情,不支持的设备给出明确提示。这样整体体验会比直接把所有设备铺开更稳定,也更容易维护。