Vue2日历组件-仿企微日程日历

概述

基于 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">&times;</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,开发者可以快速集成到现有项目中,并根据具体业务需求进行深度定制。

相关推荐
用户84298142418103 小时前
js中如何隐藏eval关键字?
前端·javascript·后端
chxii3 小时前
前端与Node.js
前端·node.js
zmirror3 小时前
React-Router v6 useNavigate 非组件不生效
前端
月弦笙音3 小时前
【React】19深度解析:掌握新一代React特性
javascript·react native·react.js
红树林073 小时前
BeautifulSoup 的页面中需要获取某个元素的 xpath 路径
前端·python·网络爬虫·beautifulsoup
三小河3 小时前
教你发布一个npm的组织包
前端
用户6120414922133 小时前
使用JSP+Servlet+JavaBean做的课程后台管理系统
java·javascript·mysql
青椒a4 小时前
002.nestjs后台管理项目-数据库之prisma(上)
前端
米诺zuo4 小时前
react 中的useContext和Provider实践
前端·react.js