借助 vue3 实现一个日期组件

568 阅读1分钟

组件主要实现四类选择器:日期选择、月份选择、日期范围选择、月份范围选择。

组件主页 index.vue

<script setup>
import DayPicker from '@/components/datepicker/dayPicker'
import MonthPicker from '@/components/datepicker/monthPicker'
import DayRangePicker from '@/components/datepicker/dayRangePicker'
import MonthRangePicker from '@/components/datepicker/monthRangePicker'

const emit = defineEmits();
const datepicker = ref(null);
const v_datepicker = ref(null);
const show = ref(false);
const selectValue = ref(null);

const props = defineProps({
  type: {
    type: String,
    default: 'day'
  },
  defaultIcon: {
    type: String,
    default: require('@/assets/images/date.svg')
  },
  placeholder: {
    type: String,
    default: '请选择日期'
  },
  size: {
    type: String,
    default: ''
  },
  disabled: {
    type: Boolean,
    default: false
  },
  closeable: {
    type: Boolean,
    default: false
  }
});

watch(show, (n, o) => {
  datepicker.value.style.border = n ? '1px solid #1890FF' : '1px solid #ebe7e7';
})

const delete_icon = computed(() => {
  return props.closeable && selectValue.value ? require('@/assets/images/close.svg') : '';
})

// 点击选择器
const onclick = (e) => {
  if (props.disabled) {
    e.preventDefault();
  } else {
    show.value = true;
  }
}

// 删除内容
const deleteContent = () => {
  if (delete_icon.value) {
    selectValue.value = null;
    v_datepicker.value.innerText = '';
  }
}

const show_data = ref(0);
const showData = (v) => {
  show_data.value = v;
  show.value = false;
  nextTick(() => {
    show_data.value = 0;
  })
}

import mit from '@/utils/mitt'
mit.on('showData', showData);

const dateCheck = (v) => {
  show.value = false;
  let option = 'day-check';
  switch (props.type) {
    case 'day':
      option = 'day-check';
      v_datepicker.value.innerText = v;
      selectValue.value = v;
      break;
    case 'dayrange':
      option = 'day-range-check';
      v_datepicker.value.innerText = `${v[0]} ~ ${v[1]}`;
      selectValue.value = v;
      break;
    case 'month':
      option = 'month-check';
      v_datepicker.value.innerText = v;
      selectValue.value = v;
      break;
    case 'monthrange':
      option = 'month-range-check';
      v_datepicker.value.innerText = `${v[0]} ~ ${v[1]}`;
      selectValue.value = v;
      break;
    default:
      break;
  }
  emit(option, v);
}
</script>

<template>
  <div :class="{'disabled':disabled}" class="date-picker-container date-picker-width" @click="onclick" ref="datepicker">
    <div class="datepicker" :placeholder="placeholder" :class="{'margin-right-30': closeable,'mini':size==='mini'}"
      ref="v_datepicker">
    </div>
    <img :class="size==='mini'?'mini-date-icon':'date-icon'" :src="defaultIcon" width="20" height="20" />
    <img class="close" :src="delete_icon" width="15" height="15" @click.stop="deleteContent" v-if="delete_icon" />
    <div :class="['down-select-panel',{'range-panel-width':type==='dayrange'||type==='monthrange'}]" v-if="show">
      <div>
        <p>
          <day-picker :value="selectValue" @date-check="dateCheck" v-if="type==='day'" />
          <month-picker :value="selectValue" @date-check="dateCheck" v-else-if="type==='month'" />
          <day-range-picker :value="selectValue" @date-check="dateCheck" v-else-if="type==='dayrange'" />
          <month-range-picker :value="selectValue" @date-check="dateCheck" v-else-if="type==='monthrange'" />
        </p>
      </div>
    </div>
    <div class="overdelay" v-if="!show_data&&show" @click.stop="show=false"></div>
  </div>
</template>

<style lang="scss" scoped>
.date-picker-container {
  position: relative;
  font-size: 14px;
  line-height: 20px;
  border: 1px solid $colorLightGray;
  border-radius: 3px;
  cursor: pointer;
}

.date-picker-width {
  width: 240px;
}

.date-icon {
  position: absolute;
  top: -2px;
  left: 5px;
  transform: translateY(50%);
}

.mini-date-icon {
  position: absolute;
  top: -1px;
  left: 5px;
  transform: translateY(20%);
}

.range-panel-width {
  width: 640px;
}

.down-select-panel {
  min-width: 320px;
  position: absolute;
  border-radius: 5px;
  border: 1px solid $colorLightGray;
  box-shadow: 0 2px 12px 0 rgb(0 0 0 / 10%);
  background-color: $colorWhite;
  margin-top: 15px;
  z-index: 999;
}

.down-select-panel > div {
  width: 100%;
  overflow-y: overlay;
}

.down-select-panel > div > p {
  margin: 0;
  padding: 12px 0;
}

.down-select-panel::before,
.down-select-panel::after {
  content: "";
  width: 0;
  height: 0;
  border-style: solid;
  border-width: 8px;
  position: absolute;
  left: 40px;
}

.down-select-panel::before,
.down-select-panel::after {
  top: -16px;
}

.down-select-panel::before {
  border-color: transparent transparent $colorLightGray transparent;
}

.down-select-panel::after {
  border-color: transparent transparent $colorWhite transparent;
}

.overdelay {
  width: 100%;
  height: 100%;
  opacity: 0;
  position: fixed;
  top: 0;
  left: 0;
  z-index: 1;
}

.selected-content {
  font-weight: bold;
  color: $colorBlue;
  background-color: $colorBgText;
}

.datepicker {
  text-align: left;
  margin: 8px 16px;
  padding-left: 15px;
  white-space: nowrap;
  overflow-x: auto;
  overflow-y: hidden;
}

.mini {
  font-size: 12px;
  line-height: 12px;
}

.datepicker:empty::before {
  content: attr(placeholder);
  color: $colorLightGray;
}

.close {
  position: absolute;
  top: 50%;
  right: 0;
  font-size: 12px;
  transform: translate(-10px, -50%);
}

.margin-right-30 {
  margin-right: 30px;
}

/* 禁用 */
.disabled {
  cursor: no-drop;
  opacity: 0.5;
  background-color: $colorDisabled;
}

.disabled-event {
  pointer-events: none;
}
</style>

日期选择器 dayPicker.vue

<!--
 * @Description: 日期选择器
 * @Author: dinghao
 * @Date: 2022-06-29 15:06:22
 * @LastEditTime: 2022-07-22 15:48:05
 * @LastEditors: dinghao
-->
<script setup>
const weeks = ['日', '一', '二', '三', '四', '五', '六'];

const emit = defineEmits();
const props = defineProps({
  value: {
    type: String,
    default: null
  }
});

// 当前日期
const now_date = new Date();
// 当前格式化日期 yyyy/MM/dd
const now_date_format = now_date.toLocaleDateString();
const year = ref(now_date.getFullYear());
const month = ref(now_date.getMonth());

// 左右年月操作
const operator = (type) => {
  switch (type) {
    case '+year': // 加年
      year.value++;
      break;
    case '+month': // 加月
      if (month.value === 12) { // 跨年年+1,月重置为1、否则月+1
        year.value++;
        month.value = 1;
      } else {
        month.value++;
      }
      break;
    case '-year': // 减年
      year.value--;
      break;
    case '-month': // 减月
      if (month.value === 1) { // 跨年年-1,月重置为12、否则月-1
        year.value--;
        month.value = 12;
      } else {
        month.value--;
      }
      break;
    default:
      break;
  }
}

// 上一月部分日期
const prev_date = computed(() => {
  return calendar('prev', year.value, month.value);
})
// 本月日期
const current_date = computed(() => {
  return calendar('current', year.value, month.value);
})
// 下一月部分日期
const next_date = computed(() => {
  return calendar('next', year.value, month.value);
})

// 计算显示每一月时得到的数组:包含上一月部分日期、本月日期、下一月部分日期
const calendar = (type, year, month) => {
  const lastdays = new Date(year, month, 0).getDate();
  const days = new Date(year, month + 1, 0).getDate();
  const week = new Date(year, month, 1).getDay();
  const prev = Array.from({ length: week }, (el, i) =>
    [month == 0 ? year - 1 : year, month == 0 ? 12 : month, lastdays + i - week + 1]
  );
  const current = Array.from({ length: days }, (el, i) =>
    [year, month + 1, i + 1]
  );
  const next = Array.from({ length: 42 - days - week }, (el, i) =>
    [month == 11 ? year + 1 : year, month == 11 ? 1 : month + 2, i + 1]
  );
  return type === 'prev' ? prev : type === 'current' ? current : next;
}

import { addZero } from '@/utils'

// 选中日期
const checkDate = (item) => {
  year.value = item[0];
  month.value = item[1] - 1;
  emit('date-check', addZero(`${item[0]}/${item[1]}/${item[2]}`));
}

onMounted(() => {
  if (props.value) {
    year.value = Number(props.value.split('/')[0]);
    month.value = Number(props.value.split('/')[1]) - 1;
  }
})

</script>

<template>
  <div>
    <div class="picker-top">
      <div>
        <span class="left-db-arrow" @click.stop="operator('-year')"></span>
        <span class="left-arrow" @click.stop="operator('-month')"></span>
      </div>
      <div>{{year}} 年 {{month+1}} 月</div>
      <div>
        <span class="right-arrow" @click.stop="operator('+month')"></span>
        <span class="right-db-arrow" @click.stop="operator('+year')"></span>
      </div>
    </div>
    <div class="picker-center">
      <span v-for="item in weeks">{{item}}</span>
    </div>
    <div class="divider"></div>
    <div class="picker-bottom">
      <!-- 上一月显示日期 -->
      <div v-for="item in prev_date">
        <span :class="['not-current-date',{'current-selected-date':addZero(`${item[0]}/${item[1]}/${item[2]}`)===value}]"
          @click.stop="checkDate(item)">{{item[2]}}</span>
      </div>
      <!-- 当前月显示日期 -->
      <div v-for="item in current_date">
        <span
          :class="[{'now-date':addZero(`${item[0]}/${item[1]}/${item[2]}`)===addZero(now_date_format)},{'current-selected-date':addZero(`${item[0]}/${item[1]}/${item[2]}`)===value}]"
          @click.stop="checkDate(item)">{{item[2]}}</span>
      </div>
      <!-- 下一月显示日期 -->
      <div v-for="item in next_date">
        <span :class="['not-current-date',{'current-selected-date':addZero(`${item[0]}/${item[1]}/${item[2]}`)===value}]"
          @click.stop="checkDate(item)">{{item[2]}}</span>
      </div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.picker-top {
  @include flex-row(space-between, center);
  height: 40px;
  padding: 0 20px;
  font-size: 16px;
  cursor: default;
}

.picker-top > div {
  @include flex-row(center, center);
}

.picker-center {
  @include flex-row(space-evenly, center);
  padding: 20px 0 10px;
  font-size: 12px;
  cursor: default;
}

.divider {
  height: 0.5px;
  margin: 0 20px;
  background-color: $colorLightGray;
}

.picker-bottom {
  @include flex-row(space-evenly, center);
  flex-wrap: wrap;
  padding: 0 15px;
  font-size: 12px;
  cursor: default;
}

.picker-bottom > div {
  @include flex-row(center, center);
  width: calc(100% / 7);
  height: 40px;
}

.picker-bottom > div > span {
  @include flex-row(center, center);
  @include width-height(25px, 25px);
  cursor: pointer;
}

.now-date {
  font-weight: bold;
  color: $colorBlue;
}

.current-selected-date {
  color: $colorWhite !important;
  border-radius: 50%;
  background-color: $colorBlue;
}

.not-current-date {
  color: $colorDullGray;
}

.picker-bottom > div > span:hover {
  color: $colorBlue;
}

.left-arrow,
.left-db-arrow,
.right-arrow,
.right-db-arrow {
  @include width-height;
  display: inline-block;
  background-size: 100% 100%;
  cursor: pointer;
}
.left-arrow {
  margin-left: 10px;
  background-image: url("@/assets/images/left_arrow.svg");
}
.left-arrow:hover {
  background-image: url("@/assets/images/left_arrow_active.svg");
}
.left-db-arrow {
  background-image: url("@/assets/images/left_db_arrow.svg");
}
.left-db-arrow:hover {
  background-image: url("@/assets/images/left_db_arrow_active.svg");
}
.right-arrow {
  margin-right: 10px;
  background-image: url("@/assets/images/right_arrow.svg");
}
.right-arrow:hover {
  background-image: url("@/assets/images/right_arrow_active.svg");
}
.right-db-arrow {
  background-image: url("@/assets/images/right_db_arrow.svg");
}
.right-db-arrow:hover {
  background-image: url("@/assets/images/right_db_arrow_active.svg");
}
</style>

月份选择器 monthPicker.vue

<script setup>
const months = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];

const emit = defineEmits();
const props = defineProps({
  value: {
    type: String,
    default: null
  }
});

// 当前日期
const now_date = new Date();
const now_month = `${now_date.getFullYear()}${now_date.getMonth()}`;
const year = ref(now_date.getFullYear());

// 左右年月操作
const operator = (type) => {
  type === '+year' ? year.value++ : year.value--;
}

// 本年月份
const current_month = computed(() => {
  return calendar(year.value);
})

// 计算显示每一月时得到的数组:包含上一月部分日期、本月日期、下一月部分日期
const calendar = (year) => {
  return [[year], [...months]]
}

import { addZero } from '@/utils'
// 选中日期
const checkMonth = (index) => {
  emit('date-check', addZero(`${current_month.value[0]}/${index + 1}`));
}

onMounted(() => {
  if (props.value) {
    year.value = Number(props.value.split('/')[0]);
  }
})

</script>

<template>
  <div>
    <div class="picker-top">
      <div>
        <span class="left-db-arrow" @click.stop="operator('-year')"></span>
      </div>
      <div>{{year}} 年</div>
      <div>
        <span class="right-db-arrow" @click.stop="operator('+year')"></span>
      </div>
    </div>
    <div class="divider"></div>
    <div class="picker-bottom">
      <!-- 当前年显示月份 -->
      <div v-for="(item,index) in current_month[1]">
        <span @click.stop="checkMonth(index)" :class="[{'now-month':addZero(`${current_month[0]}${index}`)===now_month},
          {'current-selected-month':addZero(`${current_month[0]}/${index+1}`)===value}]">{{item}}</span>
      </div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.picker-top {
  @include flex-row(space-between, center);
  height: 40px;
  padding: 0 20px;
  font-size: 16px;
  cursor: default;
}

.picker-top > div {
  @include flex-row(center, center);
}

.divider {
  height: 0.5px;
  margin: 0 20px;
  background-color: $colorLightGray;
}

.picker-bottom {
  @include flex-row(space-evenly, center);
  flex-wrap: wrap;
  padding: 0 15px;
  font-size: 12px;
  cursor: default;
}

.picker-bottom > div {
  @include flex-row(center, center);
  width: calc(100% / 4);
  height: 66px;
}

.picker-bottom > div > span {
  @include flex-row(center, center);
  @include width-height(40px, 40px);
  cursor: pointer;
}

.now-month {
  font-weight: bold;
  color: $colorBlue;
}

.current-selected-month {
  color: $colorWhite !important;
  border-radius: 50%;
  background-color: $colorBlue;
}

.picker-bottom > div > span:hover {
  color: $colorBlue;
}

.left-db-arrow,
.right-db-arrow {
  @include width-height;
  display: inline-block;
  background-size: 100% 100%;
  cursor: pointer;
}

.left-db-arrow {
  background-image: url("@/assets/images/left_db_arrow.svg");
}
.left-db-arrow:hover {
  background-image: url("@/assets/images/left_db_arrow_active.svg");
}

.right-db-arrow {
  background-image: url("@/assets/images/right_db_arrow.svg");
}
.right-db-arrow:hover {
  background-image: url("@/assets/images/right_db_arrow_active.svg");
}
</style>

日期范围选择器 dayRangePicker.vue

<script setup>
const weeks = ['日', '一', '二', '三', '四', '五', '六'];

const emit = defineEmits();
const props = defineProps({
  value: {
    type: Array,
    default: []
  }
});

// 当前日期
const now_date = new Date();
// 当前格式化日期 yyyy/MM/dd
const now_date_format = now_date.toLocaleDateString();
const year = ref(now_date.getFullYear());
const month = ref(now_date.getMonth());

// 左右年月操作
const operator = (type) => {
  switch (type) {
    case '+year': // 加年
      year.value++;
      break;
    case '+month': // 加月
      if (month.value === 12) { // 跨年年+1,月重置为1、否则月+1
        year.value++;
        month.value = 1;
      } else {
        month.value++;
      }
      break;
    case '-year': // 减年
      year.value--;
      break;
    case '-month': // 减月
      if (month.value === 1) { // 跨年年-1,月重置为12、否则月-1
        year.value--;
        month.value = 12;
      } else {
        month.value--;
      }
      break;
    default:
      break;
  }
}

// 上一月部分日期
const prev_date = computed(() => {
  return calendar('prev', year.value, month.value);
})
// 本月日期
const current_date = computed(() => {
  return calendar('current', year.value, month.value);
})
// 下一月部分日期
const next_date = computed(() => {
  return calendar('next', year.value, month.value);
})

// 上一月部分日期
const end_prev_date = computed(() => {
  return calendar('prev', year.value, month.value + 1);
})
// 本月日期
const end_current_date = computed(() => {
  return calendar('current', year.value, month.value + 1);
})
// 下一月部分日期
const end_next_date = computed(() => {
  return calendar('next', year.value, month.value + 1);
})

// 计算显示每一月时得到的数组:包含上一月部分日期、本月日期、下一月部分日期
const calendar = (type, year, month) => {
  const lastdays = new Date(year, month, 0).getDate();
  const days = new Date(year, month + 1, 0).getDate();
  const week = new Date(year, month, 1).getDay();
  const prev = Array.from({ length: week }, (el, i) =>
    [month == 0 ? year - 1 : year, month == 0 ? 12 : month, lastdays + i - week + 1]
  );
  const current = Array.from({ length: days }, (el, i) =>
    [year, month + 1, i + 1]
  );
  const next = Array.from({ length: 42 - days - week }, (el, i) =>
    [month == 11 ? year + 1 : year, month == 11 ? 1 : month + 2, i + 1]
  );
  return type === 'prev' ? prev : type === 'current' ? current : next;
}

import { addZero } from '@/utils'

const start_date = ref(null);
const end_date = ref(null);
const flag = ref(0);
// 选中起始日期
const checkStartDate = (item) => {
  flag.value++;
  if (start_date.value && end_date.value) {
    start_date.value = null;
    end_date.value = null;
  }
  if (flag.value !== 2) {
    start_date.value = addZero(`${item[0]}/${item[1]}/${item[2]}`);
  } else {
    if (!start_date.value) {
      start_date.value = addZero(`${item[0]}/${item[1]}/${item[2]}`);
    }
    if (start_date.value && !end_date.value) {
      end_date.value = addZero(`${item[0]}/${item[1]}/${item[2]}`);
    }
  }
  let arr = [];
  if (end_date.value) {
    if (Number(start_date.value.split('/')[1]) > Number(end_date.value.split('/')[1])) {
      arr = [start_date.value, end_date.value];
    } else {
      if (Number(start_date.value.replaceAll('/', '')) > Number(end_date.value.replaceAll('/', ''))) {
        arr = [end_date.value, start_date.value];
      } else {
        arr = [start_date.value, end_date.value];
      }
    }
    emit('date-check', arr);
  }
}

// 选中结束日期
const checkEndDate = (item) => {
  if (start_date.value && end_date.value) {
    start_date.value = null;
    end_date.value = null;
  }
  if (!start_date.value && end_date.value) {
    start_date.value = addZero(`${item[0]}/${item[1]}/${item[2]}`);
  }
  if (!end_date.value) {
    end_date.value = addZero(`${item[0]}/${item[1]}/${item[2]}`);
  }
  let arr = [];
  if (start_date.value) {
    if (Number(start_date.value.split('/')[1]) > Number(end_date.value.split('/')[1])) {
      arr = [start_date.value, end_date.value];
    } else {
      if (flag.value === 1 || Number(start_date.value.replaceAll('/', '')) <= Number(end_date.value.replaceAll('/', ''))) {
        arr = [start_date.value, end_date.value];
      } else {
        arr = [end_date.value, start_date.value];
      }
    }
    emit('date-check', arr);
  }
}

onMounted(() => {
  if (props.value && props.value.length > 0) {
    year.value = Number(props.value[0].split('/')[0]);
    month.value = Number(props.value[0].split('/')[1] - 1);
    start_date.value = props.value[0];
    end_date.value = props.value[1];
  }
})

</script>

<template>
  <div class="day-range">
    <div>
      <div class="picker-start-top">
        <div>
          <span class="left-db-arrow" @click.stop="operator('-year')"></span>
          <span class="left-arrow" @click.stop="operator('-month')"></span>
        </div>
        <div>{{year}} 年 {{month+1}} 月</div>
      </div>
      <div class="picker-center">
        <span v-for="item in weeks">{{item}}</span>
      </div>
      <div class="divider"></div>
      <div class="picker-bottom">
        <!-- 上一月显示日期 -->
        <div v-for="item in prev_date">
          <span
            :class="['not-current-date',{'current-selected-date':addZero(`${item[0]}/${item[1]}/${item[2]}`)===start_date}]"
            @click.stop="checkStartDate(item)">{{item[2]}}</span>
        </div>
        <!-- 当前月显示日期 -->
        <div v-for="item in current_date" :class="[
          {'select-area': start_date&&end_date&&
            addZero(`${item[0]}/${item[1]}/${item[2]}`)>=start_date&&
            addZero(`${item[0]}/${item[1]}/${item[2]}`)<=end_date
          },
          {'start-radius':addZero(`${item[0]}/${item[1]}/${item[2]}`)===start_date},
          {'end-radius':addZero(`${item[0]}/${item[1]}/${item[2]}`)===end_date}]">
          <span :class="[{'now-date':addZero(`${item[0]}/${item[1]}/${item[2]}`)===addZero(now_date_format)},
            {'current-selected-date':addZero(`${item[0]}/${item[1]}/${item[2]}`)===start_date||
            addZero(`${item[0]}/${item[1]}/${item[2]}`)===end_date}]"
            @click.stop="checkStartDate(item)">{{item[2]}}</span>
        </div>
        <!-- 下一月显示日期 -->
        <div v-for="item in next_date">
          <span
            :class="['not-current-date',{'current-selected-date':addZero(`${item[0]}/${item[1]}/${item[2]}`)===start_date}]"
            @click.stop="checkStartDate(item)">{{item[2]}}</span>
        </div>
      </div>
    </div>
    <div>
      <div class="picker-end-top">
        <div>{{year}} 年 {{month+2}} 月</div>
        <div>
          <span class="right-arrow" @click.stop="operator('+month')"></span>
          <span class="right-db-arrow" @click.stop="operator('+year')"></span>
        </div>
      </div>
      <div class="picker-center">
        <span v-for="item in weeks">{{item}}</span>
      </div>
      <div class="divider"></div>
      <div class="picker-bottom">
        <!-- 上一月显示日期 -->
        <div v-for="item in end_prev_date">
          <span class="not-current-date" @click.stop="checkEndDate(item)">{{item[2]}}</span>
        </div>
        <!-- 当前月显示日期 -->
        <div v-for="item in end_current_date" :class="[
          {'select-area': start_date&&end_date&&
          addZero(`${item[0]}/${item[1]}/${item[2]}`)>=start_date&&
          addZero(`${item[0]}/${item[1]}/${item[2]}`)<=end_date
          },
          {'start-radius':addZero(`${item[0]}/${item[1]}/${item[2]}`)===start_date},
          {'end-radius':addZero(`${item[0]}/${item[1]}/${item[2]}`)===end_date}]">
          <span
            :class="[{'now-date':addZero(`${item[0]}/${item[1]}/${item[2]}`)===addZero(now_date_format)},
            {'current-selected-date':addZero(`${item[0]}/${item[1]}/${item[2]}`)===start_date||addZero(`${item[0]}/${item[1]}/${item[2]}`)===end_date}]"
            @click.stop="checkEndDate(item)">{{item[2]}}</span>
        </div>
        <!-- 下一月显示日期 -->
        <div v-for="item in end_next_date">
          <span class="not-current-date" @click.stop="checkEndDate(item)">{{item[2]}}</span>
        </div>
      </div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.day-range,
.picker-start-top {
  @include flex-row(flex-start, center);
}

.picker-start-top,
.picker-end-top {
  height: 40px;
  padding: 0 20px;
  font-size: 16px;
  cursor: default;
  position: relative;
}

.picker-end-top {
  @include flex-row(flex-end, center);
}

.picker-start-top > div,
.picker-end-top > div {
  @include flex-row(center, center);
}

.picker-start-top > :last-child {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
}

.picker-end-top > :first-child {
  position: absolute;
  right: 50%;
  transform: translateX(50%);
}

.picker-center {
  @include flex-row(space-evenly, center);
  padding: 20px 0 10px;
  font-size: 12px;
  cursor: default;
}

.divider {
  height: 0.5px;
  margin: 0 20px;
  background-color: $colorLightGray;
}

.picker-bottom {
  @include flex-row(space-evenly, center);
  flex-wrap: wrap;
  padding: 0 15px;
  font-size: 12px;
  cursor: default;
}

.picker-bottom > div {
  @include flex-row(center, center);
  width: calc(100% / 7);
  padding: 2.5px 0;
  margin: 5px 0;
}

.picker-bottom > div > span {
  @include flex-row(center, center);
  @include width-height(30px, 30px);
  cursor: pointer;
}

.now-date {
  font-weight: bold;
  color: $colorBlue;
}

.current-selected-date {
  color: $colorWhite !important;
  border-radius: 50%;
  background-color: $colorBlue;
}

.not-current-date {
  color: $colorDullGray;
}

.picker-bottom > div > span:hover {
  color: $colorBlue;
}

.start-radius {
  border-top-left-radius: 50px;
  border-bottom-left-radius: 50px;
}

.end-radius {
  border-top-right-radius: 50px;
  border-bottom-right-radius: 50px;
}

.select-area {
  background-color: $colorBgText;
}

.left-arrow,
.left-db-arrow,
.right-arrow,
.right-db-arrow {
  @include width-height;
  display: inline-block;
  background-size: 100% 100%;
  cursor: pointer;
}
.left-arrow {
  margin-left: 10px;
  background-image: url("@/assets/images/left_arrow.svg");
}
.left-arrow:hover {
  background-image: url("@/assets/images/left_arrow_active.svg");
}
.left-db-arrow {
  background-image: url("@/assets/images/left_db_arrow.svg");
}
.left-db-arrow:hover {
  background-image: url("@/assets/images/left_db_arrow_active.svg");
}
.right-arrow {
  margin-right: 10px;
  background-image: url("@/assets/images/right_arrow.svg");
}
.right-arrow:hover {
  background-image: url("@/assets/images/right_arrow_active.svg");
}
.right-db-arrow {
  background-image: url("@/assets/images/right_db_arrow.svg");
}
.right-db-arrow:hover {
  background-image: url("@/assets/images/right_db_arrow_active.svg");
}
</style>

月份范围选择器 monthRangePicker.vue

<script setup>
const months = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];

const emit = defineEmits();
const props = defineProps({
  value: {
    type: Array,
    default: []
  }
});

// 当前日期
const now_date = new Date();
const now_month = `${now_date.getFullYear()}${now_date.getMonth()}`;
const year = ref(now_date.getFullYear());

// 左右年月操作
const operator = (type) => {
  type === '+year' ? year.value++ : year.value--;
}

// 本年月份
const current_month = computed(() => {
  return calendar(year.value);
})

// 计算显示每一月时得到的数组:包含上一月部分日期、本月日期、下一月部分日期
const calendar = (year) => {
  return [[year], [...months]]
}

import { addZero } from '@/utils'

const start_month = ref(null);
const end_month = ref(null);
const flag = ref(0);
// 选择开始日期
const checkStartMonth = (index) => {
  flag.value++;
  if (start_month.value && end_month.value) {
    start_month.value = null;
    end_month.value = null;
  }
  if (flag.value !== 2) {
    start_month.value = addZero(`${current_month.value[0]}/${index + 1}`)
  } else {
    if (!start_month.value) {
      start_month.value = addZero(`${current_month.value[0]}/${index + 1}`)
    }
    if (start_month.value && !end_month.value) {
      end_month.value = addZero(`${current_month.value[0]}/${index + 1}`)
    }
  }
  let arr = [];
  if (end_month.value) {
    if (Number(start_month.value.substr(0, 4)) > Number(end_month.value.substr(0, 4))) {
      arr = [start_month.value, end_month.value];
    } else {
      if (Number(start_month.value.replaceAll('/', '')) > Number(end_month.value.replaceAll('/', ''))) {
        arr = [end_month.value, start_month.value];
      } else {
        arr = [start_month.value, end_month.value];
      }
    }
    emit('date-check', arr);
  }
}
// 选择结束日期
const checkEndMonth = (index) => {
  if (start_month.value && end_month.value) {
    start_month.value = null;
    end_month.value = null;
  }
  if (!start_month.value && end_month.value) {
    start_month.value = addZero(`${+current_month.value[0] + 1}/${index + 1}`);
  }
  if (!end_month.value) {
    end_month.value = addZero(`${+current_month.value[0] + 1}/${index + 1}`);
  }
  let arr = [];
  if (start_month.value) {
    if (flag.value === 1 || Number(start_month.value.replaceAll('/', '')) <= Number(end_month.value.replaceAll('/', ''))) {
      arr = [start_month.value, end_month.value];
    } else {
      arr = [end_month.value, start_month.value];
    }
    emit('date-check', arr);
  }
}

// 打开月份选择器时展示框内年份与月份范围
onMounted(() => {
  if (props.value && props.value.length > 0) {
    year.value = Number(props.value[0].split('/')[0]);
    start_month.value = props.value[0];
    end_month.value = props.value[1];
  }
})

</script>

<template>
  <div class="month-range">
    <div>
      <div class="picker-start-top">
        <div>
          <span class="left-db-arrow" @click.stop="operator('-year')"></span>
        </div>
        <div>{{year}} 年</div>
      </div>
      <div class="divider"></div>
      <div class="picker-bottom">
        <!-- 当前年显示月份 -->
        <div v-for="(item,index) in current_month[1]" :class="[
          {'select-area': start_month&&end_month&&
          addZero(`${current_month[0]}/${index+1}`)>=start_month&&
          addZero(`${current_month[0]}/${index+1}`)<=end_month},
          {'start-radius':addZero(`${current_month[0]}/${index+1}`)===start_month},
          {'end-radius':addZero(`${current_month[0]}/${index+1}`)===end_month}]">
          <span @click.stop="checkStartMonth(index)" :class="[
            {'now-month':`${year}${index}`===now_month},
            {'current-selected-month':addZero(`${current_month[0]}/${index+1}`)===start_month||
            addZero(`${current_month[0]}/${index+1}`)===end_month}]">{{item}}</span>
        </div>
      </div>
    </div>
    <div>
      <div class="picker-end-top">
        <div>{{year+1}} 年</div>
        <div>
          <span class="right-db-arrow" @click.stop="operator('+year')"></span>
        </div>
      </div>
      <div class="divider"></div>
      <div class="picker-bottom">
        <!-- 当前年显示月份 -->
        <div v-for="(item,index) in current_month[1]" :class="[
          {'select-area':start_month&&end_month&&
          addZero(`${+current_month[0]+1}/${index+1}`)>=start_month&&
          addZero(`${+current_month[0]+1}/${index+1}`)<=end_month},
          {'start-radius':addZero(`${+current_month[0]+1}/${index+1}`)===start_month},
          {'end-radius':addZero(`${+current_month[0]+1}/${index+1}`)===end_month}]">
          <span @click.stop="checkEndMonth(index)" :class="[
          {'now-month':`${year+1}${index}`===now_month},
          {'current-selected-month':addZero(`${+current_month[0]+1}/${index+1}`)===end_month||
            addZero(`${+current_month[0]+1}/${index+1}`)===start_month}]">{{item}}</span>
        </div>
      </div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.month-range,
.picker-start-top {
  @include flex-row(flex-start, center);
}

.picker-start-top,
.picker-end-top {
  height: 40px;
  padding: 0 20px;
  font-size: 16px;
  cursor: default;
  position: relative;
}

.picker-end-top {
  @include flex-row(flex-end, center);
}

.picker-start-top > div,
.picker-end-top > div {
  @include flex-row(center, center);
}

.picker-start-top > :last-child {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
}

.picker-end-top > :first-child {
  position: absolute;
  right: 50%;
  transform: translateX(50%);
}

.divider {
  height: 0.5px;
  margin: 0 20px;
  background-color: $colorLightGray;
}

.picker-bottom {
  @include flex-row(space-evenly, center);
  flex-wrap: wrap;
  padding: 0 15px;
  font-size: 12px;
  cursor: default;
}

.picker-bottom > div {
  @include flex-row(center, center);
  width: calc(100% / 4);
  margin: 10px 0;
}

.picker-bottom > div > span {
  @include flex-row(center, center);
  @include width-height(60px, 35px);
  margin: 5px 0;
  cursor: pointer;
}

.start-radius {
  border-top-left-radius: 50px;
  border-bottom-left-radius: 50px;
}

.end-radius {
  border-top-right-radius: 50px;
  border-bottom-right-radius: 50px;
}

.select-area {
  background-color: $colorBgText;
}

.empty-area {
  background-color: $colorWhite;
}

.now-month {
  font-weight: bold;
  color: $colorBlue;
}

.current-selected-month {
  color: $colorWhite !important;
  border-radius: 20px;
  background-color: $colorBlue;
}

.picker-bottom > div > span:hover {
  color: $colorBlue;
}

.left-db-arrow,
.right-db-arrow {
  @include width-height;
  display: inline-block;
  background-size: 100% 100%;
  cursor: pointer;
}

.left-db-arrow {
  background-image: url("@/assets/images/left_db_arrow.svg");
}
.left-db-arrow:hover {
  background-image: url("@/assets/images/left_db_arrow_active.svg");
}

.right-db-arrow {
  background-image: url("@/assets/images/right_db_arrow.svg");
}
.right-db-arrow:hover {
  background-image: url("@/assets/images/right_db_arrow_active.svg");
}
</style>

方法 utils/index.js

export const addZero = function (str) {
  if (typeof str !== 'string') {
    str = String(str);
  }
  let arr = str.split('/');
  if (arr[1] && arr[1].length === 1) {
    arr[1] = `0${arr[1]}`;
  }
  if (arr[2] && arr[2].length === 1) {
    arr[2] = `0${arr[2]}`;
  }
  return arr.join('/');
}

效果图:

image.png image.png image.png image.png