背景

在使用element-plus开发项目过程中,需要填入人员的生卒日期,经观察,对于大部分人来说,这类日期通常是农历日期,然而我们在系统建设过程中,对于日期字段,约定成俗的都会使用公历日期,这就存在一个问题,用户只记得自己的农历日期,那么在录入生卒日期的时候,往往就需要通过其他工具,查找到农历对应的公历日期,才能正确的录入系统中,并且,录入系统后,只能看到公历日期,不能直观的将农历日期反馈到用户,所以可能日期录入错误,也不能迅速的发现并修正,于是从实际需求出发,对element-plus组件库中的DatePicker组件进行自定义,在弹窗选择日期面板中,引入农历日期的显示,方便用户操作,减少错误发生。

组件设计

通过对element-plus组件库官方文档DatePicker 日期选择器 | Element Plus (element-plus.org)的查阅,DatePicker组件提供了一个默认的插槽,用于支持对弹出框内容的自定义,因此,我们需要借助此插槽来添加农历日期的显示。

根据日常使用惯例,大部分的日历工具,都是上面显示公历日期,下面显示对应的农历日期,如果日期是传统节日或者节气的,还会显示对应的节日或节气名称,因此,我们需要在自定义组件中,增加属性showFestival用于控制是否显示节日、showJieQi用于控制是否显示节气,如果都不显示,那么全都统一显示为农历日期天数。

我们知道,农历日期和公历日期是存在差异的,差异大的时候可能会相差一个月以上,然而日期选择组件的弹窗面板空间有限,因此我们需要将农历的月份融入日期中,也就是每个月的第一天显示当前农历月份,对于农历日期,用户往往还会注重当前年份的天干与地支,他们可以根据天干地支来进一步核实是否为当前年份,因此,我们还需要增加一个属性showLunarTip,用于控制显示当前日期的完整农历日期,如二〇二四年二月廿五 【甲辰(龙)年】,这样用户可以直观的看出当前日期正不正确,当然,出于对用户体验的改善,我们希望自定义组件更加人性化,比如,有时希望鼠标悬停到对应日期上,就马上弹出tip显示完整的农历日期信息,有时候,我希望鼠标悬停1秒以上才显示农历日期,减少对日期选择的干扰,因此我们再增加一个属性lunarTipShowAfter用于控制完整农历日期的弹出触发时常。

最终效果

效果图

工具选择

毋庸置疑,要显示公历对应的具体农历日期,肯定会存在日期间的换算,农历相对公历来说,规律性比较复杂,要完全自己实现公历转对应的农历,工作量较大,因此,我们优先选择三方工具,来完成两种历法的换算。

通过对几个工具库的对比,我最终选择了lunar (6tail.cn)工具库,它提供了丰富的接口,满足绝大部分场景下的使用需求,工具的强大性,请看官方文档介绍。

代码实现

因为项目使用vue3+typescript开发,因此自定义组件也是在此环境下完成。我们需要的是对原组件DatePicker的增强封装,因此我们的自定义组件需要保留绝大部分原组件的功能。

下面,直接贴出自定义组件的实现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
<template>
<el-date-picker v-model="dateValue" v-bind="$props">
<template #default="dateCell">
<el-tooltip
:disabled="!showLunarTip"
:show-after="lunarTipShowAfter"
:content="getLunarDateStr(dateCell.date)"
placement="bottom"
>
<div :class="getDateClass(dateCell)">
<span class="solar-text">{{ dateCell.date.getDate() }}</span>
<span class="lunar-tex">{{ getLunarDay(dateCell.date) }}</span>
</div>
</el-tooltip>
</template>
</el-date-picker>
</template>

<script setup lang="ts">
import { JieQi, Solar } from 'lunar-typescript'
import { propTypes } from '@/utils/propTypes'
import { isEmpty } from '@/utils/is'
import { datePickerProps } from 'element-plus'
import type { DateCell } from 'element-plus/es/components/date-picker/src/date-picker.type'
// 带农历日期显示的选择组件
defineOptions({ name: 'LunarDatePicker' })

const emit = defineEmits(['update:modelValue'])

const props = defineProps({
...datePickerProps,
showFestival: propTypes.bool.def(true), // 是否显示节日
showJieQi: propTypes.bool.def(true), // 是否显示节气
showLunarTip: propTypes.bool.def(true), // 是否使用 tooltip 显示农历日期
lunarTipShowAfter: propTypes.number.def(0) // 在触发后多久使用 tooltip 显示农历日期,单位毫秒
})

const dateValue: Ref<typeof props.modelValue> = ref<typeof props.modelValue>('')

watch(
() => props.modelValue,
(val: typeof props.modelValue) => {
dateValue.value = val
},
{
immediate: true
}
)

watch(
() => dateValue.value,
(val) => {
emit('update:modelValue', val)
}
)

/**
* 获取当前日期显示样式
* @param dateCell 单元格日期信息
*/
const getDateClass = (dateCell: DateCell) => {
let cla = 'date-wrapper'
if (dateCell.type === 'today') {
cla += ' today'
}

if (dateCell.isCurrent || dateCell.isSelected || dateCell.start || dateCell.end) {
cla += ' active'
} else if (dateCell.inRange) {
cla += ' in-range'
}

if (dateCell.disabled) {
cla += ' disabled-date'
}
return cla
}

/**
* 获取农历 day 显示文字
*/
const getLunarDay = (date) => {
const solarDate = Solar.fromDate(date)
const lunarDate = solarDate.getLunar()
// 每月第一天显示月数
if (lunarDate.getDay() == 1) {
return lunarDate.getMonthInChinese() + '月'
}

// 显示节日
if (props.showFestival) {
const festivals = lunarDate.getFestivals()
if (!isEmpty(festivals)) {
return festivals[0]
}
}

// 显示节气
if (props.showJieQi) {
const currJieQi: JieQi = lunarDate.getCurrentJieQi() as JieQi
if (currJieQi && currJieQi?.getName()) {
return currJieQi?.getName()
}
}

return lunarDate.getDayInChinese()
}

/**
* 根据日历获取农历日期,包含年份干支和生肖
*/
const getLunarDateStr = (date: Date): string => {
const solarDate = Solar.fromDate(date)
const lunarDate = solarDate.getLunar()
return `${lunarDate.getYearInChinese()}年${lunarDate.getMonthInChinese()}月${lunarDate.getDayInChinese()} 【${lunarDate.getYearInGanZhi()}(${lunarDate.getYearShengXiao()})年】`
}
</script>

<style lang="scss" scoped>
.date-wrapper {
position: relative;
display: flex;
align-items: center;
flex-direction: column;
padding: 4px 0;
line-height: 18px;
text-align: center;

.solar-text {
font-size: 14px;
}

.lunar-text {
white-space: nowrap;
}
}

.today {
font-weight: 700;
color: var(--el-color-primary);
}

.active {
color: #fff;
background-color: var(--el-datepicker-active-color);
border-radius: 5px;
}

.in-range {
background-color: var(--el-datepicker-inrange-bg-color);
}

.disabled-date {
cursor: not-allowed;
}
</style>

相关代码

引入历法换算工具

1
npm i lunar-typescript

propTypes 工具代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { VueTypeValidableDef, VueTypesInterface, createTypes, toValidableType } from 'vue-types'
import { CSSProperties } from 'vue'

type PropTypes = VueTypesInterface & {
readonly style: VueTypeValidableDef<CSSProperties>
}
const newPropTypes = createTypes({
func: undefined,
bool: undefined,
string: undefined,
number: undefined,
object: undefined,
integer: undefined
}) as PropTypes

class propTypes extends newPropTypes {
static get style() {
return toValidableType('style', {
type: [String, Object]
})
}
}

export { propTypes }

is 工具代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
// copy to vben-admin

const toString = Object.prototype.toString

export const is = (val: unknown, type: string) => {
return toString.call(val) === `[object ${type}]`
}

export const isDef = <T = unknown>(val?: T): val is T => {
return typeof val !== 'undefined'
}

export const isUnDef = <T = unknown>(val?: T): val is T => {
return !isDef(val)
}

export const isObject = (val: any): val is Record<any, any> => {
return val !== null && is(val, 'Object')
}

export const isEmpty = <T = unknown>(val: T): val is T => {
if (val === null) {
return true
}
if (isArray(val) || isString(val)) {
return val.length === 0
}

if (val instanceof Map || val instanceof Set) {
return val.size === 0
}

if (isObject(val)) {
return Object.keys(val).length === 0
}

return false
}

export const isDate = (val: unknown): val is Date => {
return is(val, 'Date')
}

export const isNull = (val: unknown): val is null => {
return val === null
}

export const isNullAndUnDef = (val: unknown): val is null | undefined => {
return isUnDef(val) && isNull(val)
}

export const isNullOrUnDef = (val: unknown): val is null | undefined => {
return isUnDef(val) || isNull(val)
}

export const isNumber = (val: unknown): val is number => {
return is(val, 'Number')
}

export const isPromise = <T = any>(val: unknown): val is Promise<T> => {
return is(val, 'Promise') && isObject(val) && isFunction(val.then) && isFunction(val.catch)
}

export const isString = (val: unknown): val is string => {
return is(val, 'String')
}

export const isFunction = (val: unknown): val is Function => {
return typeof val === 'function'
}

export const isBoolean = (val: unknown): val is boolean => {
return is(val, 'Boolean')
}

export const isRegExp = (val: unknown): val is RegExp => {
return is(val, 'RegExp')
}

export const isArray = (val: any): val is Array<any> => {
return val && Array.isArray(val)
}

export const isWindow = (val: any): val is Window => {
return typeof window !== 'undefined' && is(val, 'Window')
}

export const isElement = (val: unknown): val is Element => {
return isObject(val) && !!val.tagName
}

export const isMap = (val: unknown): val is Map<any, any> => {
return is(val, 'Map')
}

export const isServer = typeof window === 'undefined'

export const isClient = !isServer

export const isUrl = (path: string): boolean => {
const reg =
/(((^https?:(?:\/\/)?)(?:[-:&=\+\$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&%@.\w_]*)#?(?:[\w]*))?)$/
return reg.test(path)
}

export const isDark = (): boolean => {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}

// 是否是图片链接
export const isImgPath = (path: string): boolean => {
return /(https?:\/\/|data:image\/).*?\.(png|jpg|jpeg|gif|svg|webp|ico)/gi.test(path)
}

export const isEmptyVal = (val: any): boolean => {
return val === '' || val === null || val === undefined
}

相关组件库版本

组件 版本
vue ^3.3.7
element-plus 2.4.1
lunar-typescript ^1.7.5
typescript 5.2.2
vue-types ^5.1.1