Skip to content

ArkTS 拓扑图实现

更新: 6/12/2026 字数: 0 字 时长: 0 分钟

在网络管理类应用里,拓扑图的价值不是“画几条线”这么简单,而是把项目、网关、交换机、端口、AP、摄像头、离线设备这些信息组织成一张用户能看懂、能操作、能追踪链路关系的图。

这篇文章介绍一种基于 ArkTS 做拓扑图的实现方式。整体思路并不复杂:先把接口返回的树形数据整理成统一节点,再用递归布局算法计算坐标,最后用 ArkTS 的 StackColumnRowImage 等组件把节点和连线渲染出来。

最终效果

初始聚合态

拓扑初始聚合态

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

AP 聚合展开

AP 聚合展开

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

IPC 聚合展开

IPC 聚合展开

点击 IPC x2 后,摄像头聚合节点展开成 2 个 IPC 子节点。这里的 IPC 就是摄像头。

整体设计

这套拓扑图可以拆成四层来看:

  1. 数据层:把接口数据整理成统一节点。
  2. 图构建层:处理离线设备、端口下联设备、同类设备聚合。
  3. 布局层:根据父子关系计算节点坐标和连线。
  4. 渲染与交互层:用 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;
}

这个结构里最关键的是 idparentId。只要每个节点都有唯一 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 表里补。

处理方法是:

  1. 从 MAC 表里拆出离线交换机和离线终端。
  2. 离线终端按 switch_mac 找到对应交换机。
  3. 再按 switch_port 挂到交换机对应端口。
  4. 给离线节点打上 isoffoffline 标记。

示例代码:

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,
    };
  }));
}

端口渲染时再根据 layoutsStatusPorts 选择图标。

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)
}

连线可以用 ColumnRow 画出来:

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('暂不支持跳转该设备');
}

这里真正要注意的是顺序:

  1. 聚合节点先展开。
  2. 离线节点不跳转。
  3. IPC 是摄像头,目前没有详情页,提示 暂未对接
  4. 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 组件,而是一整套数据到视图的转换流程:

  1. 把后端拓扑树和 MAC 表合并成统一节点。
  2. 把交换机端口下联设备转换成终端节点。
  3. 把多个同类终端聚合成 AP x3IPC x2 这样的节点。
  4. 用递归树布局计算每个节点的位置。
  5. 用总线式连线表达父子关系。
  6. 用 ArkTS 组件渲染节点、端口、图标、连线。
  7. 通过点击逻辑实现聚合展开、收起和设备详情跳转。

按照这个思路实现后,拓扑图既能清楚展示网络层级,又能保持移动端页面的可读性。设备多的时候先聚合,用户需要查看细节时再展开;支持详情页的设备可以进入详情,不支持的设备给出明确提示。这样整体体验会比直接把所有设备铺开更稳定,也更容易维护。