quanyawei
2023-10-20 d8b41fff43a2cee6a8f714ffa807623b15803786
uni_modules/uview-ui/components/u-calendar/month.vue
New file
@@ -0,0 +1,579 @@
<template>
   <view class="u-calendar-month-wrapper" ref="u-calendar-month-wrapper">
      <view v-for="(item, index) in months" :key="index" :class="[`u-calendar-month-${index}`]"
         :ref="`u-calendar-month-${index}`" :id="`month-${index}`">
         <text v-if="index !== 0" class="u-calendar-month__title">{{ item.year }}年{{ item.month }}月</text>
         <view class="u-calendar-month__days">
            <view v-if="showMark" class="u-calendar-month__days__month-mark-wrapper">
               <text class="u-calendar-month__days__month-mark-wrapper__text">{{ item.month }}</text>
            </view>
            <view class="u-calendar-month__days__day" v-for="(item1, index1) in item.date" :key="index1"
               :style="[dayStyle(index, index1, item1)]" @tap="clickHandler(index, index1, item1)"
               :class="[item1.selected && 'u-calendar-month__days__day__select--selected']">
               <view class="u-calendar-month__days__day__select" :style="[daySelectStyle(index, index1, item1)]">
                  <text class="u-calendar-month__days__day__select__info"
                     :class="[item1.disabled && 'u-calendar-month__days__day__select__info--disabled']"
                     :style="[textStyle(item1)]">{{ item1.day }}</text>
                  <text v-if="getBottomInfo(index, index1, item1)"
                     class="u-calendar-month__days__day__select__buttom-info"
                     :class="[item1.disabled && 'u-calendar-month__days__day__select__buttom-info--disabled']"
                     :style="[textStyle(item1)]">{{ getBottomInfo(index, index1, item1) }}</text>
                  <text v-if="item1.dot" class="u-calendar-month__days__day__select__dot"></text>
               </view>
            </view>
         </view>
      </view>
   </view>
</template>
<script>
   // #ifdef APP-NVUE
   // 由于nvue不支持百分比单位,需要查询宽度来计算每个日期的宽度
   const dom = uni.requireNativePlugin('dom')
   // #endif
   import dayjs from '../../libs/util/dayjs.js';
   export default {
      name: 'u-calendar-month',
      mixins: [uni.$u.mpMixin, uni.$u.mixin],
      props: {
         // 是否显示月份背景色
         showMark: {
            type: Boolean,
            default: true
         },
         // 主题色,对底部按钮和选中日期有效
         color: {
            type: String,
            default: '#3c9cff'
         },
         // 月份数据
         months: {
            type: Array,
            default: () => []
         },
         // 日期选择类型
         mode: {
            type: String,
            default: 'single'
         },
         // 日期行高
         rowHeight: {
            type: [String, Number],
            default: 58
         },
         // mode=multiple时,最多可选多少个日期
         maxCount: {
            type: [String, Number],
            default: Infinity
         },
         // mode=range时,第一个日期底部的提示文字
         startText: {
            type: String,
            default: '开始'
         },
         // mode=range时,最后一个日期底部的提示文字
         endText: {
            type: String,
            default: '结束'
         },
         // 默认选中的日期,mode为multiple或range是必须为数组格式
         defaultDate: {
            type: [Array, String, Date],
            default: null
         },
         // 最小的可选日期
         minDate: {
            type: [String, Number],
            default: 0
         },
         // 最大可选日期
         maxDate: {
            type: [String, Number],
            default: 0
         },
         // 如果没有设置maxDate,则往后推多少个月
         maxMonth: {
            type: [String, Number],
            default: 2
         },
         // 是否为只读状态,只读状态下禁止选择日期
         readonly: {
            type: Boolean,
            default: uni.$u.props.calendar.readonly
         },
         // 日期区间最多可选天数,默认无限制,mode = range时有效
         maxRange: {
            type: [Number, String],
            default: Infinity
         },
         // 范围选择超过最多可选天数时的提示文案,mode = range时有效
         rangePrompt: {
            type: String,
            default: ''
         },
         // 范围选择超过最多可选天数时,是否展示提示文案,mode = range时有效
         showRangePrompt: {
            type: Boolean,
            default: true
         },
         // 是否允许日期范围的起止时间为同一天,mode = range时有效
         allowSameDay: {
            type: Boolean,
            default: false
         }
      },
      data() {
         return {
            // 每个日期的宽度
            width: 0,
            // 当前选中的日期item
            item: {},
            selected: []
         }
      },
      watch: {
         selectedChange: {
            immediate: true,
            handler(n) {
               this.setDefaultDate()
            }
         }
      },
      computed: {
         // 多个条件的变化,会引起选中日期的变化,这里统一管理监听
         selectedChange() {
            return [this.minDate, this.maxDate, this.defaultDate]
         },
         dayStyle(index1, index2, item) {
            return (index1, index2, item) => {
               const style = {}
               let week = item.week
               // 不进行四舍五入的形式保留2位小数
               const dayWidth = Number(parseFloat(this.width / 7).toFixed(3).slice(0, -1))
               // 得出每个日期的宽度
               // #ifdef APP-NVUE
               style.width = uni.$u.addUnit(dayWidth)
               // #endif
               style.height = uni.$u.addUnit(this.rowHeight)
               if (index2 === 0) {
                  // 获取当前为星期几,如果为0,则为星期天,减一为每月第一天时,需要向左偏移的item个数
                  week = (week === 0 ? 7 : week) - 1
                  style.marginLeft = uni.$u.addUnit(week * dayWidth)
               }
               if (this.mode === 'range') {
                  // 之所以需要这么写,是因为DCloud公司的iOS客户端的开发者能力有限导致的bug
                  style.paddingLeft = 0
                  style.paddingRight = 0
                  style.paddingBottom = 0
                  style.paddingTop = 0
               }
               return style
            }
         },
         daySelectStyle() {
            return (index1, index2, item) => {
               let date = dayjs(item.date).format("YYYY-MM-DD"),
                  style = {}
               // 判断date是否在selected数组中,因为月份可能会需要补0,所以使用dateSame判断,而不用数组的includes判断
               if (this.selected.some(item => this.dateSame(item, date))) {
                  style.backgroundColor = this.color
               }
               if (this.mode === 'single') {
                  if (date === this.selected[0]) {
                     // 因为需要对nvue的兼容,只能这么写,无法缩写,也无法通过类名控制等等
                     style.borderTopLeftRadius = '3px'
                     style.borderBottomLeftRadius = '3px'
                     style.borderTopRightRadius = '3px'
                     style.borderBottomRightRadius = '3px'
                  }
               } else if (this.mode === 'range') {
                  if (this.selected.length >= 2) {
                     const len = this.selected.length - 1
                     // 第一个日期设置左上角和左下角的圆角
                     if (this.dateSame(date, this.selected[0])) {
                        style.borderTopLeftRadius = '3px'
                        style.borderBottomLeftRadius = '3px'
                     }
                     // 最后一个日期设置右上角和右下角的圆角
                     if (this.dateSame(date, this.selected[len])) {
                        style.borderTopRightRadius = '3px'
                        style.borderBottomRightRadius = '3px'
                     }
                     // 处于第一和最后一个之间的日期,背景色设置为浅色,通过将对应颜色进行等分,再取其尾部的颜色值
                     if (dayjs(date).isAfter(dayjs(this.selected[0])) && dayjs(date).isBefore(dayjs(this
                           .selected[len]))) {
                        style.backgroundColor = uni.$u.colorGradient(this.color, '#ffffff', 100)[90]
                        // 增加一个透明度,让范围区间的背景色也能看到底部的mark水印字符
                        style.opacity = 0.7
                     }
                  } else if (this.selected.length === 1) {
                     // 之所以需要这么写,是因为DCloud公司的iOS客户端的开发者能力有限导致的bug
                     // 进行还原操作,否则在nvue的iOS,uni-app有bug,会导致诡异的表现
                     style.borderTopLeftRadius = '3px'
                     style.borderBottomLeftRadius = '3px'
                  }
               } else {
                  if (this.selected.some(item => this.dateSame(item, date))) {
                     style.borderTopLeftRadius = '3px'
                     style.borderBottomLeftRadius = '3px'
                     style.borderTopRightRadius = '3px'
                     style.borderBottomRightRadius = '3px'
                  }
               }
               return style
            }
         },
         // 某个日期是否被选中
         textStyle() {
            return (item) => {
               const date = dayjs(item.date).format("YYYY-MM-DD"),
                  style = {}
               // 选中的日期,提示文字设置白色
               if (this.selected.some(item => this.dateSame(item, date))) {
                  style.color = '#ffffff'
               }
               if (this.mode === 'range') {
                  const len = this.selected.length - 1
                  // 如果是范围选择模式,第一个和最后一个之间的日期,文字颜色设置为高亮的主题色
                  if (dayjs(date).isAfter(dayjs(this.selected[0])) && dayjs(date).isBefore(dayjs(this
                        .selected[len]))) {
                     style.color = this.color
                  }
               }
               return style
            }
         },
         // 获取底部的提示文字
         getBottomInfo() {
            return (index1, index2, item) => {
               const date = dayjs(item.date).format("YYYY-MM-DD")
               const bottomInfo = item.bottomInfo
               // 当为日期范围模式时,且选择的日期个数大于0时
               if (this.mode === 'range' && this.selected.length > 0) {
                  if (this.selected.length === 1) {
                     // 选择了一个日期时,如果当前日期为数组中的第一个日期,则显示底部文字为“开始”
                     if (this.dateSame(date, this.selected[0])) return this.startText
                     else return bottomInfo
                  } else {
                     const len = this.selected.length - 1
                     // 如果数组中的日期大于2个时,第一个和最后一个显示为开始和结束日期
                     if (this.dateSame(date, this.selected[0]) && this.dateSame(date, this.selected[1]) &&
                        len === 1) {
                        // 如果长度为2,且第一个等于第二个日期,则提示语放在同一个item中
                        return `${this.startText}/${this.endText}`
                     } else if (this.dateSame(date, this.selected[0])) {
                        return this.startText
                     } else if (this.dateSame(date, this.selected[len])) {
                        return this.endText
                     } else {
                        return bottomInfo
                     }
                  }
               } else {
                  return bottomInfo
               }
            }
         }
      },
      mounted() {
         this.init()
      },
      methods: {
         init() {
            // 初始化默认选中
            this.$emit('monthSelected', this.selected)
            this.$nextTick(() => {
               // 这里需要另一个延时,因为获取宽度后,会进行月份数据渲染,只有渲染完成之后,才有真正的高度
               // 因为nvue下,$nextTick并不是100%可靠的
               uni.$u.sleep(10).then(() => {
                  this.getWrapperWidth()
                  this.getMonthRect()
               })
            })
         },
         // 判断两个日期是否相等
         dateSame(date1, date2) {
            return dayjs(date1).isSame(dayjs(date2))
         },
         // 获取月份数据区域的宽度,因为nvue不支持百分比,所以无法通过css设置每个日期item的宽度
         getWrapperWidth() {
            // #ifdef APP-NVUE
            dom.getComponentRect(this.$refs['u-calendar-month-wrapper'], res => {
               this.width = res.size.width
            })
            // #endif
            // #ifndef APP-NVUE
            this.$uGetRect('.u-calendar-month-wrapper').then(size => {
               this.width = size.width
            })
            // #endif
         },
         getMonthRect() {
            // 获取每个月份数据的尺寸,用于父组件在scroll-view滚动事件中,监听当前滚动到了第几个月份
            const promiseAllArr = this.months.map((item, index) => this.getMonthRectByPromise(
               `u-calendar-month-${index}`))
            // 一次性返回
            Promise.all(promiseAllArr).then(
               sizes => {
                  let height = 1
                  const topArr = []
                  for (let i = 0; i < this.months.length; i++) {
                     // 添加到months数组中,供scroll-view滚动事件中,判断当前滚动到哪个月份
                     topArr[i] = height
                     height += sizes[i].height
                  }
                  // 由于微信下,无法通过this.months[i].top的形式(引用类型)去修改父组件的month的top值,所以使用事件形式对外发出
                  this.$emit('updateMonthTop', topArr)
               })
         },
         // 获取每个月份区域的尺寸
         getMonthRectByPromise(el) {
            // #ifndef APP-NVUE
            // $uGetRect为uView自带的节点查询简化方法,详见文档介绍:https://www.uviewui.com/js/getRect.html
            // 组件内部一般用this.$uGetRect,对外的为uni.$u.getRect,二者功能一致,名称不同
            return new Promise(resolve => {
               this.$uGetRect(`.${el}`).then(size => {
                  resolve(size)
               })
            })
            // #endif
            // #ifdef APP-NVUE
            // nvue下,使用dom模块查询元素高度
            // 返回一个promise,让调用此方法的主体能使用then回调
            return new Promise(resolve => {
               dom.getComponentRect(this.$refs[el][0], res => {
                  resolve(res.size)
               })
            })
            // #endif
         },
         // 点击某一个日期
         clickHandler(index1, index2, item) {
            if (this.readonly) {
               return;
            }
            this.item = item
            const date = dayjs(item.date).format("YYYY-MM-DD")
            if (item.disabled) return
            // 对上一次选择的日期数组进行深度克隆
            let selected = uni.$u.deepClone(this.selected)
            if (this.mode === 'single') {
               // 单选情况下,让数组中的元素为当前点击的日期
               selected = [date]
            } else if (this.mode === 'multiple') {
               if (selected.some(item => this.dateSame(item, date))) {
                  // 如果点击的日期已在数组中,则进行移除操作,也就是达到反选的效果
                  const itemIndex = selected.findIndex(item => item === date)
                  selected.splice(itemIndex, 1)
               } else {
                  // 如果点击的日期不在数组中,且已有的长度小于总可选长度时,则添加到数组中去
                  if (selected.length < this.maxCount) selected.push(date)
               }
            } else {
               // 选择区间形式
               if (selected.length === 0 || selected.length >= 2) {
                  // 如果原来就为0或者大于2的长度,则当前点击的日期,就是开始日期
                  selected = [date]
               } else if (selected.length === 1) {
                  // 如果已经选择了开始日期
                  const existsDate = selected[0]
                  // 如果当前选择的日期小于上一次选择的日期,则当前的日期定为开始日期
                  if (dayjs(date).isBefore(existsDate)) {
                     selected = [date]
                  } else if (dayjs(date).isAfter(existsDate)) {
                     // 当前日期减去最大可选的日期天数,如果大于起始时间,则进行提示
                     if(dayjs(dayjs(date).subtract(this.maxRange, 'day')).isAfter(dayjs(selected[0])) && this.showRangePrompt) {
                        if(this.rangePrompt) {
                           uni.$u.toast(this.rangePrompt)
                        } else {
                           uni.$u.toast(`选择天数不能超过 ${this.maxRange} 天`)
                        }
                        return
                     }
                     // 如果当前日期大于已有日期,将当前的添加到数组尾部
                     selected.push(date)
                     const startDate = selected[0]
                     const endDate = selected[1]
                     const arr = []
                     let i = 0
                     do {
                        // 将开始和结束日期之间的日期添加到数组中
                        arr.push(dayjs(startDate).add(i, 'day').format("YYYY-MM-DD"))
                        i++
                        // 累加的日期小于结束日期时,继续下一次的循环
                     } while (dayjs(startDate).add(i, 'day').isBefore(dayjs(endDate)))
                     // 为了一次性修改数组,避免computed中多次触发,这里才用arr变量一次性赋值的方式,同时将最后一个日期添加近来
                     arr.push(endDate)
                     selected = arr
                  } else {
                     // 选择区间时,只有一个日期的情况下,且不允许选择起止为同一天的话,不允许选择自己
                     if (selected[0] === date && !this.allowSameDay) return
                     selected.push(date)
                  }
               }
            }
            this.setSelected(selected)
         },
         // 设置默认日期
         setDefaultDate() {
            if (!this.defaultDate) {
               // 如果没有设置默认日期,则将当天日期设置为默认选中的日期
               const selected = [dayjs().format("YYYY-MM-DD")]
               return this.setSelected(selected, false)
            }
            let defaultDate = []
            const minDate = this.minDate || dayjs().format("YYYY-MM-DD")
            const maxDate = this.maxDate || dayjs(minDate).add(this.maxMonth - 1, 'month').format("YYYY-MM-DD")
            if (this.mode === 'single') {
               // 单选模式,可以是字符串或数组,Date对象等
               if (!uni.$u.test.array(this.defaultDate)) {
                  defaultDate = [dayjs(this.defaultDate).format("YYYY-MM-DD")]
               } else {
                  defaultDate = [this.defaultDate[0]]
               }
            } else {
               // 如果为非数组,则不执行
               if (!uni.$u.test.array(this.defaultDate)) return
               defaultDate = this.defaultDate
            }
            // 过滤用户传递的默认数组,取出只在可允许最大值与最小值之间的元素
            defaultDate = defaultDate.filter(item => {
               return dayjs(item).isAfter(dayjs(minDate).subtract(1, 'day')) && dayjs(item).isBefore(dayjs(
                  maxDate).add(1, 'day'))
            })
            this.setSelected(defaultDate, false)
         },
         setSelected(selected, event = true) {
            this.selected = selected
            event && this.$emit('monthSelected', this.selected)
         }
      }
   }
</script>
<style lang="scss" scoped>
   @import "../../libs/css/components.scss";
   .u-calendar-month-wrapper {
      margin-top: 4px;
   }
   .u-calendar-month {
      &__title {
         font-size: 14px;
         line-height: 42px;
         height: 42px;
         color: $u-main-color;
         text-align: center;
         font-weight: bold;
      }
      &__days {
         position: relative;
         @include flex;
         flex-wrap: wrap;
         &__month-mark-wrapper {
            position: absolute;
            top: 0;
            bottom: 0;
            left: 0;
            right: 0;
            @include flex;
            justify-content: center;
            align-items: center;
            &__text {
               font-size: 155px;
               color: rgba(231, 232, 234, 0.83);
            }
         }
         &__day {
            @include flex;
            padding: 2px;
            /* #ifndef APP-NVUE */
            // vue下使用css进行宽度计算,因为某些安卓机会无法进行js获取父元素宽度进行计算得出,会有偏移
            width: calc(100% / 7);
            box-sizing: border-box;
            /* #endif */
            &__select {
               flex: 1;
               @include flex;
               align-items: center;
               justify-content: center;
               position: relative;
               &__dot {
                  width: 7px;
                  height: 7px;
                  border-radius: 100px;
                  background-color: $u-error;
                  position: absolute;
                  top: 12px;
                  right: 7px;
               }
               &__buttom-info {
                  color: $u-content-color;
                  text-align: center;
                  position: absolute;
                  bottom: 5px;
                  font-size: 10px;
                  text-align: center;
                  left: 0;
                  right: 0;
                  &--selected {
                     color: #ffffff;
                  }
                  &--disabled {
                     color: #cacbcd;
                  }
               }
               &__info {
                  text-align: center;
                  font-size: 16px;
                  &--selected {
                     color: #ffffff;
                  }
                  &--disabled {
                     color: #cacbcd;
                  }
               }
               &--selected {
                  background-color: $u-primary;
                  @include flex;
                  justify-content: center;
                  align-items: center;
                  flex: 1;
                  border-radius: 3px;
               }
               &--range-selected {
                  opacity: 0.3;
                  border-radius: 0;
               }
               &--range-start-selected {
                  border-top-right-radius: 0;
                  border-bottom-right-radius: 0;
               }
               &--range-end-selected {
                  border-top-left-radius: 0;
                  border-bottom-left-radius: 0;
               }
            }
         }
      }
   }
</style>