甘特图(Gantt)是项目管理中常用的可视化工具,用于展示任务的时间安排、进度和依赖关系。DevUI 为 Angular 应用提供了功能丰富、交互灵活的甘特图组件,支持基本时间轴展示任务条拖拽调整与 DataTable 集成等高级功能。本文将基于官方示例详细解析其用法和实现。


1. 环境与依赖

确保你的项目满足以下条件:

  • Angular 版本 ^18.0.0
  • 已安装 DevUI 组件库及相关主题样式
  • 在模块中导入 GanttModuleFullscreenModuleDataTableModule(如与表格结合使用)

2. 基本甘特图用法

请添加图片描述

2.1 核心结构

基本甘特图由以下部分组成:

  • 时间轴容器 (d-gantt-scale):显示时间刻度(日、周、月等)
  • 任务条容器 (d-gantt-bar):展示单个任务的时间范围和进度
  • 外层容器:用于滚动和全屏控制

基本用法:

  • d-gantt-scale(时间轴)容器作为时间轴标线的定位父级元素,须设置position或者是table、td、th、body元素。
  • d-gantt-scale(时间轴)容器和d-gantt-bar(时间条)容器宽度须通过GanttService提供的方法根据起止时间计算后设置,初始化之后还须订阅ganttScaleConfigChange动态设置。
  • 时间条move、resize事件会改变该时间条起止时间和时间轴的起止时间,订阅时间条resize、move事件和ganttScaleConfigChange来记录变化。
  • 响应时间条move、resize事件调整最外层容器的滚动以获得更好的体验。

2.2 代码示例与说明

<d-fullscreen [zIndex]="1080" (fullscreenLaunch)="launchFullscreen($event)">
  <div #ganttContainer class="gantt-container" fullscreen-target>
    <!-- 时间轴头部 -->
    <div class="header" [style.width]="ganttScaleWidth">
      <d-gantt-scale 
        [ganttBarContainerElement]="ganttBody" 
        [scrollElement]="ganttContainer"
        [showDaySplitLine]="true">
      </d-gantt-scale>
    </div>
    
    <!-- 工具栏(缩放、切换视图、今日按钮等) -->
    <d-gantt-tools
      [currentUnit]="unit"
      [isFullScreen]="isFullScreen"
      (goToday)="goToday()"
      (increaseUnit)="onIncreaseUnit()"
      (reduceUnit)="onReduceUnit()"
      (switchView)="onSwitchView($event)">
      <d-button bsStyle="text" fullscreen-launch class="tool">
        <i class="icon" [ngClass]="{ 
          'icon-frame-contract': isFullScreen, 
          'icon-frame-expand': !isFullScreen 
        }"></i>
      </d-button>
    </d-gantt-tools>
    
    <!-- 任务条区域 -->
    <div #ganttBody class="body" [style.width]="ganttScaleWidth">
      <div class="item" *ngFor="let item of list">
        <d-gantt-bar
          [startDate]="item?.startDate"
          [endDate]="item?.endDate"
          [id]="item?.id"
          [title]="item?.title"
          [progressRate]="item?.progressRate"
          [scrollElement]="ganttContainer"
          [status]="item?.status"
          [data]="item"
          [showTitle]="true"
          (barMoveEndEvent)="onGanttBarMoveEnd($event)"
          (barResizeEndEvent)="onGanttBarResize($event)"
          (barProgressEvent)="onBarProgressEvent($event)">
          <!-- 自定义提示模板 -->
          <ng-template #tipTemplate let-ganttInstance="ganttInstance" let-data="data">
            <div class="title">{{ data?.title }}</div>
            <div class="content">
              <div>Duration: {{ ganttInstance?.duration }}</div>
              <div>ProgressRate: {{ (ganttInstance?.progressRate || 0) + '%' }}</div>
              <div>startDate: {{ ganttInstance?.startDate | i18nDate: 'short' }}</div>
              <div>endDate: {{ ganttInstance?.endDate | i18nDate: 'short' }}</div>
            </div>
          </ng-template>
          
          <!-- 自定义标题模板(如显示逾期标签) -->
          <ng-template #titleTemplate let-data="data">
            <d-tag *ngIf="data?.overdueTime > 0 && data?.status !== 'done'"
                   class="delay-tag"
                   [tag]="'overdue ' + data?.overdueTime + ' days'"
                   [labelStyle]="'pink-w98'">
            </d-tag>
          </ng-template>
        </d-gantt-bar>
      </div>
    </div>
  </div>
</d-fullscreen>

ts代码

import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { GanttScaleUnit, GanttService, GanttTaskInfo } from 'ng-devui/gantt';
import { Subscription } from 'rxjs';
import { basicData, curYear } from './../mock-data';

@Component({
  selector: 'd-basic',
  templateUrl: './basic.component.html',
  styleUrls: ['./basic.component.scss'],
  providers: [GanttService],
})
export class BasicComponent implements OnInit, AfterViewInit, OnDestroy {
  @ViewChild('ganttContainer', { static: true }) ganttContainer: ElementRef;
  curYear = curYear;
  list = basicData;
  ganttStartDate: Date;
  ganttEndDate: Date;
  unit = GanttScaleUnit.day;
  ganttScaleWidth: string;
  ganttSacleConfigHandler: Subscription;
  originOffsetLeft = 0;
  scrollElement: HTMLElement;
  startMove = false;
  startMoveX = 0;
  isFullScreen = false;
  scaleStep = 50;
  private mouseDownHandler: Subscription | null;
  private mouseMoveHandler: Subscription | null;
  private mouseEndHandler: Subscription | null;

  constructor(private ganttService: GanttService, private ele: ElementRef) {}

  ngOnInit() {
    this.ganttStartDate = new Date(curYear, 0, 1);
    this.ganttEndDate = new Date(curYear, 11, 31);
    this.ganttService.setScaleConfig({
      startDate: this.ganttStartDate,
      endDate: this.ganttEndDate,
      unit: this.unit,
    });
    this.ganttScaleWidth = this.ganttService.getDurationWidth(this.ganttStartDate, this.ganttEndDate) + 'px';
    this.ganttSacleConfigHandler = this.ganttService.ganttScaleConfigChange.subscribe((config) => {
      if (config.startDate) {
        this.ganttStartDate = config.startDate;
      }
      if (config.endDate) {
        this.ganttEndDate = config.endDate;
      }
      if (config.startDate || config.endDate) {
        this.ganttScaleWidth = this.ganttService.getDurationWidth(this.ganttStartDate, this.ganttEndDate) + 'px';
      }
    });
    this.list.forEach(item => this.updateBarData(item));
  }

  updateBarData(item) {
    item.overdueTime = this.getOverdueTime(item.endDate, new Date());
    if (item.overdueTime > 0 && item.status !== 'done') {
      item.status = 'overdue';
    } else if(item.overdueTime <= 0 && item.status !== 'done') {
      item.status = 'normal';
    }
  }

  updateBarItemStatus(item) {
    const barData = this.list.find(data => data.id === item.id);
    if (barData) {
      this.updateBarData(barData);
    }
  }

  getOverdueTime(startDate: Date, endDate: Date): number {
    if (startDate && endDate) {
      const timeOffset = endDate.getTime() - startDate.getTime();
      const duration = timeOffset / GanttService.DAY_DURATION;
      return Math.floor(duration);
    }
  }

  goToday() {
    const today = new Date();
    const offset = this.ganttService.getDatePostionOffset(today) - this.ganttService.getScaleUnitPixel() * 3;
    if (this.scrollElement) {
      this.scrollElement.scrollTo(offset, this.scrollElement.scrollTop);
    }
  }

  onIncreaseUnit() {
    if (this.unit === GanttScaleUnit.month) {
      return;
    }
    if (this.unit === GanttScaleUnit.week) {
      this.unit = GanttScaleUnit.month;
    }
    if (this.unit === GanttScaleUnit.day) {
      this.unit = GanttScaleUnit.week;
    }
    this.ganttService.setScaleConfig({ unit: this.unit });
    this.ganttScaleWidth = this.ganttService.getDurationWidth(this.ganttStartDate, this.ganttEndDate) + 'px';
  }

  onReduceUnit() {
    if (this.unit === GanttScaleUnit.day) {
      return;
    }
    if (this.unit === GanttScaleUnit.week) {
      this.unit = GanttScaleUnit.day;
    }
    if (this.unit === GanttScaleUnit.month) {
      this.unit = GanttScaleUnit.week;
    }
    this.ganttService.setScaleConfig({ unit: this.unit });
    this.ganttScaleWidth = this.ganttService.getDurationWidth(this.ganttStartDate, this.ganttEndDate) + 'px';
  }

  onSwitchView(unit) {
    this.unit = unit;
    this.ganttService.setScaleConfig({ unit });
    this.ganttScaleWidth = this.ganttService.getDurationWidth(this.ganttStartDate, this.ganttEndDate) + 'px';
  }

  launchFullscreen({ isFullscreen }) {
    this.isFullScreen = isFullscreen;
    this.ganttService.setScaleConfig({viewChange: true});
  }

  ngAfterViewInit() {
    this.scrollElement = this.ganttContainer.nativeElement;
    this.ganttService.registContainerEvents(this.scrollElement);
    this.mouseDownHandler = this.ganttService.mouseDownListener.subscribe(this.onMousedown.bind(this));
    this.mouseMoveHandler = this.ganttService.mouseMoveListener.subscribe(this.onMouseMove.bind(this));
    this.mouseEndHandler = this.ganttService.mouseEndListener.subscribe(this.onMouseEnd.bind(this));
    this.goToday();
  }

  onGanttBarMoveEnd (info: GanttTaskInfo) {
    this.updateData(info);
    this.updateBarItemStatus(info);
  }

  onMousedown(pageX) {
    this.startMove = true;
    this.originOffsetLeft = this.scrollElement.scrollLeft;
    this.startMoveX = pageX;
  }

  onMouseMove(pageX) {
    if (this.startMove) {
      const moveOffset = this.startMoveX - pageX;
      this.scrollElement.scrollTo(this.originOffsetLeft + moveOffset, this.scrollElement.scrollTop);
    }
  }

  onMouseEnd() {
    this.startMove = false;
  }

  onGanttBarMoveStart() {
    this.originOffsetLeft = this.scrollElement.scrollLeft;
  }

  onGanttBarMoving(info: GanttTaskInfo) {
    this.adjustScrollView(info);
  }

  onGanttBarResizeStart() {
    this.originOffsetLeft = this.scrollElement.scrollLeft;
  }

  onGanttBarResizing(info: GanttTaskInfo) {
    this.adjustScrollView(info);
  }

  adjustScrollView(info: GanttTaskInfo) {
    if (info.left + info.width > this.scrollElement.scrollLeft + this.scrollElement.clientWidth) {
      this.scrollElement.scrollTo(this.scrollElement.scrollLeft + this.scaleStep, this.scrollElement.scrollTop);
    }
    if (info.left < this.scrollElement.scrollLeft) {
      this.scrollElement.scrollTo(this.scrollElement.scrollLeft - this.scaleStep, this.scrollElement.scrollTop);
    }
  }

  onGanttBarMove(info: GanttTaskInfo) {
    this.updateData(info);
  }

  onGanttBarResize(info: GanttTaskInfo) {
    this.updateData(info);
    this.updateBarItemStatus(info);
  }

  updateData(info: GanttTaskInfo) {
    const index = this.list.findIndex((data) => {
      return data.id === info.id;
    });
    if (index > -1) {
      this.list[index].startDate = info.startDate;
      this.list[index].endDate = info.endDate;
    }
  }

  onBarProgressEvent(progress: number) {
    console.log(progress);
  }

  ngOnDestroy() {
    if (this.ganttSacleConfigHandler) {
      this.ganttSacleConfigHandler.unsubscribe();
      this.ganttSacleConfigHandler = null;
    }
    this.mouseDownHandler.unsubscribe();
    this.mouseMoveHandler.unsubscribe();
    this.mouseEndHandler.unsubscribe();
  }
}

css代码

@import '~ng-devui/styles-var/devui-var.scss';

.gantt-container {
  overflow: scroll;

  .header {
    position: relative;
    border-bottom: 1px solid $devui-dividing-line;
  }

  .body {
    position: relative;
    min-height: 400px;
    height: 100%;

    .item {
      height: 40px;
      padding-top: 8px;
    }
  }
}

.tool {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 32px;
  margin-left: 12px;
  background-color: $devui-base-bg;
  box-shadow: $devui-shadow-length-base rgba(81, 112, 255, 0.4);
  cursor: pointer;

  span {
    border: 0 !important;
  }
}

.delay-tag {
  position: relative;
  width: max-content;
  margin-top: 4px;
}

::ng-deep .devui-gantt-tips {
  & .title {
    font-size: $devui-font-size-card-title;
    color: $devui-text;
    line-height: 24px;
    font-weight: bold;
    margin-bottom: 16px;
  }

  & .content {
    font-size: $devui-font-size;
    color: $devui-text;
    line-height: 24px;
  }
}

:host ::ng-deep {
  .devui-btn {
    height: 32px !important;
    color: $devui-text !important;
    padding: 0 8px !important;
  }
}

2.3 关键点说明

  1. 容器宽度计算ganttScaleWidth 需通过 GanttService 根据起止时间动态计算,并在初始化后订阅 ganttScaleConfigChange 更新。
  2. 时间轴定位d-gantt-scale 的父容器须设置 position 属性(或为 tabletdthbody 元素)。
  3. 事件响应:任务条的 moveresize 事件会改变时间数据,需订阅这些事件及 ganttScaleConfigChange 以同步状态。

3. 与 DataTable 结合的甘特图

3.1 应用场景

在项目管理中,常需左侧表格展示任务详情,右侧甘特图展示时间计划。DevUI 支持将甘特图嵌入 d-data-table 表头,实现联动滚动。
请添加图片描述

3.2 代码示例(简化结构)

<d-fullscreen [zIndex]="1080" (fullscreenLaunch)="launchFullscreen($event)">
  <section fullscreen-target class="overflow">
    <d-gantt-tools ... ></d-gantt-tools>
    
    <d-data-table
      #datatable
      [dataSource]="basicDataSource"
      [scrollable]="true"
      [fixHeader]="true"
      (tableScrollEvent)="onTableScroll($event)">
      
      <thead dTableHead>
        <tr dTableRow>
          <th dHeadCell [fixedLeft]="'0'">title</th>
          <th dHeadCell [fixedLeft]="'200px'">name</th>
          <th dHeadCell style="padding-left: 20px">
            <!-- 甘特图时间轴作为表头 -->
            <d-gantt-scale
              [scrollElement]="scrollView"
              [milestoneList]="milestoneList"
              [ganttBarContainerElement]="ganttBarContainerElement"
              [showDaySplitLine]="true">
            </d-gantt-scale>
          </th>
        </tr>
      </thead>
      
      <tbody dTableBody>
        <ng-template let-rowItem="rowItem">
          <tr dTableRow>
            <td dTableCell [fixedLeft]="'0'">{{ rowItem.title }}</td>
            <td dTableCell [fixedLeft]="'200px'">{{ rowItem.name }}</td>
            <td dTableCell>
              <!-- 根据任务类型渲染进度条或里程碑 -->
              <div *ngIf="rowItem.ganttType === 'progress'">
                <d-gantt-bar ... ></d-gantt-bar>
              </div>
              <div *ngIf="rowItem.ganttType === 'milestone'" class="devui-gantt-milestone">
                <!-- 里程碑图标(SVG) -->
              </div>
            </td>
          </tr>
        </ng-template>
      </tbody>
    </d-data-table>
  </section>
</d-fullscreen>

3.3 联动实现要点

  • 滚动同步:通过 (tableScrollEvent) 监听表格滚动,调整甘特图时间轴的横向滚动位置。
  • 里程碑集成[milestoneList] 可在时间轴上标记关键节点。
  • 自适应布局:利用 [fixedLeft] 固定左侧列,确保甘特图区域可横向滚动。

4. 核心特性与事件处理

4.1 任务条交互事件

事件名 触发时机 典型处理
barMoveStartEvent 开始拖拽移动时 显示临时位置提示
barMovingEvent 拖拽移动过程中 实时更新预览位置
barMoveEndEvent 拖拽移动结束时 保存新日期,更新后端数据
barResizeStartEvent 开始调整任务条长度时 初始化调整状态
barResizingEvent 调整过程中 实时更新任务时长预览
barResizeEndEvent 调整结束时 保存新起止时间,重新计算宽度
barProgressEvent 进度更改时 更新进度值,同步到数据源

4.2 时间轴控制

  • 单位切换:通过 d-gantt-tools(switchView) 切换日、周、月视图。
  • 回到今日(goToday) 事件可快速滚动至当前日期。
  • 缩放控制(increaseUnit)(reduceUnit) 调整时间密度。

4.3 全屏支持

使用 d-fullscreen 包裹甘特图容器,通过 fullscreen-launch 触发全屏切换,提升大屏浏览体验。


5. 最佳实践与注意事项

5.1 性能优化

  • 虚拟滚动:当任务数量较大(> 500 条)时,建议结合 cdk-virtual-scroll-viewport 只渲染可见区域的任务条。
  • 防抖处理:对 barMovingEventbarResizingEvent 等高频事件进行防抖,避免频繁计算。
  • 按需更新:仅在 barMoveEndEventbarResizeEndEvent 时更新数据源,减少不必要的重绘。

5.2 样式自定义

  • 通过 ::ng-deep 覆盖默认样式(如时间条颜色、刻度线样式)。
  • 使用 [tipTemplateRef][titleTemplateRef] 完全自定义提示内容和标题区域。

5.3 常见问题

  • 宽度计算错误:确保在时间范围变化后重新调用 GanttService 的方法计算 ganttScaleWidth
  • 滚动不同步:检查 [scrollElement] 是否指向正确的滚动容器,并在容器尺寸变化时手动触发更新。
  • 事件未触发:确认任务条数据中的 startDateendDate 为有效的 Date 对象。

6. 总结

DevUI (Gantt 组件)为 Angular 应用提供了企业级的甘特图解决方案,通过声明式配置和丰富的事件体系,可快速实现:

  • 基本时间轴与任务条展示
  • 拖拽调整任务时间与进度
  • 与 DataTable 深度集成
  • 全屏模式与视图缩放

参考文档

MateChat :https://gitcode.com/DevCloudFE/MateChat

MateChat 官网 :https://matechat.gitcode.com

DevUI官网:https://devui.design/home

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐