You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Angular 16拖拽式流程图构建器开发需求(带箭头连接)

Angular 16 拖拽式流程图构建器实现方案

Angular Material的待办拖拽示例仅支持线性列表排序,无法满足自由拖拽定位+卡片连线的需求。以下是基于Angular CDK Drag-Drop和SVG的实现方案:

核心功能实现拆解

  • 自由拖拽定位:利用@angular/cdk/drag-drop的自由拖拽模式,支持卡片在画布内任意移动
  • 卡片连线:通过SVG绘制箭头,实现卡片间的连接关系管理
  • 实时更新:拖拽卡片时同步更新连线坐标

步骤1:基础组件搭建

卡片组件(支持拖拽和连接点)

创建可拖拽的卡片组件,包含顶部/底部连接点用于触发连线:

// flow-card.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CdkDrag, CdkDragMove } from '@angular/cdk/drag-drop';

@Component({
  selector: 'app-flow-card',
  template: `
    <div class="flow-card" 
         cdkDrag 
         (cdkDragMoved)="onDragMove($event)" 
         [cdkDragBoundary]="dragBoundary"
         [style.left.px]="position.x"
         [style.top.px]="position.y">
      <div class="connection-points">
        <div class="top-point" (click)="triggerConnection('top')"></div>
        <div class="bottom-point" (click)="triggerConnection('bottom')"></div>
      </div>
      <div class="card-content">{{ cardId }}</div>
    </div>
  `,
  styles: [`
    .flow-card {
      position: absolute;
      width: 180px;
      min-height: 80px;
      background: #ffffff;
      border: 1px solid #e0e0e0;
      border-radius: 4px;
      padding: 12px;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
      cursor: move;
    }
    .connection-points {
      position: absolute;
      left: 50%;
      transform: translateX(-50%);
      width: 8px;
    }
    .top-point, .bottom-point {
      width: 8px;
      height: 8px;
      background: #1976d2;
      border-radius: 50%;
      cursor: crosshair;
    }
    .top-point { top: -4px; }
    .bottom-point { bottom: -4px; }
    .card-content { margin: 20px 0 0; }
  `]
})
export class FlowCardComponent {
  @Input() cardId: string;
  @Input() position: { x: number, y: number };
  @Input() dragBoundary: string;
  @Output() cardPositionUpdated = new EventEmitter<{ id: string, pos: { x: number, y: number } }>();
  @Output() connectionTriggered = new EventEmitter<{ cardId: string, direction: string }>();

  onDragMove(event: CdkDragMove) {
    const newPos = event.source.getFreeDragPosition();
    this.cardPositionUpdated.emit({ id: this.cardId, pos: newPos });
  }

  triggerConnection(direction: string) {
    this.connectionTriggered.emit({ cardId: this.cardId, direction });
  }
}

步骤2:画布与连线管理组件

父组件负责管理所有卡片、连线数据,通过SVG绘制箭头:

// flow-builder.component.ts
import { Component, ViewChild, ElementRef } from '@angular/core';

interface FlowCard {
  id: string;
  position: { x: number, y: number };
}

interface FlowConnection {
  startId: string;
  endId: string;
  startPoint: { x: number, y: number };
  endPoint: { x: number, y: number };
}

@Component({
  selector: 'app-flow-builder',
  template: `
    <div class="flow-canvas" #canvas>
      <!-- SVG连线层 -->
      <svg class="connections-layer">
        <line *ngFor="let conn of connections"
              [attr.x1]="conn.startPoint.x" [attr.y1]="conn.startPoint.y"
              [attr.x2]="conn.endPoint.x" [attr.y2]="conn.endPoint.y"
              stroke="#1976d2" stroke-width="2" marker-end="url(#arrowhead)"/>
        <defs>
          <marker id="arrowhead" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
            <polygon points="0 0, 10 3, 0 6" fill="#1976d2"/>
          </marker>
        </defs>
      </svg>
      
      <!-- 卡片列表 -->
      <app-flow-card
        *ngFor="let card of cards"
        [cardId]="card.id"
        [position]="card.position"
        [dragBoundary]="'.flow-canvas'"
        (cardPositionUpdated)="updateCardPosition($event)"
        (connectionTriggered)="handleConnection($event)">
      </app-flow-card>

      <!-- 添加卡片按钮 -->
      <button class="add-btn" (click)="addNewCard()">+ 添加卡片</button>
    </div>
  `,
  styles: [`
    .flow-canvas {
      position: relative;
      width: 100%;
      height: 85vh;
      border: 1px solid #f0f0f0;
      background-color: #fafafa;
      overflow: auto;
    }
    .connections-layer {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      pointer-events: none;
    }
    .add-btn {
      position: fixed;
      bottom: 20px;
      right: 20px;
      padding: 8px 16px;
      background: #1976d2;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }
  `]
})
export class FlowBuilderComponent {
  @ViewChild('canvas') canvasRef: ElementRef;
  cards: FlowCard[] = [
    { id: '初始化节点', position: { x: 150, y: 100 } },
    { id: '处理节点', position: { x: 400, y: 100 } }
  ];
  connections: FlowConnection[] = [];
  pendingConnection: { cardId: string, direction: string } | null = null;

  addNewCard() {
    this.cards.push({
      id: `节点${this.cards.length + 1}`,
      position: { x: Math.random() * 600, y: Math.random() * 400 }
    });
  }

  updateCardPosition(event: { id: string, pos: { x: number, y: number } }) {
    const targetCard = this.cards.find(c => c.id === event.id);
    if (targetCard) {
      targetCard.position = event.pos;
      this.refreshConnectionsForCard(event.id);
    }
  }

  handleConnection(event: { cardId: string, direction: string }) {
    if (!this.pendingConnection) {
      // 启动新的连线
      this.pendingConnection = event;
    } else {
      // 完成连线创建
      const startCard = this.cards.find(c => c.id === this.pendingConnection.cardId);
      const endCard = this.cards.find(c => c.id === event.cardId);
      if (startCard && endCard) {
        const startPoint = this.getCardConnectionPoint(startCard, this.pendingConnection.direction);
        const endPoint = this.getCardConnectionPoint(endCard, event.direction);
        this.connections.push({
          startId: startCard.id,
          endId: endCard.id,
          startPoint,
          endPoint
        });
      }
      this.pendingConnection = null;
    }
  }

  getCardConnectionPoint(card: FlowCard, direction: string) {
    const cardWidth = 180;
    const cardHeight = 80;
    switch (direction) {
      case 'top':
        return { x: card.position.x + cardWidth / 2, y: card.position.y };
      case 'bottom':
        return { x: card.position.x + cardWidth / 2, y: card.position.y + cardHeight };
      default:
        return card.position;
    }
  }

  refreshConnectionsForCard(cardId: string) {
    this.connections.forEach(conn => {
      if (conn.startId === cardId) {
        const card = this.cards.find(c => c.id === cardId);
        conn.startPoint = this.getCardConnectionPoint(card, 'bottom');
      }
      if (conn.endId === cardId) {
        const card = this.cards.find(c => c.id === cardId);
        conn.endPoint = this.getCardConnectionPoint(card, 'top');
      }
    });
  }
}

扩展优化建议

  • 连线删除:给SVG线条添加pointer-events: stroke,绑定点击事件实现删除
  • 卡片样式自定义:通过@Input()传递样式类或颜色参数
  • 流程图导出:将cards和connections序列化为JSON,支持保存/加载
  • 对齐辅助:拖拽时显示网格线或磁吸对齐效果

内容的提问来源于stack exchange,提问作者Poojith

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.01 16:02:12