mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-12 07:25:50 +00:00
Merge branch 'dev' into dev
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 {
|
.export-date-range-calendar-nav {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
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 { createPortal } from 'react-dom'
|
||||||
import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react'
|
import { Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
EXPORT_DATE_RANGE_PRESETS,
|
EXPORT_DATE_RANGE_PRESETS,
|
||||||
WEEKDAY_SHORT_LABELS,
|
WEEKDAY_SHORT_LABELS,
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
createDateRangeByPreset,
|
createDateRangeByPreset,
|
||||||
createDefaultDateRange,
|
createDefaultDateRange,
|
||||||
formatCalendarMonthTitle,
|
formatCalendarMonthTitle,
|
||||||
formatDateInputValue,
|
|
||||||
isSameDay,
|
isSameDay,
|
||||||
parseDateInputValue,
|
parseDateInputValue,
|
||||||
startOfDay,
|
startOfDay,
|
||||||
@@ -37,6 +36,10 @@ interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
|
|||||||
panelMonth: Date
|
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 => {
|
const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => {
|
||||||
if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null
|
if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null
|
||||||
if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null
|
if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null
|
||||||
@@ -57,16 +60,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 +124,129 @@ 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'
|
||||||
|
})
|
||||||
|
const [openTimeDropdown, setOpenTimeDropdown] = useState<ActiveBoundary | null>(null)
|
||||||
|
const startTimeSelectRef = useRef<HTMLDivElement>(null)
|
||||||
|
const endTimeSelectRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setOpenTimeDropdown(null)
|
||||||
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])
|
||||||
|
|
||||||
|
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 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 +257,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)
|
||||||
}
|
}
|
||||||
@@ -180,6 +275,11 @@ export function ExportDateRangeDialog({
|
|||||||
const previewRange = bounds
|
const previewRange = bounds
|
||||||
? { start: bounds.minDate, end: bounds.maxDate }
|
? { start: bounds.minDate, end: bounds.maxDate }
|
||||||
: createDefaultDateRange()
|
: createDefaultDateRange()
|
||||||
|
setTimeInput({
|
||||||
|
start: '00:00',
|
||||||
|
end: '23:59'
|
||||||
|
})
|
||||||
|
setOpenTimeDropdown(null)
|
||||||
setDraft(prev => ({
|
setDraft(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
preset,
|
preset,
|
||||||
@@ -196,6 +296,11 @@ export function ExportDateRangeDialog({
|
|||||||
useAllTime: false,
|
useAllTime: false,
|
||||||
dateRange: createDateRangeByPreset(preset)
|
dateRange: createDateRangeByPreset(preset)
|
||||||
}, minDate, maxDate).dateRange
|
}, minDate, maxDate).dateRange
|
||||||
|
setTimeInput({
|
||||||
|
start: '00:00',
|
||||||
|
end: '23:59'
|
||||||
|
})
|
||||||
|
setOpenTimeDropdown(null)
|
||||||
setDraft(prev => ({
|
setDraft(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
preset,
|
preset,
|
||||||
@@ -206,25 +311,149 @@ 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 +463,50 @@ 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')
|
||||||
|
setOpenTimeDropdown(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setDraft(prev => {
|
const pickedStart = startOfDay(targetDate)
|
||||||
const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
|
const start = draft.useAllTime ? startOfDay(targetDate) : draft.dateRange.start
|
||||||
const pickedStart = startOfDay(targetDate)
|
const nextStart = pickedStart <= start ? pickedStart : start
|
||||||
const nextStart = pickedStart <= start ? pickedStart : start
|
|
||||||
const nextEnd = pickedStart <= start ? endOfDay(start) : endOfDay(targetDate)
|
const newEnd = new Date(targetDate)
|
||||||
return {
|
const time = parseTime(timeInput.end)
|
||||||
...prev,
|
// If selecting same day or going backwards, use 23:59:59, otherwise use the time from timeInput
|
||||||
preset: 'custom',
|
if (pickedStart <= start) {
|
||||||
useAllTime: false,
|
newEnd.setHours(23, 59, 59, 999)
|
||||||
dateRange: {
|
setTimeInput(prev => ({ ...prev, end: '23:59' }))
|
||||||
start: nextStart,
|
} else {
|
||||||
end: nextEnd
|
newEnd.setHours(time.hours, time.minutes, 59, 999)
|
||||||
},
|
}
|
||||||
panelMonth: toMonthStart(targetDate)
|
|
||||||
}
|
setDraft(prev => ({
|
||||||
})
|
...prev,
|
||||||
|
preset: 'custom',
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: {
|
||||||
|
start: nextStart,
|
||||||
|
end: newEnd
|
||||||
|
},
|
||||||
|
panelMonth: toMonthStart(targetDate)
|
||||||
|
}))
|
||||||
setActiveBoundary('start')
|
setActiveBoundary('start')
|
||||||
}, [activeBoundary, setRangeEnd, setRangeStart])
|
setOpenTimeDropdown(null)
|
||||||
|
}, [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 +613,23 @@ export function ExportDateRangeDialog({
|
|||||||
}}
|
}}
|
||||||
onBlur={commitStartFromInput}
|
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>
|
||||||
<div
|
<div
|
||||||
className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`}
|
className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`}
|
||||||
@@ -391,6 +657,23 @@ export function ExportDateRangeDialog({
|
|||||||
}}
|
}}
|
||||||
onBlur={commitEndFromInput}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -453,7 +736,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()))
|
return {
|
||||||
const nextEndCandidate = new Date(Math.min(Math.max(originalEnd.getTime(), boundedStart.getTime()), boundedEnd.getTime()))
|
...selection,
|
||||||
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate
|
dateRange: { start, end: start }
|
||||||
const rangeChanged = nextStart.getTime() !== originalStart.getTime() || nextEnd.getTime() !== originalEnd.getTime()
|
}
|
||||||
|
}
|
||||||
|
return cloneExportDateRangeSelection(selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For useAllTime, use bounds directly
|
||||||
|
if (selection.useAllTime) {
|
||||||
|
return {
|
||||||
|
preset: selection.preset,
|
||||||
|
useAllTime: true,
|
||||||
|
dateRange: {
|
||||||
|
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 {
|
return {
|
||||||
preset: selection.useAllTime ? selection.preset : (rangeChanged ? 'custom' : selection.preset),
|
preset: selection.preset,
|
||||||
useAllTime: selection.useAllTime,
|
useAllTime: false,
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start: nextStart,
|
start: boundedStart,
|
||||||
end: nextEnd
|
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