如何封装一款移动端的日期组件

274 阅读1分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动

TIP 👉 非淡泊无以明志,非宁静无以致远。诸葛亮《诫子书》

前言

在我们日常项目开发中,我们在做业务开发的时候会涉及日期选择的功能,所以封装了这款移动端的日期选择组件。

日期选择器

属性

1. defaultDate
  • 当前日期值
  • 值为Date对象或者Date数组
    • type 为 "single" 时为Date对象
    • type 为 "range" 时为Date数组
2. type
  • 日历类型
  • 值为字符串
    • single:选择单个日期
    • range:选择日期区间
3. minDate
  • 可选日期的最小值
  • 值为Date对象
4. maxDate
  • 可选日期的最大值
  • 值为Date对象
5. startDateText
  • 开始日期的文字信息(type 为 "range" 时有效)
  • 值为字符串
6. endDateText
  • 结束日期的文字信息(type 为 "range" 时有效)
  • 值为字符串
7. allowSameDay
  • 是否允许开始日期和结束日期是同一天(type 为 "range" 时有效)
  • 值为布尔类型,默认为:false
8. disabledDate
  • 判断不可用日期的方法
  • 值为Function类型
    • 参数:val {Date} Date类型日期值
    • 返回:isDisabled {Boolean} 是否为不可用日期

事件

1. select
  • 日期被选中事件
  • 参数
    • date {Date} 被选中的日期
2. confirm
  • 日期范围选中事件(type 为 "range" 时,才会触发)
  • 参数
    • date {Array} 时间范围开始日期和结束日期数组,数组长度为2

示例

1. 选择单个日期
<template>
  <div class="calendar-demo">
    <ul class="form-list">
      <li class="form-item" @click="popupBox1">
        <div class="item-content">
          <label>单选日期:</label>
          <span>{{value1}}</span>
        </div>
      </li>
    </ul>
  </div>
</template>
<script>
import popupFullBox from '@/components/m/fullBox'
import Calendar from '@/components/m/calendar'
import dateUtil from '@/assets/js/date'

export default {
  name: 'CalendarDemo',
  data () {
    return {
      value1: null
    }
  },
  methods: {
    popupBox1 () {
      let minDate = new Date()
      minDate.setMonth(minDate.getMonth() - 11)
      let maxDate = new Date()
      maxDate.setMonth(maxDate.getMonth() + 11)
      let defaultDate = this.value1 ? new Date(this.value1) : null
      let fullBox = popupFullBox({
        title: '日期选择',
        scroll: false,
        content: Calendar,
        contentProps: {
          defaultDate,
          minDate,
          maxDate,
          disabledDate: (date) => {
            if (date.getDay() === 0 || date.getDay() === 6) {
              return true
            }
          }
        },
        contentEvents: {
          select: (v) => {
            if (v && v instanceof Date) {
              this.value1 = dateUtil.format(v, 'yyyy-MM-dd')
              // 300毫秒后关闭窗口
              fullBox.close(300)
            }
          }
        }
      })
    }
  }
}
</script>
2. 选择日期区间
<template>
  <div class="calendar-demo">
    <ul class="form-list">
      <li class="form-item" @click="popupBox2">
        <div class="item-content">
          <label>选择日期区间:</label>
          <span v-if="value2 && value2.length === 2">{{value2[0] + ' 至 ' + value2[1]}}</span>
        </div>
      </li>
    </ul>
  </div>
</template>
<script>
import popupFullBox from '@/components/m/fullBox'
import Calendar from '@/components/m/calendar'
import dateUtil from '@/assets/js/date'

export default {
  name: 'CalendarDemo',
  data () {
    return {
      value1: null,
      value2: null
    }
  },
  methods: {
    popupBox2 () {
      let defaultDate = this.value2 && this.value2.length === 2 ? [new Date(this.value2[0]), new Date(this.value2[1])] : null
      let fullBox = popupFullBox({
        title: '日期选择',
        scroll: false,
        content: Calendar,
        contentProps: {
          type: 'range',
          defaultDate,
          startDateText: '出发',
          endDateText: '返程',
          allowSameDay: true
        },
        contentEvents: {
          confirm: (v) => {
            if (v && v instanceof Array && v.length >= 2) {
              this.value2 = [dateUtil.format(v[0], 'yyyy-MM-dd'), dateUtil.format(v[1], 'yyyy-MM-dd')]
              // 300毫秒后关闭窗口
              fullBox.close(300)
            }
          }
        }
      })
    }
  }
}
</script>

实现Calendar.vue

<template>
  <div class="calendar">
    <div class="calendar-header">
      <div class="header-cell"></div>
      <div class="header-cell"></div>
      <div class="header-cell"></div>
      <div class="header-cell"></div>
      <div class="header-cell"></div>
      <div class="header-cell"></div>
      <div class="header-cell"></div>
    </div>
    <div class="calendar-content" ref="calendarContent">
      <Scroll ref="scroll" :bounce="true"
              :pullDownRefresh="{ threshold: 40, stop: 20 }" :pullUpLoad="{ threshold: -60 }"
              @onPullUp="pullUpHandle" @onPullDown="pullDownHandle">
        <div class="months">
          <Month v-for="month in monthList" :key="month.getTime()" :ref="'month' + formatDate(month, 'yyyy-MM')"
            :monthDate="month"
            :value="curValue"
            :type="type"
            :minDate="minDate"
            :maxDate="maxDate"
            :startDateText="startDateText"
            :endDateText="endDateText"
            :disabledDate="disabledDate"
            @select="handleSelect"
            ></Month>
        </div>
      </Scroll>
    </div>
  </div>
</template>
<script>
import Scroll from '@/components/base/scroll'
import Month from './components/Month.vue'
import { formatDate, getFirstDayOfMonth, curMonthStartDate, getMonthList } from './js/dateUtil.js'

export default {
  name: 'Calendar',
  components: {
    Scroll,
    Month
  },
  props: {
    // 日期值
    defaultDate: {
      type: [Date, Array]
    },
    // 日历类型(single:选择单个日期,range:选择日期区间)
    type: {
      type: String,
      default: 'single'
    },
    // 可选范围最早日期
    minDate: {
      type: Date
      // default: () => getTodayDate()
    },
    // 可选范围最晚日期
    maxDate: {
      type: Date
      /* default: () => {
        let date = getTodayDate()
        date.setMonth(date.getMonth() + 6)
        return date
      } */
    },
    // type = 'range' 时(即选择日期区间时)开始日期的文字信息
    startDateText: {
      type: String,
      default: '开始'
    },
    // type = 'range' 时(即选择日期区间时)结束日期的文字信息
    endDateText: {
      type: String,
      default: '结束'
    },
    // type = 'range' 时(即选择日期区间时)是否允许开始日期和结束日期是同一天
    allowSameDay: {
      type: Boolean,
      default: false
    },
    /**
     * 不可用日期方法
     * @param val {Date} Date类型日期值
     * @return isDisabled {Boolean | Undefined} 是否为不可用日期(如果返回undefined,按默认逻辑处理)
     */
    disabledDate: Function
  },
  data () {
    return {
      // 当前选中日期值
      curValue: this.defaultDate,
      // 展示月份Date数组
      monthList: getMonthList(this.defaultDate, this.minDate, this.maxDate),
      // 是否正在上拉加载
      isPullUp: false,
      // 是否正在下拉加载
      isPullDown: false,
    }
  },
  mounted () {
    // 滚动条滚动到当前选中月份
    this.scrollToCurrentMonth()
  },
  methods: {
    // 处理日期选择事件
    handleSelect (cell) {
      if (this.type === 'single') { // 如果是选择单个日期
        this.curValue = cell.date
        this.$emit('select', this.curValue)
      } else { // 如果是选择日期区间
        if (
          !this.curValue || // 无选中日期
          // 不是只有开始日期的情况(未选中日期或者开始和结束日期都有)
          (this.curValue instanceof Array && this.curValue.length !== 1) ||
          // 只有开始日期,并且当前选中日期早于之前选的开始日期
          (this.curValue instanceof Array && this.curValue.length === 1 && cell.time < this.curValue[0].getTime())
        ) { // 认为选中日期为开始日期
          this.curValue = [cell.date]
          this.$emit('select', cell.date)
        } else if (
          // 只有开始日期
          this.curValue instanceof Array && this.curValue.length === 1 &&
          // 当前选中日期晚于之前选的开始日期 或者 允许开始日期和结束日期是同一天的情况下,当前选中日期等于之前选的开始日期
          (cell.time > this.curValue[0].getTime() || (this.allowSameDay && cell.time === this.curValue[0].getTime()))
        ) { // 认为选中日期为结束日期
          this.curValue.push(cell.date)
          this.$emit('select', cell.date)
          this.$emit('confirm', this.curValue)
        }
      }
    },
    // 格式化日期
    formatDate,
    // 滚动条滚动到当前选中月份
    scrollToCurrentMonth () {
      // 第一个月
      let firstMonth = null
      if (this.monthList && this.monthList.length > 0) {
        firstMonth = this.monthList[0]
      }

      // 当前月(选中日期的月份或者选中日期范围开始的月份,如果没有值则为当前月份)
      let curMonth = null
      if (this.curValue instanceof Date) {
        curMonth = getFirstDayOfMonth(this.curValue)
      } else if (this.curValue instanceof Array && this.curValue.length > 0 && this.curValue[0] instanceof Date) {
        curMonth = this.curValue[0]
      } else {
        curMonth = curMonthStartDate()
      }

      if (firstMonth && curMonth) {
        setTimeout(() => {
          // 第一个月的y坐标位置
          let firstTop = null
          // 当前月的y坐标位置
          let curTop = null

          let firstRef = this.$refs['month' + formatDate(firstMonth, 'yyyy-MM')]
          if (firstRef && firstRef.length > 0) {
            let firstEl = firstRef[0].$el
            firstTop = firstEl.getBoundingClientRect().top
          }

          let curRef = this.$refs['month' + formatDate(curMonth, 'yyyy-MM')]
          if (curRef && curRef.length > 0) {
            let curEl = curRef[0].$el
            curTop = curEl.getBoundingClientRect().top
          }

          if (firstTop !== null && curTop !== null && curTop > firstTop) {
            this.$refs.scroll.scroll.scrollTo(0, firstTop - curTop, 0)
          }
        }, 100)
      }
    },
    // 上拉加载
    pullUpHandle () {
      if (this.isPullUp) {
        return
      }
      this.isPullUp = true
      if (this.monthList && this.monthList.length > 0) {
        let lastMonth = this.monthList[this.monthList.length - 1]
        let newLastMonth
        for (let i = 0; i < 6; i++) {
          let newMonth = new Date(lastMonth.getTime())
          newMonth.setMonth(newMonth.getMonth() + (i + 1))
          if (this.maxDate && newMonth.getTime() > getFirstDayOfMonth(this.maxDate).getTime()) {
            break
          } else {
            newLastMonth = newMonth
            this.monthList.push(newMonth)
          }
        }
        if (newLastMonth) {
          this.$nextTick(() => {
            this.$refs.scroll.scroll.refresh()
          })
        }
      }
      this.$refs.scroll.finish('PullUp')
      this.isPullUp = false
    },
    // 下拉加载
    pullDownHandle () {
      if (this.isPullDown) {
        return
      }
      this.isPullDown = true
      if (this.monthList && this.monthList.length > 0) {
        let firstMonth = this.monthList[0]
        if (this.minDate && firstMonth.getTime() < this.minDate.getTime()) {
          this.$refs.scroll.finish('PullDown')
          this.isPullDown = false
          return
        }

        let newFirstMonth = null
        for (let i = 0; i < 6; i++) {
          let newMonth = new Date(firstMonth.getTime())
          newMonth.setMonth(newMonth.getMonth() - (i + 1))
          if (this.minDate && newMonth.getTime() < getFirstDayOfMonth(this.minDate).getTime()) {
            break
          } else {
            newFirstMonth = newMonth
            this.monthList.unshift(newMonth)
          }
        }

        if (newFirstMonth) {
          this.$nextTick(() => {
            let newRef = this.$refs['month' + formatDate(newFirstMonth, 'yyyy-MM')]
            let oldRef = this.$refs['month' + formatDate(firstMonth, 'yyyy-MM')]
            if (newRef && newRef.length > 0 && oldRef && oldRef.length > 0) {
              let newEl = newRef[0].$el
              let oldEl = oldRef[0].$el
              let scrollY = newEl.getBoundingClientRect().top - oldEl.getBoundingClientRect().top
              this.$refs.scroll.scroll.scrollTo(0, scrollY, 0)
              setTimeout(() => {
                this.$refs.scroll.scroll.scrollTo(0, scrollY + 260, 1000)
              }, 0)
            }
          })
        }
        this.$refs.scroll.finish('PullDown')
        this.isPullDown = false
      }
    }
  }
}
</script>
<style lang="scss" scoped>
$header-height: 80px;
.calendar {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  font-size: 28px;
  background: #FFF;
  .calendar-header {
    position: relative;
    display: flex;
    height: $header-height;
    line-height: $header-height;
    box-shadow: 0 2px 10px rgba(125, 126, 128, .16);
    z-index: 2;
    .header-cell {
      flex: 1;
      text-align: center;
      font-size: 24px;
    }
  }
  .calendar-content {
    position: absolute;
    top: $header-height;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 1;
    .months {
      padding-top: 50px;
    }
  }
}
</style>

Mounth.vue

<template>
  <div class="month-block">
    <div class="month-title">{{year}}年{{month + 1}}月</div>
    <div class="calendar-days" :class="'month-' + (month + 1)">
      <div class="calendar-day" v-for="cell in cells" :key="cell.id">
        <div class="day-cell" v-if="cell.type === 'prev-month'"></div>
        <div class="day-cell" :class="cell.type" v-else @click="handleSelect(cell)">
          <div v-if="cell.selected" class="active">{{cell.text}}</div>
          <template v-else>{{cell.text}}</template>
          <div v-if="cell.topInfo" class="top-info">{{cell.topInfo}}</div>
          <div v-if="cell.bottomInfo" class="bottom-info">{{cell.bottomInfo}}</div>
        </div>
      </div>
    </div>
  </div>
</template>
<script>
import { curMonthStartDate, getDayCountOfMonth, getDateTimestamp, formatDate } from '../js/dateUtil.js'

export default {
  name: 'Month',
  props: {
    monthDate: {
      type: Date,
      default: () => curMonthStartDate()
    },
    // 当前日期值
    value: {
      type: [Date, Array]
    },
    // 日历类型(single:选择单个日期,range:选择日期区间)
    type: {
      type: String,
      default: 'single'
    },
    // 可选范围最早日期
    minDate: {
      type: Date
    },
    // 可选范围最晚日期
    maxDate: {
      type: Date
    },
    // type = 'range' 时(即选择日期区间时)开始日期的文字信息
    startDateText: {
      type: String,
      default: '开始'
    },
    // type = 'range' 时(即选择日期区间时)结束日期的文字信息
    endDateText: {
      type: String,
      default: '结束'
    },
    /**
     * 不可用日期方法
     * @param val {Date} Date类型日期值
     * @return isDisabled {Boolean | Undefined} 是否为不可用日期(如果返回undefined,按默认逻辑处理)
     */
    disabledDate: Function
  },
  data () {
    return {
      // 日期格式,默认为:yyyy-MM-dd
      format: 'yyyy-MM-dd'
    }
  },
  computed: {
    // 年份
    year () {
      return this.monthDate.getFullYear()
    },
    // 月份,从0开始,即1月份为0
    month () {
      return this.monthDate.getMonth()
    },
    // 范围选择的开始日期
    startDate () {
      if (this.type === 'range' && this.value instanceof Array &&
          this.value.length > 0 && this.value[0] instanceof Date) {
        return new Date(getDateTimestamp(this.value[0]))
      }
      return null
    },
    // 范围选择的结束日期
    endDate () {
      if (this.type === 'range' && this.value instanceof Array &&
          this.value.length > 1 && this.value[1] instanceof Date) {
        return new Date(getDateTimestamp(this.value[1]))
      }
      return null
    },
    // 计算所有日期的数组
    cells () {
      const firstDay = this.monthDate // 当月第一天
      // 当月1号是星期几(值为0至6,0表示周日)
      let firstWeekDay = firstDay.getDay()
      // 当前月的天数
      const dateCountOfMonth = getDayCountOfMonth(firstDay.getFullYear(), firstDay.getMonth())
      // 上个月的年份
      const yearOfLastMonth = firstDay.getMonth() === 0 ? firstDay.getFullYear() - 1 : firstDay.getFullYear()
      // 上个月的月份
      const monthOfLastMonth = firstDay.getMonth() === 0 ? 11 : firstDay.getMonth() - 1
      // 上个月的天数
      const dateCountOfLastMonth = getDayCountOfMonth(yearOfLastMonth, monthOfLastMonth)
      // 日历展示所有日期数组
      let cells = []

      // 计算上个月的日期
      if (firstWeekDay !== 0) { // 当月的第一天不是星期日时,需要添加上个月的日期
        let prevMonth = null
        let prevYear = null
        if (this.month === 0) { // 当前月份为1月时
          prevMonth = 11 // 月份设置为12月
          prevYear = this.year - 1 // 年份减1
        } else { // 当前月份不是1月时
          prevMonth = this.month - 1 // 月份减1
          prevYear = this.year // 年份不变
        }
        for (let i = 1; i <= firstWeekDay; i++) {
          let prevDay = dateCountOfLastMonth - (firstWeekDay - i)
          cells.push(this.createCell(prevYear, prevMonth, prevDay, 'prev-month'))
        }
      }

      // 计算本月的日期
      for (let i = 1; i <= dateCountOfMonth; i++) {
        cells.push(this.createCell(this.year, this.month, i))
      }

      return cells
    }
  },
  methods: {
    // 创建日期数据
    createCell (year, month, day, type = '') {
      const date = new Date(year, month, day)
      const time = getDateTimestamp(date)
      const value = formatDate(date, this.format)

      const cell = {}
      cell.id = type + value
      cell.date = date // 当前日期Date对象
      cell.time = time // 当前日期时间戳
      cell.value = value // 当前日期字符串
      cell.type = type // 日期类型:normal:普通;prev-month:上个月的日期,用于占位;
      cell.text = day // 当前日期显示的文字
      if (type !== 'prev-month') { // 不是上个月的日期时
        // 是否选中(单选日期时使用)
        cell.selected = this.value && this.value instanceof Date && value === formatDate(this.value)
        // 是否禁用
        cell.disabled = this.checkDisabled(date)
        // 当前日期类型
        cell.type = cell.disabled ? 'disabled' : this.getDateType(date)
        cell.topInfo = cell.type === 'start-end' ? this.startDateText : ''
        cell.bottomInfo = (cell.type === 'start-end' || cell.type === 'end') ? this.endDateText : (cell.type === 'start' ? this.startDateText : '')
      }
      return cell
    },
    // 获取日期类型
    getDateType (date) {
      if (this.startDate) {
        // 是否开始日期
        let isStart = this.startDate.getTime() === date.getTime()
        // 是否结束日期
        let isEnd = false
        // 是否开始日期和结束日期之间的日期
        let isMiddle = false
        if (this.endDate) {
          isEnd = this.endDate.getTime() === date.getTime()
          if (!isStart && !isEnd) {
            isMiddle = date.getTime() > this.startDate.getTime() && date.getTime() < this.endDate.getTime()
          }
        }
        if (isStart && isEnd) {
          return 'start-end'
        } else if (isStart) {
          return 'start'
        } else if (isEnd) {
          return 'end'
        } else if (isMiddle) {
          return 'middle'
        } else {
          return 'normal'
        }
      }
    },
    // 校验日期是否禁用
    checkDisabled (date) {
      let time = getDateTimestamp(date)
      let isDisabled = false
      if ((this.minDate && time < getDateTimestamp(this.minDate)) ||
          (this.maxDate && time > getDateTimestamp(this.maxDate))) {
        isDisabled = true
      }
      // 用户自定义是否禁用
      let customerDisabled = this.disabledDate && this.disabledDate(date)
      if (typeof customerDisabled === 'boolean') {
        // 如果用户自定义 disabledDate 方法,返回 boolean 类型值则以此值为准
        isDisabled = customerDisabled
      }
      return isDisabled
    },
    // 处理日期选中事件
    handleSelect (cell) {
      if (!cell.disabled) {
        this.$emit('select', cell)
      }
    }
  }
}
</script>
<style lang="scss" scoped>
$cell-height: 86px;
.month-block {
  margin-bottom: 30px;
  .month-title {
    text-align: center;
    margin-bottom: 20px;
    font-weight: bold;
  }
  .calendar-days {
    margin: 20px;
    display: flex;
    flex-wrap: wrap;
    background-position: center;
    background-repeat: no-repeat;
    background-size: 300px 300px;
    /*&.month-2 {
      background-image: url(../img/2.svg);
    }*/
    @for $i from 1 through 12 {
      &.month-#{$i} {
        background-image: url(../img/#{$i}.svg);
      }
    }
    .calendar-day {
      width: 14.285%;
      height: $cell-height;
      .day-cell {
        position: relative;
        height: 100%;
        line-height: $cell-height;
        text-align: center;
        &.disabled {
          color: #c8c9cc;
        }
        &.middle {
          color: $base-color;
          @include base-background-color(.1);
        }
        &.start, &.end, &.start-end {
          background: $base-color;
          color: #FFF;
        }
        &.start-end {
          border-radius: 4px;
        }
        &.start {
          border-radius: 4px 0 0 4px;
        }
        &.end {
          border-radius: 0 4px 4px 0;
        }
        .active {
          width: 54px;
          height: 54px;
          line-height: 54px;
          margin: ($cell-height - 54px) / 2 auto;
          background: $base-color;
          color: #FFF;
          border-radius: 4px;
        }
        .top-info, .bottom-info {
          position: absolute;
          left: 0;
          right: 0;
          font-size: 20px;/*yes*/
          line-height: 24px;
          color: #FFF;
          text-align: center;
        }
        .top-info {
          top: 0;
        }
        .bottom-info {
          bottom: 0;
        }
      }
    }
  }
}
</style>

「欢迎在评论区讨论」

希望看完的朋友可以给个赞,鼓励一下