概述
基于 Vue2 的高仿企业微信日程日历组件,支持月视图和周视图两种展示模式,具备完整的移动端适配能力。组件提供了丰富的日程管理功能,包括日程展示、点击交互、跨天事件处理等特性。
核心特性
🗓️ 多视图支持
- 月视图:传统日历网格布局,直观查看整月日程
- 周视图:时间轴布局,精确到小时的日程安排展示
- 响应式设计:自动适配桌面端和移动端
📱 移动端优化
- 触摸友好的交互设计
- 移动端专属的紧凑布局
- 原生日期选择器集成
🎯 智能日程管理
- 日程事件重叠自动分组
- 跨天事件特殊处理
- 实时时间指示器
- 农历日期和节日显示
安装与使用
基本用法
js
<template>
<Calendar
:events="scheduleList"
:view="currentView"
@change="handleCalendarChange"
@click-event="handleEventClick"
@selectDayChange="handleDaySelect"
>
<template #toolbarright>
<!-- 自定义工具栏内容 -->
<button @click="addSchedule">新建日程</button>
</template>
</Calendar>
</template>
<script>
import Calendar from './components/Calendar.vue'
export default {
components: {
Calendar
},
data() {
return {
currentView: 'month',
scheduleList: [
{
date: '2024-01-15',
scheduleList: [
{
id: 1,
title: '团队周会',
startTime: '2024-01-15 09:00:00',
endTime: '2024-01-15 10:30:00',
location: '会议室A'
}
]
}
]
}
},
methods: {
handleCalendarChange(params) {
console.log('视图变更:', params)
// 加载对应时间段的日程数据
},
handleEventClick(event) {
console.log('点击日程:', event)
},
handleDaySelect(date) {
console.log('选择日期:', date)
}
}
}
</script>
Props 配置
| 属性名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| events | Array | [] | 日程数据数组 |
| view | String | 'month' | 初始视图模式 |
| scheduleKey | String | 'scheduleList' | 日程列表字段名 |
| scheduleOnlyKey | String | 'scheduleId' | 日程唯一标识字段 |
事件说明
change
视图或日期范围变化时触发
json
{
view: 'month' | 'week',
begin: '2024-01-01',
end: '2024-01-31'
}
click-event
点击日程事件时触发
json
{
item: {
id: 1,
title: '会议',
startTime: '2024-01-15 09:00:00',
// ...其他日程属性
}
}
selectDayChange
选择日期变化时触发
json
'2024-01-15' // 选中的日期字符串
数据格式
日程数据结构
json
{
date: '2024-01-15', // 日期字符串 YYYY-MM-DD
scheduleList: [
{
id: 1, // 唯一标识
title: '会议主题', // 日程标题
startTime: '2024-01-15 09:00:00', // 开始时间
endTime: '2024-01-15 10:00:00', // 结束时间
location: '会议室A', // 地点(可选)
// ...其他自定义字段
}
]
}
核心功能详解
1. 视图切换逻辑
组件支持月视图和周视图的平滑切换,在移动端通过下拉选择器切换,桌面端通过按钮切换。
2. 日程重叠处理
采用智能分组算法,自动检测重叠事件并合理布局:
json
processOverlappingEvents(events) {
// 按开始时间排序
// 创建列数组存储事件
// 分配事件到合适的列
// 计算重叠计数
}
3. 跨天事件支持
特殊处理跨天日程的显示:
- 开始日:显示从开始时间到午夜
- 中间日:全天显示
- 结束日:显示从午夜到结束时间
4. 农历和节日系统
集成 lunar-javascript 库,支持:
- 农历日期显示
- 传统节日识别
- 二十四节气显示
- 阳历节日支持
5. 移动端适配
通过 CSS 媒体查询和 JavaScript 检测实现:
css
@media (max-width: 768px) {
/* 移动端专属样式 */
}
自定义样式
组件提供了丰富的 CSS 类名用于样式定制:
主要样式类
.calendar-toolbar- 工具栏容器.month-view/.week-view- 视图容器.day-cell- 日期单元格.week-event- 周视图日程项.event-item- 月视图日程项
状态类
.today- 今天日期.selected- 选中状态.other-month- 非当前月份.cross-day- 跨天事件
进阶用法
动态加载日程
javascript
arduino
async loadSchedule(range) {
const { begin, end } = range
const schedules = await api.getSchedules(begin, end)
this.scheduleList = schedules
}
自定义日程颜色
重写 getEventColor 方法: javascript
csharp
methods: {
getEventColor(event) {
// 根据事件类型返回对应颜色
const typeColors = {
meeting: '#0e7cff',
personal: '#51cf66',
urgent: '#ff6b6b'
}
return typeColors[event.type] || '#0e7cff'
}
}
浏览器兼容性
- Chrome 60+
- Firefox 55+
- Safari 12+
- Edge 79+
源码
calender
vue
<template>
<div id="my-calendar" ref="myCalendar">
<div class="calendar-toolbar">
<div class="view-switcher-mobile" v-if="isMobile">
<select
v-model="currentView"
@change="handleViewChange"
class="view-select"
>
<option value="week">周</option>
<option value="month">月</option>
</select>
</div>
<div class="view-switcher" style="flex: 1" v-else>
<button
class="view-btn"
:class="{ active: currentView === 'month' }"
@click="changeView('month')"
>
月
</button>
<button
class="view-btn"
:class="{ active: currentView === 'week' }"
@click="changeView('week')"
>
周
</button>
<button class="today-btn" @click="goToToday">今天</button>
</div>
<div class="nav-controls" style="flex: 1">
<div class="nav-btn" @click="navigate(-1)">
<i class="arrow">←</i>
</div>
<div class="current-date">
{{ currentDateText }}
</div>
<div class="nav-btn" @click="navigate(1)">
<i class="arrow">→</i>
</div>
</div>
<div class="toolbarRight" style="flex: 1">
<slot name="toolbarright"></slot>
</div>
</div>
<div v-if="loading" class="loading">
<div class="spinner"></div>
</div>
<div v-else>
<!-- 月视图 -->
<div
class="month-view-mobile"
v-show="currentView === 'month'"
v-if="isMobile"
>
<div class="weekdays-mobile">
<div v-for="(day, index) in weekDaysMobile" :key="day + '-' + index">
{{ day }}
</div>
</div>
<div class="days-grid-mobile">
<div
v-for="day in monthDays"
:key="day.date"
class="day-cell-mobile"
@click="selectDay(day)"
:class="{
'other-month': !day.isCurrentMonth,
selected: day.date === selectDayStr,
today: day.isToday,
}"
>
<div class="day-number-mobile">{{ day.day }}</div>
<div class="lunar-date-mobile" v-if="day.holiday">
{{ day.holiday }}
</div>
<div class="lunar-date-mobile" v-else>{{ day.lunar }}</div>
<div
class="events-indicator"
v-if="day.events && day.events.length > 0"
>
<div class="event-dots">
<span
v-for="(event, index) in day.events.slice(0, 3)"
:key="event.id + '-' + index"
class="event-dot"
:style="{ backgroundColor: getEventColor(event) }"
></span>
</div>
<div class="more-events" v-if="day.events.length > 3">
+{{ day.events.length - 3 }}
</div>
</div>
</div>
</div>
</div>
<div class="month-view" v-show="currentView === 'month'" v-else>
<div class="weekdays">
<div v-for="day in weekDays" :key="day">{{ day }}</div>
</div>
<div class="days-grid">
<div
v-for="day in monthDays"
:key="day.date"
class="day-cell"
@click="selectDay(day)"
:class="{
'other-month': !day.isCurrentMonth,
selected: day.date === selectDayStr,
}"
>
<div class="day-header">
<div
class="day-number"
:class="{
today: day.isToday,
'other-month': !day.isCurrentMonth,
}"
>
{{ day.day }}
</div>
<div class="day-holiday" :class="{ today: day.isToday }">
{{ day.holiday }}
</div>
<div class="lunar-date" :class="{ today: day.isToday }">
{{ day.lunar }}
</div>
</div>
<div
class="events-container"
v-show="day.events && day.events.length > 0"
>
<div
:title="event.title"
v-for="(event, index) in day.events.slice(0, 3)"
:key="index"
class="event-item"
@click.stop="handleEventClick(event)"
>
<span style="margin-right: 3px">{{
formatTime(event.startTime)
}}</span>
{{ event.title }}
</div>
<div></div>
<PopupComponent
:ref="'monthViewMoreEvent' + day.day"
v-if="day.events && day.events.length > 3 && day.isCurrentMonth"
>
<template v-slot:trigger="{ open }">
<div class="custom-trigger" @click.stop="open">
<div v-show="day.events.length > 3" class="more-events">
还有{{ day.events.length - 3 }}个日程
</div>
</div>
</template>
<template v-slot:content>
<div
class="custom-content"
style="width: 240px; max-width: 500px"
>
<div class="title">
<span style="font-size: 25px; font-weight: 600">{{
day.day
}}</span>
<span
style="
font-size: 14px;
font-weight: 600;
margin-left: 5px;
"
>{{ day.week }}</span
>
</div>
<div
class="task-list custom-scrollbar"
style="max-height: 150px; overflow-y: auto"
>
<div v-for="item in day.events" :key="item.id">
<div
class="event-item"
@click.stop="handleEventClick(item, day)"
>
<span style="margin-right: 3px">{{
formatTime(item.startTime)
}}</span>
<span :title="item.title">{{ item.title }}</span>
</div>
</div>
</div>
</div>
</template>
</PopupComponent>
<div v-else>
<div v-show="day.events.length > 3" class="more-events">
+{{ day.events.length - 3 }}个日程
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 周视图 -->
<div
class="week-view-mobile"
v-show="currentView === 'week'"
v-if="isMobile"
>
<div class="week-header-mobile">
<div class="week-dates">
<div
v-for="(day, index) in weekDaysData"
:key="index"
class="week-day-header"
:class="{
today: day.isToday,
selected: day.date === selectDayStr,
}"
@click="selectDay(day)"
>
<div class="week-day-name">{{ weekDayNamesMobile[index] }}</div>
<div class="week-day-number">{{ day.day }}</div>
<div class="week-lunar">{{ day.holiday || day.lunar }}</div>
</div>
</div>
</div>
<div class="week-events-container">
<div
class="current-time-indicator"
:style="{ top: currentTimePosition + 'px' }"
v-if="showCurrentTime"
>
<div class="time-dot"></div>
<div class="time-line"></div>
</div>
<div class="time-column-mobile">
<div v-for="hour in 24" :key="hour" class="time-slot-mobile">
{{ hour === 0 ? "00:00" : `${hour}:00` }}
</div>
</div>
<div class="events-column-mobile">
<div
v-for="(day, dayIndex) in weekDaysData"
:key="dayIndex"
class="day-events"
>
<div
v-for="event in getEventsForDay(day.date)"
:key="event.id"
class="mobile-event"
:style="getMobileEventStyle(event)"
@click="handleEventClick(event)"
>
<div class="event-time-mobile">
{{ formatTime(event.startTime) }} -
{{ formatTime(event.endTime) }}
</div>
<div class="event-title-mobile">{{ event.title }}</div>
<div class="event-location-mobile" v-if="event.location">
{{ event.location }}
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="week-container"
style="padding: 16px; width: 100%; height: 100%"
v-show="currentView === 'week'"
v-else
>
<div style="display: flex; padding-left: 60px">
<div
v-for="(day, index) in weekDaysData"
:key="index"
style="flex: 1"
>
<div class="day-header-week">
<div class="week-name" style="color: #666666">
{{ weekDayNames[index] }}
</div>
<div
class="day-date"
style="display: inline-block; margin-right: 5px"
:class="{ today: day.isToday }"
>
{{ day.day }}
</div>
<div
:class="{
'lunar-date': !day.holiday,
'day-holiday': !!day.holiday,
}"
style="display: inline-block"
>
{{ day.holiday ? day.holiday : day.lunar }}
</div>
</div>
</div>
</div>
<div class="week-time-view">
<div class="week-view">
<div class="time-column">
<!-- 时间刻度改为每小时120px高度 -->
<div v-for="hour in 25" :key="hour" class="time-slot">
{{ hour === 0 ? "00:00" : `${hour - 1}:00` }}
</div>
</div>
<div class="days-container">
<div
v-for="(day, index) in weekDaysData"
:key="index"
class="day-column"
>
<div class="events-week" style="overflow-y: auto">
<!-- 跨天事件指示器 - 高度调整为2880px (24*120) -->
<div
v-for="event in getOngoingEvents(day.date)"
:key="'ongoing-' + event.id"
class="ongoing-event-indicator"
:style="{
top: '0px',
height: '2880px',
width: 'calc(100% - 8px)',
left: '4px',
}"
>
<div class="ongoing-event-line"></div>
</div>
<!-- 正常事件 - 使用新的样式计算方法 -->
<div
v-for="event in getEventsForDay(day.date)"
:key="event.id"
class="week-event"
:class="{
'multi-column': event.columnIndex > 0,
'cross-day': isCrossDayEvent(event),
'short-event': isShortEvent(event), // 添加短事件类
}"
:style="getWeekEventStyle(event, day.date)"
@click="handleEventClick(event)"
>
<div class="event-time">
<div v-show="isCrossDayEvent(event)">
{{
formatTime(event.startTime, true) +
"至" +
formatTime(event.endTime, true)
}}
</div>
<div v-show="!isCrossDayEvent(event)">
{{
formatTime(event.startTime) +
"-" +
formatTime(event.endTime)
}}
</div>
</div>
<div class="event-title">{{ event.title }}</div>
<div
v-if="
isCrossDayEvent(event) &&
isEventStartDay(event, day.date)
"
class="cross-day-badge"
>
跨天
</div>
<div v-if="event.overlapCount > 0" class="event-badge">
+{{ event.overlapCount }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import * as lunar from "lunar-javascript";
import PopupComponent from "./childcom/popupComponent.vue";
export default {
components: { PopupComponent },
name: "Hcaleader",
props: {
// 日程列表
events: {
type: Array,
default: () => []
},
// 日程列表key值
scheduleKey: {
type: String,
default: "scheduleList",
},
//视图
view: {
type: String,
default: "month",
},
scheduleOnlyKey: {
type: String,
default: "scheduleId",
},
},
watch: {
events: {
handler(newVal, oldVal) {
console.log("scheduleList???", newVal, oldVal);
if (oldVal !== undefined && newVal !== oldVal) {
this.$nextTick(() => {
this.loading = true;
const res = this.currentDate.setHours(0, 30, 0, 0);
this.currentDate = new Date(res);
this.loading = false;
});
}
},
deep: true,
immediate: true,
},
selectDayStr: {
handler(newVal, oldVal) {
if (newVal !== oldVal) {
this.$emit("selectDayChange", newVal);
}
},
immediated: true,
deep: true,
},
},
data() {
return {
// 新增移动端相关数据
isMobile: false,
touchTimer: null,
selectedDay: "",
weekDaysMobile: ["一", "二", "三", "四", "五", "六", "日"],
weekDayNamesMobile: [
"周一",
"周二",
"周三",
"周四",
"周五",
"周六",
"周日",
],
currentTimePosition: 0,
showCurrentTime: true,
// 其他原有数据
currentView: "month",
selectDayStr: "", //选中的日
loading: false,
currentDate: new Date(),
weekDays: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
weekDayNames: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
// 阳历节日
solarFestivals: {
11: "元旦",
38: "妇女节",
51: "劳动节",
61: "儿童节",
71: "建党节",
81: "建军节",
910: "教师节",
101: "国庆节",
},
};
},
mounted() {
// 检测设备类型
this.checkMobile();
window.addEventListener("resize", this.checkMobile);
// 初始化选中日期
if (this.monthDays.length > 0) {
const today = this.monthDays.find((day) => day.isToday);
this.selectedDay = today || this.monthDays[15]; // 默认选择月中某天
this.selectDayStr = this.selectedDay.date;
}
this.changeView(this.view);
// 更新时间指示器
this.updateCurrentTimeIndicator();
setInterval(this.updateCurrentTimeIndicator, 60000); // 每分钟更新一次
},
beforeDestroy() {
window.removeEventListener("resize", this.checkMobile);
this.clearTouchTimer();
},
computed: {
currentYear() {
return this.currentDate.getFullYear();
},
// 当前日期文本显示
currentDateText() {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth() + 1;
if (this.currentView === "month") {
return `${year}年${month}月`;
} else if (this.currentView === "week") {
const weekStart = new Date(this.weekDaysData[0].date);
const weekEnd = new Date(this.weekDaysData[6].date);
const startMonth = weekStart.getMonth() + 1;
const endMonth = weekEnd.getMonth() + 1;
if (startMonth === endMonth) {
return `${year}年${startMonth}月${weekStart.getDate()}日 - ${weekEnd.getDate()}日`;
} else {
return `${year}年${startMonth}月${weekStart.getDate()}日 - ${endMonth}月${weekEnd.getDate()}日`;
}
}
},
// 月视图数据
monthDays() {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
// 当月第一天
const firstDay = new Date(year, month, 1);
// 当月最后一天
const lastDay = new Date(year, month + 1, 0);
// 当月第一天是周几(0是周日,1是周一...)
const firstDayOfWeek = firstDay.getDay() === 0 ? 7 : firstDay.getDay();
// 日历开始日期(上月最后几天)
const startDate = new Date(firstDay);
startDate.setDate(firstDay.getDate() - (firstDayOfWeek - 1));
// 日历结束日期(下月前几天)
const endDate = new Date(lastDay);
endDate.setDate(
lastDay.getDate() + (42 - lastDay.getDate() - (firstDayOfWeek - 1))
);
const days = [];
const currentDate = new Date(startDate);
while (currentDate <= endDate) {
const weekStr = this.getDayOfWeek(currentDate);
const dateStr = this.formatDate(currentDate);
const isCurrentMonth = currentDate.getMonth() === month;
const isToday = this.isToday(currentDate);
// 获取农历(简化处理)
const lunarDay = this.getLunarInfo(currentDate);
// 获取该日期的日程
const dayEvents = this.getEventsByDate(dateStr);
days.push({
date: dateStr,
day: currentDate.getDate(),
year: currentDate.getFullYear(),
month: currentDate.getMonth() + 1,
lunar: lunarDay.display,
holiday: lunarDay.holiday || "",
isCurrentMonth,
isToday,
events: dayEvents,
week: weekStr,
});
currentDate.setDate(currentDate.getDate() + 1);
}
return days;
},
// 周视图数据
weekDaysData() {
const weekStart = new Date(this.currentDate);
// 设置到本周一
weekStart.setDate(
weekStart.getDate() -
(weekStart.getDay() === 0 ? 6 : weekStart.getDay() - 1)
);
const days = [];
for (let i = 0; i < 7; i++) {
const date = new Date(weekStart);
date.setDate(weekStart.getDate() + i);
const dateStr = this.formatDate(date);
const isToday = this.isToday(date);
// 获取农历(简化处理)
const lunarDay = this.getLunarInfo(date);
days.push({
date: dateStr,
year: date.getFullYear(),
month: date.getMonth() + 1,
day: date.getDate(),
holiday: lunarDay.holiday || "",
isToday,
events: this.getEventsByDate(dateStr),
lunar: lunarDay.display,
});
}
return days;
},
},
methods: {
getDayOfWeek(dateString) {
const date = new Date(dateString);
const day = date.getDay();
const days = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
return days[day];
},
// 移动端判断方法
checkMobile() {
this.isMobile = window.innerWidth <= 768;
},
handleTouchStart(e, direction) {
// 防止默认行为
e.preventDefault();
// 设置定时器实现长按连续导航
this.touchTimer = setTimeout(() => {
this.navigate(direction);
// 递归调用实现连续导航
this.handleTouchStart(e, direction);
}, 300);
},
clearTouchTimer() {
if (this.touchTimer) {
clearTimeout(this.touchTimer);
this.touchTimer = null;
}
},
handleViewChange() {
this.emitChange();
},
selectMonth(month) {
const newDate = new Date(this.currentDate);
newDate.setMonth(month - 1);
this.currentDate = newDate;
this.changeView("month");
},
selectQuarter(quarter) {
// 切换到选定的季度
this.currentDate = new Date(quarter.startDate);
this.changeView("month");
},
isCurrentMonth(month) {
const today = new Date();
return (
this.currentYear === today.getFullYear() &&
month === today.getMonth() + 1
);
},
isCurrentQuarter(quarter) {
const today = new Date();
const quarterStart = new Date(quarter.startDate);
const quarterEnd = new Date(quarter.endDate);
return today >= quarterStart && today <= quarterEnd;
},
getMonthEvents(year, month) {
// 获取某月所有事件
const monthStr = month < 10 ? `0${month}` : `${month}`;
const monthPrefix = `${year}-${monthStr}`;
return this.events
.filter((event) => event.date.startsWith(monthPrefix))
.flatMap((event) => event[this.scheduleKey]);
},
getEventColor(event) {
// 根据事件类型返回颜色
const colors = ["#0e7cff", "#ff6b6b", "#51cf66", "#fcc419", "#ae3ec9"];
const hash = event.title.split("").reduce((a, b) => {
a = (a << 5) - a + b.charCodeAt(0);
return a & a;
}, 0);
return colors[Math.abs(hash) % colors.length];
},
getMobileEventStyle(event) {
// 移动端周视图事件样式
const start = new Date(event.startTime);
const end = new Date(event.endTime);
const startMinutes = start.getHours() * 60 + start.getMinutes();
const endMinutes = end.getHours() * 60 + end.getMinutes();
const duration = endMinutes - startMinutes;
return {
top: startMinutes * 2 + "px",
height: Math.max(duration * 2, 40) + "px",
backgroundColor: this.getEventColor(event) + "20",
borderLeft: `3px solid ${this.getEventColor(event)}`,
};
},
updateCurrentTimeIndicator() {
const now = new Date();
const currentMinutes = now.getHours() * 60 + now.getMinutes();
this.currentTimePosition = currentMinutes * 2; // 每分钟2px
this.showCurrentTime =
now >= new Date(this.weekDaysData[0].date) &&
now <= new Date(this.weekDaysData[6].date);
},
formatFullDate(day) {
if (!day || !day.date) return "";
const date = new Date(day.date);
const weekdays = [
"星期日",
"星期一",
"星期二",
"星期三",
"星期四",
"星期五",
"星期六",
];
return `${date.getFullYear()}年${
date.getMonth() + 1
}月${date.getDate()}日 ${weekdays[date.getDay()]}`;
},
showDatePicker() {
// 移动端显示原生日期选择器
if (this.isMobile) {
const input = document.createElement("input");
input.type = "date";
input.value = this.currentDate.toISOString().split("T")[0];
input.addEventListener("change", (e) => {
this.currentDate = new Date(e.target.value);
this.emitChange();
});
input.click();
}
},
//其他原有方法
openLoading() {
this.loading = true;
},
closeLoading() {
this.loading = false;
},
// 重新加载方法(实际是手动触发changa)
reload() {
this.emitChange("手动触发");
},
//获取日期板块数据
getBoardData() {
let obj = {
view: this.currentView,
begin: "",
end: "",
};
if (this.currentView === "month") {
obj.begin = this.monthDays[0].date;
obj.end = this.monthDays[this.monthDays.length - 1].date;
} else if (this.currentView === "week") {
obj.begin = this.weekDaysData[0].date;
obj.end = this.weekDaysData[this.weekDaysData.length - 1].date;
}
return obj;
},
//获取农历信息方法
getLunarInfo(date) {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
// 使用新的农历库
const solar = lunar.Solar.fromYmd(year, month, day);
const l = solar.getLunar();
// 1. 获取传统节日(农历节日)
const traditionalFestival = l.getFestivals()[0] || null;
// 2. 获取节气
const solarTerm = l.getJieQi() || null;
// 3. 获取阳历节日
const solarFestival = this.solarFestivals[month + "" + day] || null;
// 显示内容(农历日)
let display = l.getDayInChinese();
if (l.getDay() === 1) {
// 初一显示月份
display = l.getMonthInChinese() + "月";
}
// 节日显示优先级:传统节日 > 节气 > 阳历节日
const holiday = traditionalFestival || solarTerm || solarFestival;
return {
display: display,
holiday: holiday,
isTraditionalFestival: !!traditionalFestival,
isSolarTerm: !!solarTerm,
};
},
selectDay(day) {
if (this.currentDate.getMonth() + 1 !== day.month) {
this.currentDate = new Date(day.date);
this.emitChange("切换日期触发");
}
this.selectDayStr = day.date;
},
// 处理日程点击事件
handleEventClick(event, day) {
if (day && day.day) {
console.log("弹出层内点击", this.$refs["monthViewMoreEvent" + day.day]);
this.$refs["monthViewMoreEvent" + day.day] &&
this.$refs["monthViewMoreEvent" + day.day][0].closePopup();
}
console.log("@@@@handleEventClick", event);
this.$emit("click-event", { item: event });
},
// 切换视图
changeView(view) {
this.currentView = view;
// this.$emit('update:view', view);
this.emitChange();
},
// 导航(上一月/周,下一月/周)
navigate(direction) {
const newDate = new Date(this.currentDate);
if (this.currentView === "month") {
newDate.setMonth(newDate.getMonth() + direction);
} else if (this.currentView === "week") {
newDate.setDate(newDate.getDate() + direction * 7);
}
this.currentDate = newDate;
this.emitChange("导航");
},
// 返回今天
goToToday() {
this.currentDate = new Date();
this.emitChange("回到今天");
},
emitChange() {
let obj = {
view: this.currentView,
begin: "",
end: "",
};
if (this.currentView === "month") {
obj.begin = this.monthDays[0].date;
obj.end = this.monthDays[this.monthDays.length - 1].date;
} else if (this.currentView === "week") {
obj.begin = this.weekDaysData[0].date;
obj.end = this.weekDaysData[this.weekDaysData.length - 1].date;
}
console.log("emitChange", obj);
this.$emit("change", obj);
},
// 根据日期获取日程
getEventsByDate(date) {
const eventData = this.events.find((e) => e.date === date);
return eventData ? eventData[this.scheduleKey] : [];
},
// 格式化日期为 YYYY-MM-DD
formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
},
// 判断是否为今天
isToday(date) {
const today = new Date();
return (
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
);
},
// 格式化时间(周视图中使用)
formatTime(dateTime, isShowMonthAndDay) {
const date = new Date(dateTime);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
if (isShowMonthAndDay) {
return `${month}-${day} ${hours}:${minutes}`;
} else {
return `${hours}:${minutes}`;
}
},
// 计算事件在周视图中的位置(顶部位置)
getEventPosition(event) {
const start = new Date(event.startTime);
const hours = start.getHours();
const minutes = start.getMinutes();
// 每分钟对应1px(1440分钟 * 1px = 1440px)
return hours * 60 + minutes + "px";
},
getEventHeight(event) {
const start = new Date(event.startTime);
const end = new Date(event.endTime);
// 计算事件持续时间(分钟)
const duration = (end - start) / (1000 * 60);
// 每分钟对应1px
return Math.max(duration, 30) + "px";
},
// 新增方法:获取某天的正在进行中的跨天事件
getOngoingEvents(date) {
const result = [];
const currentDate = new Date(date);
this.events.forEach((dayEvents) => {
dayEvents[this.scheduleKey]?.forEach((event) => {
const eventStart = new Date(event.startTime);
const eventEnd = new Date(event.endTime);
// 检查事件是否跨天并且当前日期在事件期间内(但不是开始日)
if (
this.isCrossDayEvent(event) &&
currentDate > eventStart &&
currentDate < eventEnd
) {
result.push(event);
}
});
});
return result;
},
// 获取某天的所有事件(处理跨天事件)
getEventsForDay(date) {
const dayEvents = this.getEventsByDate(date);
const processedEvents = [];
dayEvents.forEach((event) => {
// 克隆事件对象以避免修改原始数据
const processedEvent = { ...event };
// 标记跨天事件
processedEvent.isCrossDay = this.isCrossDayEvent(event);
// 对于跨天事件,计算在当前天的显示比例
if (processedEvent.isCrossDay) {
const eventStart = new Date(event.startTime);
const eventEnd = new Date(event.endTime);
const currentDate = new Date(date);
// 如果是开始日,计算到午夜的比例
if (this.isSameDay(eventStart, currentDate)) {
const startMinutes =
eventStart.getHours() * 60 + eventStart.getMinutes();
const dayEndMinutes = 24 * 60;
processedEvent.displayRatio =
(dayEndMinutes - startMinutes) /
((eventEnd - eventStart) / (1000 * 60));
}
// 如果是结束日,计算从午夜开始的比例
else if (this.isSameDay(eventEnd, currentDate)) {
const dayStartMinutes = 0;
const endMinutes = eventEnd.getHours() * 60 + eventEnd.getMinutes();
processedEvent.displayRatio =
endMinutes / ((eventEnd - eventStart) / (1000 * 60));
}
// 如果是中间日,显示全天
else {
processedEvent.displayRatio = 1;
}
}
processedEvents.push(processedEvent);
});
// 处理事件重叠
return this.processOverlappingEvents(processedEvents);
},
// 周视图分组算法
processOverlappingEvents(events) {
if (!events.length) return [];
// 按开始时间排序
const sortedEvents = [...events].sort((a, b) => {
return new Date(a.startTime) - new Date(b.startTime);
});
// 创建列数组来存储事件
const columns = [];
const eventColumns = new Map();
// 分配事件到列
sortedEvents.forEach((event) => {
let placed = false;
// 尝试将事件放入现有列
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
const lastEvent = col[col.length - 1];
// 检查事件是否与列中最后一个事件重叠
if (!this.eventsOverlap(lastEvent, event)) {
col.push(event);
eventColumns.set(event, i);
placed = true;
break;
}
}
// 如果没有合适的列,创建新列
if (!placed) {
columns.push([event]);
eventColumns.set(event, columns.length - 1);
}
});
// 为每个事件添加列信息
sortedEvents.forEach((event) => {
event.columnIndex = eventColumns.get(event);
event.totalColumns = columns.length;
// 计算重叠计数
event.overlapCount = 0;
sortedEvents.forEach((otherEvent) => {
if (
event !== otherEvent &&
this.eventsOverlap(event, otherEvent) &&
eventColumns.get(event) === eventColumns.get(otherEvent)
) {
event.overlapCount++;
}
});
});
return sortedEvents;
},
// 检查两个事件是否重叠
eventsOverlap(eventA, eventB) {
const startA = new Date(eventA.startTime);
const endA = new Date(eventA.endTime);
const startB = new Date(eventB.startTime);
const endB = new Date(eventB.endTime);
return startA < endB && endA > startB;
},
// 检查是否为短事件(小于1小时)
isShortEvent(event) {
const start = new Date(event.startTime);
const end = new Date(event.endTime);
const duration = (end - start) / (1000 * 60); // 分钟数
return duration < 60;
},
// 获取周视图事件样式
getWeekEventStyle(event, currentDate) {
const start = new Date(event.startTime);
const end = new Date(event.endTime);
const currentDay = new Date(currentDate);
let top = 0;
let height = 0;
// 处理跨天事件
if (this.isCrossDayEvent(event)) {
if (this.isSameDay(start, currentDay)) {
// 开始日:从开始时间到午夜
const startMinutes = start.getHours() * 60 + start.getMinutes();
top = startMinutes * 2; // 乘以2,每分钟对应2px
height = (24 * 60 - startMinutes) * 2;
} else if (this.isSameDay(end, currentDay)) {
// 结束日:从午夜到结束时间
const endMinutes = end.getHours() * 60 + end.getMinutes();
top = 0;
height = endMinutes * 2;
} else {
// 中间日:全天显示
top = 0;
height = 24 * 60 * 2; // 2880px
}
} else {
// 非跨天事件 - 每分钟对应2px
const startMinutes = start.getHours() * 60 + start.getMinutes();
const endMinutes = end.getHours() * 60 + end.getMinutes();
const duration = endMinutes - startMinutes;
top = startMinutes * 2;
// 确保最小高度为60px(对应半小时)
height = Math.max(duration * 2, 60);
}
// 计算宽度和位置(考虑重叠列)
const columnWidth =
event.totalColumns > 0 ? 100 / event.totalColumns : 100;
const left = event.columnIndex * columnWidth;
const width = columnWidth;
return {
top: top + "px",
height: height + "px",
left: left + "%",
width: width + "%",
"z-index": event.columnIndex + 1, // 添加z-index确保重叠正确显示
};
},
// 新增方法:检查是否为同一天
isSameDay(date1, date2) {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
},
// 新增方法:检查是否为跨天事件
isCrossDayEvent(event) {
const start = new Date(event.startTime);
const end = new Date(event.endTime);
return !this.isSameDay(start, end);
},
// 新增方法:检查事件是否在当前日开始
isEventStartDay(event, currentDate) {
const start = new Date(event.startTime);
const currentDay = new Date(currentDate);
return this.isSameDay(start, currentDay);
},
},
};
</script>
<style scoped>
/* 移动端容器 */
.calendar-container {
max-width: 100%;
overflow: hidden;
}
/* 媒体查询 - 移动端样式 */
@media (max-width: 768px) {
.calendar-toolbar {
flex-direction: column;
padding: 10px;
gap: 12px;
}
.nav-controls {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.current-date {
font-size: 16px;
min-width: auto;
padding: 0 10px;
}
.view-select {
width: 100%;
padding: 8px;
border-radius: 8px;
border: 1px solid #ddd;
background: white;
}
.month-cell {
aspect-ratio: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative;
}
.month-cell.current-month {
background: #ecf5ff;
border: 1px solid #0e7cff;
}
.month-number {
font-size: 16px;
font-weight: 500;
margin-bottom: 5px;
}
.month-events {
display: flex;
align-items: center;
gap: 2px;
}
.event-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #0e7cff;
display: inline-block;
}
.event-count {
font-size: 12px;
color: #666;
}
/* 月视图移动端样式 */
.month-view-mobile {
padding: 0 5px;
}
.weekdays-mobile {
display: grid;
grid-template-columns: repeat(7, 1fr);
text-align: center;
font-size: 12px;
color: #666;
margin-bottom: 5px;
}
.days-grid-mobile {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.day-cell-mobile {
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 5px 0;
border-radius: 50%;
position: relative;
}
.day-cell-mobile.today {
background-color: #0e7cff;
}
.day-cell-mobile.today .day-number-mobile {
color: white;
}
.day-cell-mobile.selected {
background-color: #e6f3ff;
}
.day-cell-mobile.other-month {
opacity: 0.3;
}
.day-number-mobile {
font-size: 14px;
font-weight: 500;
margin-bottom: 2px;
}
.lunar-date-mobile {
font-size: 10px;
color: #999;
}
.events-indicator {
position: absolute;
bottom: 2px;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.event-dots {
display: flex;
gap: 1px;
justify-content: center;
}
.more-events {
font-size: 9px;
color: #666;
}
/* 周视图移动端样式 */
.week-view-mobile {
height: calc(100vh - 150px);
display: flex;
flex-direction: column;
}
.week-header-mobile {
padding: 10px 0;
background: white;
border-bottom: 1px solid #eee;
}
.week-dates {
display: flex;
justify-content: space-around;
}
.week-day-header {
display: flex;
flex-direction: column;
align-items: center;
padding: 5px;
border-radius: 12px;
min-width: 40px;
}
.week-day-header.today {
background: #0e7cff;
}
.week-day-header.today .week-day-number {
color: white;
}
.week-day-header.selected {
background: #e6f3ff;
}
.week-day-name {
font-size: 12px;
color: #666;
}
.week-day-number {
font-size: 16px;
font-weight: 500;
margin: 3px 0;
}
.week-lunar {
font-size: 10px;
color: #999;
}
.week-events-container {
flex: 1;
display: flex;
position: relative;
overflow-y: auto;
}
.time-column-mobile {
width: 50px;
flex-shrink: 0;
}
.time-slot-mobile {
height: 120px;
font-size: 10px;
color: #999;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 5px;
}
.events-column-mobile {
flex: 1;
position: relative;
}
.day-events {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.mobile-event {
position: absolute;
left: 2px;
right: 2px;
border-radius: 6px;
padding: 5px;
overflow: hidden;
}
.event-time-mobile {
font-size: 10px;
margin-bottom: 2px;
}
.event-title-mobile {
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-location-mobile {
font-size: 10px;
color: #666;
}
.current-time-indicator {
position: absolute;
left: 0;
right: 0;
height: 2px;
background-color: #ff6b6b;
z-index: 10;
pointer-events: none;
display: flex;
align-items: center;
}
.time-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #ff6b6b;
margin-right: 5px;
}
.event-title {
font-size: 16px;
margin-bottom: 5px;
}
}
/* 桌面样式保持不变,通过媒体查询隔离 */
@media (min-width: 769px) {
.month-view-mobile,
.week-view-mobile,
.day-view-mobile,
.year-view-mobile,
.quarter-view-mobile {
display: none;
}
}
/* 原有样式保持不变 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"PingFang SC", "Microsoft YaHei", sans-serif;
}
body {
background: #f0f2f5;
color: #333;
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.calendar-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #eee;
background: #fafbfc;
}
.view-switcher {
display: flex;
gap: 8px;
}
.view-btn {
padding: 4px 8px;
border-radius: 6px;
border: 1px solid #d9d9d9;
background: #fff;
color: #666;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.view-btn.active {
background: #0e7cff;
color: white;
border-color: #0e7cff;
}
.nav-controls {
display: flex;
align-items: center;
gap: 16px;
}
.nav-btn {
background: #fff;
border: 1px solid #d9d9d9;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.nav-btn:hover {
border-color: #0e7cff;
color: #0e7cff;
}
.today-btn {
padding: 4px 8px;
border-radius: 6px;
border: 1px solid #d9d9d9;
background: #fff;
color: #666;
cursor: pointer;
font-size: 14px;
}
.current-date {
font-size: 18px;
font-weight: 500;
color: #1a1a1a;
min-width: 180px;
text-align: center;
}
/* 月视图样式 */
.month-view {
padding: 16px;
}
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
text-align: center;
padding: 12px 0;
font-weight: 500;
color: #666;
border-bottom: 1px solid #eee;
}
.days-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
min-height: auto;
}
.day-cell {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: 1px solid #f0f0f0;
padding: 8px;
position: relative;
min-height: 90px;
transition: background 0.2s;
}
.day-cell.selected {
/* background: #d9ecff; */
/* border: 1px solid #409eff; */
}
.day-cell:hover {
/* background: #f9f9f9; */
cursor: pointer;
}
.day-header {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.day-number {
font-size: 16px;
font-weight: 500;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.day-holiday {
font-size: 14px;
font-weight: 500;
color: #f56c6c;
}
.day-number.today {
background: #0e7cff;
color: white;
}
.day-holiday.today {
color: #0e7cff;
}
.lunar-date.today {
color: #0e7cff;
}
.day-number.other-month {
color: #ccc;
}
.lunar-date {
font-size: 12px;
color: #999;
}
.events-container {
overflow-y: auto;
max-height: 100px;
}
.event-item {
background: #ffffff;
padding: 2px 3px;
margin-bottom: 3px;
border-radius: 2px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-item:hover {
/* background: #d0e7ff; */
background: #f9f9f9;
}
.event-item::before {
content: "";
display: inline-block;
width: 6px;
height: 6px;
background-color: #f53e37;
border-radius: 50%;
margin-right: 4px;
}
.more-events {
color: #999;
font-size: 12px;
margin-top: 4px;
cursor: pointer;
}
/* 周视图样式 */
.week-view {
height: auto;
display: flex;
}
.time-column {
width: 60px;
}
.time-slot {
height: 120px;
position: relative;
text-align: right;
padding-right: 8px;
font-size: 12px;
color: #999;
border-top: 1px solid #f0f0f0;
}
/* 移除原来的after伪元素边框 */
.time-slot::after {
display: none;
}
/* .time-slot::after {
content: '';
position: absolute;
left: 50px;
right: 0;
top: 0;
border-top: 1px solid #f0f0f0;
} */
.days-container {
display: flex;
flex: 1;
}
.day-column {
flex: 1;
border-left: 1px solid #f0f0f0;
position: relative;
}
.day-column:last-child {
border-right: 1px solid #f0f0f0;
}
.day-header-week {
text-align: center;
border-bottom: 1px solid #f0f0f0;
height: 60px; /* 固定头部高度 */
}
.day-name {
font-size: 14px;
color: #666;
}
.day-date {
font-size: 16px;
font-weight: 500;
margin-top: 5px;
}
.day-date.today {
font-size: 14px;
display: inline-block;
width: 21px;
height: 21px;
line-height: 21px;
border-radius: 50%;
background: #0e7cff;
color: white;
}
/* 周视图事件容器增加相对定位 */
.events-week {
position: relative;
height: 2880px;
background: repeating-linear-gradient(
to bottom,
transparent 0,
transparent 119px,
#f0f4f9 119px,
#f0f4f9 120px
);
}
.week-event {
position: absolute;
background: #e6f3ff;
border-left: 3px solid #0e7cff;
border-radius: 4px;
padding: 4px 6px;
overflow: hidden;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
box-sizing: border-box;
}
.week-event:hover {
background: #d0e7ff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
/* 短事件特殊样式 */
.week-event.short-event {
min-height: 60px; /* 半小时事件的最小高度 */
display: flex;
flex-direction: column;
justify-content: center;
}
/* 多列事件 */
.week-event.multi-column {
background: #d4e7ff;
border-left: 3px solid #0a68d4;
}
/* 重叠事件标记 */
.event-badge {
position: absolute;
top: 4px;
right: 4px;
background: rgba(255, 255, 255, 0.8);
border-radius: 50%;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: #0a68d4;
}
.event-time {
font-size: 11px;
color: #666;
}
/* 原有样式保持不变,新增以下样式 */
/* 跨天事件指示器 */
.ongoing-event-indicator {
position: absolute;
pointer-events: none;
z-index: 1;
}
.ongoing-event-line {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background-color: rgba(14, 124, 255, 0.2);
margin-left: 2px;
}
/* 跨天事件样式 */
.week-event.cross-day {
background: linear-gradient(45deg, #e6f3ff 0%, #d4e7ff 100%);
border-left: 3px solid #0a68d4;
}
.cross-day-badge {
position: absolute;
bottom: 2px;
right: 2px;
background: rgba(10, 104, 212, 0.9);
color: white;
font-size: 10px;
padding: 1px 4px;
border-radius: 3px;
}
/* 调整事件时间样式 */
.event-time {
font-size: 11px;
color: #666;
}
@keyframes modalIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.calendar-toolbar {
flex-direction: column;
gap: 12px;
}
.week-view {
height: 500px;
overflow-y: auto;
}
.events-week {
height: 1440px; /* 移动设备上恢复较小高度 */
}
.time-slot {
height: 60px; /* 移动设备上恢复较小高度 */
}
.view-switcher {
width: 100%;
justify-content: center;
}
.nav-controls {
width: 100%;
justify-content: space-between;
}
.days-grid {
min-height: 400px;
}
.day-cell {
min-height: 70px;
}
}
/* 加载动画 */
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 40px;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(14, 124, 255, 0.2);
border-top: 3px solid #0e7cff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #d1d5db transparent;
}
</style>
popupComponent
vue
<template>
<div class="popup-container">
<!-- 触发元素 -->
<slot name="trigger" :open="openPopup">
<button class="default-trigger" @click="openPopup">点击触发弹出层</button>
</slot>
<!-- 弹出层 -->
<transition name="popup-fade">
<div v-if="isVisible" class="popup-overlay" :style="{ zIndex: computedZIndex }" @click.self="closePopup">
<div ref="popupContent" class="popup-content" :class="[finalPlacement, customClass]" :style="popupStyle">
<button class="close-btn" @click="closePopup">×</button>
<div class="popup-body">
<slot name="content"></slot>
</div>
</div>
</div>
</transition>
</div>
</template>
<script>
export default {
name: 'PopupComponent',
props: {
// 弹出层位置,支持 'top', 'bottom', 'left', 'right'
placement: {
type: String,
default: 'right',
validator: (value) => ['top', 'bottom', 'left', 'right'].includes(value),
},
// 自定义样式类
customClass: {
type: String,
default: '',
},
// 偏移量
offset: {
type: Number,
default: 20,
},
// 是否启用自动调整位置
autoAdjust: {
type: Boolean,
default: true,
},
},
data() {
return {
isVisible: false,
computedZIndex: 1000,
popupStyle: {},
finalPlacement: this.placement,
};
},
mounted() {
// 添加全局点击事件监听器
document.addEventListener('click', this.handleOutsideClick);
},
beforeDestroy() {
// 移除全局点击事件监听器
document.removeEventListener('click', this.handleOutsideClick);
},
methods: {
openPopup(event) {
if (!this.isVisible) {
this.isVisible = true;
this.finalPlacement = this.placement;
this.$nextTick(() => {
this.calculatePosition(event);
this.calculateZIndex();
});
}
},
closePopup() {
this.isVisible = false;
},
calculatePosition(event) {
const triggerEl = event.currentTarget;
const triggerRect = triggerEl.getBoundingClientRect();
const popupRect = this.$refs.popupContent.getBoundingClientRect();
let top, left;
this.finalPlacement = this.placement;
// 视口尺寸
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// 初始位置计算
switch (this.placement) {
case 'top':
top = triggerRect.top - popupRect.height - this.offset;
left = triggerRect.left + (triggerRect.width - popupRect.width) / 2;
// 如果上方空间不足,自动调整为下方
if (this.autoAdjust && top < 10) {
top = triggerRect.bottom + this.offset;
this.finalPlacement = 'bottom';
}
break;
case 'bottom':
top = triggerRect.bottom + this.offset;
left = triggerRect.left + (triggerRect.width - popupRect.width) / 2;
// 如果下方空间不足,自动调整为上方
if (this.autoAdjust && top + popupRect.height > viewportHeight - 10) {
top = triggerRect.top - popupRect.height - this.offset;
this.finalPlacement = 'top';
}
break;
case 'left':
top = triggerRect.top + (triggerRect.height - popupRect.height) / 2;
left = triggerRect.left - popupRect.width - this.offset;
// 如果左侧空间不足,自动调整为右侧
if (this.autoAdjust && left < 10) {
left = triggerRect.right + this.offset;
this.finalPlacement = 'right';
}
break;
case 'right':
top = triggerRect.top + (triggerRect.height - popupRect.height) / 2;
left = triggerRect.right + this.offset;
// 如果右侧空间不足,自动调整为左侧
if (this.autoAdjust && left + popupRect.width > viewportWidth - 10) {
left = triggerRect.left - popupRect.width - this.offset;
this.finalPlacement = 'left';
}
break;
default:
top = triggerRect.bottom + this.offset;
left = triggerRect.left + (triggerRect.width - popupRect.width) / 2;
this.finalPlacement = 'bottom';
}
// 二次边界检查,确保调整后不超出视口
if (left < 10) left = 10;
if (left + popupRect.width > viewportWidth - 10) {
left = viewportWidth - popupRect.width - 10;
}
if (top < 10) top = 10;
if (top + popupRect.height > viewportHeight - 10) {
top = viewportHeight - popupRect.height - 10;
}
this.popupStyle = {
top: `${top}px`,
left: `${left}px`,
};
// 更新CSS类以反映最终的位置
this.$refs.popupContent.className = `popup-content ${this.finalPlacement} ${this.customClass}`;
},
calculateZIndex() {
// 计算当前页面最大z-index
const allElements = document.querySelectorAll('*');
let maxZIndex = 1000;
Array.from(allElements).forEach((element) => {
const zIndex = parseInt(window.getComputedStyle(element).zIndex, 10);
if (!isNaN(zIndex) && zIndex > maxZIndex) {
maxZIndex = zIndex;
}
});
this.computedZIndex = maxZIndex + 1;
},
handleOutsideClick(event) {
// 如果点击了弹出层外部,关闭弹出层
if (this.isVisible && !this.$el.contains(event.target)) {
this.closePopup();
}
},
},
};
</script>
<style scoped>
/* 样式保持不变,与之前相同 */
.popup-container {
display: inline-block;
position: relative;
}
.default-trigger {
padding: 10px 20px;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.default-trigger:hover {
background: #2980b9;
}
.popup-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: transparent;
z-index: 1000;
}
.popup-content {
position: absolute;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
min-width: 200px;
animation: popup-appear 0.3s ease;
}
.popup-body {
padding: 10px;
}
.close-btn {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 25px;
cursor: pointer;
color: #7f8c8d;
transition: color 0.3s;
z-index: 10;
}
/* 箭头样式 */
.popup-content::before {
content: '';
position: absolute;
width: 0;
height: 0;
border-style: solid;
}
.popup-content.top::before {
bottom: -10px;
left: 50%;
transform: translateX(-50%);
border-width: 10px 10px 0 10px;
border-color: white transparent transparent transparent;
}
.popup-content.bottom::before {
top: -10px;
left: 50%;
transform: translateX(-50%);
border-width: 0 10px 10px 10px;
border-color: transparent transparent white transparent;
}
.popup-content.left::before {
right: -10px;
top: 50%;
transform: translateY(-50%);
border-width: 10px 0 10px 10px;
border-color: transparent transparent transparent white;
}
.popup-content.right::before {
left: -10px;
top: 50%;
transform: translateY(-50%);
border-width: 10px 10px 10px 0;
border-color: transparent white transparent transparent;
}
/* 动画效果 */
.popup-fade-enter-active,
.popup-fade-leave-active {
transition: opacity 0.3s;
}
.popup-fade-enter,
.popup-fade-leave-to {
opacity: 0;
}
@keyframes popup-appear {
from {
opacity: 0;
transform: translateY(10px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
</style>
总结
这个 Vue2 日历组件为企业级应用提供了完整的日程管理解决方案,具有高度可定制性和优秀的用户体验。无论是简单的个人日程管理还是复杂的企业级应用,都能满足需求。 通过合理的组件设计和丰富的 API,开发者可以快速集成到现有项目中,并根据具体业务需求进行深度定制。