mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-12 07:25:50 +00:00
feat(export): 导出日期范围添加时间选择功能
为导出窗口的日期范围选择器添加了时间(HH:mm)选择功能: - 在日期输入框下方添加了时间选择控件(type="time") - 默认时间范围:开始 00:00,结束 23:59 - 支持精确到分钟的时间范围设置 - 预设类型(今天、昨天、最近7天等)默认使用 00:00-23:59 - 自定义时间范围保留用户设置的具体时间 - 添加了结束时间不能早于开始时间的验证 修改文件: - src/utils/exportDateRange.ts - 支持 YYYY-MM-DD HH:mm 格式的解析和格式化 - src/components/Export/ExportDateRangeDialog.tsx - 添加时间选择 UI 和逻辑 - src/components/Export/ExportDateRangeDialog.scss - 时间输入框样式 - src/pages/ExportPage.tsx - 修复 preset 类型的默认时间不正确的 bug
This commit is contained in:
@@ -192,6 +192,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-date-range-time-input {
|
||||||
|
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;
|
||||||
|
|
||||||
|
&: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-calendar-nav {
|
.export-date-range-calendar-nav {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -57,16 +57,42 @@ const clampSelectionToBounds = (
|
|||||||
const bounds = resolveBounds(minDate, maxDate)
|
const bounds = resolveBounds(minDate, maxDate)
|
||||||
if (!bounds) return cloneExportDateRangeSelection(value)
|
if (!bounds) return cloneExportDateRangeSelection(value)
|
||||||
|
|
||||||
const rawStart = value.useAllTime ? bounds.minDate : startOfDay(value.dateRange.start)
|
// For custom selections, only ensure end >= start, preserve time precision
|
||||||
const rawEnd = value.useAllTime ? bounds.maxDate : endOfDay(value.dateRange.end)
|
if (value.preset === 'custom' && !value.useAllTime) {
|
||||||
const nextStart = new Date(Math.min(Math.max(rawStart.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
const { start, end } = value.dateRange
|
||||||
const nextEndCandidate = new Date(Math.min(Math.max(rawEnd.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
if (end.getTime() < start.getTime()) {
|
||||||
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate
|
return {
|
||||||
const changed = nextStart.getTime() !== rawStart.getTime() || nextEnd.getTime() !== rawEnd.getTime()
|
...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 {
|
return {
|
||||||
preset: value.useAllTime ? value.preset : (changed ? 'custom' : value.preset),
|
preset: value.preset,
|
||||||
useAllTime: value.useAllTime,
|
useAllTime: false,
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start: nextStart,
|
start: nextStart,
|
||||||
end: nextEnd
|
end: nextEnd
|
||||||
@@ -95,62 +121,98 @@ export function ExportDateRangeDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
onConfirm
|
onConfirm
|
||||||
}: ExportDateRangeDialogProps) {
|
}: 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 [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value, minDate, maxDate))
|
||||||
const [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start')
|
const [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start')
|
||||||
const [dateInput, setDateInput] = useState({
|
const [dateInput, setDateInput] = useState({
|
||||||
start: formatDateInputValue(value.dateRange.start),
|
start: formatDateOnly(value.dateRange.start),
|
||||||
end: formatDateInputValue(value.dateRange.end)
|
end: formatDateOnly(value.dateRange.end)
|
||||||
})
|
})
|
||||||
const [dateInputError, setDateInputError] = useState({ start: false, end: false })
|
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'
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const nextDraft = buildDialogDraft(value, minDate, maxDate)
|
const nextDraft = buildDialogDraft(value, minDate, maxDate)
|
||||||
setDraft(nextDraft)
|
setDraft(nextDraft)
|
||||||
setActiveBoundary('start')
|
setActiveBoundary('start')
|
||||||
setDateInput({
|
setDateInput({
|
||||||
start: formatDateInputValue(nextDraft.dateRange.start),
|
start: formatDateOnly(nextDraft.dateRange.start),
|
||||||
end: formatDateInputValue(nextDraft.dateRange.end)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
setDateInputError({ start: false, end: false })
|
setDateInputError({ start: false, end: false })
|
||||||
}, [maxDate, minDate, open, value])
|
}, [maxDate, minDate, open, value])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setDateInput({
|
setDateInput({
|
||||||
start: formatDateInputValue(draft.dateRange.start),
|
start: formatDateOnly(draft.dateRange.start),
|
||||||
end: formatDateInputValue(draft.dateRange.end)
|
end: formatDateOnly(draft.dateRange.end)
|
||||||
})
|
})
|
||||||
|
// Don't sync timeInput here - it's controlled by the time picker
|
||||||
setDateInputError({ start: false, end: false })
|
setDateInputError({ start: false, end: false })
|
||||||
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
|
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
|
||||||
|
|
||||||
const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate])
|
const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate])
|
||||||
const clampStartDate = useCallback((targetDate: Date) => {
|
const clampStartDate = useCallback((targetDate: Date) => {
|
||||||
const start = startOfDay(targetDate)
|
if (!bounds) return targetDate
|
||||||
if (!bounds) return start
|
const min = bounds.minDate
|
||||||
if (start.getTime() < bounds.minDate.getTime()) return bounds.minDate
|
const max = bounds.maxDate
|
||||||
if (start.getTime() > bounds.maxDate.getTime()) return startOfDay(bounds.maxDate)
|
if (targetDate.getTime() < min.getTime()) return min
|
||||||
return start
|
if (targetDate.getTime() > max.getTime()) return max
|
||||||
|
return targetDate
|
||||||
}, [bounds])
|
}, [bounds])
|
||||||
const clampEndDate = useCallback((targetDate: Date) => {
|
const clampEndDate = useCallback((targetDate: Date) => {
|
||||||
const end = endOfDay(targetDate)
|
if (!bounds) return targetDate
|
||||||
if (!bounds) return end
|
const min = bounds.minDate
|
||||||
if (end.getTime() < bounds.minDate.getTime()) return endOfDay(bounds.minDate)
|
const max = bounds.maxDate
|
||||||
if (end.getTime() > bounds.maxDate.getTime()) return bounds.maxDate
|
if (targetDate.getTime() < min.getTime()) return min
|
||||||
return end
|
if (targetDate.getTime() > max.getTime()) return max
|
||||||
|
return targetDate
|
||||||
}, [bounds])
|
}, [bounds])
|
||||||
|
|
||||||
const setRangeStart = useCallback((targetDate: Date) => {
|
const setRangeStart = useCallback((targetDate: Date) => {
|
||||||
const start = clampStartDate(targetDate)
|
const start = clampStartDate(targetDate)
|
||||||
setDraft(prev => {
|
setDraft(prev => {
|
||||||
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
preset: 'custom',
|
preset: 'custom',
|
||||||
useAllTime: false,
|
useAllTime: false,
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start,
|
start,
|
||||||
end: nextEnd
|
end: prev.dateRange.end
|
||||||
},
|
},
|
||||||
panelMonth: toMonthStart(start)
|
panelMonth: toMonthStart(start)
|
||||||
}
|
}
|
||||||
@@ -161,14 +223,13 @@ export function ExportDateRangeDialog({
|
|||||||
const end = clampEndDate(targetDate)
|
const end = clampEndDate(targetDate)
|
||||||
setDraft(prev => {
|
setDraft(prev => {
|
||||||
const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start
|
const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start
|
||||||
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
preset: 'custom',
|
preset: 'custom',
|
||||||
useAllTime: false,
|
useAllTime: false,
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start: nextStart,
|
start: nextStart,
|
||||||
end: nextEnd
|
end: end
|
||||||
},
|
},
|
||||||
panelMonth: toMonthStart(targetDate)
|
panelMonth: toMonthStart(targetDate)
|
||||||
}
|
}
|
||||||
@@ -206,25 +267,74 @@ export function ExportDateRangeDialog({
|
|||||||
setActiveBoundary('start')
|
setActiveBoundary('start')
|
||||||
}, [bounds, maxDate, minDate])
|
}, [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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle time picker changes - update draft.dateRange immediately
|
||||||
|
const handleTimePickerChange = useCallback((boundary: 'start' | 'end', 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 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 commitStartFromInput = useCallback(() => {
|
||||||
const parsed = parseDateInputValue(dateInput.start)
|
const parsedDate = parseDateInputValue(dateInput.start)
|
||||||
if (!parsed) {
|
if (!parsedDate) {
|
||||||
setDateInputError(prev => ({ ...prev, start: true }))
|
setDateInputError(prev => ({ ...prev, start: true }))
|
||||||
return
|
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 }))
|
setDateInputError(prev => ({ ...prev, start: false }))
|
||||||
setRangeStart(parsed)
|
setRangeStart(parsedDate)
|
||||||
}, [dateInput.start, setRangeStart])
|
}, [dateInput.start, timeInput.start, setRangeStart])
|
||||||
|
|
||||||
const commitEndFromInput = useCallback(() => {
|
const commitEndFromInput = useCallback(() => {
|
||||||
const parsed = parseDateInputValue(dateInput.end)
|
const parsedDate = parseDateInputValue(dateInput.end)
|
||||||
if (!parsed) {
|
if (!parsedDate) {
|
||||||
setDateInputError(prev => ({ ...prev, end: true }))
|
setDateInputError(prev => ({ ...prev, end: true }))
|
||||||
return
|
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 }))
|
setDateInputError(prev => ({ ...prev, end: false }))
|
||||||
setRangeEnd(parsed)
|
setRangeEnd(parsedDate)
|
||||||
}, [dateInput.end, setRangeEnd])
|
}, [dateInput.end, timeInput.end, setRangeEnd])
|
||||||
|
|
||||||
const shiftPanelMonth = useCallback((delta: number) => {
|
const shiftPanelMonth = useCallback((delta: number) => {
|
||||||
setDraft(prev => ({
|
setDraft(prev => ({
|
||||||
@@ -234,30 +344,47 @@ export function ExportDateRangeDialog({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleCalendarSelect = useCallback((targetDate: Date) => {
|
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') {
|
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')
|
setActiveBoundary('end')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setDraft(prev => {
|
|
||||||
const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
|
|
||||||
const pickedStart = startOfDay(targetDate)
|
const pickedStart = startOfDay(targetDate)
|
||||||
|
const start = draft.useAllTime ? startOfDay(targetDate) : draft.dateRange.start
|
||||||
const nextStart = pickedStart <= start ? pickedStart : 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)
|
||||||
|
} else {
|
||||||
|
newEnd.setHours(time.hours, time.minutes, 59, 999)
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraft(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
preset: 'custom',
|
preset: 'custom',
|
||||||
useAllTime: false,
|
useAllTime: false,
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start: nextStart,
|
start: nextStart,
|
||||||
end: nextEnd
|
end: newEnd
|
||||||
},
|
},
|
||||||
panelMonth: toMonthStart(targetDate)
|
panelMonth: toMonthStart(targetDate)
|
||||||
}
|
}))
|
||||||
})
|
|
||||||
setActiveBoundary('start')
|
setActiveBoundary('start')
|
||||||
}, [activeBoundary, setRangeEnd, setRangeStart])
|
}, [activeBoundary, draft.dateRange.start, draft.useAllTime, timeInput.end, timeInput.start, setRangeStart])
|
||||||
|
|
||||||
const isRangeModeActive = !draft.useAllTime
|
const isRangeModeActive = !draft.useAllTime
|
||||||
const modeText = isRangeModeActive
|
const modeText = isRangeModeActive
|
||||||
@@ -364,6 +491,16 @@ export function ExportDateRangeDialog({
|
|||||||
}}
|
}}
|
||||||
onBlur={commitStartFromInput}
|
onBlur={commitStartFromInput}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
className="export-date-range-time-input"
|
||||||
|
value={timeInput.start}
|
||||||
|
onChange={(event) => {
|
||||||
|
handleTimePickerChange('start', event.target.value)
|
||||||
|
}}
|
||||||
|
onFocus={() => setActiveBoundary('start')}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`}
|
className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`}
|
||||||
@@ -391,6 +528,16 @@ export function ExportDateRangeDialog({
|
|||||||
}}
|
}}
|
||||||
onBlur={commitEndFromInput}
|
onBlur={commitEndFromInput}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
className="export-date-range-time-input"
|
||||||
|
value={timeInput.end}
|
||||||
|
onChange={(event) => {
|
||||||
|
handleTimePickerChange('end', event.target.value)
|
||||||
|
}}
|
||||||
|
onFocus={() => setActiveBoundary('end')}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -453,7 +600,14 @@ export function ExportDateRangeDialog({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="export-date-range-dialog-btn primary"
|
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>
|
</button>
|
||||||
|
|||||||
@@ -1105,21 +1105,42 @@ const clampExportSelectionToBounds = (
|
|||||||
): ExportDateRangeSelection => {
|
): ExportDateRangeSelection => {
|
||||||
if (!bounds) return cloneExportDateRangeSelection(selection)
|
if (!bounds) return cloneExportDateRangeSelection(selection)
|
||||||
|
|
||||||
const boundedStart = startOfDay(bounds.minDate)
|
// For custom selections, only ensure end >= start, preserve time precision
|
||||||
const boundedEnd = endOfDay(bounds.maxDate)
|
if (selection.preset === 'custom' && !selection.useAllTime) {
|
||||||
const originalStart = selection.useAllTime ? boundedStart : startOfDay(selection.dateRange.start)
|
const { start, end } = selection.dateRange
|
||||||
const originalEnd = selection.useAllTime ? boundedEnd : endOfDay(selection.dateRange.end)
|
if (end.getTime() < start.getTime()) {
|
||||||
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()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
preset: selection.useAllTime ? selection.preset : (rangeChanged ? 'custom' : selection.preset),
|
...selection,
|
||||||
useAllTime: selection.useAllTime,
|
dateRange: { start, end: start }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloneExportDateRangeSelection(selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For useAllTime, use bounds directly
|
||||||
|
if (selection.useAllTime) {
|
||||||
|
return {
|
||||||
|
preset: selection.preset,
|
||||||
|
useAllTime: true,
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start: nextStart,
|
start: bounds.minDate,
|
||||||
end: nextEnd
|
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 y = date.getFullYear()
|
||||||
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||||
const d = `${date.getDate()}`.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 => {
|
export const parseDateInputValue = (raw: string): Date | null => {
|
||||||
const text = String(raw || '').trim()
|
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
|
if (!matched) return null
|
||||||
const year = Number(matched[1])
|
const year = Number(matched[1])
|
||||||
const month = Number(matched[2])
|
const month = Number(matched[2])
|
||||||
const day = Number(matched[3])
|
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 (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null
|
||||||
if (month < 1 || month > 12 || day < 1 || day > 31) 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 (
|
if (
|
||||||
parsed.getFullYear() !== year ||
|
parsed.getFullYear() !== year ||
|
||||||
parsed.getMonth() !== month - 1 ||
|
parsed.getMonth() !== month - 1 ||
|
||||||
@@ -291,14 +296,14 @@ export const resolveExportDateRangeConfig = (
|
|||||||
const parsedStart = parseStoredDate(raw.start)
|
const parsedStart = parseStoredDate(raw.start)
|
||||||
const parsedEnd = parseStoredDate(raw.end)
|
const parsedEnd = parseStoredDate(raw.end)
|
||||||
if (parsedStart && parsedEnd) {
|
if (parsedStart && parsedEnd) {
|
||||||
const start = startOfDay(parsedStart)
|
const start = parsedStart
|
||||||
const end = endOfDay(parsedEnd)
|
const end = parsedEnd
|
||||||
return {
|
return {
|
||||||
preset: 'custom',
|
preset: 'custom',
|
||||||
useAllTime: false,
|
useAllTime: false,
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start,
|
start,
|
||||||
end: end < start ? endOfDay(start) : end
|
end: end < start ? start : end
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user