完善Element UI日期选择器的年范围选择功能

Element UI (Vue 2) 的 el-date-picker 功能强大,但遗憾的是缺少原生的年份范围选择支持。当项目需要筛选跨年度的数据时,这一缺失就成了痛点。为了填补这个空白并提升组件实用性,我对其进行了定制化扩展,成功添加了年范围选择能力。本博客将详细介绍这一功能的实现过程,帮助你轻松应对类似需求。

 准备工作

将 element 中的 date-picker 源码复制一份到项目中src下的components下,(源码位置:node_modules\element-ui\packages\date-picker)然后在 main.js 中使用

import DatePicker from './components/date-picker';
Vue.component('DatePicker', DatePicker);

 

<!-- 使用 -->
<DatePicker
    v-model="value"
    type="yearrange">
</DatePicker>

这个时候会报错,代码无法解析jsx语法错误。原因是这个源码中包含时间选择器,这个组件中引入滚动条ElScrollbar所以会报错,这里只需要日期选择器,所以可以注释掉这几行代码

src\components\date-picker\src\basic\time-spinner.vue

// import ElScrollbar from 'element-ui/packages/scrollbar';

// components: { ElScrollbar },

src\components\date-picker\src\panel\time-select.vue

// import ElScrollbar from 'element-ui/packages/scrollbar';

// components: { ElScrollbar },

如果有eslint代码检查,需要把src/components/date-picker加入到忽略文件中不然会报出很多代码格式的错误和警告 。

年范围选择完善

1、在src\components\date-picker\src\panel 文件夹下新增文件 year-range.vue,定义年范围选择规则,代码如下:

<template>
  <transition
    name="el-zoom-in-top"
    @after-leave="$emit('dodestroy')"
  >
    <div
      v-show="visible"
      class="el-picker-panel el-date-range-picker el-popper"
      :class="[
        {
          'has-sidebar': $slots.sidebar || shortcuts,
        },
        popperClass,
      ]"
    >
      <div class="el-picker-panel__body-wrapper">
        <slot
          name="sidebar"
          class="el-picker-panel__sidebar"
        ></slot>
        <div
          class="el-picker-panel__sidebar"
          v-if="shortcuts"
        >
          <button
            type="button"
            class="el-picker-panel__shortcut"
            v-for="(shortcut, key) in shortcuts"
            :key="key"
            @click="handleShortcutClick(shortcut)"
          >
            {{ shortcut.text }}
          </button>
        </div>
        <div class="el-picker-panel__body">
          <div class="el-picker-panel__content el-date-range-picker__content is-left">
            <div class="el-date-range-picker__header">
              <button
                type="button"
                @click="leftPrevDecade"
                class="el-picker-panel__icon-btn el-icon-d-arrow-left"
              ></button>
              <button
                type="button"
                v-if="unlinkPanels"
                @click="leftNextDecade"
                :disabled="!enableDecadeArrow"
                :class="{ 'is-disabled': !enableDecadeArrow }"
                class="el-picker-panel__icon-btn el-icon-d-arrow-right"
              ></button>
              <div>{{ leftDecadeLabel }}</div>
            </div>
            <year-table
              selection-mode="range"
              :date="leftDate"
              :default-value="defaultValue"
              :min-date="minDate"
              :max-date="maxDate"
              :range-state="rangeState"
              :disabled-date="disabledDate"
              @changerange="handleChangeRange"
              @pick="handleRangePick"
            >
            </year-table>
          </div>
          <div class="el-picker-panel__content el-date-range-picker__content is-right">
            <div class="el-date-range-picker__header">
              <button
                type="button"
                v-if="unlinkPanels"
                @click="rightPrevDecade"
                :disabled="!enableDecadeArrow"
                :class="{ 'is-disabled': !enableDecadeArrow }"
                class="el-picker-panel__icon-btn el-icon-d-arrow-left"
              ></button>
              <button
                type="button"
                @click="rightNextDecade"
                class="el-picker-panel__icon-btn el-icon-d-arrow-right"
              ></button>
              <div>{{ rightDecadeLabel }}</div>
            </div>
            <year-table
              selection-mode="range"
              :date="rightDate"
              :default-value="defaultValue"
              :min-date="minDate"
              :max-date="maxDate"
              :range-state="rangeState"
              :disabled-date="disabledDate"
              @changerange="handleChangeRange"
              @pick="handleRangePick"
            >
            </year-table>
          </div>
        </div>
      </div>
    </div>
  </transition>
</template>

<script type="text/babel">
import { isDate, modifyWithTimeString, prevYear, nextYear, nextMonth } from 'element-ui/src/utils/date-util';
import Clickoutside from 'element-ui/src/utils/clickoutside';
import Locale from 'element-ui/src/mixins/locale';
import YearTable from '../basic/year-table';
import ElInput from 'element-ui/packages/input';
import ElButton from 'element-ui/packages/button';

// 计算十年的起始年份
const getDecadeStartYear = (year) => {
  return Math.floor(year / 10) * 10;
};

// 计算默认值
const calcDefaultValue = (defaultValue) => {
  if (Array.isArray(defaultValue)) {
    return [new Date(defaultValue[0]), new Date(defaultValue[1])];
  } else if (defaultValue) {
    const startYear = getDecadeStartYear(new Date(defaultValue).getFullYear());
    return [new Date(startYear, 0, 1), new Date(startYear + 9, 11, 31)];
  } else {
    const now = new Date();
    const startYear = getDecadeStartYear(now.getFullYear());
    return [new Date(startYear, 0, 1), new Date(startYear + 9, 11, 31)];
  }
};

// 获取下一个十年的日期
const nextDecade = (date) => {
  const year = date.getFullYear();
  const month = date.getMonth();
  const day = date.getDate();
  return new Date(year + 10, month, day);
};

// 获取上一个十年的日期
const prevDecade = (date) => {
  const year = date.getFullYear();
  const month = date.getMonth();
  const day = date.getDate();
  return new Date(year - 10, month, day);
};

export default {
  mixins: [Locale],

  directives: { Clickoutside },

  computed: {
    btnDisabled() {
      return !(this.minDate && this.maxDate && !this.selecting && this.isValidValue([this.minDate, this.maxDate]));
    },

    leftDecadeLabel() {
      const startYear = getDecadeStartYear(this.leftDate.getFullYear());
      return `${startYear} - ${startYear + 9}`;
    },

    rightDecadeLabel() {
      const startYear = getDecadeStartYear(this.rightDate.getFullYear());
      return `${startYear} - ${startYear + 9}`;
    },

    leftDecadeYear() {
      return getDecadeStartYear(this.leftDate.getFullYear());
    },

    rightDecadeYear() {
      const leftDecadeYear = getDecadeStartYear(this.leftDate.getFullYear());
      const rightDecadeYear = getDecadeStartYear(this.rightDate.getFullYear());
      return leftDecadeYear === rightDecadeYear ? leftDecadeYear + 10 : rightDecadeYear;
    },

    enableDecadeArrow() {
      return this.unlinkPanels && this.rightDecadeYear > this.leftDecadeYear + 10;
    },
  },

  data() {
    return {
      popperClass: '',
      value: [],
      defaultValue: null,
      defaultTime: null,
      minDate: '',
      maxDate: '',
      leftDate: new Date(),
      rightDate: nextDecade(new Date()),
      rangeState: {
        endDate: null,
        selecting: false,
        row: null,
        column: null,
      },
      shortcuts: '',
      visible: '',
      disabledDate: '',
      format: '',
      arrowControl: false,
      unlinkPanels: false,
    };
  },

  watch: {
    value(newVal) {
      if (!newVal) {
        this.minDate = null;
        this.maxDate = null;
      } else if (Array.isArray(newVal)) {
        this.minDate = isDate(newVal[0]) ? new Date(newVal[0]) : null;
        this.maxDate = isDate(newVal[1]) ? new Date(newVal[1]) : null;
        if (this.minDate) {
          this.leftDate = this.minDate;
          if (this.unlinkPanels && this.maxDate) {
            const minDateYear = getDecadeStartYear(this.minDate.getFullYear());
            const maxDateYear = getDecadeStartYear(this.maxDate.getFullYear());
            this.rightDate = minDateYear === maxDateYear ? nextDecade(this.maxDate) : this.maxDate;
          } else {
            this.rightDate = nextDecade(this.leftDate);
          }
        } else {
          this.leftDate = calcDefaultValue(this.defaultValue)[0];
          this.rightDate = nextDecade(this.leftDate);
        }
      }
    },

    defaultValue(val) {
      if (!Array.isArray(this.value)) {
        const [left, right] = calcDefaultValue(val);
        this.leftDate = left;
        this.rightDate = val && val[1] && getDecadeStartYear(left.getFullYear()) !== getDecadeStartYear(right.getFullYear()) && this.unlinkPanels ? right : nextDecade(this.leftDate);
      }
    },
  },

  methods: {
    handleClear() {
      this.minDate = null;
      this.maxDate = null;
      this.leftDate = calcDefaultValue(this.defaultValue)[0];
      this.rightDate = nextDecade(this.leftDate);
      this.$emit('pick', null);
    },

    handleChangeRange(val) {
      this.minDate = val.minDate;
      this.maxDate = val.maxDate;
      this.rangeState = val.rangeState;
    },

    handleRangePick(val, close = true) {
      const defaultTime = this.defaultTime || [];
      const minDate = modifyWithTimeString(val.minDate, defaultTime[0]);
      const maxDate = modifyWithTimeString(val.maxDate, defaultTime[1]);
      if (this.maxDate === maxDate && this.minDate === minDate) {
        return;
      }
      this.onPick && this.onPick(val);
      this.maxDate = maxDate;
      this.minDate = minDate;

      // workaround for https://github.com/ElemeFE/element/issues/7539, should remove this block when we don't have to care about Chromium 55 - 57
      setTimeout(() => {
        this.maxDate = maxDate;
        this.minDate = minDate;
      }, 10);
      if (!close) return;
      this.handleConfirm();
    },

    handleShortcutClick(shortcut) {
      if (shortcut.onClick) {
        shortcut.onClick(this);
      }
    },

    // leftPrev*, rightNext* need to take care of `unlinkPanels`
    leftPrevDecade() {
      this.leftDate = prevDecade(this.leftDate);
      if (!this.unlinkPanels) {
        this.rightDate = prevDecade(this.rightDate);
      }
    },

    rightNextDecade() {
      if (!this.unlinkPanels) {
        this.leftDate = nextDecade(this.leftDate);
      }
      this.rightDate = nextDecade(this.rightDate);
    },

    // leftNext*, rightPrev* are called when `unlinkPanels` is true
    leftNextDecade() {
      this.leftDate = nextDecade(this.leftDate);
    },

    rightPrevDecade() {
      this.rightDate = prevDecade(this.rightDate);
    },

    handleConfirm(visible = false) {
      if (this.isValidValue([this.minDate, this.maxDate])) {
        this.$emit('pick', [this.minDate, this.maxDate], visible);
      }
    },

    isValidValue(value) {
      return (
        Array.isArray(value) &&
        value &&
        value[0] &&
        value[1] &&
        isDate(value[0]) &&
        isDate(value[1]) &&
        value[0].getTime() <= value[1].getTime() &&
        (typeof this.disabledDate === 'function' ? !this.disabledDate(value[0]) && !this.disabledDate(value[1]) : true)
      );
    },

    resetView() {
      // NOTE: this is a hack to reset {min, max}Date on picker open.
      // TODO: correct way of doing so is to refactor {min, max}Date to be dependent on value and internal selection state
      //       an alternative would be resetView whenever picker becomes visible, should also investigate date-panel's resetView
      this.minDate = this.value && isDate(this.value[0]) ? new Date(this.value[0]) : null;
      this.maxDate = this.value && isDate(this.value[0]) ? new Date(this.value[1]) : null;
    },
  },

  components: { YearTable, ElInput, ElButton },
};
</script>

2、完善 basic 文件夹下 year-table.vue 的逻辑,添加年范围选择规则

<template>
  <table
    @click="handleYearTableClick"
    @mousemove="handleMouseMove"
    class="el-year-table"
  >
    <tbody>
      <tr>
        <td
          class="available"
          :class="getCellStyle(startYear + 0)"
        >
          <div>
            <a class="cell">{{ startYear }}</a>
          </div>
        </td>
        <td
          class="available"
          :class="getCellStyle(startYear + 1)"
        >
          <div>
            <a class="cell">{{ startYear + 1 }}</a>
          </div>
        </td>
        <td
          class="available"
          :class="getCellStyle(startYear + 2)"
        >
          <div>
            <a class="cell">{{ startYear + 2 }}</a>
          </div>
        </td>
        <td
          class="available"
          :class="getCellStyle(startYear + 3)"
        >
          <div>
            <a class="cell">{{ startYear + 3 }}</a>
          </div>
        </td>
      </tr>
      <tr>
        <td
          class="available"
          :class="getCellStyle(startYear + 4)"
        >
          <div>
            <a class="cell">{{ startYear + 4 }}</a>
          </div>
        </td>
        <td
          class="available"
          :class="getCellStyle(startYear + 5)"
        >
          <div>
            <a class="cell">{{ startYear + 5 }}</a>
          </div>
        </td>
        <td
          class="available"
          :class="getCellStyle(startYear + 6)"
        >
          <div>
            <a class="cell">{{ startYear + 6 }}</a>
          </div>
        </td>
        <td
          class="available"
          :class="getCellStyle(startYear + 7)"
        >
          <div>
            <a class="cell">{{ startYear + 7 }}</a>
          </div>
        </td>
      </tr>
      <tr>
        <td
          class="available"
          :class="getCellStyle(startYear + 8)"
        >
          <div>
            <a class="cell">{{ startYear + 8 }}</a>
          </div>
        </td>
        <td
          class="available"
          :class="getCellStyle(startYear + 9)"
        >
          <div>
            <a class="cell">{{ startYear + 9 }}</a>
          </div>
        </td>
        <td></td>
        <td></td>
      </tr>
    </tbody>
  </table>
</template>

<script type="text/babel">
import { hasClass } from 'element-ui/src/utils/dom';
import { isDate, range, nextDate, getDayCountOfYear } from 'element-ui/src/utils/date-util';
import { arrayFindIndex, coerceTruthyValueToArray } from 'element-ui/src/utils/util';

const datesInYear = (year) => {
  const numOfDays = getDayCountOfYear(year);
  const firstDay = new Date(year, 0, 1);
  return range(numOfDays).map((n) => nextDate(firstDay, n));
};

// 将年份转换为时间戳
const getYearTimestamp = function (year) {
  if (typeof year === 'number') {
    return new Date(year, 0, 1).getTime();
  } else if (year instanceof Date) {
    return new Date(year.getFullYear(), 0, 1).getTime();
  } else {
    return NaN;
  }
};

export default {
  props: {
    disabledDate: {},
    value: {},
    selectionMode: {
      default: 'year',
    },
    minDate: {},
    maxDate: {},
    defaultValue: {
      validator(val) {
        // null or valid Date Object
        return val === null || (val instanceof Date && isDate(val)) || (Array.isArray(val) && val.every(isDate));
      },
    },
    date: {},
    rangeState: {
      default: () => ({
        endDate: null,
        selecting: false,
        row: null,
        column: null,
      }),
    },
  },

  data() {
    return {
      lastRow: null,
      lastColumn: null,
    };
  },

  watch: {
    'rangeState.endDate'(newVal) {
      this.markRange(this.minDate, newVal);
    },

    minDate(newVal, oldVal) {
      if (getYearTimestamp(newVal) !== getYearTimestamp(oldVal)) {
        this.markRange(this.minDate, this.maxDate);
      }
    },

    maxDate(newVal, oldVal) {
      if (getYearTimestamp(newVal) !== getYearTimestamp(oldVal)) {
        this.markRange(this.minDate, this.maxDate);
      }
    },
  },

  computed: {
    startYear() {
      return Math.floor(this.date.getFullYear() / 10) * 10;
    },
  },

  methods: {
    getCellStyle(year) {
      const style = {};
      const today = new Date();

      style.disabled = typeof this.disabledDate === 'function' ? datesInYear(year).every(this.disabledDate) : false;
      style.current = arrayFindIndex(coerceTruthyValueToArray(this.value), (date) => date.getFullYear() === year) >= 0;
      style.today = today.getFullYear() === year;
      style.default = this.defaultValue && this.defaultValue.getFullYear && this.defaultValue.getFullYear() === year;

      // 添加范围选择的样式
      if (this.selectionMode === 'range' || this.selectionMode === 'daterange') {
        const yearTimestamp = getYearTimestamp(year);
        const minDateTimestamp = this.minDate ? getYearTimestamp(this.minDate) : -1;
        const maxDateTimestamp = this.maxDate ? getYearTimestamp(this.maxDate) : -1;
        const rangeEndTimestamp = this.rangeState.endDate ? getYearTimestamp(this.rangeState.endDate) : -1;

        // 处理选择中的范围
        if (this.rangeState.selecting && minDateTimestamp > -1 && rangeEndTimestamp > -1) {
          const [start, end] = [Math.min(minDateTimestamp, rangeEndTimestamp), Math.max(minDateTimestamp, rangeEndTimestamp)];
          style['in-range'] = yearTimestamp >= start && yearTimestamp <= end;
        } else if (minDateTimestamp > -1 && maxDateTimestamp > -1) {
          style['in-range'] = yearTimestamp >= minDateTimestamp && yearTimestamp <= maxDateTimestamp;
        }
        style['start-date'] = minDateTimestamp > -1 && yearTimestamp === minDateTimestamp;
        style['end-date'] = (maxDateTimestamp > -1 && yearTimestamp === maxDateTimestamp) || (minDateTimestamp > -1 && yearTimestamp === rangeEndTimestamp);
      }

      return style;
    },

    getYearOfCell(year) {
      return new Date(year, 0, 1);
    },

    markRange(minDate, maxDate) {
      // 标记范围内的年份
      if (!minDate && !maxDate) return;
      const startYear = this.startYear;
      const rows = [0, 1, 2]; // 3行
      const cells = [0, 1, 2, 3]; // 每行4列

      for (let i = 0; i < 3; i++) {
        for (let j = 0; j < 4; j++) {
          const index = i * 4 + j;
          if (index < 10) {
            // 只有前10个单元格有年份
            const yearValue = startYear + index;
            const yearTimestamp = getYearTimestamp(yearValue);
            const minDateTimestamp = minDate ? getYearTimestamp(minDate) : -1;
            const maxDateTimestamp = maxDate ? getYearTimestamp(maxDate) : -1;

            // 更新样式
            this.$forceUpdate();
          }
        }
      }
    },

    handleMouseMove(event) {
      if (!this.rangeState.selecting) return;

      let target = event.target;
      if (target.tagName === 'A') {
        target = target.parentNode.parentNode;
      }
      if (target.tagName === 'DIV') {
        target = target.parentNode;
      }
      if (target.tagName !== 'TD') return;

      const row = target.parentNode.rowIndex;
      const column = target.cellIndex;
      // 计算年份
      const startYear = this.startYear;
      const yearIndex = row * 4 + column;
      if (yearIndex >= 10) return; // 只有前10个单元格有年份

      const year = startYear + yearIndex;
      // 检查是否禁用
      if (this.getCellStyle(year).disabled) return;

      // 只在鼠标移动到新单元格时更新rangeState
      if (row !== this.lastRow || column !== this.lastColumn) {
        this.lastRow = row;
        this.lastColumn = column;
        this.$emit('changerange', {
          minDate: this.minDate,
          maxDate: this.maxDate,
          rangeState: {
            selecting: true,
            endDate: this.getYearOfCell(year),
          },
        });
      }
    },

    handleYearTableClick(event) {
      const target = event.target;
      let year;
      if (target.tagName === 'DIV') {
        const aTag = target.querySelector('a');
        if (aTag && !hasClass(target, 'disabled')) {
          year = aTag.textContent || aTag.innerText;
        } else {
          return;
        }
      } else if (target.tagName === 'A') {
        if (hasClass(target.parentNode, 'disabled')) return;
        year = target.textContent || target.innerText;
      } else if (target.tagName === 'TD' && !hasClass(target, 'disabled')) {
        const cell = target.querySelector('.cell');
        if (cell) {
          year = cell.textContent || cell.innerText;
        }
      } else {
        return;
      }

      if (!year) return;
      year = Number(year);

      if (this.selectionMode === 'range' || this.selectionMode === 'daterange') {
        if (!this.rangeState.selecting) {
          // 开始选择范围
          this.$emit('pick', { minDate: this.getYearOfCell(year), maxDate: null });
          this.rangeState.selecting = true;
        } else {
          // 完成范围选择
          const minDate = this.minDate;
          const maxDate = this.getYearOfCell(year);

          if (minDate && maxDate) {
            const minYear = minDate.getFullYear();
            const maxYear = maxDate.getFullYear();
            if (maxYear < minYear) {
              // 如果结束年份小于开始年份,交换它们
              this.$emit('pick', { minDate: maxDate, maxDate: minDate });
            } else {
              this.$emit('pick', { minDate, maxDate });
            }
          }
          this.rangeState.selecting = false;
        }
      } else {
        this.$emit('pick', year);
      }
    },
  },
};
</script>
<style lang="scss">
.el-year-table td.start-date .cell,
.el-year-table td.end-date .cell {
  color: #ffffff;
  background-color: #409eff;
}
.el-year-table td .cell {
  border-radius: 18px;
}
.el-year-table td.start-date div {
  border-top-left-radius: 24px;
  border-bottom-left-radius: 24px;
}
.el-year-table td.end-date div {
  border-top-right-radius: 24px;
  border-bottom-right-radius: 24px;
}
.el-year-table td.in-range div {
  background-color: #f2f6fc;
}
.el-year-table td {
  padding: 20px 0;
}
</style>

3、修改 src\components\date-picker\src\picker\date-picker.js 文件中 getPanel 方法,当type传值为 yearrange 时指向刚创建的year-range.vue文件
 

import YearRangePanel from '../panel/year-range';

const getPanel = function (type) {
  if (type === 'daterange' || type === 'datetimerange') {
    return DateRangePanel;
  } else if (type === 'monthrange') {
    return MonthRangePanel;
  } else if (type === 'yearrange') {
    return YearRangePanel;
  }
  return DatePanel;
};

至此,我们已经详细拆解了为 Element UI (Vue 2) 的日期选择器添加年范围选择功能的具体实现方案。从识别原生组件的局限,到一步步定制逻辑,最终实现了符合业务需求的组件扩展。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值