You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
604 lines
15 KiB
604 lines
15 KiB
<script lang="ts" setup>
|
|
import { computed, defineEmits, defineProps, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
|
|
const props = defineProps({
|
|
modelValue: {
|
|
type: [String, Date, null],
|
|
default: null,
|
|
},
|
|
minDate: {
|
|
type: [String, Date],
|
|
default: null,
|
|
},
|
|
maxDate: {
|
|
type: [String, Date],
|
|
default: null,
|
|
},
|
|
format: {
|
|
type: String,
|
|
default: 'YYYY-MM-DD HH:mm:ss',
|
|
},
|
|
})
|
|
|
|
const emits = defineEmits(['update:modelValue', 'change'])
|
|
const dateText = ref()
|
|
const hoursOptions = ref(Array.from({ length: 24 }, (_, i) => i))
|
|
const minuteOptions = ref(Array.from({ length: 60 }, (_, i) => i))
|
|
const secondOptions = ref(Array.from({ length: 60 }, (_, i) => i))
|
|
// 初始化日期时间
|
|
const selectedDate = ref<Date | null>(null)
|
|
const currentYear = ref(0)
|
|
const currentMonth = ref(0)
|
|
const today = new Date()
|
|
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
|
const isDropdownVisible = ref(false)
|
|
const selectedHour = ref('00')
|
|
const selectedMinute = ref('00')
|
|
const selectedSecond = ref('00')
|
|
|
|
// 格式化显示值
|
|
const displayValue = () => {
|
|
if (!selectedDate.value) {
|
|
return ''
|
|
}
|
|
const year = selectedDate.value.getFullYear()
|
|
const month = (selectedDate.value.getMonth() + 1).toString().padStart(2, '0')
|
|
const date = selectedDate.value.getDate().toString().padStart(2, '0')
|
|
const hour = selectedDate.value.getHours().toString().padStart(2, '0')
|
|
const minute = selectedDate.value.getMinutes().toString().padStart(2, '0')
|
|
const second = selectedDate.value.getSeconds().toString().padStart(2, '0')
|
|
dateText.value = `${year}-${month}-${date} ${hour}:${minute}:${second}`
|
|
}
|
|
|
|
watch(selectedDate, (newVal) => {
|
|
if (newVal) {
|
|
const year = newVal.getFullYear()
|
|
const month = (newVal.getMonth() + 1).toString().padStart(2, '0')
|
|
const date = newVal.getDate().toString().padStart(2, '0')
|
|
const hour = newVal.getHours().toString().padStart(2, '0')
|
|
const minute = newVal.getMinutes().toString().padStart(2, '0')
|
|
const second = newVal.getSeconds().toString().padStart(2, '0')
|
|
dateText.value = `${year}-${month}-${date} ${hour}:${minute}:${second}`
|
|
}
|
|
})
|
|
|
|
// 计算当前月份的天数及上个月和下个月的部分天数
|
|
const daysInMonth: any = computed(() => {
|
|
const days: Record<string, any>[] = []
|
|
|
|
// 获取当前月份第一天是星期几
|
|
const firstDay = new Date(currentYear.value, currentMonth.value, 1).getDay()
|
|
|
|
// 获取上个月的最后几天
|
|
const prevMonthLastDay = new Date(currentYear.value, currentMonth.value, 0).getDate()
|
|
for (let i = firstDay; i > 0; i--) {
|
|
const date = new Date(currentYear.value, currentMonth.value - 1, prevMonthLastDay - i + 1)
|
|
days.push({ date, month: currentMonth.value - 1 })
|
|
}
|
|
|
|
// 获取当前月份的所有天数
|
|
const currentMonthDays = new Date(currentYear.value, currentMonth.value + 1, 0).getDate()
|
|
for (let i = 1; i <= currentMonthDays; i++) {
|
|
const date = new Date(currentYear.value, currentMonth.value, i)
|
|
days.push({ date, month: currentMonth.value })
|
|
}
|
|
|
|
// 获取下个月的前几天
|
|
const remainingDays = 42 - days.length // 6行7列共42个格子
|
|
for (let i = 1; i <= remainingDays; i++) {
|
|
const date = new Date(currentYear.value, currentMonth.value + 1, i)
|
|
days.push({ date, month: currentMonth.value + 1 })
|
|
}
|
|
|
|
return days
|
|
})
|
|
|
|
// 初始化
|
|
onMounted(() => {
|
|
const initialDate = props.modelValue ? new Date(Number(props.modelValue)) : new Date()
|
|
selectedDate.value = initialDate
|
|
currentYear.value = initialDate.getFullYear()
|
|
currentMonth.value = initialDate.getMonth()
|
|
selectedHour.value = initialDate.getHours().toString().padStart(2, '0')
|
|
selectedMinute.value = initialDate.getMinutes().toString().padStart(2, '0')
|
|
selectedSecond.value = initialDate.getSeconds().toString().padStart(2, '0')
|
|
})
|
|
|
|
// 监听props变化
|
|
watch(() => props.modelValue, (newVal) => {
|
|
if (newVal) {
|
|
const date = new Date(Number(newVal))
|
|
if (!Number.isNaN(date.getTime())) {
|
|
selectedDate.value = date
|
|
currentYear.value = date.getFullYear()
|
|
currentMonth.value = date.getMonth()
|
|
selectedHour.value = date.getHours().toString().padStart(2, '0')
|
|
selectedMinute.value = date.getMinutes().toString().padStart(2, '0')
|
|
selectedSecond.value = date.getSeconds().toString().padStart(2, '0')
|
|
}
|
|
}
|
|
else {
|
|
selectedDate.value = null
|
|
}
|
|
}, { deep: true })
|
|
|
|
// 日期操作
|
|
const prevMonth = () => {
|
|
if (currentMonth.value === 0) {
|
|
currentMonth.value = 11
|
|
currentYear.value--
|
|
}
|
|
else {
|
|
currentMonth.value--
|
|
}
|
|
}
|
|
|
|
const nextMonth = () => {
|
|
if (currentMonth.value === 11) {
|
|
currentMonth.value = 0
|
|
currentYear.value++
|
|
}
|
|
else {
|
|
currentMonth.value++
|
|
}
|
|
}
|
|
|
|
const prevYear = () => {
|
|
currentYear.value--
|
|
}
|
|
|
|
const nextYear = () => {
|
|
currentYear.value++
|
|
}
|
|
|
|
// 日期选择
|
|
const selectDate = (date: Date) => {
|
|
if (!isDateInRange(date)) {
|
|
return
|
|
}
|
|
|
|
if (!selectedDate.value) {
|
|
selectedDate.value = new Date(date)
|
|
}
|
|
else {
|
|
// 保留原来的时分秒
|
|
selectedDate.value.setFullYear(date.getFullYear())
|
|
selectedDate.value.setMonth(date.getMonth())
|
|
selectedDate.value.setDate(date.getDate())
|
|
}
|
|
displayValue()
|
|
}
|
|
|
|
// 时间更新
|
|
const updateTime = () => {
|
|
console.log('selectedDate.value--', selectedDate.value, selectedHour.value)
|
|
if (selectedDate.value) {
|
|
selectedDate.value.setHours(Number(selectedHour.value))
|
|
selectedDate.value.setMinutes(Number(selectedMinute.value))
|
|
selectedDate.value.setSeconds(Number(selectedSecond.value))
|
|
}
|
|
displayValue()
|
|
}
|
|
|
|
// 确认选择
|
|
const confirmSelection = () => {
|
|
if (selectedDate.value) {
|
|
const dateValue = formatDate(selectedDate.value, props.format)
|
|
// emits('update:modelValue', dateValue)
|
|
emits('change', dateValue)
|
|
}
|
|
isDropdownVisible.value = false
|
|
}
|
|
|
|
// 日期格式化工具函数
|
|
const formatDate = (date: Date, format: string): string => {
|
|
if (!date) {
|
|
return ''
|
|
}
|
|
|
|
const year = date.getFullYear()
|
|
const month = date.getMonth() + 1
|
|
const day = date.getDate()
|
|
const hour = date.getHours()
|
|
const minute = date.getMinutes()
|
|
const second = date.getSeconds()
|
|
return format
|
|
.replace('YYYY', year.toString())
|
|
.replace('MM', month.toString().padStart(2, '0'))
|
|
.replace('DD', day.toString().padStart(2, '0'))
|
|
.replace('HH', hour.toString().padStart(2, '0'))
|
|
.replace('mm', minute.toString().padStart(2, '0'))
|
|
.replace('ss', second.toString().padStart(2, '0'))
|
|
}
|
|
|
|
// 取消选择
|
|
const cancelSelection = () => {
|
|
// 恢复之前的值
|
|
// if (props.modelValue) {
|
|
// const date = new Date(props.modelValue)
|
|
// if (!isNaN(date.getTime())) {
|
|
// selectedDate.value = date
|
|
// selectedHour.value = date.getHours().toString().padStart(2, '0')
|
|
// selectedMinute.value = date.getMinutes().toString().padStart(2, '0')
|
|
// selectedSecond.value = date.getSeconds().toString().padStart(2, '0')
|
|
// }
|
|
// } else {
|
|
// selectedDate.value = null
|
|
// }
|
|
isDropdownVisible.value = false
|
|
}
|
|
|
|
// 切换下拉框显示
|
|
const toggleDropdown = (event?: MouseEvent) => {
|
|
if (event) {
|
|
event.stopPropagation()
|
|
}
|
|
isDropdownVisible.value = !isDropdownVisible.value
|
|
}
|
|
|
|
// 工具函数
|
|
const isSameDay = (date1: Date, date2: Date | null) => {
|
|
if (!date2) {
|
|
return false
|
|
}
|
|
return (
|
|
date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate()
|
|
)
|
|
}
|
|
|
|
const isDateInRange = (date: Date) => {
|
|
const min = props.minDate ? new Date(props.minDate) : null
|
|
const max = props.maxDate ? new Date(props.maxDate) : null
|
|
|
|
if (min && date < min) {
|
|
return false
|
|
}
|
|
if (max && date > max) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 点击外部关闭下拉框
|
|
const closeDropdownOnClickOutside = (event: MouseEvent) => {
|
|
if (!isDropdownVisible.value) {
|
|
return
|
|
}
|
|
|
|
const target = event.target as HTMLElement
|
|
const container = document.querySelector('.date-time-picker')
|
|
|
|
if (container && !container.contains(target)) {
|
|
isDropdownVisible.value = false
|
|
}
|
|
}
|
|
|
|
// 添加和移除事件监听
|
|
onMounted(() => {
|
|
document.addEventListener('click', closeDropdownOnClickOutside)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('click', closeDropdownOnClickOutside)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="date-time-picker">
|
|
<div class="input-group">
|
|
<input
|
|
v-model="dateText"
|
|
type="text"
|
|
readonly
|
|
placeholder="请选择日期时间"
|
|
class="form-input"
|
|
@click="toggleDropdown"
|
|
>
|
|
<span class="input-icon" @click="toggleDropdown">
|
|
<i class="fa fa-calendar" />
|
|
</span>
|
|
</div>
|
|
<transition name="fade">
|
|
<div v-show="isDropdownVisible" class="dropdown-container">
|
|
<div class="calendar-header">
|
|
<button class="nav-btn" @click="prevYear">
|
|
<el-icon><DArrowLeft /></el-icon>
|
|
</button>
|
|
<button class="nav-btn" @click="prevMonth">
|
|
<el-icon><ArrowLeft /></el-icon>
|
|
</button>
|
|
<span class="current-date">
|
|
{{ currentYear }}年 {{ currentMonth + 1 }}月
|
|
</span>
|
|
<button class="nav-btn" @click="nextMonth">
|
|
<el-icon><ArrowRight /></el-icon>
|
|
</button>
|
|
<button class="nav-btn" @click="nextYear">
|
|
<el-icon><DArrowRight /></el-icon>
|
|
</button>
|
|
</div>
|
|
<div class="weekdays">
|
|
<div v-for="day in weekdays" :key="day" class="weekday">
|
|
{{ day }}
|
|
</div>
|
|
</div>
|
|
<div class="days">
|
|
<div
|
|
v-for="(day, index) in daysInMonth"
|
|
:key="index"
|
|
class="day"
|
|
:class="{
|
|
'other-month': day.month !== currentMonth,
|
|
'selected': isSameDay(day.date, selectedDate),
|
|
'today': isSameDay(day.date, today),
|
|
'disabled': !isDateInRange(day.date),
|
|
}"
|
|
@click="selectDate(day.date)"
|
|
>
|
|
{{ day.date.getDate() }}
|
|
</div>
|
|
</div>
|
|
<div class="time-selector">
|
|
<div class="time-label">
|
|
时间:
|
|
</div>
|
|
<el-select v-model="selectedHour" class="time-select" @change="updateTime">
|
|
<el-option v-for="i in hoursOptions" :key="i" :value="i.toString().padStart(2, '0')" style="font-size: 20px;line-height: 2px;height: 5rem;display: flex; align-items: center;">
|
|
{{ i.toString().padStart(2, '0') }}
|
|
</el-option>
|
|
</el-select>
|
|
<span class="time-separator">:</span>
|
|
<el-select v-model="selectedMinute" class="time-select" @change="updateTime">
|
|
<el-option v-for="i in minuteOptions" :key="i" :value="i.toString().padStart(2, '0')" style="font-size: 20px;line-height: 2px;height: 5rem;display: flex; align-items: center;">
|
|
{{ i.toString().padStart(2, '0') }}
|
|
</el-option>
|
|
</el-select>
|
|
<span class="time-separator">:</span>
|
|
<el-select v-model="selectedSecond" class="time-select" @change="updateTime">
|
|
<el-option v-for="i in secondOptions" :key="i" :value="i.toString().padStart(2, '0')" style="font-size: 20px;line-height: 2px;height: 5rem;display: flex; align-items: center;">
|
|
{{ i.toString().padStart(2, '0') }}
|
|
</el-option>
|
|
</el-select>
|
|
</div>
|
|
<div class="calendar-footer">
|
|
<button class="confirm-btn" @click="confirmSelection">
|
|
确定
|
|
</button>
|
|
<button class="cancel-btn" @click="cancelSelection">
|
|
取消
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
$fontSize: 1.5rem;
|
|
:deep(body) {
|
|
--el-font-size-base: 20px;
|
|
}
|
|
.date-time-picker {
|
|
position: relative;
|
|
width: 340px;
|
|
}
|
|
|
|
.input-group {
|
|
display: flex;
|
|
position: relative;
|
|
}
|
|
|
|
.form-input {
|
|
flex: 1;
|
|
padding: 10px 14px;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 4px;
|
|
font-size: $fontSize;
|
|
outline: none;
|
|
transition: border-color 0.2s;
|
|
}
|
|
|
|
.form-input:focus {
|
|
border-color: #4f46e5;
|
|
}
|
|
|
|
.input-icon {
|
|
position: absolute;
|
|
right: 12px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: #94a3b8;
|
|
cursor: pointer;
|
|
font-size: $fontSize;
|
|
}
|
|
|
|
.dropdown-container {
|
|
position: absolute;
|
|
top: calc(100% + 4px);
|
|
right: 0;
|
|
width: 28rem;
|
|
background-color: white;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 4px;
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
|
z-index: 100;
|
|
padding: 12px;
|
|
}
|
|
|
|
.calendar-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.nav-btn {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: #64748b;
|
|
font-size: $fontSize;
|
|
padding: 6px;
|
|
border-radius: 4px;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.nav-btn:hover {
|
|
background-color: #f1f5f9;
|
|
}
|
|
|
|
.current-date {
|
|
font-weight: 500;
|
|
color: #334155;
|
|
font-size: $fontSize;
|
|
}
|
|
|
|
.weekdays {
|
|
display: grid;
|
|
grid-template-columns: repeat(7, 1fr);
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.weekday {
|
|
text-align: center;
|
|
font-size: $fontSize;
|
|
color: #64748b;
|
|
padding: 6px;
|
|
}
|
|
|
|
.days {
|
|
display: grid;
|
|
grid-template-columns: repeat(7, 1fr);
|
|
gap: 3px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.day {
|
|
text-align: center;
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
font-size: $fontSize;
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.day:hover:not(.disabled) {
|
|
background-color: #e0f2fe;
|
|
}
|
|
|
|
.day.other-month {
|
|
color: #94a3b8;
|
|
}
|
|
|
|
.day.selected {
|
|
background-color: #3b82f6;
|
|
color: white;
|
|
}
|
|
|
|
.day.today:not(.selected) {
|
|
font-weight: 500;
|
|
color: #3b82f6;
|
|
}
|
|
|
|
.day.disabled {
|
|
color: #cbd5e1;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.time-selector {
|
|
display: flex;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
.time-select {
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
font-size: 20px;
|
|
outline: none;
|
|
background-color: white;
|
|
margin-right: 4px;
|
|
/* 最大高度限制,溢出滚动 */
|
|
max-height: 3rem;
|
|
width: 8rem;
|
|
border: none;
|
|
}
|
|
}
|
|
|
|
select option {
|
|
max-height: 3rem;
|
|
border: 1px solid red;
|
|
}
|
|
|
|
.time-label {
|
|
margin-right: 12px;
|
|
font-size: $fontSize;
|
|
color: #334155;
|
|
width: 5rem;
|
|
}
|
|
|
|
.select-option{
|
|
max-height: 3rem;
|
|
}
|
|
|
|
select {
|
|
padding: 6px 10px;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 4px;
|
|
font-size: $fontSize;
|
|
outline: none;
|
|
background-color: white;
|
|
margin-right: 6px;
|
|
width: 60px;
|
|
}
|
|
|
|
.time-separator {
|
|
margin: 0 3px;
|
|
font-size: $fontSize;
|
|
color: #334155;
|
|
}
|
|
|
|
.calendar-footer {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 10px;
|
|
}
|
|
|
|
.confirm-btn, .cancel-btn {
|
|
padding: 8px 16px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
font-size: $fontSize;
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.confirm-btn {
|
|
background-color: #3b82f6;
|
|
color: white;
|
|
}
|
|
|
|
.confirm-btn:hover {
|
|
background-color: #2563eb;
|
|
}
|
|
|
|
.cancel-btn {
|
|
background-color: #f1f5f9;
|
|
color: #334155;
|
|
}
|
|
|
|
.cancel-btn:hover {
|
|
background-color: #e2e8f0;
|
|
}
|
|
|
|
.fade-enter-active, .fade-leave-active {
|
|
transition: opacity 0.2s;
|
|
}
|
|
|
|
.fade-enter-from, .fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
// 修改选择框本身的宽高
|
|
:deep(.el-select__wrapper){
|
|
height: 3rem;
|
|
font-size: 20px;
|
|
}
|
|
</style>
|