
需求
1:相同类型的显示在一起
2:数量多余3条合并为总计
groupData = [{name: '事件类型1',backgroundColor:'', // 背景色borderColor:'', // 边框颜色noSummary:false, // 是否需要超过条合并events: [{start: '2025-10-01',end: '2025-10-04',title: '我是事件名'},{start: '2025-10-01',end: '2025-10-04',title: '我是事件名'},{start: '2025-10-01',end: '2025-10-04',title: '我是事件名'}]}]
<!--* @description: 事件日历* @Author: * @Date: 2025-10-14 10:53:48
-->
<template><div class="timeline-container"><div class="title title-box"><div class="header-left"><div class="label">{{ title }}</div><el-date-picker:clearable="false"v-model="timeRange"type="daterange"size="mini"value-format="yyyy-MM-dd"range-separator="至"start-placeholder="开始日期"end-placeholder="结束日期"style="width: 220px; margin-left: 15px"></el-date-picker></div><div class="legend"><div class="legend-item" v-for="(item, index) in groups" :key="index"><div class="legend-color" :style="itemStyle(item, index)"></div><div class="legend-name">{{ item.name }}</div></div></div></div><div class="content scroll-container" ref="scrollContainer"><div class="time-axis" :style="{ width: timelineWidth + 'px' }"><divv-for="dayIndex in totalDays":key="dayIndex"class="time-segment time-bg":style="{ width: dayWidth + 'px' }":class="{ 'time-bg-odd': dayIndex % 2 !== 0 }"><div class="date">{{ getFormattedDate(dayIndex - 1) }}</div></div></div><div class="scroll-container events-box"><div class="events" :style="{ width: timelineWidth + 'px' }"><div v-for="(group, index) in filterGroups" :key="index"><divclass="event-track":style="{height: getTrackHeight(group.events) + 'px',width: timelineWidth + 'px'}"ref="eventTrack"><divv-for="event in group.events":key="event.id"class="event":style="{ ...getEventStyle(event, group), ...itemStyle(group, index) }"@click="onEventClick(event)"><divclass="event-info"v-if="!event.isSummary":style="{ 'font-size': itemFontSize + 'px' }"><span> {{ event.title }}</span></div><divclass="event-info-sum":class="{'event-info-sum-small': dayWidth == 80}":style="{ 'font-size': itemFontSize + 2 + 'px' }"v-else><span> {{ group.name }}</span><span class="event-info-sum-title">{{ event.title }}</span></div></div></div></div></div></div></div></div>
</template><script>
import dayjs from 'dayjs';export default {props: {// 标题title: {type: String,default: '重点关注'},// 数据groupData: {type: Array,default: () => []},// 合并事件的数量summaryCount: {type: Number,default: 3},// 每个事件的高度itemHeight: {type: Number,default: 18},// 每个事件的间距itemMargin: {type: Number,default: 3},// 字体大小itemFontSize: {type: Number,default: 12}},name: 'EventCalendar',components: {},data() {return {// 默认显示当前一周范围timeRange: '',// 每天占用的宽度dayWidth: 80,defaultDayWidth: 80,groups: [],// 轨道宽度(用于计算事件位置)trackWidth: 800,rowId: '',colorGroups: [{borderColor: '#f3ab3d', // 黄色backgroundColor: '#fffaec'},{borderColor: '#fb7a36', // 橙色backgroundColor: '#fff1e8'},{borderColor: '#de5d58', // 红色backgroundColor: '#f2d0cf'},{borderColor: '#65ab5a', // 绿色backgroundColor: '#f3fff1'},{borderColor: '#0c58e9', // 蓝色backgroundColor: '#e8efff'}]};},computed: {// 当前时间范围的总天数totalDays: {get() {if (this.timeRange && Array.isArray(this.timeRange) && this.timeRange.length === 2) {const startDate = new Date(this.timeRange[0]);const endDate = new Date(this.timeRange[1]);const diffTime = endDate - startDate;const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; // +1 确保包含起止日期return diffDays;}return 7; // 默认显示7天},set(newValue) {// 允许通过setter设置值}},// 时间轴的总宽度timelineWidth() {return this.segmentWidth * this.totalDays;},// 每个时间段的宽度segmentWidth() {return this.dayWidth;},// 过滤后的事件组filterGroups() {return this.groups.filter((group) => group.events.length > 0);},itemStyle() {return (item, index) => {const borderColor =item.borderColor || this.colorGroups[index].borderColor || this.colorGroups[4].borderColor;const backgroundColor =item.backgroundColor ||this.colorGroups[index].backgroundColor ||this.colorGroups[4].backgroundColor;return {backgroundColor: backgroundColor,border: '1px solid ' + borderColor};};}},created() {// 初始化时间范围:以当前日期为结束时间,向前推7天this.initTimeRange();},watch: {timeRange: {handler(newVal, oldVal) {// 当时间范围改变时,更新时间轴this.updateTimeRange();},immediate: true},groupData: {handler() {this.handleGroupData();},deep: true}},mounted() {// 初始化轨道宽度this.updateTrackWidth();window.addEventListener('resize', this.updateTrackWidth);},beforeDestroy() {window.removeEventListener('resize', this.updateTrackWidth);},methods: {// 处理分组数据,根据needSummary判断是否需要合并事件handleGroupData() {this.groups = this.groupData.map((item) => {// 直接修改会造成监听死循环const temp = {...item};if (temp.noSummary !== true) {// 多余3条需要 合并为汇总事件temp.events = this.processEventsForDailyLimit(temp.events, temp.name);}return temp;});this.$nextTick(() => {// 重新计算事件位置this.$forceUpdate();});},// 处理事件,当某一天事件数超过3条时,只显示汇总信息processEventsForDailyLimit(events, name) {// 检查是否为空数组if (!events || events.length === 0) {return [];}// 按日期分组统计事件数量,包括跨日期事件const dailyEventGroups = {};// 遍历所有事件,对每个事件处理其覆盖的所有日期events.forEach((event) => {// 如果已经是汇总事件,则跳过if (event.isSummary) {return;}// 解析开始和结束日期const startDate = dayjs(event.start);const endDate = dayjs(event.end);// 遍历事件覆盖的所有日期let currentDate = startDate;while (currentDate.valueOf() <= endDate.valueOf()) {const dateStr = currentDate.format('YYYY-MM-DD');if (!dailyEventGroups[dateStr]) {dailyEventGroups[dateStr] = [];}// 将事件添加到每个覆盖的日期dailyEventGroups[dateStr].push(event);// 移动到下一天currentDate = currentDate.add(1, 'day');}});// 处理每个日期的事件const processedEvents = [];const dateWithSummary = new Set(); // 标记哪些日期使用了汇总事件const eventsWithSummary = new Set(); // 标记哪些事件在某些日期被汇总处理// 第一步:处理需要显示汇总信息的日期Object.keys(dailyEventGroups).forEach((date) => {const dayEvents = dailyEventGroups[date];const uniqueEvents = [...new Map(dayEvents.map((event) => [event.id, event])).values()];// 如果当天事件数超过3条,添加汇总事件if (uniqueEvents.length > this.summaryCount) {const summaryEventId = `summary-${date}-${uniqueEvents[0].mainType || 'default'}`;// 创建汇总事件processedEvents.push({name: name,id: summaryEventId,start: date,end: date,title: `${uniqueEvents.length}`,isSummary: true,originalEvents: uniqueEvents});dateWithSummary.add(date);// 标记这些事件在某些日期被汇总处理uniqueEvents.forEach((event) => eventsWithSummary.add(event.id));}});// 第二步:处理没有使用汇总事件的日期,显示完整的横跨事件// 对于在某些日期被汇总但在其他日期需要正常显示的事件events.forEach((event) => {// 跳过汇总事件if (event.isSummary) {return;}// 检查事件是否在某些日期被汇总处理if (eventsWithSummary.has(event.id)) {// 解析开始和结束日期const startDate = dayjs(event.start);const endDate = dayjs(event.end);// 检查事件是否有未被汇总的日期范围let hasNonSummaryDates = false;let nonSummaryStart = null;let nonSummaryEnd = null;let currentDate = startDate;while (currentDate.valueOf() <= endDate.valueOf()) {const dateStr = currentDate.format('YYYY-MM-DD');if (!dateWithSummary.has(dateStr)) {// 找到一个未被汇总的日期hasNonSummaryDates = true;// 更新非汇总日期范围if (!nonSummaryStart) {nonSummaryStart = currentDate;}nonSummaryEnd = currentDate;} else if (hasNonSummaryDates && nonSummaryStart && nonSummaryEnd) {// 如果之前有非汇总日期范围,且当前日期是汇总日期,说明需要分割事件// 创建一个只包含非汇总日期范围的新事件processedEvents.push({...event,id: `${event.id}-segment-${nonSummaryStart.format('YYYY-MM-DD')}`,start: nonSummaryStart.format('YYYY-MM-DD'),end: nonSummaryEnd.format('YYYY-MM-DD')});// 重置非汇总日期范围hasNonSummaryDates = false;nonSummaryStart = null;nonSummaryEnd = null;}currentDate = currentDate.add(1, 'day');}// 处理最后一个可能的非汇总日期范围if (hasNonSummaryDates && nonSummaryStart && nonSummaryEnd) {processedEvents.push({...event,id: `${event.id}-segment-${nonSummaryStart.format('YYYY-MM-DD')}`,start: nonSummaryStart.format('YYYY-MM-DD'),end: nonSummaryEnd.format('YYYY-MM-DD')});}} else {// 对于完全没有被汇总的事件,直接添加原始事件processedEvents.push(event);}});return processedEvents;},// 更新轨道宽度updateTrackWidth() {if (this.$refs.scrollContainer) {const clientWidth = this.$refs.scrollContainer.clientWidth;if (clientWidth && clientWidth > this.totalDays * this.defaultDayWidth) {this.dayWidth = (clientWidth - 10) / this.totalDays;} else {this.dayWidth = this.defaultDayWidth;}}},// 更新时间范围async updateTimeRange() {// 当选择不同的时间范围时,更新起始日期和总天数if (this.timeRange && Array.isArray(this.timeRange) && this.timeRange.length === 2) {// 更新当前开始日期this.currentStartDate = new Date(this.timeRange[0]);// 计算选择的日期范围内的天数const startDate = new Date(this.timeRange[0]);const endDate = new Date(this.timeRange[1]);const diffTime = endDate - startDate;const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; // +1 确保包含起止日期// 更新总天数this.totalDays = diffDays;this.updateTrackWidth();this.$emit('getData', this.timeRange);}},// 获取事件样式getEventStyle(event, group) {const startDate = new Date(event.start);const endDate = new Date(event.end);// 精确计算事件在时间轴上的位置和宽度const startOffset = this.getDayOffset(startDate);const endOffset = this.getDayOffset(endDate);// 如果事件不在当前时间范围内,则不显示if (endOffset < 0 || startOffset > this.totalDays - 1) {return { display: 'none' };}// 确保起始位置和宽度计算精确,特别是对于包含性日期范围const clampedStartOffset = Math.max(0, startOffset);const clampedEndOffset = Math.min(this.totalDays - 1, endOffset);const left = clampedStartOffset * this.segmentWidth;// 加1确保包含结束日期当天const width = (clampedEndOffset - clampedStartOffset + 1) * this.segmentWidth;const top = this.getEventPosition(event, group.events);// 设置不同的高度:普通事件12px,汇总事件36pxconst height = event.isSummary ? this.summaryCount * this.itemHeight : this.itemHeight;return {left: `${left}px`,width: `${width}px`,top: `${top}px`,height: `${height}px`};},// 计算日期相对于当前开始日期的偏移天数(更精确的实现)getDayOffset(date) {// 清除时间部分,只比较日期const startDate = new Date(this.currentStartDate);startDate.setHours(0, 0, 0, 0);const targetDate = new Date(date);targetDate.setHours(0, 0, 0, 0);// 计算天数差const diffTime = targetDate - startDate;// 使用 Math.round 确保精确计算const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24));return diffDays;},// 获取事件在轨道中的垂直位置getEventPosition(event, events) {const lanes = this.arrangeEventsInLanes(events);return lanes.positions[event.id] || 10;},// 获取轨道高度getTrackHeight(events) {const lanes = this.arrangeEventsInLanes(events);return lanes.height;},// 将事件分配到不同的行(车道)以避免重叠arrangeEventsInLanes(events) {// 首先按开始时间排序const sortedEvents = [...events].sort((a, b) => {return new Date(a.start) - new Date(b.start);});// 初始化车道const lanes = [];const positions = {};// 为每个事件分配车道sortedEvents.forEach((event) => {const startDate = new Date(event.start);const endDate = new Date(event.end);// 计算事件在时间轴上的位置和宽度const startOffset = this.getDayOffset(startDate);const endOffset = Math.min(this.totalDays - 1, this.getDayOffset(endDate));// 如果事件不在当前时间范围内,则不显示if (endOffset < 0 || startOffset > this.totalDays - 1) return;const clampedStartOffset = Math.max(0, startOffset);const clampedEndOffset = Math.min(this.totalDays - 1, endOffset);const left = clampedStartOffset * this.segmentWidth;const width = (clampedEndOffset - clampedStartOffset + 1) * this.segmentWidth;// 寻找可用的车道let laneIndex = 0;while (laneIndex < lanes.length) {const lane = lanes[laneIndex];let conflict = false;// 检查是否与当前车道中的事件冲突for (const existingEvent of lane) {const existingLeft = existingEvent.left;const existingRight = existingLeft + existingEvent.width;const currentRight = left + width;// 如果有重叠,则冲突if (!(currentRight <= existingLeft || left >= existingRight)) {conflict = true;break;}}if (!conflict) {break;}laneIndex++;}// 如果所有车道都有冲突,创建新车道if (laneIndex >= lanes.length) {lanes.push([]);}// 将事件添加到车道lanes[laneIndex].push({id: event.id,left: left,width: width});// 记录事件的位置(车道索引 * 事件高度 + 边距)const eventHeight = event.isSummary ? this.summaryCount * this.itemHeight : this.itemHeight;positions[event.id] = laneIndex * (eventHeight + this.itemMargin) + this.itemMargin;});// 计算轨道高度let maxHeight = 0;lanes.forEach((lane, index) => {const hasSummary = lane.some((item) => {const event = events.find((e) => e.id === item.id);return event && event.isSummary;});const height = hasSummary ? this.summaryCount * this.itemHeight : this.itemHeight;maxHeight = Math.max(maxHeight, index * (height + this.itemMargin) + height + this.itemMargin);});return {positions: positions,height: maxHeight};},// 获取事件日期范围显示getEventDateRange(event) {const startDate = new Date(event.start);const endDate = new Date(event.end);const startStr = this.formatDate(startDate);if (startDate.getTime() === endDate.getTime()) {return startStr;} else {return `${startStr} - ${this.formatDate(endDate)}`;}},// 获取格式化的日期getFormattedDate(dayIndex) {// 使用dayjs替代原生Date对象处理日期const date = dayjs(this.currentStartDate).add(dayIndex, 'day');return date.format('YYYY-MM-DD');},// 格式化日期为 YYYY-MM-DDformatDate(date) {// 使用dayjs替代原生Date对象处理日期格式化return dayjs(date).format('YYYY-MM-DD');},// 获取以当前日期为结束时间的一周时间范围initTimeRange() {const today = dayjs();// 向前推6天,得到一周的开始日期const startOfWeek = today.subtract(6, 'day').format('YYYY-MM-DD');this.timeRange = [startOfWeek, today.format('YYYY-MM-DD')];},// 事件点击处理onEventClick(data) {this.$emit('itemClick', data);}}
};
</script><style scoped lang="scss">
.label {display: flex;// justify-content: center;align-items: center;font-weight: bold;&::before {content: '';display: inline-block;width: 6px;height: 14px;background: linear-gradient(to bottom, #6dc5ff, #0091ff);margin-right: 5px;border-radius: 6px;}
}
.timeline-container {height: 100%;width: 100%;overflow: hidden;
}
.header-left {display: flex;align-items: center;
}.title-box {height: 40px;display: flex;align-items: center;justify-content: space-between;
}
.header-title {font-size: 14px;color: #333;font-weight: bold;margin-right: 10px;&::before {content: '';display: inline-block;width: 6px;height: 14px;background: linear-gradient(to bottom, #6dc5ff, #0091ff);margin-right: 5px;border-radius: 6px;}
}
.content {width: 100%;height: calc(100% - 50px);position: relative;overflow-x: auto;
}.scroll-container:hover {/* 滚动条滑块在悬停时的样式 */&::-webkit-scrollbar-thumb {background: rgba(0, 0, 0, 0.1); /* 在悬停时略微可见 */}&::-webkit-scrollbar-thumb:hover {background: rgba(0, 0, 0, 0.1); /* 在悬停时略微可见 */}
}.scroll-container {/* 滚动条滑块 */&::-webkit-scrollbar-thumb {background: transparent; /* 设置滑块为完全透明 */}/* 滚动条轨道(背景) */&::-webkit-scrollbar-track {background: transparent; /* 设置轨道为完全透明 */height: 3px;}&::-webkit-scrollbar-track-piece {background-color: transparent; /* 设置轨道为完全透明 不设置不生效 */}
}
.events-box {height: calc(100% - 30px);overflow-x: hidden; /* 水平方向不滚动 */width: max-content; /* 关键:宽度根据内容自适应 */overflow-y: auto;display: flex;scroll-behavior: smooth;
}
.time-bg {background: #f8f9fe;border-left: 1px solid #fff;border-right: 1px solid #fff;
}
.time-bg-odd {background: #f0f5fb;
}
.scroll-container::-webkit-scrollbar-thumb:hover {background: #555;
}.events {position: relative;
}.color-group {overflow: hidden;
}.event-track {position: relative;background-color: transparent;min-height: 16px;
}.event {position: absolute;min-height: 16px;border-radius: 20px;display: flex;align-items: center;padding: 0 10px;color: #333;cursor: pointer;overflow: hidden;white-space: nowrap;
}.event:hover {transform: translateY(-2px);box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.event-info-sum {display: flex;justify-content: space-between;align-items: center;width: 100%;border-radius: 5px;height: 100%;font-size: 12px;.event-info-sum-title {font-size: 14px;font-weight: bold;}
}
.event-info-sum-small {flex-direction: column;justify-content: center;align-items: center;
}
.event-info {display: flex;justify-content: space-between;width: 100%;font-size: 12px;span {overflow: hidden;text-overflow: ellipsis;white-space: nowrap;}
}.time-axis {display: flex;position: relative;background: white;border-radius: 0 0 8px 8px;overflow: hidden;height: 100%;position: absolute;top: 0;
}.time-segment {text-align: center;padding: 10px 0;// min-width: 100px;position: relative;
}.time-segment:last-child {border-right: none;
}.date {color: #7e7e7e;font-size: 12px;position: absolute;bottom: 0;width: 100%;text-align: center;padding: 5px 0;border-top: 1px solid #d0dbed;background: #fff;&::before {content: '';position: absolute;top: -1px;left: -1px;width: 1px;height: 8px;background: #d0dbed;}&::after {content: '';position: absolute;top: -1px;right: -1px;width: 1px;height: 8px;background: #d0dbed;}
}.legend {display: flex;justify-content: center;
}.legend-item {display: flex;align-items: center;font-size: 13px;margin-left: 5px;
}.legend-color {width: 14px;height: 14px;border-radius: 50%;margin: 0 5px;
}
</style>