实现element-plus季度范围选择器

3,885 阅读2分钟

element-plus作为前端开发人员应该是不陌生的,近期在业务开发的过程中需要实现一个季度范围选择和年份范围选择,而element-plus的datePicker组件的支持的范围选择类型有,datetimerangedaterangemonthrange,并不支持年范围选择和月范围选择。所以本文将介绍下如何基于element-plus封装一个范围选择器,先从季度开始。

完整演示效果

时间切换.gif

实现思路

  1. 首先应该实现一个年份的面板,可以实现年份的切换。
  2. 实现一个显示季度的表格组件,用来显示年份下的季度。ps:使用表格的原因是可以直接复用element-plus的样式。
  3. 根据传入的参数给单元格设置样式如:禁用、起始和结束样式、单元格在给定时间范围内的样式。

完整代码

面板切换组件
<template>
  <div class="el-picker-panel el-date-range-picker date-range-picker">
    <div class="el-picker-panel__body-wrapper">
      <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"
              class="el-picker-panel__icon-btn d-arrow-left"
              @click="leftPrevYear"
            >
              <el-icon><d-arrow-left /></el-icon>
            </button>
            <div>{{ leftLabel }}</div>
          </div>
          <BasicQuarterTable
            :min-date="minDate"
            :max-date="maxDate"
            :range-state="rangeState"
            :date="leftDate"
            :disabled-date="disabledDate"
            @pick="handleRangePick"
            @select="onSelect"
            @changerange="handleChangeRange"
          />
        </div>

        <div
          class="el-picker-panel__content el-date-range-picker__content is-right"
        >
          <div class="el-date-range-picker__header">
            <button
              type="button"
              class="el-picker-panel__icon-btn d-arrow-right"
              @click="rightNextYear"
            >
              <el-icon><d-arrow-right /></el-icon>
            </button>
            <div>{{ rightLabel }}</div>
          </div>
          <BasicQuarterTable
            :min-date="minDate"
            :max-date="maxDate"
            :range-state="rangeState"
            :date="rightDate"
            :disabled-date="disabledDate"
            @pick="handleRangePick"
            @select="onSelect"
            @changerange="handleChangeRange"
          />
        </div>
      </div>
    </div>
  </div>
</template>
<script>
import { defineComponent } from 'vue'
import { useDatePicker } from './hooks/useDatePicker'
import { useDatePanel } from './hooks/useDatePanel'
import BasicQuarterTable from './src/BasicQuarterTable'
export default defineComponent({
  name: 'QuarterPanel',
  components: {
    BasicQuarterTable
  },
  props: {
    range: {
      type: Array,
      default() {
        return null
      }
    },
    defaultValue: {
      type: Array,
      require: true,
      default() {
        return null
      }
    }
  },
  emits: ['pick'],
  setup(props) {
    const {
      leftDate,
      rightDate,
      minDate,
      maxDate,
      disabledDate,
      rangeState,
      onSelect,
      handleRangePick,
      handleChangeRange
    } = useDatePicker(props)
    const { leftPrevYear, rightNextYear, leftLabel, rightLabel } = useDatePanel(
      {
        leftDate,
        rightDate
      }
    )

    return {
      minDate,
      maxDate,
      disabledDate,
      rangeState,
      leftPrevYear,
      rightNextYear,
      leftLabel,
      rightLabel,
      leftDate,
      rightDate,
      onSelect,
      handleRangePick,
      handleChangeRange
    }
  }
})
</script>
<style>
.date-range-picker {
  border: 1px solid #ccc;
}
</style>
</script>
useDatePicker.js
import _ from 'lodash'
import { getCurrentInstance, watch, ref, unref } from 'vue'
import dayjs from 'dayjs'
export const useDatePicker = (props) => {
  // 时间范围 超出该范围禁用
  const { range } = props
  const { emit } = getCurrentInstance()
  // 默认值起始时间
  const minDate = ref()
  // 默认值结束时间
  const maxDate = ref()
  // 左侧面板时间 ps:面板时间与默认值时间无关 只是用于面板title显示 不需要过于纠结
  const leftDate = ref()
  // 右侧面板时间
  const rightDate = ref()

  const rangeState = ref({
    endDate: null,
    selecing: false
  })
  // 设置禁用
  const disabledDate = (cell) => {
    const [start, end] = range.map((item) => dayjs(item))

    return !(
      dayjs(cell).isSameOrAfter(start) && dayjs(cell).isSameOrBefore(end)
    )
  }

  const onSelect = (selecting) => {
    rangeState.value.selecting = selecting
    if (!selecting) {
      rangeState.value.endDate = null
    }
  }

  const handleChangeRange = (val) => {
    rangeState.value = val
  }

  const isValidRange = (range) => {
    if (!_.isArray(range)) return false

    const [left, right] = range

    return (
      dayjs.isDayjs(left) && dayjs.isDayjs(right) && left.isSameOrBefore(right)
    )
  }

  const handleRangeConfirm = () => {
    const _minDate = unref(minDate)
    const _maxDate = unref(maxDate)

    if (isValidRange([_minDate, _maxDate])) {
      // eslint-disable-next-line no-undef
      emit('pick', [_minDate, _maxDate])
    }
  }

  const handleRangePick = (val) => {
    const minDate_ = val.minDate
    const maxDate_ = val.maxDate
    if (maxDate.value === maxDate_ && minDate.value === minDate_) {
      return
    }
    maxDate.value = maxDate_
    minDate.value = minDate_

    handleRangeConfirm()
  }

  const restoreDefault = () => {
    // 将默认转为dayjs时间对象
    const [start, end] = props.defaultValue.map((item) => dayjs(item))
    minDate.value = start
    maxDate.value = end
    leftDate.value = start
    rightDate.value = start.add(1, 'year')
  }

  watch(
    () => props.defaultValue,
    (value) => {
      if (value) {
        restoreDefault()
      }
    },
    {
      immediate: true
    }
  )

  return {
    minDate,
    maxDate,
    leftDate,
    rightDate,
    rangeState,
    disabledDate,
    handleRangePick,
    onSelect,
    handleChangeRange
  }
}
useDatePanel.js
import { computed } from 'vue'
export const useDatePanel = ({ leftDate, rightDate }) => {
  const leftPrevYear = () => {
    leftDate.value = leftDate.value.subtract(1, 'year')
    rightDate.value = rightDate.value.subtract(1, 'year')
  }

  const rightNextYear = () => {
    leftDate.value = leftDate.value.add(1, 'year')
    rightDate.value = rightDate.value.add(1, 'year')
  }

  const leftLabel = computed(() => {
    return `${leftDate.value.year()}`
  })

  const rightLabel = computed(() => {
    return `${rightDate.value.year()}`
  })

  return {
    leftPrevYear,
    rightNextYear,
    leftLabel,
    rightLabel
  }
}


季度表格组件

<template>
  <!-- 使用element的类名进行样式复用 -->
  <table role="grid" class="el-month-table">
    <tbody>
      <tr>
        <td
          v-for="(item, key) in rows"
          :key="key"
          :class="getCellStyle(item)"
          @click="handleMonthTableClick"
          @mousemove="handleMouseMove"
        >
          <div>
            <span class="cell"> 第{{ item.text }}季度 </span>
          </div>
        </td>
      </tr>
    </tbody>
  </table>
</template>
<script>
// eslint-disable-next-line no-unused-vars
import { computed, defineComponent, ref } from 'vue'
import dayjs from 'dayjs'
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
dayjs.extend(quarterOfYear)
export default defineComponent({
  name: 'BasicQuarterTable',
  props: {
    minDate: {
      type: Object,
      default() {
        return null
      }
    },
    maxDate: {
      type: Object,
      default() {
        return null
      }
    },
    date: {
      type: Object,
      default() {
        return null
      }
    },
    disabledDate: {
      type: Function,
      default() {
        return null
      }
    },
    rangeState: {
      type: Object,
      default() {
        return null
      }
    }
  },
  emits: ['pick', 'select', 'changerange'],
  setup(props, { emit }) {
    const rows = computed(() => {
      const rows = []
      const now = dayjs().startOf('quarter')
      for (let i = 1; i < 5; i++) {
        // 初始化单元格对象 设置默认属性
        const cell = {
          type: 'normal',
          inRange: false,
          start: false,
          end: false,
          text: null,
          disabled: false
        }

        // 将props.date转换为Day.js对象,并将日期设置为所在年份的开始日期,然后获取第i个季度的开始日期
        const calTime = props.date.startOf('year').quarter(i)

        // 计算日期范围的结束日期
        const calEndDate =
          props.rangeState.endDate ||
          props.maxDate ||
          (props.rangeState.selecting && props.minDate) ||
          null

        // 判断当前季度是否在给定的日期范围内
        cell.inRange =
          !!(
            props.minDate &&
            calTime.isSameOrAfter(props.minDate, 'quarter') &&
            calEndDate &&
            calTime.isSameOrBefore(calEndDate, 'quarter')
          ) ||
          !!(
            props.minDate &&
            calTime.isSameOrBefore(props.minDate, 'quarter') &&
            calEndDate &&
            calTime.isSameOrAfter(calEndDate, 'quarter')
          )

        // 根据给定的日期范围设置单元格的开始和结束状态
        if (props.minDate.isSameOrAfter(calEndDate)) {
          cell.start = !!(calEndDate && calTime.isSame(calEndDate, 'quarter'))
          cell.end = props.minDate && calTime.isSame(props.minDate, 'quarter')
        } else {
          // 当前单元格日期与props.minDate相同,则设置为开始季度
          cell.start = !!(
            props.minDate && calTime.isSame(props.minDate, 'quarter')
          )
          // 当前单元格日期与props.minDate相同,则设置为开始季度
          cell.end = !!(calEndDate && calTime.isSame(calEndDate, 'quarter'))
        }
        // 判断当前季度是否是今天所在的季度,如果是,则将单元格类型设置为'today'
        const isToday = now.isSame(calTime)
        if (isToday) {
          cell.type = 'today'
        }
        cell.text = calTime.quarter()
        cell.disabled = props.disabledDate?.(calTime.toDate()) || false
        rows.push(cell)
      }
      return rows
    })
    
    // 动态计算单元格的样式
    const getCellStyle = (cell) => {
      const style = {}
      const year = props.date.year()
      const quarter = cell.text
      style.disabled = props.disabledDate
        ? props.disabledDate(
            dayjs().startOf('quarter').quarter(quarter).year(year)
          )
        : false

      style.today = cell.type === 'today'

      if (cell.inRange) {
        style['in-range'] = true

        if (cell.start) {
          style['start-date'] = true
        }

        if (cell.end) {
          style['end-date'] = true
        }
      }
      return style
    }

    const hasClass = (el, cls) => {
      if (!el || !cls) return false
      if (cls.includes(' '))
        throw new Error('className should not contain space.')
      return el.classList.contains(cls)
    }

    const handleMonthTableClick = (event) => {
      const target = event.target.closest('td')

      if (target?.tagName !== 'TD') return
      if (hasClass(target, 'disabled')) return

      const quarter = target.cellIndex + 1
      const newDate = props.date.startOf('year').quarter(quarter)

      // 点击单元格 触发pick事件 将结束时间设置为null 此时因为maxDate改变rows重新计算
      if (!props.rangeState.selecting) {
        emit('pick', { minDate: newDate, maxDate: null })
        emit('select', true)
      } else {
        if (props.minDate && newDate >= props.minDate) {
          emit('pick', { minDate: props.minDate, maxDate: newDate })
        } else {
          emit('pick', { minDate: newDate, maxDate: props.minDate })
        }
        emit('select', false)
      }
    }
    
    // 存储最后一次选择的列索引  避免重复触发事件引起rows重新计算
    const lastColumn = ref()

    // 鼠标移入时频繁的触发changrange事件 改变maxData 引起rows重新计算 更改样式
    const handleMouseMove = (event) => {
      if (!props.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 column = target.cellIndex
      if (rows.value[column].disabled) return
      if (column !== lastColumn.value) {
        lastColumn.value = column
        emit('changerange', {
          selecting: true,
          endDate: props.date.startOf('year').quarter(target.cellIndex + 1)
        })
      }
    }
    return {
      rows,
      getCellStyle,
      handleMonthTableClick,
      handleMouseMove
    }
  }
})
</script>