用slider组件改造时间范围滑块

543 阅读3分钟

背景

在教育考勤业务中,需要配置一段考勤范围,例如:在上课的前5分钟-上课后的5分钟时间考勤时间,在上课5分钟之后,算是迟到。

效果图

image.png

改造步骤

  1. 项目依赖中安装el-slider组件(需要使用其样式)
  2. 将组件主要源码拿过来,封成自定义组件(需要调整气泡的展示)
  3. 上课前使用一个slider,上课后使用一个slider,组合得到大致模样
  4. 修改时间抽和滑块按钮样式

原代码

image.png

// index.vue

<template>
  <div class="custom-slider-box">
    <div class="top-legend">
      <div class="class-attend-time">
        <div class="left-time-bar"></div>
        <div class="right-time-text">考勤时间</div>
      </div>
      <div class="late-come-time">
         <div class="left-time-bar"></div>
        <div class="right-time-text">迟到时间</div>
      </div>
    </div>
    <div class="top-config-slider">
      <div class="course-begin-before">
        <customSlider :max="20" v-model="value1" :format-tooltip="formatTooltip1"></customSlider>
      </div>
      <div class="course-begin-after">
        <span class="class-begin-time">{{'上课'}}</span>
        <customSlider :max="50" v-model="value2" :format-tooltip="formatTooltip2"></customSlider>
      </div>
    </div>
  </div>
</template>

<script>
import customSlider from '@/EduMyCourse/components/customSlider/main.vue'
export default {
  name: '1111',
  components:{
    customSlider
  },
  props:{},
  data(){
    return {
      value1:15,
      value2:5
    }
  },
  watch:{},
  computed:{},
  methods:{
    formatTooltip1(val){
      return `前${20-val}min`
    },
    formatTooltip2(val){
      return `后${val}min`
    }
  },
  created(){},
  mounted(){}
}
</script>
<style lang="less" scoped>
.legend-common{
  display: flex;
  align-items: center;
  margin-right: 30px;
  .left-time-bar{
    width: 20px;
    height: 10px;
    margin-right: 6px;
  }
}
.slider-common{
  border-right: 1px solid #c1c1c1;
  /deep/ .el-slider{
    .el-slider__runway{
      height: 32px;
      border-radius: 0;
      margin: 4px 0;
      .el-slider__runway-click-area{
        height: 32px;
        top:0;
      }
      .el-slider__bar{
        height: 32px;
        border-radius: 0;
      }
      .el-slider__button-wrapper{
        top: -2px;
        .el-slider__button{
          width: 10px;
          height: 36px;
          border-radius: 2px;
          position: relative;
          border: none;
          box-shadow: 0 0 3px;
          &:hover::after{
            width: 0;
            height: 0;
            background-color: transparent;
          }
        }
      }
    } 
  }
}
.custom-slider-box{
  padding:50px;
  .top-legend{
     display: flex;
     margin-bottom: 50px;
     .class-attend-time{
      .legend-common();
      .left-time-bar{
        background-color: rgb(2, 191, 15);
      }
     }
     .late-come-time{
      .legend-common();
      .left-time-bar{
        background-color: rgb(255, 204, 0);
      } 
     }
  }
  .top-config-slider{
     display: flex;
     height: 40px;
     align-items: center;
  }
  .course-begin-before{
    width: 270px;
    .slider-common();
    border-left: 1px solid #c1c1c1;
    /deep/ .el-slider{
      .el-slider__runway{
        background-color: rgb(2, 191, 15);
        .el-slider__bar{
         background-color: #f2f2f2;
        }
      } 
    }
  }
  .course-begin-after{
    width: 450px;
    .slider-common();
    position: relative;
    .class-begin-time{
      height: 20px;
      line-height: 20px;
      position: absolute;
      left: 0;
      bottom: -30px;
      transform: translate(-50%);
      color: #999;
    }
    /deep/ .el-slider{
      .el-slider__runway{
        background-color: rgb(255, 204, 0);
        .el-slider__bar{
         background-color: rgb(2, 191, 15);
        }
      } 
    }
  }
}
</style>
// main.vue
<template>
  <div
    :class="{
      'is-vertical': vertical,
      'el-slider--with-input': showInput,
      'el-slider--with-button': internalShowButton
    }"
    class="el-slider"
  >
    <!-- v-repeat-click="onFirstButtonClick" -->
    <el-button
      v-if="internalShowButton"
      :icon="`h-icon-${vertical ? 'add' : 'minus'}`"
    />
    <div
      ref="slider"
      :class="{
        'show-input': showInput,
        'show-button': internalShowButton,
        disabled: disabled
      }"
      :style="runwayStyle"
      class="el-slider__runway"
    >
      <!-- @click="onSliderClick" -->
      <div
        class="el-slider__runway-click-area"
        style="cursor: default;"
      />
      
      <div
        :style="barStyle"
        class="el-slider__bar"
      />
      <slider-button
        ref="button1"
        v-model="firstValue"
        :vertical="vertical"
        @slider-click-value-change="sliderClickValueChange"
        @drag-end="dragEnd"
        @button-down="buttonDown(sliderInput)"
      />
      <slider-button
        v-if="range"
        ref="button2"
        v-model="secondValue"
        :vertical="vertical"
        @drag-end="dragEnd"
        @button-down="buttonDown(sliderInput2)"
      />
      <div v-if="showStops">
        <template v-for="(item, index) in stops">
          <div
            :key="index"
            class="el-slider__stop-wrap"
          >
            <div
              :style="
                vertical
                  ? { bottom: item.stepWidth + '%' }
                  : { left: item.stepWidth + '%' }
              "
              class="el-slider__stop el-slider__stop--top el-slider__stop--left"
            />
            <div
              :style="
                vertical
                  ? { bottom: item.stepWidth + '%' }
                  : { left: item.stepWidth + '%' }
              "
              class="el-slider__stop el-slider__stop--bottom el-slider__stop--right"
            />
            <div
              v-show="showStopsNumber"
              :style="
                vertical
                  ? { bottom: item.stepWidth + '%' }
                  : { left: item.stepWidth + '%' }
              "
              class="el-slider__mark"
            >
              {{ item.stepValue }}
            </div>
          </div>
        </template>
      </div>
    </div>
    <el-button
      v-if="internalShowButton"
      v-repeat-click="onSecondButtonClick"
      :icon="`h-icon-${vertical ? 'minus' : 'add'}`"
    />
  </div>
</template>

<script>
import SliderButton from './button.vue';
// import RepeatClick from 'hui/src/directives/repeat-click';

export default {
  name: 'custom-Slider',

  components: {
    SliderButton
  },
  // directives: {
  //   RepeatClick
  // },
  props: {
    min: {
      type: Number,
      default: 0
    },
    max: {
      type: Number,
      default: 100
    },
    step: {
      type: Number,
      default: 1
    },
    marks: {
      type: [Array, Object],
      default: () => []
    },
    value: {
      type: [Number, Array],
      default: 0
    },
    showInput: {
      type: Boolean,
      default: false
    },
    showButton: {
      type: Boolean,
      default: false
    },
    showInputControls: {
      type: Boolean,
      default: true
    },
    showStops: {
      type: Boolean,
      default: false
    },
    showStopsNumber: {
      type: Boolean,
      default: false
    },
    showTooltip: {
      type: Boolean,
      default: true
    },
    formatTooltip: {
      type: Function,
      default: null
    },
    disabled: {
      type: Boolean,
      default: false
    },
    range: {
      type: Boolean,
      default: false
    },
    vertical: {
      type: Boolean,
      default: false
    },
    height: {
      type: String,
      default: ''
    }
  },

  data() {
    return {
      firstValue: null,
      secondValue: null,
      oldValue: null,
      dragging: false,
      sliderSize: 1,
      sliderInput: null,
      sliderInput2: null
    };
  },

  computed: {
    stops() {
      const result = [];
      const totalWidth = 100;

      if (
        this.marks.length > 0 ||
        (Object.keys(this.marks) && Object.keys(this.marks).length > 0)
      ) {
        // TODO: 判断数据正确性
        const markObj = Array.isArray(this.marks)
          ? this.marks.reduce((prev, curr) => {
            prev[curr] = curr;
            return prev;
          }, {})
          : this.marks;

        for (const key in markObj) {
          result.push({
            stepWidth: ((key - this.min) * totalWidth) / (this.max - this.min),
            stepValue: markObj[key]
          });
        }
        return result;
      }
      if (this.step === 0) {
        process.env.NODE_ENV !== 'production' &&
          console.warn('[HUI Warn][Slider]step should not be 0.');
        return [];
      }
      const stopCount = (this.max - this.min) / this.step;
      const stepWidth = (totalWidth * this.step) / (this.max - this.min);
      for (let i = 0; i <= stopCount; i++) {
        result.push({
          stepWidth: i * stepWidth,
          stepValue: this.min + i * this.step
        });
      }
      return result;
    },

    minValue() {
      return Math.min(this.firstValue, this.secondValue);
    },

    maxValue() {
      return Math.max(this.firstValue, this.secondValue);
    },

    barSize() {
      return this.range
        ? `${(100 * (this.maxValue - this.minValue)) / (this.max - this.min)}%`
        : `${(100 * (this.firstValue - this.min)) / (this.max - this.min)}%`;
    },

    barStart() {
      return this.range
        ? `${(100 * (this.minValue - this.min)) / (this.max - this.min)}%`
        : '0%';
    },

    precision() {
      const precisions = [this.min, this.max, this.step].map(item => {
        const decimal = ('' + item).split('.')[1];
        return decimal ? decimal.length : 0;
      });
      return Math.max.apply(null, precisions);
    },

    runwayStyle() {
      return this.vertical ? { height: this.height } : {};
    },

    barStyle() {
      return this.vertical
        ? {
          height: this.barSize,
          bottom: this.barStart
        }
        : {
          width: this.barSize,
          left: this.barStart
        };
    },
    internalShowButton() {
      return this.showButton && !this.range && !this.showInput;
    }
  },

  watch: {
    value(val, oldVal) {
      if (
        this.dragging ||
        (Array.isArray(val) &&
          Array.isArray(oldVal) &&
          val.every((item, index) => item === oldVal[index]))
      ) {
        return;
      }
      this.setValues();
    },

    dragging(val) {
      if (!val) {
        this.setValues();
      }
    },

    firstValue(val) {
      if (this.range) {
        this.$emit('input', [this.minValue, this.maxValue]);
      } else {
        this.$emit('input', val);
      }
    },

    secondValue() {
      if (this.range) {
        this.$emit('input', [this.minValue, this.maxValue]);
      }
    },

    min() {
      this.setValues();
    },

    max() {
      this.setValues();
    }
  },

  mounted() {
    if (this.range) {
      if (Array.isArray(this.value)) {
        this.firstValue = Math.max(this.min, this.value[0]);
        this.secondValue = Math.min(this.max, this.value[1]);
      } else {
        this.firstValue = this.min;
        this.secondValue = this.max;
      }
      this.oldValue = [this.firstValue, this.secondValue];
    } else {
      if (typeof this.value !== 'number' || isNaN(this.value)) {
        this.firstValue = this.min;
      } else {
        this.firstValue = Math.min(this.max, Math.max(this.min, this.value));
      }
      this.oldValue = this.firstValue;
    }
    this.resetSize();
    window.addEventListener('resize', this.resetSize);
  },

  beforeDestroy() {
    window.removeEventListener('resize', this.resetSize);
    if (this.sliderInput) {
      this.sliderInput.$destroy(true);
    }
    if (this.sliderInput2) {
      this.sliderInput2.$destroy(true);
    }
  },

  methods: {
    valueChanged() {
      if (this.range) {
        return ![this.minValue, this.maxValue].every(
          (item, index) => item === this.oldValue[index]
        );
      }
      return this.value !== this.oldValue;
    },
    setValues() {
      if (this.min > this.max) {
        console.error('[Hui Error][Slider]min should not be greater than max.');
        return;
      }
      const val = this.value;
      if (this.range && Array.isArray(val)) {
        if (val[1] < this.min) {
          this.$emit('input', [this.min, this.min]);
        } else if (val[0] > this.max) {
          this.$emit('input', [this.max, this.max]);
        } else if (val[0] < this.min) {
          this.$emit('input', [this.min, val[1]]);
        } else if (val[1] > this.max) {
          this.$emit('input', [val[0], this.max]);
        } else {
          this.firstValue = val[0];
          this.secondValue = val[1];
          if (this.valueChanged()) {
            this.$emit('change', [this.minValue, this.maxValue]);
            this.oldValue = val.slice();
          }
        }
      } else if (!this.range && typeof val === 'number' && !isNaN(val)) {
        if (val < this.min) {
          this.$emit('input', this.min);
        } else if (val > this.max) {
          this.$emit('input', this.max);
        } else {
          this.firstValue = val;
          if (this.valueChanged()) {
            this.$emit('change', val);
            this.oldValue = val;
          }
        }
      }
    },

    setPosition(percent, type) {
      const targetValue = this.min + (percent * (this.max - this.min)) / 100;
      if (!this.range) {
        this.$refs.button1.setPosition(percent, type);
        return;
      }
      let button;
      if (
        Math.abs(this.minValue - targetValue) <
        Math.abs(this.maxValue - targetValue)
      ) {
        button = this.firstValue < this.secondValue ? 'button1' : 'button2';
      } else {
        button = this.firstValue > this.secondValue ? 'button1' : 'button2';
      }
      this.$refs[button].setPosition(percent, type);
    },

    onSliderClick(event) {
      if (this.disabled || this.dragging) {
        return;
      }
      // add by zhangxiaogang
      this.$emit(
        'before-click',
        event,
        this.range && Array.isArray(this.value)
          ? [this.minValue, this.maxValue]
          : this.value
      );
      this.resetSize();
      if (this.vertical) {
        const sliderOffsetBottom = this.$refs.slider.getBoundingClientRect()
          .bottom;
        this.setPosition(
          ((sliderOffsetBottom - event.clientY) / this.sliderSize) * 100,
          'slider-click-value-change'
        );
      } else {
        const sliderOffsetLeft = this.$refs.slider.getBoundingClientRect().left;
        this.setPosition(
          ((event.clientX - sliderOffsetLeft) / this.sliderSize) * 100,
          'slider-click-value-change'
        );
      }
    },
    sliderClickValueChange(value) {
      if (this.range && Array.isArray(value)) {
        this.$emit('slider-click', [this.minValue, this.maxValue]);
      } else {
        this.$emit('slider-click', value);
      }
    },
    onSecondButtonClick() {
      this.vertical
        ? (this.firstValue -= this.step)
        : (this.firstValue += this.step);
    },
    onFirstButtonClick() {
      this.vertical
        ? (this.firstValue += this.step)
        : (this.firstValue -= this.step);
    },
    resetSize() {
      if (this.$refs.slider) {
        this.sliderSize = this.$refs.slider[
          `client${this.vertical ? 'Height' : 'Width'}`
        ];
      }
    },

    // add by zhangxiaogang
    dragEnd() {
      if (this.range && Array.isArray(this.value)) {
        this.$emit('drag-end', [this.minValue, this.maxValue]);
      } else {
        this.$emit('drag-end', this.value);
      }
    },

    // range show-input 情况下,点击 button聚焦 input add by yangzhini
    buttonDown(sliderInput) {
      // 点击失去焦点
      if (this.$refs.input && this.$refs.input.$refs.input.$refs.input) {
        this.$refs.input.$refs.input.$refs.input.blur();
      }
      if (!sliderInput) {
        return;
      }
      this.sliderInputSelect(sliderInput);
    },

    sliderInputSelect(sliderInput) {
      if (!sliderInput) {
        return;
      }
      sliderInput.$refs.rangeInput.$refs.input.$refs.input.select();
    },

    handleBlur(event) {
      this.$emit('blur', event);
    }
  }
};
</script>

// button.vue
<template>
  <div
    ref="button"
    :style="wrapperStyle"
    class="el-slider__button-wrapper"
    @mouseenter="handleMouseEnter"
    @mouseleave="handleMouseLeave"
    @mousedown="onButtonDown"
  >
    <el-tooltip
      ref="tooltip"
      :disabled="!showTooltip"
      effect="light"
      placement="top"
      v-model="toolTipShow"
      :manual="true"
    >
      <span slot="content">
        {{ formatValue }}
      </span>
      <div
        class="el-slider__button"
      />
    </el-tooltip>
  </div>
</template>

<script>
export default {
  name: 'CustomSliderButton',
  props: {
    value: {
      type: Number,
      default: 0
    },
    vertical: {
      type: Boolean,
      default: false
    }
  },

  data() {
    return {
      hovering: false,
      dragging: false,
      startX: 0,
      currentX: 0,
      startY: 0,
      currentY: 0,
      startPosition: 0,
      newPosition: null,
      oldValue: this.value,
      toolTipShow: true
    };
  },

  computed: {
    disabled() {
      return this.$parent.disabled;
    },

    max() {
      return this.$parent.max;
    },

    min() {
      return this.$parent.min;
    },

    step() {
      return this.$parent.step;
    },

    showTooltip() {
      return (
        this.$parent.showTooltip &&
        !(this.$parent.showInput && this.$parent.range)
      );
    },

    precision() {
      return this.$parent.precision;
    },

    currentPosition() {
      return `${((this.value - this.min) / (this.max - this.min)) * 100}%`;
    },

    enableFormat() {
      return this.$parent.formatTooltip instanceof Function;
    },

    formatValue() {
      return (
        (this.enableFormat && this.$parent.formatTooltip(this.value)) ||
        this.value
      );
    },

    wrapperStyle() {
      return this.vertical
        ? { bottom: this.currentPosition }
        : { left: this.currentPosition };
    }
  },

  watch: {
    dragging(val) {
      this.$parent.dragging = val;
    }
  },

  methods: {
    displayTooltip() {
      // this.$refs.tooltip && (this.$refs.tooltip.showPopper = true);
    },

    hideTooltip() {
      // this.$refs.tooltip && (this.$refs.tooltip.showPopper = false);
    },

    handleMouseEnter() {
      // this.hovering = true;
      // this.displayTooltip();
    },

    handleMouseLeave() {
      // this.hovering = false;
      // this.hideTooltip();
    },

    onButtonDown(event) {
      if (this.disabled) {
        return;
      }
      event.preventDefault();
      this.$emit('button-down');
      this.onDragStart(event);
      window.addEventListener('mousemove', this.onDragging);
      window.addEventListener('mouseup', this.onDragEnd);
      window.addEventListener('contextmenu', this.onDragEnd);
    },

    onDragStart(event) {
      this.dragging = true;
      if (this.vertical) {
        this.startY = event.clientY;
      } else {
        this.startX = event.clientX;
      }
      this.startPosition = parseFloat(this.currentPosition);
      // add by zhangxiaogang
      this.$parent.$emit('drag-start', event, this.oldValue);
    },

    onDragging(event) {
      if (this.dragging) {
        // this.displayTooltip();
        this.$parent.resetSize();
        let diff = 0;
        if (this.vertical) {
          this.currentY = event.clientY;
          diff =
            ((this.startY - this.currentY) / this.$parent.sliderSize) * 100;
        } else {
          this.currentX = event.clientX;
          diff =
            ((this.currentX - this.startX) / this.$parent.sliderSize) * 100;
        }
        this.newPosition = this.startPosition + diff;
        this.setPosition(this.newPosition);
      }
    },

    onDragEnd() {
      if (this.dragging) {
        /*
         * 防止在 mouseup 后立即触发 click,导致滑块有几率产生一小段位移
         * 不使用 preventDefault 是因为 mouseup 和 click 没有注册在同一个 DOM 上
         */
        setTimeout(() => {
          this.dragging = false;
          // this.hideTooltip();
          this.setPosition(this.newPosition);
          // add by zhangxiaogang
          this.$emit('drag-end');
        }, 0);
        window.removeEventListener('mousemove', this.onDragging);
        window.removeEventListener('mouseup', this.onDragEnd);
        window.removeEventListener('contextmenu', this.onDragEnd);
      }
    },

    setPosition(newPosition, type) {
      if (newPosition === null) {
        return;
      }
      if (newPosition < 0) {
        newPosition = 0;
      } else if (newPosition > 100) {
        newPosition = 100;
      }
      const lengthPerStep = 100 / ((this.max - this.min) / this.step);
      const steps = Math.round(newPosition / lengthPerStep);
      let value =
        steps * lengthPerStep * (this.max - this.min) * 0.01 + this.min;
      value = parseFloat(value.toFixed(this.precision));
      this.newPosition = newPosition;
      this.$emit('input', value);
      type && this.$emit(type, value);
      this.$refs.tooltip && this.$refs.tooltip.updatePopper();
      if (!this.dragging && this.value !== this.oldValue) {
        this.oldValue = this.value;
      }
    }
  }
};
</script>