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) 和松开时的坐标,计算出选中的矩形区域。
- 计算范围:确定选区的起点和终点(X轴和Y轴的最大最小值)。
- 判断操作类型 :遍历选区内的所有格子。
- 如果有未选中的格子,则执行全选操作(将该区域染成当前类型颜色)。
- 如果全是已选中的格子,则执行反选操作(清除颜色)。
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)。
- 数组转时间 :
filterTime方法会将选中的格子索引(0, 1, 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>