Calendar

3 阅读11分钟

date-table

<script>
import fecha from 'element-ui/src/utils/date';
import { range as rangeArr, getFirstDayOfMonth, getPrevMonthLastDays, getMonthDays, getI18nSettings, validateRangeInOneMonth } from 'element-ui/src/utils/date-util';

export default {
  props: {
    // 选中的日期,格式为 yyyy-MM-dd
    selectedDay: String,
    // 日期范围,数组格式 [start, end]
    range: {
      type: Array,
      // 验证器:确保范围在同一个月内
      validator(val) {
        if (!(val && val.length)) return true;
        const [start, end] = val;
        return validateRangeInOneMonth(start, end);
      }
    },
    // 当前显示的月份日期
    date: Date,
    // 是否隐藏表头(星期几)
    hideHeader: Boolean,
    // 每周的第一天(0-6,0 表示周日)
    firstDayOfWeek: Number
  },

  // 注入父组件 elCalendar 实例
  inject: ['elCalendar'],

  methods: {
    // 将天数数组转换为嵌套数组(每行 7 天)
    toNestedArr(days) {
      return rangeArr(days.length / 7).map((_, index) => {
        const start = index * 7;
        return days.slice(start, start + 7);
      });
    },

    // 格式化日期字符串
    getFormateDate(day, type) {
      if (!day || ['prev', 'current', 'next'].indexOf(type) === -1) {
        throw new Error('invalid day or type');
      }
      let prefix = this.curMonthDatePrefix;
      if (type === 'prev') {
        prefix = this.prevMonthDatePrefix;
      } else if (type === 'next') {
        prefix = this.nextMonthDatePrefix;
      }
      // 将日期补齐为两位数,如 1 -> 01
      day = `00${day}`.slice(-2);
      return `${prefix}-${day}`;
    },

    // 获取日期单元格的类名
    getCellClass({ text, type}) {
      const classes = [type];
      if (type === 'current') {
        const date = this.getFormateDate(text, type);
        // 如果是选中的日期,添加 is-selected 类
        if (date === this.selectedDay) {
          classes.push('is-selected');
        }
        // 如果是今天,添加 is-today 类
        if (date === this.formatedToday) {
          classes.push('is-today');
        }
      }
      return classes;
    },

    // 选择日期
    pickDay({ text, type }) {
      const date = this.getFormateDate(text, type);
      this.$emit('pick', date);
    },

    // 日期单元格渲染代理
    cellRenderProxy({ text, type }) {
      // 检查是否有自定义的日期单元格插槽
      let render = this.elCalendar.$scopedSlots.dateCell;
      if (!render) return <span>{ text }</span>;

      const day = this.getFormateDate(text, type);
      const date = new Date(day);
      const data = {
        isSelected: this.selectedDay === day,
        type: `${type}-month`,
        day
      };
      // 调用自定义渲染函数
      return render({ date, data });
    }
  },

  computed: {
    // 获取国际化设置的星期名称
    WEEK_DAYS() {
      return getI18nSettings().dayNames;
    },
    // 上个月的日期前缀(yyyy-MM)
    prevMonthDatePrefix() {
      const temp = new Date(this.date.getTime());
      temp.setDate(0);
      return fecha.format(temp, 'yyyy-MM');
    },
    // 当前月的日期前缀(yyyy-MM)
    curMonthDatePrefix() {
      return fecha.format(this.date, 'yyyy-MM');
    },
    // 下个月的日期前缀(yyyy-MM)
    nextMonthDatePrefix() {
      const temp = new Date(this.date.getFullYear(), this.date.getMonth() + 1, 1);
      return fecha.format(temp, 'yyyy-MM');
    },
    // 格式化的今天日期
    formatedToday() {
      return this.elCalendar.formatedToday;
    },
    // 是否在范围内
    isInRange() {
      return this.range && this.range.length;
    },
    // 计算日历的行数据
    rows() {
      let days = [];
      // 如果存在范围,渲染范围内的日期
      if (this.isInRange) {
        const [start, end] = this.range;
        // 当前月的日期范围
        const currentMonthRange = rangeArr(end.getDate() - start.getDate() + 1).map((_, index) => ({
          text: start.getDate() + index,
          type: 'current'
        }));
        // 计算剩余的天数,补齐到整行
        let remaining = currentMonthRange.length % 7;
        remaining = remaining === 0 ? 0 : 7 - remaining;
        // 下个月的日期范围
        const nextMonthRange = rangeArr(remaining).map((_, index) => ({
          text: index + 1,
          type: 'next'
        }));
        days = currentMonthRange.concat(nextMonthRange);
      } else {
        // 正常渲染完整月份的日历
        const date = this.date;
        // 获取当月第一天是星期几
        let firstDay = getFirstDayOfMonth(date);
        firstDay = firstDay === 0 ? 7 : firstDay;
        // 获取每周的第一天(默认为 1,即周一)
        const firstDayOfWeek = typeof this.firstDayOfWeek === 'number' ? this.firstDayOfWeek : 1;
        // 计算偏移量
        const offset = (7 + firstDay - firstDayOfWeek) % 7;
        // 上个月的日期
        const prevMonthDays = getPrevMonthLastDays(date, offset).map(day => ({
          text: day,
          type: 'prev'
        }));
        // 当前月的日期
        const currentMonthDays = getMonthDays(date).map(day => ({
          text: day,
          type: 'current'
        }));
        days = [...prevMonthDays, ...currentMonthDays];
        // 下个月的日期,补齐到 42 天(6 行)
        const nextMonthDays = rangeArr(42 - days.length).map((_, index) => ({
          text: index + 1,
          type: 'next'
        }));
        days = days.concat(nextMonthDays);
      }
      return this.toNestedArr(days);
    },
    // 计算星期几的显示顺序
    weekDays() {
      const start = this.firstDayOfWeek;
      const { WEEK_DAYS } = this;

      if (typeof start !== 'number' || start === 0) {
        return WEEK_DAYS.slice();
      } else {
        // 根据每周的第一天调整星期顺序
        return WEEK_DAYS.slice(start).concat(WEEK_DAYS.slice(0, start));
      }
    }
  },

  // 渲染函数
  render() {
    // 如果隐藏表头,不渲染 thead
    const thead = this.hideHeader ? null : (<thead>
      {
        this.weekDays.map(day => <th key={day}>{ day }</th>)
      }
    </thead>);
    return (
      <table
        class={{
          'el-calendar-table': true,
          'is-range': this.isInRange
        }}
        cellspacing="0"
        cellpadding="0">
        {
          thead
        }
        <tbody>
          {
            this.rows.map((row, index) => <tr
              class={{
                'el-calendar-table__row': true,
                'el-calendar-table__row--hide-border': index === 0 && this.hideHeader
              }}
              key={index}>
              {
                row.map((cell, key) => <td key={key}
                  class={ this.getCellClass(cell) }
                  onClick={this.pickDay.bind(this, cell)}>
                  <div class="el-calendar-day">
                    {
                      this.cellRenderProxy(cell)
                    }
                  </div>
                </td>)
              }
            </tr>)
          }
        </tbody>
      </table>);
  }
};
</script>

Calendar

<template>
  <div class="el-calendar">
    <!-- 日历头部区域 -->
    <div class="el-calendar__header">
      <!-- 显示当前年月 -->
      <div class="el-calendar__title">
        {{ i18nDate }}
      </div>
      <!-- 按钮组(仅在非范围模式下显示) -->
      <div
        class="el-calendar__button-group"
        v-if="validatedRange.length === 0">
        <el-button-group>
          <!-- 上个月按钮 -->
          <el-button
            type="plain"
            size="mini"
            @click="selectDate('prev-month')">
            {{ t('el.datepicker.prevMonth') }}
          </el-button>
          <!-- 今天按钮 -->
          <el-button
            type="plain"
            size="mini"
            @click="selectDate('today')">
            {{ t('el.datepicker.today') }}
          </el-button>
          <!-- 下个月按钮 -->
          <el-button
            type="plain"
            size="mini"
            @click="selectDate('next-month')">
            {{ t('el.datepicker.nextMonth') }}
          </el-button>
        </el-button-group>
      </div>
    </div>
    <!-- 日历主体区域(非范围模式) -->
    <div
      class="el-calendar__body"
      v-if="validatedRange.length === 0"
      key="no-range">
      <date-table
        :date="date"
        :selected-day="realSelectedDay"
        :first-day-of-week="realFirstDayOfWeek"
        @pick="pickDay" />
    </div>
    <!-- 日历主体区域(范围模式) -->
    <div
      v-else
      class="el-calendar__body"
      key="has-range">
      <date-table
        v-for="(range, index) in validatedRange"
        :key="index"
        :date="range[0]"
        :selected-day="realSelectedDay"
        :range="range"
        :hide-header="index !== 0"
        :first-day-of-week="realFirstDayOfWeek"
        @pick="pickDay" />
    </div>
  </div>
</template>

<script>
import Locale from 'element-ui/src/mixins/locale';
import fecha from 'element-ui/src/utils/date';
import ElButton from 'element-ui/packages/button';
import ElButtonGroup from 'element-ui/packages/button-group';
import DateTable from './date-table';
import { validateRangeInOneMonth } from 'element-ui/src/utils/date-util';

// 有效的日期选择类型
const validTypes = ['prev-month', 'today', 'next-month'];
// 星期几的英文名称
const weekDays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
// 一天的毫秒数
const oneDay = 86400000;

export default {
  name: 'ElCalendar',

  // 混入国际化 mixin
  mixins: [Locale],

  // 注册子组件
  components: {
    DateTable,
    ElButton,
    ElButtonGroup
  },

  props: {
    // 当前选中的日期
    value: [Date, String, Number],
    // 日期范围
    range: {
      type: Array,
      // 验证器:确保范围格式正确
      validator(range) {
        if (Array.isArray(range)) {
          return range.length === 2 && range.every(
            item => typeof item === 'string' ||
            typeof item === 'number' ||
            item instanceof Date);
        } else {
          return true;
        }
      }
    },
    // 每周的第一天(0-6,0 表示周日)
    firstDayOfWeek: {
      type: Number,
      default: 1
    }
  },

  // 向子组件提供当前组件实例
  provide() {
    return {
      elCalendar: this
    };
  },

  methods: {
    // 选择日期
    pickDay(day) {
      this.realSelectedDay = day;
    },

    // 选择日期类型(上个月、今天、下个月)
    selectDate(type) {
      if (validTypes.indexOf(type) === -1) {
        throw new Error(`invalid type ${type}`);
      }
      let day = '';
      if (type === 'prev-month') {
        // 上个月的第一天
        day = `${this.prevMonthDatePrefix}-01`;
      } else if (type === 'next-month') {
        // 下个月的第一天
        day = `${this.nextMonthDatePrefix}-01`;
      } else {
        // 今天
        day = this.formatedToday;
      }

      // 如果选择的日期与当前日期相同,不处理
      if (day === this.formatedDate) return;
      this.pickDay(day);
    },

    // 将值转换为 Date 对象
    toDate(val) {
      if (!val) {
        throw new Error('invalid val');
      }
      return val instanceof Date ? val : new Date(val);
    },

    // 验证范围的开始或结束日期
    rangeValidator(date, isStart) {
      const firstDayOfWeek = this.realFirstDayOfWeek;
      // 期望的星期几
      const expected = isStart ? firstDayOfWeek : (firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1);
      const message = `${isStart ? 'start' : 'end'} of range should be ${weekDays[expected]}.`;
      if (date.getDay() !== expected) {
        console.warn('[ElementCalendar]', message, 'Invalid range will be ignored.');
        return false;
      }
      return true;
    }
  },

  computed: {
    // 上个月的日期前缀(yyyy-MM)
    prevMonthDatePrefix() {
      const temp = new Date(this.date.getTime());
      temp.setDate(0);
      return fecha.format(temp, 'yyyy-MM');
    },
    // 当前月的日期前缀(yyyy-MM)
    curMonthDatePrefix() {
      return fecha.format(this.date, 'yyyy-MM');
    },
    // 下个月的日期前缀(yyyy-MM)
    nextMonthDatePrefix() {
      const temp = new Date(this.date.getFullYear(), this.date.getMonth() + 1, 1);
      return fecha.format(temp, 'yyyy-MM');
    },
    // 格式化的当前日期
    formatedDate() {
      return fecha.format(this.date, 'yyyy-MM-dd');
    },
    // 国际化的日期显示(年月)
    i18nDate() {
      const year = this.date.getFullYear();
      const month = this.date.getMonth() + 1;
      return `${year} ${this.t('el.datepicker.year')} ${this.t('el.datepicker.month' + month)}`;
    },
    // 格式化的今天日期
    formatedToday() {
      return fecha.format(this.now, 'yyyy-MM-dd');
    },
    // 实际选中的日期(计算属性)
    realSelectedDay: {
      get() {
        // 如果没有 value,使用 selectedDay
        if (!this.value) return this.selectedDay;
        // 否则使用格式化的当前日期
        return this.formatedDate;
      },
      set(val) {
        // 更新 selectedDay
        this.selectedDay = val;
        // 将字符串转换为 Date 对象并触发 input 事件
        const date = new Date(val);
        this.$emit('input', date);
      }
    },
    // 当前显示的日期
    date() {
      if (!this.value) {
        // 如果没有 value,使用选中的日期
        if (this.realSelectedDay) {
          const d = this.selectedDay.split('-');
          return new Date(d[0], d[1] - 1, d[2]);
        } else if (this.validatedRange.length) {
          // 如果有范围,使用范围的开始日期
          return this.validatedRange[0][0];
        }
        // 否则使用今天的日期
        return this.now;
      } else {
        // 将 value 转换为 Date 对象
        return this.toDate(this.value);
      }
    },
    // 验证并计算日期范围
    validatedRange() {
      let range = this.range;
      if (!range) return [];
      // 验证范围的开始和结束日期
      range = range.reduce((prev, val, index) => {
        const date = this.toDate(val);
        if (this.rangeValidator(date, index === 0)) {
          prev = prev.concat(date);
        }
        return prev;
      }, []);
      if (range.length === 2) {
        const [start, end] = range;
        // 结束日期应该大于开始日期
        if (start > end) {
          console.warn('[ElementCalendar]end time should be greater than start time');
          return [];
        }
        // 开始和结束时间在同一个月
        if (validateRangeInOneMonth(start, end)) {
          return [
            [start, end]
          ];
        }
        const data = [];
        // 计算下个月的第一天
        let startDay = new Date(start.getFullYear(), start.getMonth() + 1, 1);
        // 本月的最后一天
        const lastDay = this.toDate(startDay.getTime() - oneDay);
        // 检查是否超过两个月
        if (!validateRangeInOneMonth(startDay, end)) {
          console.warn('[ElementCalendar]start time and end time interval must not exceed two months');
          return [];
        }
        // 第一个月的时间范围
        data.push([
          start,
          lastDay
        ]);
        // 下一月的时间范围,需要计算一下该月的第一个周起始日
        const firstDayOfWeek = this.realFirstDayOfWeek;
        const nextMontFirstDay = startDay.getDay();
        let interval = 0;
        // 计算间隔天数
        if (nextMontFirstDay !== firstDayOfWeek) {
          if (firstDayOfWeek === 0) {
            interval = 7 - nextMontFirstDay;
          } else {
            interval = firstDayOfWeek - nextMontFirstDay;
            interval = interval > 0 ? interval : 7 + interval;
          }
        }
        // 调整下个月的开始日期
        startDay = this.toDate(startDay.getTime() + interval * oneDay);
        // 如果还有剩余天数,添加第二个月的范围
        if (startDay.getDate() < end.getDate()) {
          data.push([
            startDay,
            end
          ]);
        }
        return data;
      }
      return [];
    },
    // 实际的每周第一天
    realFirstDayOfWeek() {
      // 验证 firstDayOfWeek 的值是否在 0-6 之间
      if (this.firstDayOfWeek < 1 || this.firstDayOfWeek > 6) {
        return 0;
      }
      return Math.floor(this.firstDayOfWeek);
    }
  },

  data() {
    return {
      // 内部状态:选中的日期
      selectedDay: '',
      // 当前时间
      now: new Date()
    };
  }
};
</script>

ElCalendar 组件教学文档

组件概述

ElCalendar 是一个日历组件,用于显示和选择日期。它由两个子组件组成:

  • ElCalendar:日历容器组件,负责整体布局、日期选择和范围管理
  • DateTable:日期表格组件,负责渲染日历的日期单元格

该组件支持单日期选择、日期范围选择、自定义每周第一天、国际化等功能,常用于日程管理、日期选择、范围预订等场景。


一、DateTable 组件详解

1.1 Props 属性

selectedDay
selectedDay: String

作用:选中的日期。

格式yyyy-MM-dd

示例'2024-01-15'

range
range: {
  type: Array,
  validator(val) {
    if (!(val && val.length)) return true;
    const [start, end] = val;
    return validateRangeInOneMonth(start, end);
  }
}

作用:日期范围。

格式[start, end]

验证器

  • 确保范围在同一个月内
  • 使用 validateRangeInOneMonth 函数验证
date
date: Date

作用:当前显示的月份日期。

类型:Date 对象

hideHeader
hideHeader: Boolean

作用:是否隐藏表头(星期几)。

类型:布尔值

默认值false

firstDayOfWeek
firstDayOfWeek: Number

作用:每周的第一天。

类型:数字

范围:0-6(0 表示周日,1 表示周一,以此类推)

1.2 Inject 注入

inject: ['elCalendar']

作用:注入父组件 elCalendar 实例。

说明

  • 获取父组件的实例
  • 访问父组件的属性和方法
  • 用于获取国际化设置和自定义插槽

1.3 Methods 方法

toNestedArr
toNestedArr(days) {
  return rangeArr(days.length / 7).map((_, index) => {
    const start = index * 7;
    return days.slice(start, start + 7);
  });
}

作用:将天数数组转换为嵌套数组(每行 7 天)。

说明

  • 将一维数组转换为二维数组
  • 每个子数组包含 7 天(一周)
  • 用于渲染日历表格的行

示例

// 输入:[1, 2, 3, ..., 42]
// 输出:[[1, 2, 3, 4, 5, 6, 7], [8, 9, ..., 14], ...]
getFormateDate
getFormateDate(day, type) {
  if (!day || ['prev', 'current', 'next'].indexOf(type) === -1) {
    throw new Error('invalid day or type');
  }
  let prefix = this.curMonthDatePrefix;
  if (type === 'prev') {
    prefix = this.prevMonthDatePrefix;
  } else if (type === 'next') {
    prefix = this.nextMonthDatePrefix;
  }
  day = `00${day}`.slice(-2);
  return `${prefix}-${day}`;
}

作用:格式化日期字符串。

参数

  • day:日期数字(1-31)
  • type:日期类型('prev'、'current'、'next')

返回值yyyy-MM-dd 格式的日期字符串

逻辑

  1. 验证参数有效性
  2. 根据类型选择月份前缀
  3. 将日期补齐为两位数(如 1 -> 01)
  4. 拼接成完整日期字符串

示例

getFormateDate(15, 'current'); // 返回:'2024-01-15'
getFormateDate(28, 'prev');   // 返回:'2023-12-28'
getFormateDate(1, 'next');    // 返回:'2024-02-01'
getCellClass
getCellClass({ text, type}) {
  const classes = [type];
  if (type === 'current') {
    const date = this.getFormateDate(text, type);
    if (date === this.selectedDay) {
      classes.push('is-selected');
    }
    if (date === this.formatedToday) {
      classes.push('is-today');
    }
  }
  return classes;
}

作用:获取日期单元格的类名。

参数

  • text:日期数字
  • type:日期类型

返回值:类名数组

逻辑

  1. 添加基础类型类名('prev'、'current'、'next')
  2. 如果是当前月:
    • 如果是选中的日期,添加 'is-selected'
    • 如果是今天,添加 'is-today'

示例

getCellClass({ text: 15, type: 'current' });
// 返回:['current', 'is-selected', 'is-today']

getCellClass({ text: 28, type: 'prev' });
// 返回:['prev']
pickDay
pickDay({ text, type }) {
  const date = this.getFormateDate(text, type);
  this.$emit('pick', date);
}

作用:选择日期。

逻辑

  1. 格式化日期字符串
  2. 触发 'pick' 事件,传递日期
cellRenderProxy
cellRenderProxy({ text, type }) {
  let render = this.elCalendar.$scopedSlots.dateCell;
  if (!render) return <span>{ text }</span>;

  const day = this.getFormateDate(text, type);
  const date = new Date(day);
  const data = {
    isSelected: this.selectedDay === day,
    type: `${type}-month`,
    day
  };
  return render({ date, data });
}

作用:日期单元格渲染代理。

逻辑

  1. 检查是否有自定义的日期单元格插槽
  2. 如果没有,返回默认的日期文本
  3. 如果有,调用自定义渲染函数

自定义渲染

<el-calendar>
  <template #dateCell="{ date, data }">
    <div>{{ date.getDate() }}</div>
    <div v-if="data.isSelected">已选择</div>
  </template>
</el-calendar>

1.4 Computed 计算属性

WEEK_DAYS
WEEK_DAYS() {
  return getI18nSettings().dayNames;
}

作用:获取国际化设置的星期名称。

返回值:星期名称数组

示例

// 中文:['周日', '周一', '周二', '周三', '周四', '周五', '周六']
// 英文:['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
prevMonthDatePrefix
prevMonthDatePrefix() {
  const temp = new Date(this.date.getTime());
  temp.setDate(0);
  return fecha.format(temp, 'yyyy-MM');
}

作用:获取上个月的日期前缀。

逻辑

  1. 复制当前日期
  2. 设置日期为 0(上个月的最后一天)
  3. 格式化为 yyyy-MM

示例

// 当前日期:2024-01-15
// 返回:'2023-12'
curMonthDatePrefix
curMonthDatePrefix() {
  return fecha.format(this.date, 'yyyy-MM');
}

作用:获取当前月的日期前缀。

返回值yyyy-MM 格式的字符串

nextMonthDatePrefix
nextMonthDatePrefix() {
  const temp = new Date(this.date.getFullYear(), this.date.getMonth() + 1, 1);
  return fecha.format(temp, 'yyyy-MM');
}

作用:获取下个月的日期前缀。

逻辑

  1. 创建下个月的第一天
  2. 格式化为 yyyy-MM
formatedToday
formatedToday() {
  return this.elCalendar.formatedToday;
}

作用:获取格式化的今天日期。

说明:从父组件获取,避免重复计算。

isInRange
isInRange() {
  return this.range && this.range.length;
}

作用:判断是否在范围内。

返回值:布尔值

rows
rows() {
  let days = [];
  if (this.isInRange) {
    // 范围模式:渲染范围内的日期
    const [start, end] = this.range;
    const currentMonthRange = rangeArr(end.getDate() - start.getDate() + 1).map((_, index) => ({
      text: start.getDate() + index,
      type: 'current'
    }));
    let remaining = currentMonthRange.length % 7;
    remaining = remaining === 0 ? 0 : 7 - remaining;
    const nextMonthRange = rangeArr(remaining).map((_, index) => ({
      text: index + 1,
      type: 'next'
    }));
    days = currentMonthRange.concat(nextMonthRange);
  } else {
    // 正常模式:渲染完整月份的日历
    const date = this.date;
    let firstDay = getFirstDayOfMonth(date);
    firstDay = firstDay === 0 ? 7 : firstDay;
    const firstDayOfWeek = typeof this.firstDayOfWeek === 'number' ? this.firstDayOfWeek : 1;
    const offset = (7 + firstDay - firstDayOfWeek) % 7;
    const prevMonthDays = getPrevMonthLastDays(date, offset).map(day => ({
      text: day,
      type: 'prev'
    }));
    const currentMonthDays = getMonthDays(date).map(day => ({
      text: day,
      type: 'current'
    }));
    days = [...prevMonthDays, ...currentMonthDays];
    const nextMonthDays = rangeArr(42 - days.length).map((_, index) => ({
      text: index + 1,
      type: 'next'
    }));
    days = days.concat(nextMonthDays);
  }
  return this.toNestedArr(days);
}

作用:计算日历的行数据。

逻辑

范围模式

  1. 计算当前月的日期范围
  2. 补齐到整行(7 天)
  3. 添加下个月的日期

正常模式

  1. 获取当月第一天是星期几
  2. 计算偏移量
  3. 添加上个月的日期
  4. 添加当前月的日期
  5. 补齐到 42 天(6 行)
  6. 转换为嵌套数组
weekDays
weekDays() {
  const start = this.firstDayOfWeek;
  const { WEEK_DAYS } = this;

  if (typeof start !== 'number' || start === 0) {
    return WEEK_DAYS.slice();
  } else {
    return WEEK_DAYS.slice(start).concat(WEEK_DAYS.slice(0, start));
  }
}

作用:计算星期几的显示顺序。

逻辑

  • 如果 firstDayOfWeek 为 0 或不是数字,保持原顺序
  • 否则,根据每周的第一天调整顺序

示例

// firstDayOfWeek = 1(周一)
// 返回:['周一', '周二', '周三', '周四', '周五', '周六', '周日']

// firstDayOfWeek = 0(周日)
// 返回:['周日', '周一', '周二', '周三', '周四', '周五', '周六']

1.5 Render 渲染函数

render() {
  const thead = this.hideHeader ? null : (<thead>
    {
      this.weekDays.map(day => <th key={day}>{ day }</th>)
    }
  </thead>);
  return (
    <table
      class={{
        'el-calendar-table': true,
        'is-range': this.isInRange
      }}
      cellspacing="0"
      cellpadding="0">
      {
        thead
      }
      <tbody>
        {
          this.rows.map((row, index) => <tr
            class={{
              'el-calendar-table__row': true,
              'el-calendar-table__row--hide-border': index === 0 && this.hideHeader
            }}
            key={index}>
            {
              row.map((cell, key) => <td key={key}
                class={ this.getCellClass(cell) }
                onClick={this.pickDay.bind(this, cell)}>
                <div class="el-calendar-day">
                  {
                    this.cellRenderProxy(cell)
                  }
                </div>
              </td>)
            }
          </tr>)
        }
      </tbody>
    </table>);
}

作用:渲染日历表格。

结构

  • <table>:表格容器
  • <thead>:表头(星期几)
  • <tbody>:表格主体(日期行)
  • <tr>:表格行
  • <td>:表格单元格

说明

  • 使用 JSX 语法渲染
  • 根据条件渲染表头
  • 动态绑定类名
  • 绑定点击事件

二、ElCalendar 组件详解

2.1 模板部分(Template)

2.1.1 日历头部
<div class="el-calendar__header">
  <div class="el-calendar__title">
    {{ i18nDate }}
  </div>
  <div class="el-calendar__button-group" v-if="validatedRange.length === 0">
    <el-button-group>
      <el-button type="plain" size="mini" @click="selectDate('prev-month')">
        {{ t('el.datepicker.prevMonth') }}
      </el-button>
      <el-button type="plain" size="mini" @click="selectDate('today')">
        {{ t('el.datepicker.today') }}
      </el-button>
      <el-button type="plain" size="mini" @click="selectDate('next-month')">
        {{ t('el.datepicker.nextMonth') }}
      </el-button>
    </el-button-group>
  </div>
</div>

作用:显示当前年月和导航按钮。

组成

  1. 标题:显示当前年月(国际化)
  2. 按钮组:上个月、今天、下个月

条件

  • 只在非范围模式下显示按钮组
2.1.2 日历主体(非范围模式)
<div class="el-calendar__body" v-if="validatedRange.length === 0" key="no-range">
  <date-table
    :date="date"
    :selected-day="realSelectedDay"
    :first-day-of-week="realFirstDayOfWeek"
    @pick="pickDay" />
</div>

作用:渲染单个日期表格。

条件

  • 非范围模式

传递的属性

  • date:当前显示的月份
  • selected-day:选中的日期
  • first-day-of-week:每周的第一天
2.1.3 日历主体(范围模式)
<div v-else class="el-calendar__body" key="has-range">
  <date-table
    v-for="(range, index) in validatedRange"
    :key="index"
    :date="range[0]"
    :selected-day="realSelectedDay"
    :range="range"
    :hide-header="index !== 0"
    :first-day-of-week="realFirstDayOfWeek"
    @pick="pickDay" />
</div>

作用:渲染多个日期表格(范围模式)。

条件

  • 范围模式

传递的属性

  • date:范围的开始日期
  • range:日期范围
  • hide-header:第一个表格显示表头,后续表格隐藏

2.2 脚本部分(Script)

2.2.1 常量定义
const validTypes = ['prev-month', 'today', 'next-month'];
const weekDays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const oneDay = 86400000;

作用:定义常量。

说明

  • validTypes:有效的日期选择类型
  • weekDays:星期几的英文名称
  • oneDay:一天的毫秒数(24 * 60 * 60 * 1000)
2.2.2 Mixins 混入
mixins: [Locale]

作用:混入国际化 mixin。

说明

  • 提供 t() 方法用于翻译
  • 自动处理语言切换
2.2.3 Props 属性
value
value: [Date, String, Number]

作用:当前选中的日期。

类型

  • Date 对象
  • 字符串(日期字符串)
  • 数字(时间戳)
range
range: {
  type: Array,
  validator(range) {
    if (Array.isArray(range)) {
      return range.length === 2 && range.every(
        item => typeof item === 'string' ||
        typeof item === 'number' ||
        item instanceof Date);
    } else {
      return true;
    }
  }
}

作用:日期范围。

格式[start, end]

验证器

  • 确保数组长度为 2
  • 确保每个元素是有效的日期类型
firstDayOfWeek
firstDayOfWeek: {
  type: Number,
  default: 1
}

作用:每周的第一天。

类型:数字

默认值:1(周一)

范围:0-6

2.2.4 Provide 提供
provide() {
  return {
    elCalendar: this
  };
}

作用:向子组件提供当前组件实例。

说明

  • 子组件可以通过 inject 访问
  • 用于获取国际化设置和自定义插槽
2.2.5 Methods 方法
pickDay
pickDay(day) {
  this.realSelectedDay = day;
}

作用:选择日期。

逻辑

  • 更新 realSelectedDay 计算属性
  • 触发 input 事件
selectDate
selectDate(type) {
  if (validTypes.indexOf(type) === -1) {
    throw new Error(`invalid type ${type}`);
  }
  let day = '';
  if (type === 'prev-month') {
    day = `${this.prevMonthDatePrefix}-01`;
  } else if (type === 'next-month') {
    day = `${this.nextMonthDatePrefix}-01`;
  } else {
    day = this.formatedToday;
  }

  if (day === this.formatedDate) return;
  this.pickDay(day);
}

作用:选择日期类型(上个月、今天、下个月)。

参数

  • type:日期类型('prev-month'、'today'、'next-month')

逻辑

  1. 验证类型有效性
  2. 根据类型计算目标日期
  3. 如果目标日期与当前日期相同,不处理
  4. 否则,选择目标日期
toDate
toDate(val) {
  if (!val) {
    throw new Error('invalid val');
  }
  return val instanceof Date ? val : new Date(val);
}

作用:将值转换为 Date 对象。

逻辑

  • 如果已经是 Date 对象,直接返回
  • 否则,转换为 Date 对象
rangeValidator
rangeValidator(date, isStart) {
  const firstDayOfWeek = this.realFirstDayOfWeek;
  const expected = isStart ? firstDayOfWeek : (firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1);
  const message = `${isStart ? 'start' : 'end'} of range should be ${weekDays[expected]}.`;
  if (date.getDay() !== expected) {
    console.warn('[ElementCalendar]', message, 'Invalid range will be ignored.');
    return false;
  }
  return true;
}

作用:验证范围的开始或结束日期。

参数

  • date:要验证的日期
  • isStart:是否是开始日期

逻辑

  1. 计算期望的星期几
  2. 检查日期的星期几是否匹配
  3. 如果不匹配,输出警告并返回 false
  4. 否则,返回 true
2.2.6 Computed 计算属性
prevMonthDatePrefix
prevMonthDatePrefix() {
  const temp = new Date(this.date.getTime());
  temp.setDate(0);
  return fecha.format(temp, 'yyyy-MM');
}

作用:获取上个月的日期前缀。

curMonthDatePrefix
curMonthDatePrefix() {
  return fecha.format(this.date, 'yyyy-MM');
}

作用:获取当前月的日期前缀。

nextMonthDatePrefix
nextMonthDatePrefix() {
  const temp = new Date(this.date.getFullYear(), this.date.getMonth() + 1, 1);
  return fecha.format(temp, 'yyyy-MM');
}

作用:获取下个月的日期前缀。

formatedDate
formatedDate() {
  return fecha.format(this.date, 'yyyy-MM-dd');
}

作用:获取格式化的当前日期。

i18nDate
i18nDate() {
  const year = this.date.getFullYear();
  const month = this.date.getMonth() + 1;
  return `${year} ${this.t('el.datepicker.year')} ${this.t('el.datepicker.month' + month)}`;
}

作用:获取国际化的日期显示(年月)。

示例

// 中文:'2024 年 1 月'
// 英文:'2024 January'
formatedToday
formatedToday() {
  return fecha.format(this.now, 'yyyy-MM-dd');
}

作用:获取格式化的今天日期。

realSelectedDay
realSelectedDay: {
  get() {
    if (!this.value) return this.selectedDay;
    return this.formatedDate;
  },
  set(val) {
    this.selectedDay = val;
    const date = new Date(val);
    this.$emit('input', date);
  }
}

作用:实际选中的日期(计算属性)。

getter

  • 如果没有 value,使用 selectedDay
  • 否则,使用格式化的当前日期

setter

  • 更新 selectedDay
  • 将字符串转换为 Date 对象
  • 触发 input 事件
date
date() {
  if (!this.value) {
    if (this.realSelectedDay) {
      const d = this.selectedDay.split('-');
      return new Date(d[0], d[1] - 1, d[2]);
    } else if (this.validatedRange.length) {
      return this.validatedRange[0][0];
    }
    return this.now;
  } else {
    return this.toDate(this.value);
  }
}

作用:当前显示的日期。

逻辑

  1. 如果没有 value
    • 如果有选中的日期,使用选中的日期
    • 如果有范围,使用范围的开始日期
    • 否则,使用今天的日期
  2. 否则,将 value 转换为 Date 对象
validatedRange
validatedRange() {
  let range = this.range;
  if (!range) return [];
  range = range.reduce((prev, val, index) => {
    const date = this.toDate(val);
    if (this.rangeValidator(date, index === 0)) {
      prev = prev.concat(date);
    }
    return prev;
  }, []);
  if (range.length === 2) {
    const [start, end] = range;
    if (start > end) {
      console.warn('[ElementCalendar]end time should be greater than start time');
      return [];
    }
    if (validateRangeInOneMonth(start, end)) {
      return [[start, end]];
    }
    const data = [];
    let startDay = new Date(start.getFullYear(), start.getMonth() + 1, 1);
    const lastDay = this.toDate(startDay.getTime() - oneDay);
    if (!validateRangeInOneMonth(startDay, end)) {
      console.warn('[ElementCalendar]start time and end time interval must not exceed two months');
      return [];
    }
    data.push([start, lastDay]);
    const firstDayOfWeek = this.realFirstDayOfWeek;
    const nextMontFirstDay = startDay.getDay();
    let interval = 0;
    if (nextMontFirstDay !== firstDayOfWeek) {
      if (firstDayOfWeek === 0) {
        interval = 7 - nextMontFirstDay;
      } else {
        interval = firstDayOfWeek - nextMontFirstDay;
        interval = interval > 0 ? interval : 7 + interval;
      }
    }
    startDay = this.toDate(startDay.getTime() + interval * oneDay);
    if (startDay.getDate() < end.getDate()) {
      data.push([startDay, end]);
    }
    return data;
  }
  return [];
}

作用:验证并计算日期范围。

逻辑

  1. 验证范围的开始和结束日期
  2. 检查结束日期是否大于开始日期
  3. 如果在同一个月,返回单个范围
  4. 如果跨月,计算两个月的范围
  5. 确保不超过两个月
realFirstDayOfWeek
realFirstDayOfWeek() {
  if (this.firstDayOfWeek < 1 || this.firstDayOfWeek > 6) {
    return 0;
  }
  return Math.floor(this.firstDayOfWeek);
}

作用:实际的每周第一天。

逻辑

  • 验证 firstDayOfWeek 的值是否在 0-6 之间
  • 如果不在范围内,返回 0(周日)
  • 否则,返回向下取整的值
2.2.7 Data 数据
data() {
  return {
    selectedDay: '',
    now: new Date()
  };
}

作用:定义组件的响应式数据。

字段

  • selectedDay:内部状态,选中的日期
  • now:当前时间

三、组件使用示例

3.1 基础用法

<template>
  <el-calendar v-model="value"></el-calendar>
</template>

<script>
export default {
  data() {
    return {
      value: new Date()
    }
  }
}
</script>

说明:最简单的用法,绑定当前日期。

3.2 自定义每周第一天

<template>
  <el-calendar v-model="value" :first-day-of-week="0"></el-calendar>
</template>

说明:设置每周的第一天为周日。

3.3 日期范围

<template>
  <el-calendar :range="['2024-01-01', '2024-01-15']"></el-calendar>
</template>

说明:显示日期范围内的日历。

3.4 自定义日期单元格

<template>
  <el-calendar v-model="value">
    <template #dateCell="{ date, data }">
      <div>{{ date.getDate() }}</div>
      <div v-if="data.isSelected" class="selected">已选择</div>
    </template>
  </el-calendar>
</template>

说明:使用作用域插槽自定义日期单元格的渲染。

3.5 监听日期选择

<template>
  <el-calendar v-model="value" @input="handleDateChange"></el-calendar>
</template>

<script>
export default {
  data() {
    return {
      value: new Date()
    }
  },
  methods: {
    handleDateChange(date) {
      console.log('选择的日期:', date);
    }
  }
}
</script>

说明:监听日期变化事件。

3.6 国际化

<template>
  <el-calendar v-model="value"></el-calendar>
</template>

<script>
import ElementUI from 'element-ui';
import locale from 'element-ui/lib/locale/lang/en';

Vue.use(ElementUI, { locale });
</script>

说明:切换语言为英文。


四、核心知识点总结

4.1 日期处理

  1. 日期格式化

    • 使用 fecha.format() 格式化日期
    • 支持多种格式('yyyy-MM-dd'、'yyyy-MM' 等)
  2. 日期计算

    • 使用 Date 对象进行日期计算
    • 设置日期、月份、年份
  3. 日期验证

    • 验证日期范围
    • 检查星期几

4.2 组件通信

  1. Provide/Inject

    • 父组件提供实例
    • 子组件注入实例
    • 跨层级通信
  2. Props/Events

    • Props:父组件向子组件传递数据
    • Events:子组件向父组件传递数据

4.3 计算属性

  1. 复杂计算

    • 封装复杂的计算逻辑
    • 响应式更新
  2. Getter/Setter

    • realSelectedDay 使用 getter/setter
    • 实现双向绑定

4.4 渲染函数

  1. JSX 语法

    • 使用 JSX 渲染复杂的结构
    • 动态生成 DOM
  2. 条件渲染

    • 根据条件渲染不同的内容
    • 使用三元运算符

4.5 国际化

  1. Mixin 混入

    • 混入 Locale mixin
    • 提供 t() 方法
  2. 动态翻译

    • 根据语言切换显示
    • 支持多语言

五、常见问题

5.1 如何设置日历的初始日期?

方法:使用 v-model 绑定日期。

<template>
  <el-calendar v-model="value"></el-calendar>
</template>

<script>
export default {
  data() {
    return {
      value: new Date('2024-01-15')
    }
  }
}
</script>

5.2 如何自定义日期单元格的样式?

方法:使用作用域插槽。

<template>
  <el-calendar v-model="value">
    <template #dateCell="{ date, data }">
      <div :class="{ 'is-today': isToday(date) }">
        {{ date.getDate() }}
      </div>
    </template>
  </el-calendar>
</template>

<script>
export default {
  methods: {
    isToday(date) {
      const today = new Date();
      return date.getDate() === today.getDate() &&
             date.getMonth() === today.getMonth() &&
             date.getFullYear() === today.getFullYear();
    }
  }
}
</script>

5.3 如何实现日期范围选择?

方法:使用 range 属性。

<template>
  <el-calendar :range="range"></el-calendar>
</template>

<script>
export default {
  data() {
    return {
      range: ['2024-01-01', '2024-01-15']
    }
  }
}
</script>

5.4 如何设置每周的第一天?

方法:使用 first-day-of-week 属性。

<template>
  <el-calendar v-model="value" :first-day-of-week="1"></el-calendar>
</template>

可选值

  • 0:周日
  • 1:周一
  • 2:周二
  • ...
  • 6:周六

5.5 如何监听日期变化?

方法:监听 input 事件。

<template>
  <el-calendar v-model="value" @input="handleDateChange"></el-calendar>
</template>

<script>
export default {
  methods: {
    handleDateChange(date) {
      console.log('日期变化:', date);
    }
  }
}
</script>

六、扩展建议

6.1 功能扩展

  1. 多选日期:支持选择多个日期
  2. 禁用日期:添加 disabled-date 属性
  3. 快捷选项:添加快捷日期选择
  4. 日期标记:在特定日期上显示标记
  5. 拖拽选择:支持拖拽选择日期范围

6.2 样式扩展

  1. 主题定制:支持多种主题颜色
  2. 自定义样式:允许覆盖默认样式
  3. 动画效果:添加切换动画
  4. 响应式设计:适配移动端

6.3 交互扩展

  1. 悬停提示:添加 Tooltip
  2. 右键菜单:添加右键菜单功能
  3. 键盘导航:支持键盘快捷键
  4. 触摸手势:支持移动端手势

七、总结

ElCalendar 是一个功能完善的日历组件,由两个子组件组成。通过学习这个组件,我们掌握了:

  1. 日期处理:日期格式化、计算、验证
  2. 组件通信:Provide/Inject、Props/Events
  3. 计算属性:复杂计算、Getter/Setter
  4. 渲染函数:JSX 语法、条件渲染
  5. 国际化:Mixin 混入、动态翻译

这个组件的设计思路和实现方式展示了 Vue 组件开发的最佳实践,特别是:

  • 使用 Provide/Inject 实现跨层级通信
  • 通过计算属性实现双向绑定
  • 使用渲染函数处理复杂的 DOM 结构
  • 注重日期计算和验证