复制代码
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>