Element UI (Vue 2) 的
el-date-picker功能强大,但遗憾的是缺少原生的年份范围选择支持。当项目需要筛选跨年度的数据时,这一缺失就成了痛点。为了填补这个空白并提升组件实用性,我对其进行了定制化扩展,成功添加了年范围选择能力。本博客将详细介绍这一功能的实现过程,帮助你轻松应对类似需求。
准备工作
将 element 中的 date-picker 源码复制一份到项目中src下的components下,(源码位置:node_modules\element-ui\packages\date-picker)然后在 main.js 中使用
import DatePicker from './components/date-picker';
Vue.component('DatePicker', DatePicker);
<!-- 使用 -->
<DatePicker
v-model="value"
type="yearrange">
</DatePicker>
这个时候会报错,代码无法解析jsx语法错误。原因是这个源码中包含时间选择器,这个组件中引入滚动条ElScrollbar所以会报错,这里只需要日期选择器,所以可以注释掉这几行代码
src\components\date-picker\src\basic\time-spinner.vue
// import ElScrollbar from 'element-ui/packages/scrollbar';
// components: { ElScrollbar },
src\components\date-picker\src\panel\time-select.vue
// import ElScrollbar from 'element-ui/packages/scrollbar';
// components: { ElScrollbar },
如果有eslint代码检查,需要把src/components/date-picker加入到忽略文件中不然会报出很多代码格式的错误和警告 。
年范围选择完善
1、在src\components\date-picker\src\panel 文件夹下新增文件 year-range.vue,定义年范围选择规则,代码如下:
<template>
<transition
name="el-zoom-in-top"
@after-leave="$emit('dodestroy')"
>
<div
v-show="visible"
class="el-picker-panel el-date-range-picker el-popper"
:class="[
{
'has-sidebar': $slots.sidebar || shortcuts,
},
popperClass,
]"
>
<div class="el-picker-panel__body-wrapper">
<slot
name="sidebar"
class="el-picker-panel__sidebar"
></slot>
<div
class="el-picker-panel__sidebar"
v-if="shortcuts"
>
<button
type="button"
class="el-picker-panel__shortcut"
v-for="(shortcut, key) in shortcuts"
:key="key"
@click="handleShortcutClick(shortcut)"
>
{{ shortcut.text }}
</button>
</div>
<div class="el-picker-panel__body">
<div class="el-picker-panel__content el-date-range-picker__content is-left">
<div class="el-date-range-picker__header">
<button
type="button"
@click="leftPrevDecade"
class="el-picker-panel__icon-btn el-icon-d-arrow-left"
></button>
<button
type="button"
v-if="unlinkPanels"
@click="leftNextDecade"
:disabled="!enableDecadeArrow"
:class="{ 'is-disabled': !enableDecadeArrow }"
class="el-picker-panel__icon-btn el-icon-d-arrow-right"
></button>
<div>{{ leftDecadeLabel }}</div>
</div>
<year-table
selection-mode="range"
:date="leftDate"
:default-value="defaultValue"
:min-date="minDate"
:max-date="maxDate"
:range-state="rangeState"
:disabled-date="disabledDate"
@changerange="handleChangeRange"
@pick="handleRangePick"
>
</year-table>
</div>
<div class="el-picker-panel__content el-date-range-picker__content is-right">
<div class="el-date-range-picker__header">
<button
type="button"
v-if="unlinkPanels"
@click="rightPrevDecade"
:disabled="!enableDecadeArrow"
:class="{ 'is-disabled': !enableDecadeArrow }"
class="el-picker-panel__icon-btn el-icon-d-arrow-left"
></button>
<button
type="button"
@click="rightNextDecade"
class="el-picker-panel__icon-btn el-icon-d-arrow-right"
></button>
<div>{{ rightDecadeLabel }}</div>
</div>
<year-table
selection-mode="range"
:date="rightDate"
:default-value="defaultValue"
:min-date="minDate"
:max-date="maxDate"
:range-state="rangeState"
:disabled-date="disabledDate"
@changerange="handleChangeRange"
@pick="handleRangePick"
>
</year-table>
</div>
</div>
</div>
</div>
</transition>
</template>
<script type="text/babel">
import { isDate, modifyWithTimeString, prevYear, nextYear, nextMonth } from 'element-ui/src/utils/date-util';
import Clickoutside from 'element-ui/src/utils/clickoutside';
import Locale from 'element-ui/src/mixins/locale';
import YearTable from '../basic/year-table';
import ElInput from 'element-ui/packages/input';
import ElButton from 'element-ui/packages/button';
// 计算十年的起始年份
const getDecadeStartYear = (year) => {
return Math.floor(year / 10) * 10;
};
// 计算默认值
const calcDefaultValue = (defaultValue) => {
if (Array.isArray(defaultValue)) {
return [new Date(defaultValue[0]), new Date(defaultValue[1])];
} else if (defaultValue) {
const startYear = getDecadeStartYear(new Date(defaultValue).getFullYear());
return [new Date(startYear, 0, 1), new Date(startYear + 9, 11, 31)];
} else {
const now = new Date();
const startYear = getDecadeStartYear(now.getFullYear());
return [new Date(startYear, 0, 1), new Date(startYear + 9, 11, 31)];
}
};
// 获取下一个十年的日期
const nextDecade = (date) => {
const year = date.getFullYear();
const month = date.getMonth();
const day = date.getDate();
return new Date(year + 10, month, day);
};
// 获取上一个十年的日期
const prevDecade = (date) => {
const year = date.getFullYear();
const month = date.getMonth();
const day = date.getDate();
return new Date(year - 10, month, day);
};
export default {
mixins: [Locale],
directives: { Clickoutside },
computed: {
btnDisabled() {
return !(this.minDate && this.maxDate && !this.selecting && this.isValidValue([this.minDate, this.maxDate]));
},
leftDecadeLabel() {
const startYear = getDecadeStartYear(this.leftDate.getFullYear());
return `${startYear} - ${startYear + 9}`;
},
rightDecadeLabel() {
const startYear = getDecadeStartYear(this.rightDate.getFullYear());
return `${startYear} - ${startYear + 9}`;
},
leftDecadeYear() {
return getDecadeStartYear(this.leftDate.getFullYear());
},
rightDecadeYear() {
const leftDecadeYear = getDecadeStartYear(this.leftDate.getFullYear());
const rightDecadeYear = getDecadeStartYear(this.rightDate.getFullYear());
return leftDecadeYear === rightDecadeYear ? leftDecadeYear + 10 : rightDecadeYear;
},
enableDecadeArrow() {
return this.unlinkPanels && this.rightDecadeYear > this.leftDecadeYear + 10;
},
},
data() {
return {
popperClass: '',
value: [],
defaultValue: null,
defaultTime: null,
minDate: '',
maxDate: '',
leftDate: new Date(),
rightDate: nextDecade(new Date()),
rangeState: {
endDate: null,
selecting: false,
row: null,
column: null,
},
shortcuts: '',
visible: '',
disabledDate: '',
format: '',
arrowControl: false,
unlinkPanels: false,
};
},
watch: {
value(newVal) {
if (!newVal) {
this.minDate = null;
this.maxDate = null;
} else if (Array.isArray(newVal)) {
this.minDate = isDate(newVal[0]) ? new Date(newVal[0]) : null;
this.maxDate = isDate(newVal[1]) ? new Date(newVal[1]) : null;
if (this.minDate) {
this.leftDate = this.minDate;
if (this.unlinkPanels && this.maxDate) {
const minDateYear = getDecadeStartYear(this.minDate.getFullYear());
const maxDateYear = getDecadeStartYear(this.maxDate.getFullYear());
this.rightDate = minDateYear === maxDateYear ? nextDecade(this.maxDate) : this.maxDate;
} else {
this.rightDate = nextDecade(this.leftDate);
}
} else {
this.leftDate = calcDefaultValue(this.defaultValue)[0];
this.rightDate = nextDecade(this.leftDate);
}
}
},
defaultValue(val) {
if (!Array.isArray(this.value)) {
const [left, right] = calcDefaultValue(val);
this.leftDate = left;
this.rightDate = val && val[1] && getDecadeStartYear(left.getFullYear()) !== getDecadeStartYear(right.getFullYear()) && this.unlinkPanels ? right : nextDecade(this.leftDate);
}
},
},
methods: {
handleClear() {
this.minDate = null;
this.maxDate = null;
this.leftDate = calcDefaultValue(this.defaultValue)[0];
this.rightDate = nextDecade(this.leftDate);
this.$emit('pick', null);
},
handleChangeRange(val) {
this.minDate = val.minDate;
this.maxDate = val.maxDate;
this.rangeState = val.rangeState;
},
handleRangePick(val, close = true) {
const defaultTime = this.defaultTime || [];
const minDate = modifyWithTimeString(val.minDate, defaultTime[0]);
const maxDate = modifyWithTimeString(val.maxDate, defaultTime[1]);
if (this.maxDate === maxDate && this.minDate === minDate) {
return;
}
this.onPick && this.onPick(val);
this.maxDate = maxDate;
this.minDate = minDate;
// workaround for https://github.com/ElemeFE/element/issues/7539, should remove this block when we don't have to care about Chromium 55 - 57
setTimeout(() => {
this.maxDate = maxDate;
this.minDate = minDate;
}, 10);
if (!close) return;
this.handleConfirm();
},
handleShortcutClick(shortcut) {
if (shortcut.onClick) {
shortcut.onClick(this);
}
},
// leftPrev*, rightNext* need to take care of `unlinkPanels`
leftPrevDecade() {
this.leftDate = prevDecade(this.leftDate);
if (!this.unlinkPanels) {
this.rightDate = prevDecade(this.rightDate);
}
},
rightNextDecade() {
if (!this.unlinkPanels) {
this.leftDate = nextDecade(this.leftDate);
}
this.rightDate = nextDecade(this.rightDate);
},
// leftNext*, rightPrev* are called when `unlinkPanels` is true
leftNextDecade() {
this.leftDate = nextDecade(this.leftDate);
},
rightPrevDecade() {
this.rightDate = prevDecade(this.rightDate);
},
handleConfirm(visible = false) {
if (this.isValidValue([this.minDate, this.maxDate])) {
this.$emit('pick', [this.minDate, this.maxDate], visible);
}
},
isValidValue(value) {
return (
Array.isArray(value) &&
value &&
value[0] &&
value[1] &&
isDate(value[0]) &&
isDate(value[1]) &&
value[0].getTime() <= value[1].getTime() &&
(typeof this.disabledDate === 'function' ? !this.disabledDate(value[0]) && !this.disabledDate(value[1]) : true)
);
},
resetView() {
// NOTE: this is a hack to reset {min, max}Date on picker open.
// TODO: correct way of doing so is to refactor {min, max}Date to be dependent on value and internal selection state
// an alternative would be resetView whenever picker becomes visible, should also investigate date-panel's resetView
this.minDate = this.value && isDate(this.value[0]) ? new Date(this.value[0]) : null;
this.maxDate = this.value && isDate(this.value[0]) ? new Date(this.value[1]) : null;
},
},
components: { YearTable, ElInput, ElButton },
};
</script>
2、完善 basic 文件夹下 year-table.vue 的逻辑,添加年范围选择规则
<template>
<table
@click="handleYearTableClick"
@mousemove="handleMouseMove"
class="el-year-table"
>
<tbody>
<tr>
<td
class="available"
:class="getCellStyle(startYear + 0)"
>
<div>
<a class="cell">{{ startYear }}</a>
</div>
</td>
<td
class="available"
:class="getCellStyle(startYear + 1)"
>
<div>
<a class="cell">{{ startYear + 1 }}</a>
</div>
</td>
<td
class="available"
:class="getCellStyle(startYear + 2)"
>
<div>
<a class="cell">{{ startYear + 2 }}</a>
</div>
</td>
<td
class="available"
:class="getCellStyle(startYear + 3)"
>
<div>
<a class="cell">{{ startYear + 3 }}</a>
</div>
</td>
</tr>
<tr>
<td
class="available"
:class="getCellStyle(startYear + 4)"
>
<div>
<a class="cell">{{ startYear + 4 }}</a>
</div>
</td>
<td
class="available"
:class="getCellStyle(startYear + 5)"
>
<div>
<a class="cell">{{ startYear + 5 }}</a>
</div>
</td>
<td
class="available"
:class="getCellStyle(startYear + 6)"
>
<div>
<a class="cell">{{ startYear + 6 }}</a>
</div>
</td>
<td
class="available"
:class="getCellStyle(startYear + 7)"
>
<div>
<a class="cell">{{ startYear + 7 }}</a>
</div>
</td>
</tr>
<tr>
<td
class="available"
:class="getCellStyle(startYear + 8)"
>
<div>
<a class="cell">{{ startYear + 8 }}</a>
</div>
</td>
<td
class="available"
:class="getCellStyle(startYear + 9)"
>
<div>
<a class="cell">{{ startYear + 9 }}</a>
</div>
</td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
</template>
<script type="text/babel">
import { hasClass } from 'element-ui/src/utils/dom';
import { isDate, range, nextDate, getDayCountOfYear } from 'element-ui/src/utils/date-util';
import { arrayFindIndex, coerceTruthyValueToArray } from 'element-ui/src/utils/util';
const datesInYear = (year) => {
const numOfDays = getDayCountOfYear(year);
const firstDay = new Date(year, 0, 1);
return range(numOfDays).map((n) => nextDate(firstDay, n));
};
// 将年份转换为时间戳
const getYearTimestamp = function (year) {
if (typeof year === 'number') {
return new Date(year, 0, 1).getTime();
} else if (year instanceof Date) {
return new Date(year.getFullYear(), 0, 1).getTime();
} else {
return NaN;
}
};
export default {
props: {
disabledDate: {},
value: {},
selectionMode: {
default: 'year',
},
minDate: {},
maxDate: {},
defaultValue: {
validator(val) {
// null or valid Date Object
return val === null || (val instanceof Date && isDate(val)) || (Array.isArray(val) && val.every(isDate));
},
},
date: {},
rangeState: {
default: () => ({
endDate: null,
selecting: false,
row: null,
column: null,
}),
},
},
data() {
return {
lastRow: null,
lastColumn: null,
};
},
watch: {
'rangeState.endDate'(newVal) {
this.markRange(this.minDate, newVal);
},
minDate(newVal, oldVal) {
if (getYearTimestamp(newVal) !== getYearTimestamp(oldVal)) {
this.markRange(this.minDate, this.maxDate);
}
},
maxDate(newVal, oldVal) {
if (getYearTimestamp(newVal) !== getYearTimestamp(oldVal)) {
this.markRange(this.minDate, this.maxDate);
}
},
},
computed: {
startYear() {
return Math.floor(this.date.getFullYear() / 10) * 10;
},
},
methods: {
getCellStyle(year) {
const style = {};
const today = new Date();
style.disabled = typeof this.disabledDate === 'function' ? datesInYear(year).every(this.disabledDate) : false;
style.current = arrayFindIndex(coerceTruthyValueToArray(this.value), (date) => date.getFullYear() === year) >= 0;
style.today = today.getFullYear() === year;
style.default = this.defaultValue && this.defaultValue.getFullYear && this.defaultValue.getFullYear() === year;
// 添加范围选择的样式
if (this.selectionMode === 'range' || this.selectionMode === 'daterange') {
const yearTimestamp = getYearTimestamp(year);
const minDateTimestamp = this.minDate ? getYearTimestamp(this.minDate) : -1;
const maxDateTimestamp = this.maxDate ? getYearTimestamp(this.maxDate) : -1;
const rangeEndTimestamp = this.rangeState.endDate ? getYearTimestamp(this.rangeState.endDate) : -1;
// 处理选择中的范围
if (this.rangeState.selecting && minDateTimestamp > -1 && rangeEndTimestamp > -1) {
const [start, end] = [Math.min(minDateTimestamp, rangeEndTimestamp), Math.max(minDateTimestamp, rangeEndTimestamp)];
style['in-range'] = yearTimestamp >= start && yearTimestamp <= end;
} else if (minDateTimestamp > -1 && maxDateTimestamp > -1) {
style['in-range'] = yearTimestamp >= minDateTimestamp && yearTimestamp <= maxDateTimestamp;
}
style['start-date'] = minDateTimestamp > -1 && yearTimestamp === minDateTimestamp;
style['end-date'] = (maxDateTimestamp > -1 && yearTimestamp === maxDateTimestamp) || (minDateTimestamp > -1 && yearTimestamp === rangeEndTimestamp);
}
return style;
},
getYearOfCell(year) {
return new Date(year, 0, 1);
},
markRange(minDate, maxDate) {
// 标记范围内的年份
if (!minDate && !maxDate) return;
const startYear = this.startYear;
const rows = [0, 1, 2]; // 3行
const cells = [0, 1, 2, 3]; // 每行4列
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 4; j++) {
const index = i * 4 + j;
if (index < 10) {
// 只有前10个单元格有年份
const yearValue = startYear + index;
const yearTimestamp = getYearTimestamp(yearValue);
const minDateTimestamp = minDate ? getYearTimestamp(minDate) : -1;
const maxDateTimestamp = maxDate ? getYearTimestamp(maxDate) : -1;
// 更新样式
this.$forceUpdate();
}
}
}
},
handleMouseMove(event) {
if (!this.rangeState.selecting) return;
let target = event.target;
if (target.tagName === 'A') {
target = target.parentNode.parentNode;
}
if (target.tagName === 'DIV') {
target = target.parentNode;
}
if (target.tagName !== 'TD') return;
const row = target.parentNode.rowIndex;
const column = target.cellIndex;
// 计算年份
const startYear = this.startYear;
const yearIndex = row * 4 + column;
if (yearIndex >= 10) return; // 只有前10个单元格有年份
const year = startYear + yearIndex;
// 检查是否禁用
if (this.getCellStyle(year).disabled) return;
// 只在鼠标移动到新单元格时更新rangeState
if (row !== this.lastRow || column !== this.lastColumn) {
this.lastRow = row;
this.lastColumn = column;
this.$emit('changerange', {
minDate: this.minDate,
maxDate: this.maxDate,
rangeState: {
selecting: true,
endDate: this.getYearOfCell(year),
},
});
}
},
handleYearTableClick(event) {
const target = event.target;
let year;
if (target.tagName === 'DIV') {
const aTag = target.querySelector('a');
if (aTag && !hasClass(target, 'disabled')) {
year = aTag.textContent || aTag.innerText;
} else {
return;
}
} else if (target.tagName === 'A') {
if (hasClass(target.parentNode, 'disabled')) return;
year = target.textContent || target.innerText;
} else if (target.tagName === 'TD' && !hasClass(target, 'disabled')) {
const cell = target.querySelector('.cell');
if (cell) {
year = cell.textContent || cell.innerText;
}
} else {
return;
}
if (!year) return;
year = Number(year);
if (this.selectionMode === 'range' || this.selectionMode === 'daterange') {
if (!this.rangeState.selecting) {
// 开始选择范围
this.$emit('pick', { minDate: this.getYearOfCell(year), maxDate: null });
this.rangeState.selecting = true;
} else {
// 完成范围选择
const minDate = this.minDate;
const maxDate = this.getYearOfCell(year);
if (minDate && maxDate) {
const minYear = minDate.getFullYear();
const maxYear = maxDate.getFullYear();
if (maxYear < minYear) {
// 如果结束年份小于开始年份,交换它们
this.$emit('pick', { minDate: maxDate, maxDate: minDate });
} else {
this.$emit('pick', { minDate, maxDate });
}
}
this.rangeState.selecting = false;
}
} else {
this.$emit('pick', year);
}
},
},
};
</script>
<style lang="scss">
.el-year-table td.start-date .cell,
.el-year-table td.end-date .cell {
color: #ffffff;
background-color: #409eff;
}
.el-year-table td .cell {
border-radius: 18px;
}
.el-year-table td.start-date div {
border-top-left-radius: 24px;
border-bottom-left-radius: 24px;
}
.el-year-table td.end-date div {
border-top-right-radius: 24px;
border-bottom-right-radius: 24px;
}
.el-year-table td.in-range div {
background-color: #f2f6fc;
}
.el-year-table td {
padding: 20px 0;
}
</style>
3、修改 src\components\date-picker\src\picker\date-picker.js 文件中 getPanel 方法,当type传值为 yearrange 时指向刚创建的year-range.vue文件
import YearRangePanel from '../panel/year-range';
const getPanel = function (type) {
if (type === 'daterange' || type === 'datetimerange') {
return DateRangePanel;
} else if (type === 'monthrange') {
return MonthRangePanel;
} else if (type === 'yearrange') {
return YearRangePanel;
}
return DatePanel;
};
至此,我们已经详细拆解了为 Element UI (Vue 2) 的日期选择器添加年范围选择功能的具体实现方案。从识别原生组件的局限,到一步步定制逻辑,最终实现了符合业务需求的组件扩展。
2098

被折叠的 条评论
为什么被折叠?



