Files
ToolBox/src/components/DateTimePicker.vue
T
renjue d738ca5dd8
CI / docker (push) Failing after 1h50m32s
初始化 ToolBox 开发者工具箱。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 17:31:43 +08:00

819 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="datetime-picker-wrapper">
<!-- 自定义日期时间选择器面板 -->
<Transition name="picker">
<div v-if="show" class="datetime-picker-panel" @click.stop>
<div class="picker-container">
<!-- 左侧日期选择区域 -->
<div class="date-picker-section">
<!-- 年月导航 -->
<div class="date-header">
<div class="nav-buttons">
<button @click="prevYear" class="nav-btn" title="上一年">«</button>
<button @click="prevMonth" class="nav-btn" title="上一月"></button>
</div>
<div
v-if="!isEditingMonthYear"
@click="startEditingMonthYear"
class="current-month-year editable"
title="点击输入年月"
>
{{ currentViewDate.getFullYear() }} - {{ String(currentViewDate.getMonth() + 1).padStart(2, '0') }}
</div>
<input
v-else
v-model="monthYearInput"
@blur="confirmMonthYear"
@keyup.enter="confirmMonthYear"
@keyup.esc="cancelEditingMonthYear"
class="month-year-input"
placeholder="YYYY-MM"
ref="monthYearInputRef"
/>
<div class="nav-buttons">
<button @click="nextMonth" class="nav-btn" title="下一月"></button>
<button @click="nextYear" class="nav-btn" title="下一年">»</button>
</div>
</div>
<!-- 星期标题 -->
<div class="weekdays">
<div class="weekday" v-for="day in ['日', '一', '二', '三', '四', '五', '六']" :key="day">
{{ day }}
</div>
</div>
<!-- 日期网格 -->
<div class="calendar-grid">
<div
v-for="(day, index) in getCalendarDays()"
:key="index"
@click="selectDate(day)"
:class="[
'calendar-day',
{ 'other-month': !day.isCurrentMonth },
{ 'today': isToday(day) },
{ 'selected': isSelected(day) }
]"
>
{{ day.date.getDate() }}
<span v-if="isToday(day) && !isSelected(day)" class="today-dot"></span>
</div>
</div>
<!-- 此刻按钮 -->
<button @click="selectNow" class="now-btn">此刻</button>
</div>
<!-- 右侧时间选择区域 -->
<div class="time-picker-section">
<div class="time-header">选择时间</div>
<div class="time-selectors">
<!-- 小时选择 -->
<div class="time-column">
<div class="time-list" ref="hourListRef">
<div
v-for="hour in generateTimeOptions('hour')"
:key="hour"
:data-value="hour"
@click="selectTime('hour', hour)"
:class="['time-item', { 'selected': selectedTime.hour === parseInt(hour) }]"
>
{{ hour }}
</div>
</div>
</div>
<!-- 分钟选择 -->
<div class="time-column">
<div class="time-list" ref="minuteListRef">
<div
v-for="minute in generateTimeOptions('minute')"
:key="minute"
:data-value="minute"
@click="selectTime('minute', minute)"
:class="['time-item', { 'selected': selectedTime.minute === parseInt(minute) }]"
>
{{ minute }}
</div>
</div>
</div>
<!-- 秒选择 -->
<div class="time-column">
<div class="time-list" ref="secondListRef">
<div
v-for="second in generateTimeOptions('second')"
:key="second"
:data-value="second"
@click="selectTime('second', second)"
:class="['time-item', { 'selected': selectedTime.second === parseInt(second) }]"
>
{{ second }}
</div>
</div>
</div>
</div>
<!-- 确定按钮 -->
<button @click="confirmSelection" class="confirm-btn">确定</button>
</div>
</div>
</div>
</Transition>
<!-- 遮罩层 -->
<Transition name="mask">
<div v-if="show" class="picker-mask" @click="close"></div>
</Transition>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
precisionType: {
type: String,
default: 'milliseconds', // seconds, milliseconds, nanoseconds
validator: (value) => ['seconds', 'milliseconds', 'nanoseconds'].includes(value)
},
show: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'update:show', 'confirm'])
// 日期时间选择器状态
const currentViewDate = ref(new Date()) // 当前查看的年月
const selectedDate = ref(null) // 选中的日期
const selectedTime = ref({ hour: 0, minute: 0, second: 0 }) // 选中的时间
const hourListRef = ref(null)
const minuteListRef = ref(null)
const secondListRef = ref(null)
const isEditingMonthYear = ref(false) // 是否正在编辑年月
const monthYearInput = ref('') // 年月输入值
const monthYearInputRef = ref(null) // 年月输入框引用
// 日期选择功能
const getCalendarDays = () => {
const year = currentViewDate.value.getFullYear()
const month = currentViewDate.value.getMonth()
// 获取当月第一天和最后一天
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
// 获取第一天是星期几(0=周日)
const firstDayWeek = firstDay.getDay()
// 获取上个月的最后几天
const prevMonthLastDay = new Date(year, month, 0).getDate()
const days = []
// 添加上个月的日期
for (let i = firstDayWeek - 1; i >= 0; i--) {
days.push({
date: new Date(year, month - 1, prevMonthLastDay - i),
isCurrentMonth: false
})
}
// 添加当月的日期
for (let i = 1; i <= lastDay.getDate(); i++) {
days.push({
date: new Date(year, month, i),
isCurrentMonth: true
})
}
// 添加下个月的日期,补齐到42个(6行7列)
const remainingDays = 42 - days.length
for (let i = 1; i <= remainingDays; i++) {
days.push({
date: new Date(year, month + 1, i),
isCurrentMonth: false
})
}
return days
}
const prevYear = () => {
const date = new Date(currentViewDate.value)
date.setFullYear(date.getFullYear() - 1)
currentViewDate.value = date
}
const nextYear = () => {
const date = new Date(currentViewDate.value)
date.setFullYear(date.getFullYear() + 1)
currentViewDate.value = date
}
const prevMonth = () => {
const date = new Date(currentViewDate.value)
date.setMonth(date.getMonth() - 1)
currentViewDate.value = date
}
const nextMonth = () => {
const date = new Date(currentViewDate.value)
date.setMonth(date.getMonth() + 1)
currentViewDate.value = date
}
const selectDate = (day) => {
selectedDate.value = new Date(day.date)
// 滚动到选中的时间项
nextTick(() => {
scrollToSelected()
})
}
const isToday = (day) => {
const today = new Date()
return day.date.getDate() === today.getDate() &&
day.date.getMonth() === today.getMonth() &&
day.date.getFullYear() === today.getFullYear()
}
const isSelected = (day) => {
if (!selectedDate.value) return false
return day.date.getDate() === selectedDate.value.getDate() &&
day.date.getMonth() === selectedDate.value.getMonth() &&
day.date.getFullYear() === selectedDate.value.getFullYear()
}
// 时间选择功能
const generateTimeOptions = (type) => {
if (type === 'hour') {
return Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
} else {
return Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
}
}
const selectTime = (type, value) => {
selectedTime.value[type] = parseInt(value)
// 滚动到选中的项
nextTick(() => {
scrollToSelected()
})
}
const scrollToSelected = () => {
// 滚动小时列表
if (hourListRef.value) {
const hourItem = hourListRef.value.querySelector(`[data-value="${String(selectedTime.value.hour).padStart(2, '0')}"]`)
if (hourItem) {
hourItem.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
// 滚动分钟列表
if (minuteListRef.value) {
const minuteItem = minuteListRef.value.querySelector(`[data-value="${String(selectedTime.value.minute).padStart(2, '0')}"]`)
if (minuteItem) {
minuteItem.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
// 滚动秒列表
if (secondListRef.value) {
const secondItem = secondListRef.value.querySelector(`[data-value="${String(selectedTime.value.second).padStart(2, '0')}"]`)
if (secondItem) {
secondItem.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
}
// 面板控制功能
const close = () => {
emit('update:show', false)
isEditingMonthYear.value = false
monthYearInput.value = ''
}
const confirmSelection = () => {
if (!selectedDate.value) {
selectedDate.value = new Date()
}
const date = new Date(selectedDate.value)
date.setHours(selectedTime.value.hour)
date.setMinutes(selectedTime.value.minute)
date.setSeconds(selectedTime.value.second)
date.setMilliseconds(0)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
const milliseconds = String(date.getMilliseconds()).padStart(3, '0')
// 根据精度类型设置格式
let formattedDate = ''
if (props.precisionType === 'seconds') {
formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
} else if (props.precisionType === 'milliseconds') {
formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`
} else {
// 纳秒级
const nanosecondsStr = '000000'
formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}${nanosecondsStr}`
}
emit('update:modelValue', formattedDate)
emit('confirm', formattedDate)
close()
}
const selectNow = () => {
const now = new Date()
selectedDate.value = new Date(now)
currentViewDate.value = new Date(now)
selectedTime.value = {
hour: now.getHours(),
minute: now.getMinutes(),
second: now.getSeconds()
}
nextTick(() => {
scrollToSelected()
})
}
// 开始编辑年月
const startEditingMonthYear = () => {
isEditingMonthYear.value = true
const year = currentViewDate.value.getFullYear()
const month = String(currentViewDate.value.getMonth() + 1).padStart(2, '0')
monthYearInput.value = `${year}-${month}`
nextTick(() => {
if (monthYearInputRef.value) {
monthYearInputRef.value.focus()
monthYearInputRef.value.select()
}
})
}
// 确认年月输入
const confirmMonthYear = () => {
const input = monthYearInput.value.trim()
// 验证格式:YYYY-MM 或 YYYY-M
const match = input.match(/^(\d{4})-(\d{1,2})$/)
if (match) {
const year = parseInt(match[1])
const month = parseInt(match[2])
// 验证年份和月份范围
if (year >= 1000 && year <= 9999 && month >= 1 && month <= 12) {
const date = new Date(year, month - 1, 1)
currentViewDate.value = date
isEditingMonthYear.value = false
monthYearInput.value = ''
} else {
// 这里可以添加错误提示,但组件中不依赖父组件的 showToast
if (monthYearInputRef.value) {
monthYearInputRef.value.focus()
monthYearInputRef.value.select()
}
}
} else {
if (monthYearInputRef.value) {
monthYearInputRef.value.focus()
monthYearInputRef.value.select()
}
}
}
// 取消编辑年月
const cancelEditingMonthYear = () => {
isEditingMonthYear.value = false
monthYearInput.value = ''
}
// 监听 show 变化,初始化选择器
watch(() => props.show, (newVal) => {
if (newVal) {
// 如果输入框有值,尝试解析并设置到选择器
if (props.modelValue) {
try {
const dateStr = props.modelValue.trim()
let date = null
if (dateStr.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d{1,3})?$/)) {
date = new Date(dateStr.replace(' ', 'T'))
} else if (dateStr.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?/)) {
date = new Date(dateStr)
} else {
date = new Date(dateStr)
}
if (!isNaN(date.getTime())) {
selectedDate.value = new Date(date)
currentViewDate.value = new Date(date)
selectedTime.value = {
hour: date.getHours(),
minute: date.getMinutes(),
second: date.getSeconds()
}
}
} catch (error) {
// 如果解析失败,使用当前时间
const now = new Date()
selectedDate.value = new Date(now)
currentViewDate.value = new Date(now)
selectedTime.value = {
hour: now.getHours(),
minute: now.getMinutes(),
second: now.getSeconds()
}
}
} else {
// 如果输入框为空,使用当前时间
const now = new Date()
selectedDate.value = new Date(now)
currentViewDate.value = new Date(now)
selectedTime.value = {
hour: now.getHours(),
minute: now.getMinutes(),
second: now.getSeconds()
}
}
// 等待DOM更新后滚动到选中项
nextTick(() => {
scrollToSelected()
})
} else {
isEditingMonthYear.value = false
monthYearInput.value = ''
}
})
</script>
<style scoped>
.datetime-picker-wrapper {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
width: 100%;
}
/* 日期时间选择器面板 */
.picker-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 998;
}
.datetime-picker-panel {
position: relative;
z-index: 999;
width: 500px;
min-width: 500px;
background: #ffffff;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
border: 1px solid #e5e5e5;
overflow: hidden;
}
.picker-container {
display: flex;
min-height: 320px;
width: 100%;
}
/* 左侧日期选择区域 */
.date-picker-section {
flex: 1;
min-width: 0;
padding: 0.75rem;
display: flex;
flex-direction: column;
border-right: 1px solid #e5e5e5;
}
.date-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.nav-buttons {
display: flex;
gap: 0.125rem;
}
.nav-btn {
padding: 0.125rem 0.375rem;
background: transparent;
border: 1px solid #d0d0d0;
border-radius: 3px;
color: #333333;
cursor: pointer;
font-size: 0.75rem;
transition: all 0.2s;
min-width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.nav-btn:hover {
background: #f5f5f5;
border-color: #1a1a1a;
}
.current-month-year {
font-size: 0.8125rem;
font-weight: 500;
color: #333333;
}
.current-month-year.editable {
cursor: pointer;
padding: 0.125rem 0.25rem;
border-radius: 3px;
transition: all 0.2s;
user-select: none;
}
.current-month-year.editable:hover {
background: #f5f5f5;
}
.month-year-input {
font-size: 0.8125rem;
font-weight: 500;
color: #333333;
border: 1px solid #1a1a1a;
border-radius: 3px;
padding: 0.125rem 0.25rem;
text-align: center;
width: 80px;
outline: none;
background: #ffffff;
}
.month-year-input:focus {
border-color: #1a1a1a;
box-shadow: 0 0 0 2px rgba(26, 26, 26, 0.1);
}
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0;
margin-bottom: 0.375rem;
}
.weekday {
width: 28px;
text-align: center;
font-size: 0.6875rem;
color: #666666;
font-weight: 500;
padding: 0.25rem 0;
margin: 0 auto;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0;
flex: 1;
}
.calendar-day {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: #333333;
cursor: pointer;
border-radius: 3px;
transition: all 0.2s;
position: relative;
padding: 0;
margin: 0 auto;
}
.calendar-day:hover {
background: #f5f5f5;
}
.calendar-day.other-month {
color: #999999;
}
.calendar-day.today {
font-weight: 500;
}
.calendar-day.selected {
background: #1a1a1a;
color: #ffffff;
}
.today-dot {
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
background: #1a1a1a;
border-radius: 50%;
}
.calendar-day.selected .today-dot {
background: #ffffff;
}
.now-btn {
margin-top: 0.5rem;
padding: 0.375rem 0.75rem;
background: #ffffff;
border: 1px solid #d0d0d0;
border-radius: 4px;
color: #666666;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
align-self: flex-start;
}
.now-btn:hover {
background: #f5f5f5;
border-color: #1a1a1a;
color: #1a1a1a;
}
/* 右侧时间选择区域 */
.time-picker-section {
flex: 0 0 160px;
min-width: 160px;
padding: 0.75rem;
display: flex;
flex-direction: column;
background: #fafafa;
}
.time-header {
font-size: 0.75rem;
font-weight: 500;
color: #333333;
margin-bottom: 0.5rem;
text-align: center;
}
.time-selectors {
display: flex;
gap: 0.25rem;
flex: 1;
overflow: hidden;
}
.time-column {
flex: 1;
display: flex;
flex-direction: column;
}
.time-list {
flex: 1;
overflow-y: auto;
scroll-behavior: smooth;
max-height: 240px;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.time-list::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.time-item {
padding: 0.25rem 0.375rem;
text-align: center;
font-size: 0.75rem;
color: #333333;
cursor: pointer;
transition: all 0.2s;
border-radius: 3px;
margin: 0.0625rem 0;
}
.time-item:hover {
background: #f0f0f0;
}
.time-item.selected {
background: #1a1a1a;
color: #ffffff;
font-weight: 500;
}
.confirm-btn {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: #1a1a1a;
border: none;
border-radius: 4px;
color: #ffffff;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
width: 100%;
}
.confirm-btn:hover {
background: #333333;
}
.confirm-btn:active {
transform: scale(0.98);
}
/* 过渡动画 */
.picker-enter-active,
.picker-leave-active {
transition: all 0.3s ease;
}
.picker-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.picker-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.mask-enter-active,
.mask-leave-active {
transition: opacity 0.3s ease;
}
.mask-enter-from,
.mask-leave-to {
opacity: 0;
}
@media (max-width: 768px) {
.datetime-picker-panel {
width: calc(100vw - 2rem);
min-width: calc(100vw - 2rem);
max-width: calc(100vw - 2rem);
left: 50%;
transform: translateX(-50%);
}
.picker-container {
flex-direction: column;
min-height: 280px;
}
.date-picker-section {
border-right: none;
border-bottom: 1px solid #e5e5e5;
}
.time-picker-section {
flex: 1;
min-width: 140px;
min-height: 250px;
}
.time-selectors {
max-height: 200px;
}
}
</style>