可以横跨时间轴,分类显示的事件

需求
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' }">
                <div
                    v-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">
                        <div
                            class="event-track"
                            :style="{
                                height: getTrackHeight(group.events) + 'px',
                                width: timelineWidth + 'px'
                            }"
                            ref="eventTrack"
                        >
                            <div
                                v-for="event in group.events"
                                :key="event.id"
                                class="event"
                                :style="{ ...getEventStyle(event, group), ...itemStyle(group, index) }"
                                @click="onEventClick(event)"
                            >
                                <div
                                    class="event-info"
                                    v-if="!event.isSummary"
                                    :style="{ 'font-size': itemFontSize + 'px' }"
                                >
                                    <span> {{ event.title }}</span>
                                </div>
                                <div
                                    class="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,汇总事件36px
            const 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-DD
        formatDate(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>
相关推荐
SuperherRo3 小时前
JS逆向-安全辅助项目&JSRpc远程调用&Burp插件autoDecode&浏览器拓展V_Jstools(上)
javascript·安全·项目
比老马还六3 小时前
Blockly文件积木开发
前端
Nayana3 小时前
Element-Plus源码分析-select组件
vue.js
Nayana3 小时前
Element-Plus源码分析--form组件
前端
Bellafu6663 小时前
selenium对每种前端控件的操作,python举例
前端·python·selenium
littleboyck4 小时前
VSCode 全自动调试Vue/React项目
前端·visual studio code
Jonathan Star4 小时前
跨域处理的核心是解决浏览器的“同源策略”限制,主流方案
javascript·chrome·爬虫
洛小豆4 小时前
她问我::is-logged 是啥?我说:前面加冒号,就是 Vue 在发暗号
前端·vue.js·面试
我有一棵树4 小时前
前端开发中 SCSS 变量与 CSS 变量的区别与实践选择,—— 两种变量别混为一谈
前端·css·scss