Vue 实战:实现一个可视化的多类型时间段选择器

Vue 实战:实现一个可视化的多类型时间段选择器

在许多业务场景中,例如设置课程表排期、会议室预约等,我们需要一个直观的时间段选择工具。传统的下拉框或输入框操作繁琐,不够直观。

本文将分享一个基于 Vue 2 (Options API) 实现的可视化时间段选择器组件。该组件支持通过鼠标框选来设置时间范围,支持多种时间类型,并能自动合并连续的时间段,同时支持回显和数据处理。

1. 组件功能概览

  • 可视化交互:以表格形式展示 24 小时(或自定义时间),用户可以直接鼠标按下并拖拽来选择时间段。
  • 多类型支持 :支持定义多种时间类型(默认 5 种),每种类型对应不同的颜色(通过 colorArr 配置)。
  • 自动合并 :选择离散的时间块后,组件会自动计算并将连续的时间段合并为字符串(如 08:00~12:00, 14:00~18:00)。
  • 数据回显 :支持传入已选中的时间数据(timeInfo),自动解析并渲染在表格上。
  • 编辑模式控制:支持禁用状态,只有点击"编辑"后才能进行操作。

2. 核心逻辑解析

2.1 表格网格初始化 (init)

组件本质上是一个二维网格系统。我们需要将一天的时间划分为若干个小单元格。

  • X轴 :代表时间粒度。默认 xNum=12,配合 yNum=4,总共 48 个单元格,若每格代表 30 分钟,则刚好覆盖 24 小时。
  • Y轴:代表行,用于分段显示(如上午、下午)。

init 方法中,我们构建 rowUnit 二维数组,每个单元格存储其时间标签(如 "08:30")和当前状态(颜色、类型)。

javascript 复制代码
// 初始化网格数据
for (let i = 0; i < this.yNum; i++) {
  let arr = []
  for (let j = 0; j < this.xNum; j++) {
    // 计算当前格子的具体时间
    let v = i * (this.xNum/2)
    let n = Math.floor(j / 2)+v
    let hour =  n<10?'0'+n:n
    let min = j%2==0?':00':':30'
    arr.push({class: null, bgColor: null, timeData: j,label:hour+min})
  }
  this.rowUnit.push(arr)
  // ...初始化数据存储结构
}

2.2 鼠标拖拽选择逻辑 (handleMouseDown / handleMouseUp)

这是组件交互的核心。通过记录鼠标按下时的坐标 (beginTime, beginChooseLine) 和松开时的坐标,计算出选中的矩形区域。

  1. 计算范围:确定选区的起点和终点(X轴和Y轴的最大最小值)。
  2. 判断操作类型 :遍历选区内的所有格子。
    • 如果有未选中的格子,则执行全选操作(将该区域染成当前类型颜色)。
    • 如果全是已选中的格子,则执行反选操作(清除颜色)。
javascript 复制代码
handleMouseUp(i, item, type, chooseTypeNum) {
  // ...省略部分代码

  let xStart = Math.min(begin, i) // 计算X轴起止点
  let xEnd = Math.max(begin, i)
  let yStart = Math.min(this.beginChooseLine, item.id); // 计算Y轴起止点
  let yEnd = Math.max(this.beginChooseLine, item.id);

  // 判断是"添加"还是"取消"
  if (isAdd()) {
    // 染色逻辑
    for (let y = yStart; y < yEnd + 1; y++) {
      for (let x = xStart; x < xEnd + 1; x++) {
        this.rowUnit[y][x].bgColor = this.colorArr[typeN]
        // ...
      }
    }
  } else {
    // 擦除逻辑
    // ...
  }
}

2.3 时间转换与合并 (filterTime & mergeTimeRanges)

表格只是表现形式,业务需要的是时间字符串(如 08:00~12:00)。

  1. 数组转时间filterTime 方法会将选中的格子索引(0, 1, 2...)转换为具体的时间点,并将连续的索引合并。
  2. 字符串合并mergeTimeRanges 处理跨行或分散的时间段,确保最终输出的是最简时间范围列表。
javascript 复制代码
// 核心合并逻辑示例
function sortCut(arr) {
  // 将连续的数组 [0,1,2,5,6] 转换为 [[0,2], [5,6]]
  // ...
}

// 最终生成 "08:00~10:00" 这样的字符串

3. 完整代码实现

以下是组件的完整代码,包含 HTML 模板、JS 逻辑和 SCSS 样式。

vue 复制代码
<template>
  <div class="time-calendar-box">
    <div class="calendar">
      <table class="calendar-table">
        <thead class="calendar-head">
          <tr>
            <th rowspan="6" class="week-td"><slot name="label">{{label}}</slot></th>
            <th colspan="48" class="table-title-box"> <slot name="title"></slot></th>
          </tr>
        </thead>
        <tbody id="tableBody">
          <tr v-for="item in timeVList" :key="item.id">
            <td>{{ item.label }}</td>
            <td 
              @mousedown.prevent="handleMouseDown(index,item)" 
              @mouseup.prevent="handleMouseUp(index,item)"
              class="calendar-atom-time" 
              :style="{background: child.bgColor}" 
              :class="child.class" 
              v-for="(child,index) in rowUnit[item.id]"
              :key="''+item.id+index"
            >
              {{child.label}}
            </td>
          </tr>
          
          <!-- 按钮区域 -->
          <tr v-show="hasButton">
            <td colspan="49" class="timeContent">
              <el-button 
                v-show="currentStatus=='add'"
                @click="handleCancel" 
                :disabled="disabled&&!isNeedEdit"
              >
                取消
              </el-button>
              <el-button 
                v-show="currentStatus=='add'"
                @click="handleSubmit" 
                :disabled="disabled&&!isNeedEdit"
              >
                确定
              </el-button>
              <el-button
                @click="handleEdit" 
                v-show="currentStatus=='edit'" 
                :disabled="btnDisabled"
              >
                编辑
              </el-button>
              <slot name="calendar-btn"></slot>
            </td>
          </tr>

          <!-- 结果展示区域 -->
          <tr v-show="isShow">
            <td colspan="49" class="timeDetail">
              <div>
                <div v-for="(item,index) in finalData" :key="index" v-show="item['arr'+chooseTypeNum]">
                  {{ item['arr'+chooseTypeNum] }}
                </div>
              </div>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    label: {
      type: String,
      default: "时间/年份"
    },
    chooseTypeNum: [Number, String], // 当前选中的类型索引
    typeNum: {
      type: Number,
      default: 5
    },
    colorArr: {
      type: Array,
      default: () => ["#93C360", "#59E2C2", "#F7E332", "#8D8D8D", "#505050"]
    },
    dataInfo: Object,
    xNum: { Number, default: 12 }, // X轴单元格数量
    yNum: { Number, default: 4 },   // Y轴行数
    timeVList: {
      type: Array,
      default: () => [
        { id: 0, label: "00:00 - 06:00" },
        { id: 1, label: "06:00 - 12:00" },
        { id: 2, label: "12:00 - 18:00" },
        { id: 3, label: "18:00 - 24:00" }
      ]
    },
    disabled: { type: Boolean, default: false },
    onlyVal: [String],
    className: { type: String, default: '' },
    hasButton: { type: Boolean, default: true },
    isNeedEdit: { type: Boolean, default: false },
    isShow: { type: Boolean, default: false }, // 是否在底部显示最终结果文本
    timeInfo: [String, Array], // 回显数据
    btnDisabled: { type: Boolean, default: false },
    status: [String, Number]
  },
  name: 'timeSelect',
  data() {
    return {
      rowUnit: [], // 存储格子数据的二维数组
      timeContent: [],
      timeStr: [],
      beginChooseLine: 0,
      beginTime: 0,
      downEvent: false,
      currentStatus: 'add',
      finalData: []
    }
  },
  created() {},
  mounted() {
    if(this.timeInfo == '') {
      this.init()
    }
  },
  watch: {
    chooseTypeNum: {
      handler(val) {
        if (!val) return
        this.handleSetDomStatus('remove')
      },
      immediate: true
    },
    timeInfo: {
      handler(val) {
        if (!val) return
        this.init()
        // 回显逻辑:将传入的时间字符串解析为格子坐标并染色
        for(let k = 0; k<val.length; k++) {
          let xIndex, yIndex, info
          for(let j = 0; j<this.timeVList.length; j++) {
            // 获取该行的时间节点列表
            let tList = this.getHalfHourTimes(this.timeVList[j].label.split('-')[0],this.timeVList[j].label.split('-')[1])
            if(tList.includes(val[k].startTime.substring(0, 5))) {
              xIndex = tList.findIndex(el=>el == val[k].startTime.substring(0, 5))
              this.beginTime = xIndex
              this.beginChooseLine = this.timeVList[j].id
              info = this.timeVList[j]
            }
            if(tList.includes(val[k].endTime.substring(0, 5)) || (info&&val[k].endTime.substring(0, 5)=='24:00')) {
              let index = tList.findIndex(el=>el == val[k].endTime.substring(0, 5))
              yIndex = index==0 || index==-1? this.xNum - 1: index-1
              // 模拟鼠标抬起,触发染色
              this.handleMouseUp(yIndex,info,'look',Number(val[k].timeType)-1)
              break
            }
          }
        }
        this.handleSubmit('look')
      },
      immediate: true
    },
  },
  methods: {
    // 获取半小时粒度的时间数组 ['00:00', '00:30', ...]
    getHalfHourTimes(startTime, endTime) {
      const [startHour, startMinute] = startTime.split(':').map(Number);
      const [endHour, endMinute] = endTime.split(':').map(Number);
      const times = [];
      for (let hour = startHour; hour < endHour; hour++) {
        for (let minute = 0; minute < 60; minute += 30) {
          times.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`);
        }
      }
      if (endMinute >= 30) {
        for (let minute = 0; minute < 30; minute += 30) {
          times.push(`${String(endHour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`);
        }
      }
      return times;
    },
    handleSetDomStatus(type) {
      let dom = document.querySelectorAll(`.ui-selected-${this.chooseTypeNum} ${this.className}`)
      for (let i = 0; i < dom.length; i++) {
        if (type == 'remove') {
          dom[i].classList.remove('is-disabled')
        } else {
          dom[i].classList.add('is-disabled')
        }
      }
    },
    init() {
      this.rowUnit = []
      this.timeContent = []
      this.timeStr = []
      for (let i = 0; i < this.yNum; i++) {
        let arr = []
        for (let j = 0; j < this.xNum; j++) {
          let v = i * (this.xNum/2)
          let n = Math.floor(j / 2)+v
          let hour =  n<10?'0'+n:n
          let min = j%2==0?':00':':30'
          arr.push({class: null, bgColor: null, timeData: j,label:hour+min})
        }
        this.rowUnit.push(arr)
        let obj = {}, obj1 = {}
        for (let k = 0; k < this.typeNum; k++) {
          obj['arr' + k] = []
          obj1['arr' + k] = ''
        }
        this.timeContent.push(obj)
        this.timeStr.push(obj1)
      }
    },
    handleMouseDown(i, item) {
      this.downEvent = true
      this.beginChooseLine = item.id
      this.beginTime = i
    },
    handleGetChooseSpecialDateRange() {
      let info={}
      this.finalData=[]
      for (let i = 0; i < this.typeNum; i++) {
        info[i] = { dateList: [] }
        this.finalData[i] = {['arr'+i]:''}
        this.timeStr.forEach(el=>{
          if(el['arr'+i]!='') {
            info[i].dateList.push(el['arr'+i])
          }
        })
        this.finalData[i]['arr'+i] = this.mergeTimeRanges(info[i].dateList).join(',')
      }
    },
    getTimeValue(time) {
      let [hours, minutes] = time.split(':').map(Number);
      return hours * 60 + minutes;
    },
    mergeTimeRanges(ranges) {
      let list = []
      for(let i = 0; i<ranges.length; i++) {
        let arr = ranges[i].split(',')
        for(let k = 0; k<arr.length; k++) {
          list.push(arr[k])
        }
      }
      list.sort((a, b) => {
        let startA = this.getTimeValue(a.split('~')[0]);
        let startB = this.getTimeValue(b.split('~')[0]);
        return startA - startB;
      });
      let mergedRanges = [];
      for (let range of list) {
        let [start, end] = range.split('~');
        if (mergedRanges.length === 0 || this.getTimeValue(start) > this.getTimeValue(mergedRanges[mergedRanges.length - 1].split('~')[1])) {
          mergedRanges.push(range);
        } else {
          let lastRange = mergedRanges[mergedRanges.length - 1];
          let [lastStart, lastEnd] = lastRange.split('~');
          let newEnd = this.getTimeValue(end) > this.getTimeValue(lastEnd) ? end : lastEnd;
          mergedRanges[mergedRanges.length - 1] = `${lastStart}~${newEnd}`;
        }
      }
      return mergedRanges;
    },
    handleSubmit(type) {
      this.handleSetDomStatus()
      this.$emit('submit', this.timeStr,this.onlyVal,type)
    },
    handleSetBtnStatus() {
      this.currentStatus = this.currentStatus == 'add'?'edit':'add'
    },
    handleCancel() {
      for (let i = 0; i < this.yNum; i++) {
        this.timeContent[i]['arr' + this.chooseTypeNum] = []
        this.timeStr[i]['arr' + this.chooseTypeNum] = ''
        for (let j = 0; j < this.xNum; j++) {
          if (this.rowUnit[i][j].bgColor == this.colorArr[this.chooseTypeNum]) {
            this.rowUnit[i][j].bgColor = null
            this.rowUnit[i][j].class = null
          }
        }
      }
    },
    handleEdit() {
      this.$emit('edit', this.onlyVal)
    },
    handleMouseUp(i, item,type,chooseTypeNum) {
      let flag = true
      if(type == 'look') {
        flag = true
      } else {
        if (!this.downEvent && this.disabled) {
          flag = false
        }
      }
      if(!flag) return
      
      let typeN = chooseTypeNum!==undefined?chooseTypeNum:this.chooseTypeNum
      let _this = this
      let begin = this.beginTime
      let xStart = Math.min(begin, i) 
      let xEnd = Math.max(begin, i) 
      let yStart = Math.min(this.beginChooseLine, item.id);
      let yEnd = Math.max(this.beginChooseLine, item.id);
      
      function isAdd() {
        for (let y = yStart; y < yEnd + 1; y++) {
          for (let x = xStart; x < xEnd + 1; x++) {
            if (_this.rowUnit[y][x].bgColor == null) {
              return true
            }
          }
        }
        return false
      }

      if (isAdd()) {
        for (let y = yStart; y < yEnd + 1; y++) {
          for (let x = xStart; x < xEnd + 1; x++) {
            if (this.rowUnit[y][x].bgColor == null) {
              this.rowUnit[y][x].bgColor = this.colorArr[typeN]
              this.rowUnit[y][x].class = 'ui-selected ui-selected-' + typeN + ' '+this.className
              this.timeContent[y]['arr' + typeN].push(this.rowUnit[y][x].timeData)
            }
          }
        }
      } else { 
        for (let y1 = yStart; y1 < yEnd + 1; y1++) {
          for (let x1 = xStart; x1 < xEnd + 1; x1++) {
            if (this.rowUnit[y1][x1].bgColor == this.colorArr[typeN]) {
              this.rowUnit[y1][x1].bgColor = null
              this.rowUnit[y1][x1].class = null
              const index = this.timeContent[y1]['arr' + typeN].indexOf(this.rowUnit[y1][x1].timeData);
              if (index > -1) {
                this.timeContent[y1]['arr' + typeN].splice(index, 1);
              }
            }
          }
        }
      }
      this.downEvent = false
      this.filterTime(yStart, yEnd,typeN)
    },
    filterTime(start, end,chooseTypeNum) {
      let _this = this
      function sortCut(arr) {
        const result = [];
        let temp = [];
        for (let i = 0; i < arr.length; i++) {
          if (i === 0 || arr[i] - arr[i - 1] === 1) {
            temp.push(arr[i]);
          } else {
            result.push(temp);
            temp = [arr[i]];
          }
        }
        result.push(temp);
        return result;
      }

      function toStr(val, index) {
        const num = index * (_this.xNum/2) + val;
        let str = '';
        if (Number.isInteger(num)) {
          str = (num < 10 ? ('0' + num) : num.toString()) + ':00';
        } else {
          str = (Math.floor(num) < 10 ? ('0' + Math.floor(num)) : Math.floor(num).toString()) + ':30';
        }
        return str;
      }

      function timeToStr(arr, i) {
        let str = '';
        arr.forEach((item, index) => {
          if (index === 0) {
            str += `${toStr(item[0], i)}~${toStr(item[1], i)}`;
          } else {
            str += `, ${toStr(item[0], i)}~${toStr(item[1], i)}`;
          }
        });
        return str;
      }
      let typeN = chooseTypeNum!==undefined?chooseTypeNum:this.chooseTypeNum
      for (let i = start; i < end + 1; i++) {
        const sortedArr = this.timeContent[i]['arr' + typeN].sort((a, b) => a - b);
        if (sortedArr.length == 0) {
          this.timeStr[i]['arr' + typeN] = ''
        } else {
          const arr1 = sortCut(sortedArr);
          const arr2 = arr1.map(item => [item[0] / 2, item[item.length - 1] / 2 + 0.5]);
          this.timeStr[i]['arr' + typeN] = timeToStr(arr2, i);
        }
      }
      this.handleGetChooseSpecialDateRange()
      if(!this.hasButton) {
        this.handleSetDomStatus()
        this.$emit('submit', this.timeStr,this.onlyVal)
      }
    },
    clear() {
      this.init()
    },
  }
}
</script>

<style lang="scss" scoped>
.time-calendar-box {
  width: 100%;
  .calendar {
    -webkit-user-select: none;
    position: relative;
    display: inline-block;
    width: 100%;
    a {
      cursor: pointer;
      color: #2F88FF;
      font-size: 14px;
    }
    .calendar-table {
      border-collapse: collapse;
      border-radius: 4px;
      width: 100%;
      tr {
        border: 1px solid #fff;
        font-size: 14px;
        text-align: center;
        min-width: 11px;
        line-height: 1.8em;
        transition: background 200ms ease;
        color: #fff;
        .calendar-atom-time {
          &:hover {
            background: rgb(28, 41, 70)
          }
          &.is-disabled {
            cursor: not-allowed;
            &:hover {
              background: transparent;
            }
          }
        }
      }
      td, th {
        border: 1px solid #fff;
        font-size: 14px;
        text-align: center;
        min-width: 11px;
        line-height: 1.8em;
        transition: background 200ms ease;
        color: #fff;
        white-space: nowrap;
        padding: 5px 6px;
      }
      td.timeContent {
        padding: 10px 20px;
        text-align: right;
        ::v-deep .el-button {
          padding: 7px 20px;
        }
      }
      td.timeDetail {
        > div {
          display: flex;
          flex-wrap: wrap;
        }
        padding: 10px 20px;
      }
      thead th, tbody tr td:first-child {
        background: #233357;
      }
    }
  }
}
</style>

4. 使用示例

在父组件中使用该组件时,可以通过 timeInfo 传入数据实现回显,或者监听 submit 事件获取用户选择的结果。

html 复制代码
<template>
  <time-calendar
    :time-info="timeInfo"
    :choose-type-num="currentType"
    :type-num="5"
    :disabled="!isEditing"
    @submit="onSubmit"
    @edit="onEdit"
  />

</template>

<script>
  import TimeCalendar from './components/time.vue'
export default {
  components: {
    TimeCalendar
  },
  data() {
    return {
      timeInfo: [
        { startTime: "00:00:00", endTime: "06:00:00", timeType: "1" },
        { startTime: "08:00:00", endTime: "12:00:00", timeType: "2" }
      ],
      currentType: 0,
      isEditing: false
    }
  },
  methods: {
    onSubmit(data, value) {
      console.log('用户选择的时间字符串:', timeStr)
      // timeStr 格式示例: { arr0: "00:00~06:00", arr1: "08:00~12:00" }
    },
    onEdit(value) {
      this.isEditing = true
    },
    
  }
}
</script>
相关推荐
打小就很皮...8 小时前
《在 React/Vue 项目中引入 Supademo 实现交互式新手指引》
前端·supademo·新手指引
C澒8 小时前
系统初始化成功率下降排查实践
前端·安全·运维开发
摘星编程8 小时前
React Native + OpenHarmony:自定义useFormik表单处理
javascript·react native·react.js
C澒8 小时前
面单打印服务的监控检查事项
前端·后端·安全·运维开发·交通物流
pas1368 小时前
39-mini-vue 实现解析 text 功能
前端·javascript·vue.js
qq_532453538 小时前
使用 GaussianSplats3D 在 Vue 3 中构建交互式 3D 高斯点云查看器
前端·vue.js·3d
Swift社区9 小时前
Flutter 路由系统,对比 RN / Web / iOS 有什么本质不同?
前端·flutter·ios
2601_949833399 小时前
flutter_for_openharmony口腔护理app实战+我的实现
开发语言·javascript·flutter
雾眠气泡水@9 小时前
前端:解决同一张图片由于页面大小不统一导致图片模糊
前端
开发者小天9 小时前
python中计算平均值
开发语言·前端·python