前言
最近有一个日历相关的功能需求,用于记录主站的各类促销活动。
其中比较棘手的需求是日历需要拥有甘特图那样的功能,持续一段时间的活动需要在日历中以长条形态显示,而不同活动的持续时间不同、排序顺序也不同,互相拼凑留空的逻辑非常复杂。
产品给的参考图如下(涉及公司隐私就全打码了):

思路分析
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>
成品
图中均为测试数据,不具备任何真实性和隐私。

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

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