在Angular中实现基于nz-calendar的日历甘特图

前言

最近有一个日历相关的功能需求,用于记录主站的各类促销活动。

其中比较棘手的需求是日历需要拥有甘特图那样的功能,持续一段时间的活动需要在日历中以长条形态显示,而不同活动的持续时间不同、排序顺序也不同,互相拼凑留空的逻辑非常复杂。

产品给的参考图如下(涉及公司隐私就全打码了):

思路分析

ng-zorro的日历组件(nz-calendar)我们可以通过控制台源码看出来实际上日历的原理就是一个表格(table),要在日历受限的单元格空间内实现甘特图的长条跨度效果,核心难点在于跨行对齐

我本打算寻找一个拥有这样功能的Angular日历组件,但Github上的Angular日历简陋得像七八年前的样式(仔细一看更新日期,真的是七八年前更新的!)

常规日历组件的 dateCellRender 是以"天"为单位渲染的,如果单纯在每一天里渲染各自的活动,长条活动会被切断。为了让长条在视觉上连贯且不出现错位,必须引入轨道算法(Track Algorithm)

按周分类:将日历视图分为6周,每周作为一个逻辑块,如果活动换行,就固定在第一列(周一)显示活动名称即可,解决了比较棘手的换行样式逻辑问题。

轨道索引 :为每个活动分配一个垂直方向的索引(Track Index) 。如果活动 A 占用了第 0 层轨道,那么同一时间段内的活动 B 只能排在第 1 层轨道。

等高占位:如果周一有三个活动(占用3层轨道),而周二只有一个活动(占用第2层轨道),那么周二的第 0、1 层必须渲染隐藏的占位符(Placeholder),以确保周二的活动能与周一的第三个活动在水平高度上对齐。

数据结构

后端返回的数据是基于日期的聚合结构(例如 DateData[]),即每一天下面挂载一个活动列表。

csharp 复制代码
interface DateData {
  date: string;         // 日期
  events: EventItem[];  // 当天的活动列表
}
​
interface EventItem {
  products_activity_id: string; // 活动ID
  title: string;                // 活动名称
  color_type: string;           // 活动颜色
}

但在甘特图中,我们需要知道活动的完整生命周期。因此,首先需要将数据"平铺",生成以活动 ID 为主键的结构。

(如果你的数据格式已经是这样了,就不需要转换)

typescript 复制代码
interface FlattenedEvent {
  start: string;// 活动起始日期
  end: string;  // 活动结束日期
  id: string;   // ID
  title: string;// 活动标题
  color: string;// 活动颜色(根据活动状态改变)
}
​
flattenEvents(data: DateData[]): FlattenedEvent[] {
  const eventMap = new Map<string, FlattenedEvent>();
  // 确保日期升序排列
  const sortedData = [...data].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
​
  sortedData.forEach((dateData) => {
    dateData.events.forEach((event) => {
      const eventId = event.products_activity_id;
      if (eventMap.has(eventId)) {
        // 更新已有活动的结束日期
        eventMap.get(eventId)!.end = dateData.date;
      } else {
        // 记录新活动
        eventMap.set(eventId, {
          start: dateData.date,
          end: dateData.date,
          id: eventId,
          title: event.title,
          color: event.color_type,
        });
      }
    });
  });
  return Array.from(eventMap.values());
}

代码实现

轨道算法实现

generateCalendarMatrix 方法中,我们计算出每一天对应的 daySlots

typescript 复制代码
// 事件映射表,key为日期字符串,value为该日期的活动数组
eventsMap: Record<string, any[]> = {};
​
// 日历矩阵数据,key为日期字符串,value为当天的槽位数组(事件+占位符)
calendarMatrix: Record<string, any[]> = {};
​
// 当前悬停的活动ID,用于高亮显示
hoveredEventId: string | null = null; 
​
/**
   * 核心逻辑:甘特图矩阵生成
   * 负责计算每一天应该显示哪些活动条,以及它们所在的"轨道"层级
   */
generateCalendarMatrix(events: FlattenedEvent[]) {
  this.calendarMatrix = {};
  const viewStart = this.getCalendarViewStart(this.month); // 获取日历第一格日期
​
  // 6周循环
  for (let week = 0; week < 6; week++) {
    const weekStart = new Date(viewStart);
    weekStart.setDate(viewStart.getDate() + week * 7);
    const weekEnd = new Date(weekStart);
    weekEnd.setDate(weekStart.getDate() + 6);
​
    // 筛选并排序:开始早且跨度长的优先占据上方轨道
    const weekEvents = processedEvents.filter(e => e._end >= weekStart && e._start <= weekEnd)
      .sort((a, b) => a._start.getTime() - b._start.getTime() || (b._end - b._start) - (a._end - a._start));
​
    const tracks: any[][] = [];
    let weekMaxTrackIndex = -1;
​
    weekEvents.forEach(event => {
      const eventStartDay = Math.max(0, Math.floor((event._start - weekStart) / 86400000));
      const eventEndDay = Math.min(6, Math.floor((event._end - weekStart) / 86400000));
​
      let trackIndex = 0;
      while (true) {
        if (!tracks[trackIndex]) tracks[trackIndex] = new Array(7).fill(null);
        // 碰撞检测
        if (tracks[trackIndex].slice(eventStartDay, eventEndDay + 1).every(v => v === null)) {
          for (let d = eventStartDay; d <= eventEndDay; d++) {
            tracks[trackIndex][d] = {
              event,
              showTitle: d === eventStartDay || d === 0, // 仅在起点或周一显示标题
              isRealStart: d === Math.floor((event._start - weekStart) / 86400000),
              isRealEnd: d === Math.floor((event._end - weekStart) / 86400000)
            };
          }
          weekMaxTrackIndex = Math.max(weekMaxTrackIndex, trackIndex);
          break;
        }
        trackIndex++;
      }
    });
​
    // 填充矩阵:缺失轨道补 Placeholder
    for (let day = 0; day < 7; day++) {
      const dateStr = this.formatDateKey(new Date(weekStart.getTime() + day * 86400000));
      this.calendarMatrix[dateStr] = Array.from({ length: weekMaxTrackIndex + 1 }, (_, i) => 
        tracks[i]?.[day] ? { type: 'event', ...tracks[i][day] } : { type: 'placeholder' }
      );
    }
  }
}
​
/**
   * 修正日历起始日期:由于 nz-calendar 通常从周一开始显示,
   * 需要计算当前月第一天距离该周周一的偏移量。
   */
getCalendarViewStart(currentMonth: Date): Date {
    const startOfMonth = new Date(
        currentMonth.getFullYear(),
        currentMonth.getMonth(),
        1
    );
    let day = startOfMonth.getDay(); // 0 是周日
​
    // 计算偏移量:如果是周日(0),前面有6天;其他情况则是 day-1
    const offset = day === 0 ? 6 : day - 1;
​
    const startView = new Date(startOfMonth);
    startView.setDate(startOfMonth.getDate() - offset);
    return startView;
}
​
/**
   * 根据颜色类型返回对应的CSS颜色值
   */
private getColor(type: string): string {
    const map: Record<string, string> = {
        '1': '#f5f5f5', // 浅灰 (草稿/未开始)
        '2': '#F7A4A4', // 浅红 (促销类型A)
        '3': '#B6E2A1', // 浅绿 (促销类型B)
        '4': '#bae7ff', // 浅蓝 (其他)
    };
    return map[type] || '#B6E2A1'; // 默认浅绿
}
​
/**
   * 日期格式转换
   */
formatDateKey(date: Date): string {
    return `${date.getFullYear()}-${(date.getMonth() + 1)
        .toString()
        .padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
}
​
/**
   * 鼠标移入事件处理
   */
onMouseEnter(eventId: string) {
    this.hoveredEventId = eventId;
}
​
/**
   * 鼠标移出事件处理
   */
onMouseLeave() {
    this.hoveredEventId = null;
}

样式支撑

为了实现视觉上的长条效果,需要对 nz-calendar 的默认样式进行深度覆盖,并利用 CSS 类的配合处理圆角。

css 复制代码
/* 容器:垂直排列 */
.calendar-slot-container {
    display: flex;
    flex-direction: column;
}
​
/* 活动条与占位符:高度必须一致 */
.event-slot, .placeholder {
    height: 22px;
    margin-bottom: 2px;
}
​
.event-bar {
    border-radius: 0; /* 默认直角实现无缝连接 */
    border-top: 0.5px lightgray solid;
    border-bottom: 0.5px lightgray solid;
}
​
/* 仅在活动逻辑起点显示左圆角 */
.event-bar.is-real-start {
    border-top-left-radius: 4px;
    border-bottom-left-radius: 4px;
    margin-left: 3px;
    border-left: 0.5px lightgray solid;
}
​
/* 仅在活动逻辑终点显示右圆角 */
.event-bar.is-real-end {
    border-top-right-radius: 4px;
    border-bottom-right-radius: 4px;
    margin-right: 3px;
    border-right: 0.5px lightgray solid;
}

HTML 模板渲染

利用 ng-template 渲染矩阵数据,通过 item.type 区分渲染实体活动还是占位符。

ini 复制代码
<nz-card [nzTitle]="getYearMonth(date)">
    <nz-calendar [(ngModel)]="date" (ngModelChange)="onCalendarChange($event)" [nzDateCell]="tpl"
                 [nzCustomHeader]="customHeader">
    </nz-calendar>
​
    <ng-template #customHeader>
        <div style="padding: 4px;width: 100%;" nz-flex nzJustify="end"></div>
    </ng-template>
​
    <ng-template #tpl let-date>
        <div class="calendar-slot-container">
            @for (item of dateCellRender(date); track $index) {
            @if (item.type === 'placeholder') {
            <div class="placeholder"></div>
            } @else {
            <div class="event-slot event-bar"
                 [style.background-color]="hoveredEventId === item.event.id ? '#1890FF' : item.event.color"
                 [class.is-real-start]="item.isRealStart" [class.is-real-end]="item.isRealEnd"
                 [class.is-hovered]="hoveredEventId === item.event.id" (mouseenter)="onMouseEnter(item.event.id)"
                 (mouseleave)="onMouseLeave()">
​
                @if (item.showTitle) {
                <span class="event-title" [style.color]="hoveredEventId === item.event.id ? 'white' : '#000'">
                    {{ item.event.title }}
                </span>
                }
            </div>
            }
            }
        </div>
    </ng-template>
</nz-card>

成品

图中均为测试数据,不具备任何真实性和隐私。

如图中展示,每个活动的轨道展示正常,空白符渲染合理,并且会根据状态渲染对应的颜色。

如图中展示,鼠标移入后,日历中对应的活动均会高亮,即使跨周也会显示。

相关推荐
UIUV2 小时前
JavaScript中this指向机制与异步回调解决方案详解
前端·javascript·代码规范
momo1002 小时前
IndexedDB 实战:封装一个通用工具类,搞定所有本地存储需求
前端·javascript
liuniansilence2 小时前
🚀 高并发场景下的救星:BullMQ如何实现智能流量削峰填谷
前端·分布式·消息队列
GISer_Jing2 小时前
今天看了京东零售JDS的保温直播,秋招,好像真的结束了,接下来就是论文+工作了!!!加油干论文,学&分享技术
前端·零售
Mapmost2 小时前
【高斯泼溅】如何将“歪头”的3DGS模型精准“钉”在地图上,杜绝后续误差?
前端
废春啊3 小时前
前端工程化
运维·服务器·前端
爱上妖精的尾巴3 小时前
6-9 WPS JS宏Map、 set、get、delete、clear()映射的添加、修改、删除
前端·wps·js宏·jsa
爱分享的鱼鱼3 小时前
对比理解 Vue 响应式 API:data(), ref、reactive、computed 与 watch 详解
前端·vue.js
JS_GGbond3 小时前
【性能优化】给Vue应用“瘦身”:让你的网页快如闪电的烹饪秘籍
前端·vue.js