mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-12 07:25:50 +00:00
Merge pull request #716 from zgshe/feature/export-date-range-time-picker-v2
feat(export): 导出日期范围添加时间选择功能
This commit is contained in:
@@ -192,6 +192,149 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
height: 30px;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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
|
||||
@@ -57,16 +60,42 @@ const clampSelectionToBounds = (
|
||||
const bounds = resolveBounds(minDate, maxDate)
|
||||
if (!bounds) return cloneExportDateRangeSelection(value)
|
||||
|
||||
const rawStart = value.useAllTime ? bounds.minDate : startOfDay(value.dateRange.start)
|
||||
const rawEnd = value.useAllTime ? bounds.maxDate : endOfDay(value.dateRange.end)
|
||||
const nextStart = new Date(Math.min(Math.max(rawStart.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||
const nextEndCandidate = new Date(Math.min(Math.max(rawEnd.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate
|
||||
const changed = nextStart.getTime() !== rawStart.getTime() || nextEnd.getTime() !== rawEnd.getTime()
|
||||
// For custom selections, only ensure end >= start, preserve time precision
|
||||
if (value.preset === 'custom' && !value.useAllTime) {
|
||||
const { start, end } = value.dateRange
|
||||
if (end.getTime() < start.getTime()) {
|
||||
return {
|
||||
...value,
|
||||
dateRange: { start, end: start }
|
||||
}
|
||||
}
|
||||
return cloneExportDateRangeSelection(value)
|
||||
}
|
||||
|
||||
// For useAllTime, use bounds directly
|
||||
if (value.useAllTime) {
|
||||
return {
|
||||
preset: value.preset,
|
||||
useAllTime: true,
|
||||
dateRange: {
|
||||
start: bounds.minDate,
|
||||
end: bounds.maxDate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For preset selections (not custom), clamp dates to bounds and use default times
|
||||
const nextStart = new Date(Math.min(Math.max(value.dateRange.start.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||
const nextEndCandidate = new Date(Math.min(Math.max(value.dateRange.end.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? nextStart : nextEndCandidate
|
||||
|
||||
// Set default times: start at 00:00:00, end at 23:59:59
|
||||
nextStart.setHours(0, 0, 0, 0)
|
||||
nextEnd.setHours(23, 59, 59, 999)
|
||||
|
||||
return {
|
||||
preset: value.useAllTime ? value.preset : (changed ? 'custom' : value.preset),
|
||||
useAllTime: value.useAllTime,
|
||||
preset: value.preset,
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
@@ -95,62 +124,129 @@ export function ExportDateRangeDialog({
|
||||
onClose,
|
||||
onConfirm
|
||||
}: ExportDateRangeDialogProps) {
|
||||
// Helper: Format date only (YYYY-MM-DD) for the date input field
|
||||
const formatDateOnly = (date: Date): string => {
|
||||
const y = date.getFullYear()
|
||||
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const d = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
// Helper: Format time only (HH:mm) for the time input field
|
||||
const formatTimeOnly = (date: Date): string => {
|
||||
const h = `${date.getHours()}`.padStart(2, '0')
|
||||
const m = `${date.getMinutes()}`.padStart(2, '0')
|
||||
return `${h}:${m}`
|
||||
}
|
||||
|
||||
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value, minDate, maxDate))
|
||||
const [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start')
|
||||
const [dateInput, setDateInput] = useState({
|
||||
start: formatDateInputValue(value.dateRange.start),
|
||||
end: formatDateInputValue(value.dateRange.end)
|
||||
start: formatDateOnly(value.dateRange.start),
|
||||
end: formatDateOnly(value.dateRange.end)
|
||||
})
|
||||
const [dateInputError, setDateInputError] = useState({ start: false, end: false })
|
||||
|
||||
// Default times: start at 00:00, end at 23:59
|
||||
const [timeInput, setTimeInput] = useState({
|
||||
start: '00:00',
|
||||
end: '23:59'
|
||||
})
|
||||
const [openTimeDropdown, setOpenTimeDropdown] = useState<ActiveBoundary | null>(null)
|
||||
const startTimeSelectRef = useRef<HTMLDivElement>(null)
|
||||
const endTimeSelectRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const nextDraft = buildDialogDraft(value, minDate, maxDate)
|
||||
setDraft(nextDraft)
|
||||
setActiveBoundary('start')
|
||||
setDateInput({
|
||||
start: formatDateInputValue(nextDraft.dateRange.start),
|
||||
end: formatDateInputValue(nextDraft.dateRange.end)
|
||||
start: formatDateOnly(nextDraft.dateRange.start),
|
||||
end: formatDateOnly(nextDraft.dateRange.end)
|
||||
})
|
||||
// For preset-based selections (not custom), use default times 00:00 and 23:59
|
||||
// For custom selections, preserve the time from value.dateRange
|
||||
if (nextDraft.useAllTime || nextDraft.preset !== 'custom') {
|
||||
setTimeInput({
|
||||
start: '00:00',
|
||||
end: '23:59'
|
||||
})
|
||||
} else {
|
||||
setTimeInput({
|
||||
start: formatTimeOnly(nextDraft.dateRange.start),
|
||||
end: formatTimeOnly(nextDraft.dateRange.end)
|
||||
})
|
||||
}
|
||||
setOpenTimeDropdown(null)
|
||||
setDateInputError({ start: false, end: false })
|
||||
}, [maxDate, minDate, open, value])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setDateInput({
|
||||
start: formatDateInputValue(draft.dateRange.start),
|
||||
end: formatDateInputValue(draft.dateRange.end)
|
||||
start: formatDateOnly(draft.dateRange.start),
|
||||
end: formatDateOnly(draft.dateRange.end)
|
||||
})
|
||||
// Don't sync timeInput here - it's controlled by the time picker
|
||||
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) => {
|
||||
const start = startOfDay(targetDate)
|
||||
if (!bounds) return start
|
||||
if (start.getTime() < bounds.minDate.getTime()) return bounds.minDate
|
||||
if (start.getTime() > bounds.maxDate.getTime()) return startOfDay(bounds.maxDate)
|
||||
return start
|
||||
if (!bounds) return targetDate
|
||||
const min = bounds.minDate
|
||||
const max = bounds.maxDate
|
||||
if (targetDate.getTime() < min.getTime()) return min
|
||||
if (targetDate.getTime() > max.getTime()) return max
|
||||
return targetDate
|
||||
}, [bounds])
|
||||
const clampEndDate = useCallback((targetDate: Date) => {
|
||||
const end = endOfDay(targetDate)
|
||||
if (!bounds) return end
|
||||
if (end.getTime() < bounds.minDate.getTime()) return endOfDay(bounds.minDate)
|
||||
if (end.getTime() > bounds.maxDate.getTime()) return bounds.maxDate
|
||||
return end
|
||||
if (!bounds) return targetDate
|
||||
const min = bounds.minDate
|
||||
const max = bounds.maxDate
|
||||
if (targetDate.getTime() < min.getTime()) return min
|
||||
if (targetDate.getTime() > max.getTime()) return max
|
||||
return targetDate
|
||||
}, [bounds])
|
||||
|
||||
const setRangeStart = useCallback((targetDate: Date) => {
|
||||
const start = clampStartDate(targetDate)
|
||||
setDraft(prev => {
|
||||
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
|
||||
return {
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start,
|
||||
end: nextEnd
|
||||
end: prev.dateRange.end
|
||||
},
|
||||
panelMonth: toMonthStart(start)
|
||||
}
|
||||
@@ -161,14 +257,13 @@ export function ExportDateRangeDialog({
|
||||
const end = clampEndDate(targetDate)
|
||||
setDraft(prev => {
|
||||
const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start
|
||||
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
|
||||
return {
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
end: end
|
||||
},
|
||||
panelMonth: toMonthStart(targetDate)
|
||||
}
|
||||
@@ -180,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,
|
||||
@@ -196,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,
|
||||
@@ -206,25 +311,149 @@ export function ExportDateRangeDialog({
|
||||
setActiveBoundary('start')
|
||||
}, [bounds, maxDate, minDate])
|
||||
|
||||
const parseTimeValue = (timeStr: string): { hours: number; minutes: number } | null => {
|
||||
const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim())
|
||||
if (!matched) return null
|
||||
const hours = Number(matched[1])
|
||||
const minutes = Number(matched[2])
|
||||
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
|
||||
return { hours, minutes }
|
||||
}
|
||||
|
||||
const updateBoundaryTime = useCallback((boundary: ActiveBoundary, timeStr: string) => {
|
||||
setTimeInput(prev => ({ ...prev, [boundary]: timeStr }))
|
||||
|
||||
const parsedTime = parseTimeValue(timeStr)
|
||||
if (!parsedTime) return
|
||||
|
||||
setDraft(prev => {
|
||||
const dateObj = boundary === 'start' ? prev.dateRange.start : prev.dateRange.end
|
||||
const newDate = new Date(dateObj)
|
||||
newDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
|
||||
return {
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
...prev.dateRange,
|
||||
[boundary]: newDate
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
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 (
|
||||
<div className="export-date-range-time-dropdown" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="export-date-range-time-dropdown-header">
|
||||
<span>{boundary === 'start' ? '开始时间' : '结束时间'}</span>
|
||||
<strong>{currentTime}</strong>
|
||||
</div>
|
||||
<div className="export-date-range-time-quick-list">
|
||||
{QUICK_TIME_OPTIONS.map(option => (
|
||||
<button
|
||||
key={`${boundary}-${option}`}
|
||||
type="button"
|
||||
className={`export-date-range-time-quick-item ${currentTime === option ? 'active' : ''}`}
|
||||
onClick={() => updateBoundaryTime(boundary, option)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="export-date-range-time-columns">
|
||||
<div className="export-date-range-time-column">
|
||||
<span className="export-date-range-time-column-label">小时</span>
|
||||
<div className="export-date-range-time-column-list">
|
||||
{HOUR_OPTIONS.map(option => (
|
||||
<button
|
||||
key={`${boundary}-hour-${option}`}
|
||||
type="button"
|
||||
className={`export-date-range-time-option ${parsedCurrent.hours === Number(option) ? 'active' : ''}`}
|
||||
onClick={() => handleTimeColumnSelect(boundary, 'hour', option)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="export-date-range-time-column">
|
||||
<span className="export-date-range-time-column-label">分钟</span>
|
||||
<div className="export-date-range-time-column-list">
|
||||
{MINUTE_OPTIONS.map(option => (
|
||||
<button
|
||||
key={`${boundary}-minute-${option}`}
|
||||
type="button"
|
||||
className={`export-date-range-time-option ${parsedCurrent.minutes === Number(option) ? 'active' : ''}`}
|
||||
onClick={() => handleTimeColumnSelect(boundary, 'minute', option)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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())
|
||||
|
||||
const commitStartFromInput = useCallback(() => {
|
||||
const parsed = parseDateInputValue(dateInput.start)
|
||||
if (!parsed) {
|
||||
const parsedDate = parseDateInputValue(dateInput.start)
|
||||
if (!parsedDate) {
|
||||
setDateInputError(prev => ({ ...prev, start: true }))
|
||||
return
|
||||
}
|
||||
// Only apply time picker value if date input doesn't contain time
|
||||
if (!dateInputHasTime(dateInput.start)) {
|
||||
const parsedTime = parseTimeValue(timeInput.start)
|
||||
if (parsedTime) {
|
||||
parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
|
||||
}
|
||||
}
|
||||
setDateInputError(prev => ({ ...prev, start: false }))
|
||||
setRangeStart(parsed)
|
||||
}, [dateInput.start, setRangeStart])
|
||||
setRangeStart(parsedDate)
|
||||
}, [dateInput.start, timeInput.start, setRangeStart])
|
||||
|
||||
const commitEndFromInput = useCallback(() => {
|
||||
const parsed = parseDateInputValue(dateInput.end)
|
||||
if (!parsed) {
|
||||
const parsedDate = parseDateInputValue(dateInput.end)
|
||||
if (!parsedDate) {
|
||||
setDateInputError(prev => ({ ...prev, end: true }))
|
||||
return
|
||||
}
|
||||
// Only apply time picker value if date input doesn't contain time
|
||||
if (!dateInputHasTime(dateInput.end)) {
|
||||
const parsedTime = parseTimeValue(timeInput.end)
|
||||
if (parsedTime) {
|
||||
parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
|
||||
}
|
||||
}
|
||||
setDateInputError(prev => ({ ...prev, end: false }))
|
||||
setRangeEnd(parsed)
|
||||
}, [dateInput.end, setRangeEnd])
|
||||
setRangeEnd(parsedDate)
|
||||
}, [dateInput.end, timeInput.end, setRangeEnd])
|
||||
|
||||
const shiftPanelMonth = useCallback((delta: number) => {
|
||||
setDraft(prev => ({
|
||||
@@ -234,30 +463,50 @@ export function ExportDateRangeDialog({
|
||||
}, [])
|
||||
|
||||
const handleCalendarSelect = useCallback((targetDate: Date) => {
|
||||
// Use time from timeInput state (which is updated by the time picker)
|
||||
const parseTime = (timeStr: string): { hours: number; minutes: number } => {
|
||||
const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim())
|
||||
if (!matched) return { hours: 0, minutes: 0 }
|
||||
return { hours: Number(matched[1]), minutes: Number(matched[2]) }
|
||||
}
|
||||
|
||||
if (activeBoundary === 'start') {
|
||||
setRangeStart(targetDate)
|
||||
const newStart = new Date(targetDate)
|
||||
const time = parseTime(timeInput.start)
|
||||
newStart.setHours(time.hours, time.minutes, 0, 0)
|
||||
setRangeStart(newStart)
|
||||
setActiveBoundary('end')
|
||||
setOpenTimeDropdown(null)
|
||||
return
|
||||
}
|
||||
|
||||
setDraft(prev => {
|
||||
const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
|
||||
const pickedStart = startOfDay(targetDate)
|
||||
const start = draft.useAllTime ? startOfDay(targetDate) : draft.dateRange.start
|
||||
const nextStart = pickedStart <= start ? pickedStart : start
|
||||
const nextEnd = pickedStart <= start ? endOfDay(start) : endOfDay(targetDate)
|
||||
return {
|
||||
|
||||
const newEnd = new Date(targetDate)
|
||||
const time = parseTime(timeInput.end)
|
||||
// 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)
|
||||
}
|
||||
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
end: newEnd
|
||||
},
|
||||
panelMonth: toMonthStart(targetDate)
|
||||
}
|
||||
})
|
||||
}))
|
||||
setActiveBoundary('start')
|
||||
}, [activeBoundary, setRangeEnd, setRangeStart])
|
||||
setOpenTimeDropdown(null)
|
||||
}, [activeBoundary, draft.dateRange.start, draft.useAllTime, timeInput.end, timeInput.start, setRangeStart])
|
||||
|
||||
const isRangeModeActive = !draft.useAllTime
|
||||
const modeText = isRangeModeActive
|
||||
@@ -364,6 +613,23 @@ export function ExportDateRangeDialog({
|
||||
}}
|
||||
onBlur={commitStartFromInput}
|
||||
/>
|
||||
<div
|
||||
className={`export-date-range-time-select ${openTimeDropdown === 'start' ? 'open' : ''}`}
|
||||
ref={startTimeSelectRef}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="export-date-range-time-trigger"
|
||||
onClick={() => toggleTimeDropdown('start')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={openTimeDropdown === 'start'}
|
||||
>
|
||||
<span className="export-date-range-time-trigger-value">{timeInput.start}</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{openTimeDropdown === 'start' && renderTimeDropdown('start')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`}
|
||||
@@ -391,6 +657,23 @@ export function ExportDateRangeDialog({
|
||||
}}
|
||||
onBlur={commitEndFromInput}
|
||||
/>
|
||||
<div
|
||||
className={`export-date-range-time-select ${openTimeDropdown === 'end' ? 'open' : ''}`}
|
||||
ref={endTimeSelectRef}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="export-date-range-time-trigger"
|
||||
onClick={() => toggleTimeDropdown('end')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={openTimeDropdown === 'end'}
|
||||
>
|
||||
<span className="export-date-range-time-trigger-value">{timeInput.end}</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{openTimeDropdown === 'end' && renderTimeDropdown('end')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -453,7 +736,14 @@ export function ExportDateRangeDialog({
|
||||
<button
|
||||
type="button"
|
||||
className="export-date-range-dialog-btn primary"
|
||||
onClick={() => onConfirm(cloneExportDateRangeSelection(draft))}
|
||||
onClick={() => {
|
||||
// Validate: end time should not be earlier than start time
|
||||
if (draft.dateRange.end.getTime() < draft.dateRange.start.getTime()) {
|
||||
setDateInputError({ start: true, end: true })
|
||||
return
|
||||
}
|
||||
onConfirm(cloneExportDateRangeSelection(draft))
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
|
||||
@@ -1105,21 +1105,42 @@ const clampExportSelectionToBounds = (
|
||||
): ExportDateRangeSelection => {
|
||||
if (!bounds) return cloneExportDateRangeSelection(selection)
|
||||
|
||||
const boundedStart = startOfDay(bounds.minDate)
|
||||
const boundedEnd = endOfDay(bounds.maxDate)
|
||||
const originalStart = selection.useAllTime ? boundedStart : startOfDay(selection.dateRange.start)
|
||||
const originalEnd = selection.useAllTime ? boundedEnd : endOfDay(selection.dateRange.end)
|
||||
const nextStart = new Date(Math.min(Math.max(originalStart.getTime(), boundedStart.getTime()), boundedEnd.getTime()))
|
||||
const nextEndCandidate = new Date(Math.min(Math.max(originalEnd.getTime(), boundedStart.getTime()), boundedEnd.getTime()))
|
||||
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate
|
||||
const rangeChanged = nextStart.getTime() !== originalStart.getTime() || nextEnd.getTime() !== originalEnd.getTime()
|
||||
|
||||
// For custom selections, only ensure end >= start, preserve time precision
|
||||
if (selection.preset === 'custom' && !selection.useAllTime) {
|
||||
const { start, end } = selection.dateRange
|
||||
if (end.getTime() < start.getTime()) {
|
||||
return {
|
||||
preset: selection.useAllTime ? selection.preset : (rangeChanged ? 'custom' : selection.preset),
|
||||
useAllTime: selection.useAllTime,
|
||||
...selection,
|
||||
dateRange: { start, end: start }
|
||||
}
|
||||
}
|
||||
return cloneExportDateRangeSelection(selection)
|
||||
}
|
||||
|
||||
// For useAllTime, use bounds directly
|
||||
if (selection.useAllTime) {
|
||||
return {
|
||||
preset: selection.preset,
|
||||
useAllTime: true,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
start: bounds.minDate,
|
||||
end: bounds.maxDate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For preset selections (not custom), clamp dates to bounds and use default times
|
||||
const boundedStart = new Date(Math.min(Math.max(selection.dateRange.start.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||
const boundedEnd = new Date(Math.min(Math.max(selection.dateRange.end.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||
// Use default times: start at 00:00, end at 23:59:59
|
||||
boundedStart.setHours(0, 0, 0, 0)
|
||||
boundedEnd.setHours(23, 59, 59, 999)
|
||||
return {
|
||||
preset: selection.preset,
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: boundedStart,
|
||||
end: boundedEnd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,19 +138,24 @@ export const formatDateInputValue = (date: Date): string => {
|
||||
const y = date.getFullYear()
|
||||
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const d = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
const h = `${date.getHours()}`.padStart(2, '0')
|
||||
const min = `${date.getMinutes()}`.padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${h}:${min}`
|
||||
}
|
||||
|
||||
export const parseDateInputValue = (raw: string): Date | null => {
|
||||
const text = String(raw || '').trim()
|
||||
const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
|
||||
const matched = /^(\d{4})-(\d{2})-(\d{2})(?:\s+(\d{2}):(\d{2}))?$/.exec(text)
|
||||
if (!matched) return null
|
||||
const year = Number(matched[1])
|
||||
const month = Number(matched[2])
|
||||
const day = Number(matched[3])
|
||||
const hour = matched[4] !== undefined ? Number(matched[4]) : 0
|
||||
const minute = matched[5] !== undefined ? Number(matched[5]) : 0
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return null
|
||||
const parsed = new Date(year, month - 1, day)
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null
|
||||
const parsed = new Date(year, month - 1, day, hour, minute, 0, 0)
|
||||
if (
|
||||
parsed.getFullYear() !== year ||
|
||||
parsed.getMonth() !== month - 1 ||
|
||||
@@ -291,14 +296,14 @@ export const resolveExportDateRangeConfig = (
|
||||
const parsedStart = parseStoredDate(raw.start)
|
||||
const parsedEnd = parseStoredDate(raw.end)
|
||||
if (parsedStart && parsedEnd) {
|
||||
const start = startOfDay(parsedStart)
|
||||
const end = endOfDay(parsedEnd)
|
||||
const start = parsedStart
|
||||
const end = parsedEnd
|
||||
return {
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start,
|
||||
end: end < start ? endOfDay(start) : end
|
||||
end: end < start ? start : end
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user