From cde359098620c69d1931e1175bf24d4f65ece612 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sun, 12 Apr 2026 07:10:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=B8=80=E4=B8=8Bui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Export/ExportDateRangeDialog.scss | 130 ++++++++++++- .../Export/ExportDateRangeDialog.tsx | 182 +++++++++++++++--- 2 files changed, 282 insertions(+), 30 deletions(-) diff --git a/src/components/Export/ExportDateRangeDialog.scss b/src/components/Export/ExportDateRangeDialog.scss index d4c5f9c..215520e 100644 --- a/src/components/Export/ExportDateRangeDialog.scss +++ b/src/components/Export/ExportDateRangeDialog.scss @@ -192,7 +192,18 @@ } } -.export-date-range-time-input { +.export-date-range-time-select { + position: relative; + width: 100%; + + &.open .export-date-range-time-trigger { + border-color: var(--primary); + box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18); + color: var(--primary); + } +} + +.export-date-range-time-trigger { width: 100%; min-width: 0; border-radius: 8px; @@ -203,20 +214,125 @@ padding: 0 9px; font-size: 12px; font-family: inherit; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease; &:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18); } +} - &::-webkit-calendar-picker-indicator { - cursor: pointer; - opacity: 0.6; - &:hover { - opacity: 1; - } +.export-date-range-time-trigger-value { + flex: 1; + min-width: 0; + text-align: left; +} + +.export-date-range-time-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + z-index: 24; + border: 1px solid var(--border-color); + border-radius: 12px; + background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary)); + box-shadow: var(--shadow-md); + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); +} + +.export-date-range-time-dropdown-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + + span { + font-size: 11px; + color: var(--text-secondary); } + + strong { + font-size: 13px; + color: var(--text-primary); + } +} + +.export-date-range-time-quick-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.export-date-range-time-quick-item, +.export-date-range-time-option { + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--text-primary); + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; + + &:hover { + background: var(--bg-tertiary); + } + + &.active { + border-color: rgba(var(--primary-rgb), 0.28); + background: rgba(var(--primary-rgb), 0.12); + color: var(--primary); + } +} + +.export-date-range-time-quick-item { + min-width: 52px; + height: 28px; + padding: 0 10px; + font-size: 11px; +} + +.export-date-range-time-columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.export-date-range-time-column { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.export-date-range-time-column-label { + font-size: 11px; + color: var(--text-secondary); +} + +.export-date-range-time-column-list { + max-height: 168px; + overflow-y: auto; + padding-right: 2px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; +} + +.export-date-range-time-option { + min-height: 28px; + padding: 0 8px; + font-size: 11px; } .export-date-range-calendar-nav { diff --git a/src/components/Export/ExportDateRangeDialog.tsx b/src/components/Export/ExportDateRangeDialog.tsx index 346b44c..d2cbabf 100644 --- a/src/components/Export/ExportDateRangeDialog.tsx +++ b/src/components/Export/ExportDateRangeDialog.tsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react' +import { Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react' import { EXPORT_DATE_RANGE_PRESETS, WEEKDAY_SHORT_LABELS, @@ -10,7 +10,6 @@ import { createDateRangeByPreset, createDefaultDateRange, formatCalendarMonthTitle, - formatDateInputValue, isSameDay, parseDateInputValue, startOfDay, @@ -37,6 +36,10 @@ interface ExportDateRangeDialogDraft extends ExportDateRangeSelection { panelMonth: Date } +const HOUR_OPTIONS = Array.from({ length: 24 }, (_, index) => `${index}`.padStart(2, '0')) +const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, index) => `${index}`.padStart(2, '0')) +const QUICK_TIME_OPTIONS = ['00:00', '08:00', '12:00', '18:00', '23:59'] + const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => { if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null @@ -149,6 +152,9 @@ export function ExportDateRangeDialog({ start: '00:00', end: '23:59' }) + const [openTimeDropdown, setOpenTimeDropdown] = useState(null) + const startTimeSelectRef = useRef(null) + const endTimeSelectRef = useRef(null) useEffect(() => { if (!open) return @@ -172,6 +178,7 @@ export function ExportDateRangeDialog({ end: formatTimeOnly(nextDraft.dateRange.end) }) } + setOpenTimeDropdown(null) setDateInputError({ start: false, end: false }) }, [maxDate, minDate, open, value]) @@ -185,6 +192,33 @@ export function ExportDateRangeDialog({ setDateInputError({ start: false, end: false }) }, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open]) + useEffect(() => { + if (!openTimeDropdown) return + + const handlePointerDown = (event: MouseEvent) => { + const target = event.target as Node + const activeContainer = openTimeDropdown === 'start' + ? startTimeSelectRef.current + : endTimeSelectRef.current + if (!activeContainer?.contains(target)) { + setOpenTimeDropdown(null) + } + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpenTimeDropdown(null) + } + } + + document.addEventListener('mousedown', handlePointerDown) + document.addEventListener('keydown', handleEscape) + return () => { + document.removeEventListener('mousedown', handlePointerDown) + document.removeEventListener('keydown', handleEscape) + } + }, [openTimeDropdown]) + const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate]) const clampStartDate = useCallback((targetDate: Date) => { if (!bounds) return targetDate @@ -241,6 +275,11 @@ export function ExportDateRangeDialog({ const previewRange = bounds ? { start: bounds.minDate, end: bounds.maxDate } : createDefaultDateRange() + setTimeInput({ + start: '00:00', + end: '23:59' + }) + setOpenTimeDropdown(null) setDraft(prev => ({ ...prev, preset, @@ -257,6 +296,11 @@ export function ExportDateRangeDialog({ useAllTime: false, dateRange: createDateRangeByPreset(preset) }, minDate, maxDate).dateRange + setTimeInput({ + start: '00:00', + end: '23:59' + }) + setOpenTimeDropdown(null) setDraft(prev => ({ ...prev, preset, @@ -276,8 +320,7 @@ export function ExportDateRangeDialog({ return { hours, minutes } } - // Handle time picker changes - update draft.dateRange immediately - const handleTimePickerChange = useCallback((boundary: 'start' | 'end', timeStr: string) => { + const updateBoundaryTime = useCallback((boundary: ActiveBoundary, timeStr: string) => { setTimeInput(prev => ({ ...prev, [boundary]: timeStr })) const parsedTime = parseTimeValue(timeStr) @@ -299,6 +342,82 @@ export function ExportDateRangeDialog({ }) }, []) + const toggleTimeDropdown = useCallback((boundary: ActiveBoundary) => { + setActiveBoundary(boundary) + setOpenTimeDropdown(prev => (prev === boundary ? null : boundary)) + }, []) + + const handleTimeColumnSelect = useCallback((boundary: ActiveBoundary, field: 'hour' | 'minute', value: string) => { + const parsedCurrent = parseTimeValue(timeInput[boundary]) ?? { + hours: boundary === 'start' ? 0 : 23, + minutes: boundary === 'start' ? 0 : 59 + } + const nextHours = field === 'hour' ? Number(value) : parsedCurrent.hours + const nextMinutes = field === 'minute' ? Number(value) : parsedCurrent.minutes + updateBoundaryTime(boundary, `${`${nextHours}`.padStart(2, '0')}:${`${nextMinutes}`.padStart(2, '0')}`) + }, [timeInput, updateBoundaryTime]) + + const renderTimeDropdown = (boundary: ActiveBoundary) => { + const currentTime = timeInput[boundary] + const parsedCurrent = parseTimeValue(currentTime) ?? { + hours: boundary === 'start' ? 0 : 23, + minutes: boundary === 'start' ? 0 : 59 + } + + return ( +
event.stopPropagation()}> +
+ {boundary === 'start' ? '开始时间' : '结束时间'} + {currentTime} +
+
+ {QUICK_TIME_OPTIONS.map(option => ( + + ))} +
+
+
+ 小时 +
+ {HOUR_OPTIONS.map(option => ( + + ))} +
+
+
+ 分钟 +
+ {MINUTE_OPTIONS.map(option => ( + + ))} +
+
+
+
+ ) + } + // Check if date input string contains time (YYYY-MM-DD HH:mm format) const dateInputHasTime = (dateStr: string): boolean => /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}$/.test(dateStr.trim()) @@ -357,6 +476,7 @@ export function ExportDateRangeDialog({ newStart.setHours(time.hours, time.minutes, 0, 0) setRangeStart(newStart) setActiveBoundary('end') + setOpenTimeDropdown(null) return } @@ -369,6 +489,7 @@ export function ExportDateRangeDialog({ // If selecting same day or going backwards, use 23:59:59, otherwise use the time from timeInput if (pickedStart <= start) { newEnd.setHours(23, 59, 59, 999) + setTimeInput(prev => ({ ...prev, end: '23:59' })) } else { newEnd.setHours(time.hours, time.minutes, 59, 999) } @@ -384,6 +505,7 @@ export function ExportDateRangeDialog({ panelMonth: toMonthStart(targetDate) })) setActiveBoundary('start') + setOpenTimeDropdown(null) }, [activeBoundary, draft.dateRange.start, draft.useAllTime, timeInput.end, timeInput.start, setRangeStart]) const isRangeModeActive = !draft.useAllTime @@ -491,16 +613,23 @@ export function ExportDateRangeDialog({ }} onBlur={commitStartFromInput} /> - { - handleTimePickerChange('start', event.target.value) - }} - onFocus={() => setActiveBoundary('start')} +
event.stopPropagation()} - /> + > + + {openTimeDropdown === 'start' && renderTimeDropdown('start')} +
- { - handleTimePickerChange('end', event.target.value) - }} - onFocus={() => setActiveBoundary('end')} +
event.stopPropagation()} - /> + > + + {openTimeDropdown === 'end' && renderTimeDropdown('end')} +