需求分析
- 可查看日期,可选择上一月或者下一月,上一年或者下一年。
- 高亮当前选择月(黑色),高亮今天样式(背景灰色),高亮选择的日期(蓝色边框)。
- input框与日期面板选择值双向绑定,修改input值日期面板值同步修改,反之同理。并且input的值输入错误时会进行纠偏。
- 可清除input中的值,并关闭popover。
- 可选择年月,年月界面与日期可来回跳转。
- 可控制时间选择范围,超出时间范围时弹出提示。
方法实现
一、思路:
1、主要的日期面板通过数值展示通过计算得出,首先通过new Date() 找到当天,并推理出面板的第一项为几月几号,因为面板固定为六行七列一共42项(为啥是42项?我们后面再说),因此我们找到了面板的第一项就能往后推41项,从而将面板上42项的值全部展示出来。这一系列计算可以通过计算属性return一个最终值,所以当我们进行日期操作时,比如点击上一月下一月都能通过计算属性所依赖的值变化实时算出面板需要展示的值。
2、之后相关的需求实现都是建立在思路1的计算属性的基础上。比如控制时间范围选择,通过传[begin, end]数组确定范围,但选择日期时与数组进行比较,超出范围则做相应的逻辑判断。
二、方法实现
1、datePicker外部组件传参定义:
value:定义默认时间。
scope:定义时间范围选择。
input:this.$emit事件传递的值。
TDatePicker则为日期选择组件,日期选择的绝大部分逻辑都封装在组件里,并且与input值双向绑定,用户可进行复杂的日期选择交互。
<template>
<div class="box">
<t-date-picker :scope="scope" :value="d" @input="d = $event"></t-date-picker>
</div>
</template>
<script>
import TDatePicker from './date-picker/date-picker'
export default {
name: 'demo',
components: {TDatePicker},
data() {
return {
d: new Date(),
scope: [new Date(1980, 0, 1), new Date(2080, 0, 1)]
}
},
}
</script>
2、datePicker内部组件实现
1、日期面板的实现。
先通过计算属性得出当前面板42项的数值数组。这里需要注意js中Date类中getDay()方法返回的星期日的值为0,周一到周六则对应是直觉的1~6。
计算的思路是先得出当前月的1号,通过当前月的1号是星期几计算出前面还需要补几项,这样就能确定日期面板的第一项的值,最后往后数41个值即可。
computed: {
visibleDays() {
let date = new Date(this.display.year, this.display.month, 1)
let first = helper.firstDayOfMonth(date)
let n = first.getDay()
let x = first - (n === 0 ? 6 : n -1) * 86400 * 1000
let array = []
for (let i = 0; i < 42; i++) {
array.push(new Date(x + i * 86400 * 1000))
}
return array
},
},
html的渲染:
42项前面面板的值我们通过上面的计算属性拿到了,现在需要给42项排成六行七列展示在面板正确的位置上。我们可以先计算好在渲染到页面上,也可以在template中计算,这里我们选择在template中计算,通过两个v-for循环,先遍历列(星期)再遍历行(周)。然后每项具体的值通过 (row - 1) * 7 + col - 1方法找出来。
<div :class="`${className}-weekdays`">
<span :class="`${className}-weekday`" v-for="i in [1,2,3,4,5,6,0]" :key="i">{{weekdays[i]}}</span>
</div>
<div :class="`${className}-row`" v-for="i in 6" :key="i">
<span
:class="[`${className}-cell`
,{currentMonth: isCurrentMonth(getVisibleDay(i,j))
,selected: isSelected(getVisibleDay(i,j))
,today: isToday(getVisibleDay(i,j))}
]"
v-for="j in 7"
:key="j"
@click="onClickCell(getVisibleDay(i,j))">
{{getVisibleDay(i,j).getDate()}}
</span>
</div>
对应方法:
getVisibleDay(row, col) {
return this.visibleDays[(row - 1) * 7 + col - 1]
},
isCurrentMonth(date) {
let [year1, month1] = helper.getYearMonthDate(date)
return year1 === this.display.year && month1 === this.display.month
},
isSelected(date) {
if (!this.value) {
return false
}
let [y, m, d] = helper.getYearMonthDate(date)
let [y2, m2, d2] = helper.getYearMonthDate(this.value)
return y===y2 && m===m2 && d===d2
},
isToday(date) {
let [y, m, d] = helper.getYearMonthDate(date)
let [y2, m2, d2] = helper.getYearMonthDate(new Date())
return y===y2 && m===m2 && d===d2
},
2、可选择上一月或者下一月,上一年或者下一年的实现。
日期面板的值通过计算属性得出来后,选择上下年月就好办多了。
通过display: {year, month}变量控制日期面板的年月显示,上下年月的选择通过调用方法进行操控。helper为date的辅助函数集,里面有封装好的相应方法。源码可查看文章底部我的github链接。
html:
<span @click="onClickPrevYear" :class="[`${className}-preYear`,`${className}-navItem`]">
<t-icon name="leftleft"></t-icon>
</span>
<span @click="onClickPrevMonth" :class="[`${className}-preMonth`,`${className}-navItem`]">
<t-icon name="left"></t-icon>
</span>
<span :class="`${className}-yearAndMonth`" @click="onClickMonth">
<span>{{display.year}}年</span>
<span>{{display.month + 1}}月</span>
</span>
<span @click="onClickNextMonth" :class="[`${className}-nextMonth`,`${className}-navItem`]">
<t-icon name="right"></t-icon>
</span>
<span @click="onClickNextYear" :class="[`${className}-nextYear`,`${className}-navItem`]">
<t-icon name="rightright"></t-icon>
</span>
变量声明:
import helper from './helper'
data() {
let [year, month] = helper.getYearMonthDate(this.value || new Date())
return {
helper: helper,
display: {year, month},
}
},
方法:
onClickPrevYear() {
const oldDate = new Date(this.display.year, this.display.month)
const newDate = helper.addYear(oldDate, -1)
const [year, month] = helper.getYearMonthDate(newDate)
this.display = {year, month}
},
onClickPrevMonth() {
const oldDate = new Date(this.display.year, this.display.month)
const newDate = helper.addMonth(oldDate, -1)
const [year, month] = helper.getYearMonthDate(newDate)
this.display = {year, month}
},
onClickNextYear() {
const oldDate = new Date(this.display.year, this.display.month)
const newDate = helper.addYear(oldDate, 1)
const [year, month] = helper.getYearMonthDate(newDate)
this.display = {year, month}
},
onClickNextMonth() {
const oldDate = new Date(this.display.year, this.display.month)
const newDate = helper.addMonth(oldDate, 1)
const [year, month] = helper.getYearMonthDate(newDate)
this.display = {year, month}
},
3、高亮当前选择月(黑色),高亮选择的日期(蓝色边框),高亮今天样式(背景灰色)。
这里在template日期面板展示的时候做一个样式的处理即可。
<span
:class="[`${className}-cell`
,{currentMonth: isCurrentMonth(getVisibleDay(i,j))
,selected: isSelected(getVisibleDay(i,j))
,today: isToday(getVisibleDay(i,j))}
]"
v-for="j in 7"
:key="j"
@click="onClickCell(getVisibleDay(i,j))">
{{getVisibleDay(i,j).getDate()}}
</span>
4、input框与日期面板选择值双向绑定,修改input值日期面板值同步修改,反之同理。并且input的值输入错误时会进行纠偏。
日期面板的选择值为 this.value,input中的值通过formattedValue计算属性计算得出,若input的值改变了将调用onInput方法判断input的值是否通过日期格式正则校验,若通过则将值通过this.$emit方法传给父组件再由父组件分发,若没通过正则校验则将原来的formattedValue值填回input中。
html:
<t-input type="text" :value="formattedValue" @input="onInput" @change="onChange" ref="input"/>
计算属性:
formattedValue() {
if (!this.value) {return ''}
const [year, month, day] = helper.getYearMonthDate(this.value)
return `${year}-${helper.pad2(month + 1)}-${helper.pad2(day)}`
},
方法:
onInput(value) {
let regex = /^\d{4}-\d{2}-\d{2}$/g;
if (value.match(regex)) {
let [year, month, day] = value.split('-')
month = month - 1
year = year - 0
this.display = {year, month}
this.$emit('input', new Date(year, month, day))
}
},
onChange() { //如果input的值校验格式错误,则将formattedValue的值回选
this.$refs.input.setRawValue(this.formattedValue)
},
5、可选择年月,并控制时间选择范围,超出时间范围时弹出提示。
年月的展示我们通过下拉框的方式进行选择,市面上的日期选择器的年月选择大多是一个平铺的面板,这里我们折中一下。
年的遍历通过scope选择的日期范围进行遍历,月遍历好办固定的12个月。当选择年月的事件触发时,我们将选择的值与scope传过来的值做个对比,若超出scope的范围则做出提示。
html:
<div :class="`${className}-selects`">
<select @change="onSelectYear" :value="display.year">
<option v-for="year in years" :value="year" :key="year">{{year}}</option>
</select> 年
<select @change="onSelectMonth" :value="display.month">
<option v-for="month in [0,1,2,3,4,5,6,7,8,9,10,11]"
:value="month" :key="month">{{month + 1}}</option>
</select> 月
</div>
props:
props: {
scope: {
type: Array,
default: ()=> [new Date(1950, 0, 1), helper.addYear(new Date(), 100)]
}
},
计算属性:
years() {
return helper.range(
this.scope[0].getFullYear(),
this.scope[1].getFullYear() + 1
)
}
方法:
onSelectYear(e) {
const year = e.target.value - 0
const d = new Date(year, this.display.month)
if (d >= this.scope[0] && d <= this.scope[1]) {
this.display.year = year
} else {
alert('超过规定时间范围')
e.target.value = this.display.year
}
},
onSelectMonth(e) {
const month = e.target.value - 0
const d = new Date(this.display.year, month)
if (d >= this.scope[0] && d <= this.scope[1]) {
this.display.month = month
} else {
alert('超过规定时间范围')
e.target.value = this.display.month
}
},
三、一些需要注意的坑
1、说到前面的问题,日期面板为什么固定是42项?
我们可以先确定列的数量,参考市面上其它日期组件的实现,都是为一周七天的展示,因此我们的列确定为七列。行怎么确定?我们可以算一下一个月的最大天数,是31天,那么周的最大跨度为四周二十八天,多出来的3天可再加两周(前一周1天后一周2天),则最大跨度为六周,因此行按六计算,最后得出6*7=42项。那么我们的行数能不写死,每个月自适应展示吗?交互不太好,组件翻页时高度会有变形。
2、类型转化的问题。
onInput(value) {
let regex = /^\d{4}-\d{2}-\d{2}$/g;
if (value.match(regex)) {
let [year, month, day] = value.split('-')
month = month - 1
year = year - 0
this.display = {year, month}
this.$emit('input', new Date(year, month, day))
}
},
其中month与year是通过value.split切割出来的,是string类型,在与其它日期值作比较时会有bug,因为this.display的日期值是number,string与number相比不相等,因此这里需要做类型的转化,通过 year - 0 转化为number类型。
完整代码可参考我的GitHub:https://github.com/A-Tione/tione/blob/master/src/date-picker/date-picker.vue