import { useCallback, useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react' import { EXPORT_DATE_RANGE_PRESETS, WEEKDAY_SHORT_LABELS, addMonths, buildCalendarCells, cloneExportDateRangeSelection, createDateRangeByPreset, createDefaultDateRange, formatCalendarMonthTitle, formatDateInputValue, isSameDay, parseDateInputValue, startOfDay, endOfDay, toMonthStart, type ExportDateRangePreset, type ExportDateRangeSelection } from '../../utils/exportDateRange' import './ExportDateRangeDialog.scss' interface ExportDateRangeDialogProps { open: boolean value: ExportDateRangeSelection title?: string minDate?: Date | null maxDate?: Date | null onClose: () => void onConfirm: (value: ExportDateRangeSelection) => void } type ActiveBoundary = 'start' | 'end' interface ExportDateRangeDialogDraft extends ExportDateRangeSelection { panelMonth: Date } 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 const normalizedMin = startOfDay(minDate) const normalizedMax = endOfDay(maxDate) if (normalizedMin.getTime() > normalizedMax.getTime()) return null return { minDate: normalizedMin, maxDate: normalizedMax } } const clampSelectionToBounds = ( value: ExportDateRangeSelection, minDate?: Date | null, maxDate?: Date | null ): ExportDateRangeSelection => { 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() return { preset: value.useAllTime ? value.preset : (changed ? 'custom' : value.preset), useAllTime: value.useAllTime, dateRange: { start: nextStart, end: nextEnd } } } const buildDialogDraft = ( value: ExportDateRangeSelection, minDate?: Date | null, maxDate?: Date | null ): ExportDateRangeDialogDraft => { const nextValue = clampSelectionToBounds(value, minDate, maxDate) return { ...nextValue, panelMonth: toMonthStart(nextValue.dateRange.start) } } export function ExportDateRangeDialog({ open, value, title = '时间范围设置', minDate, maxDate, onClose, onConfirm }: ExportDateRangeDialogProps) { const [draft, setDraft] = useState(() => buildDialogDraft(value, minDate, maxDate)) const [activeBoundary, setActiveBoundary] = useState('start') const [dateInput, setDateInput] = useState({ start: formatDateInputValue(value.dateRange.start), end: formatDateInputValue(value.dateRange.end) }) const [dateInputError, setDateInputError] = useState({ start: false, end: false }) 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) }) setDateInputError({ start: false, end: false }) }, [maxDate, minDate, open, value]) useEffect(() => { if (!open) return setDateInput({ start: formatDateInputValue(draft.dateRange.start), end: formatDateInputValue(draft.dateRange.end) }) setDateInputError({ start: false, end: false }) }, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open]) 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 }, [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 }, [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 }, panelMonth: toMonthStart(start) } }) }, [clampStartDate]) const setRangeEnd = useCallback((targetDate: Date) => { 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 }, panelMonth: toMonthStart(targetDate) } }) }, [clampEndDate, clampStartDate]) const applyPreset = useCallback((preset: Exclude) => { if (preset === 'all') { const previewRange = bounds ? { start: bounds.minDate, end: bounds.maxDate } : createDefaultDateRange() setDraft(prev => ({ ...prev, preset, useAllTime: true, dateRange: previewRange, panelMonth: toMonthStart(previewRange.start) })) setActiveBoundary('start') return } const range = clampSelectionToBounds({ preset, useAllTime: false, dateRange: createDateRangeByPreset(preset) }, minDate, maxDate).dateRange setDraft(prev => ({ ...prev, preset, useAllTime: false, dateRange: range, panelMonth: toMonthStart(range.start) })) setActiveBoundary('start') }, [bounds, maxDate, minDate]) const commitStartFromInput = useCallback(() => { const parsed = parseDateInputValue(dateInput.start) if (!parsed) { setDateInputError(prev => ({ ...prev, start: true })) return } setDateInputError(prev => ({ ...prev, start: false })) setRangeStart(parsed) }, [dateInput.start, setRangeStart]) const commitEndFromInput = useCallback(() => { const parsed = parseDateInputValue(dateInput.end) if (!parsed) { setDateInputError(prev => ({ ...prev, end: true })) return } setDateInputError(prev => ({ ...prev, end: false })) setRangeEnd(parsed) }, [dateInput.end, setRangeEnd]) const shiftPanelMonth = useCallback((delta: number) => { setDraft(prev => ({ ...prev, panelMonth: addMonths(prev.panelMonth, delta) })) }, []) const handleCalendarSelect = useCallback((targetDate: Date) => { if (activeBoundary === 'start') { setRangeStart(targetDate) setActiveBoundary('end') return } setDraft(prev => { const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start const pickedStart = startOfDay(targetDate) const nextStart = pickedStart <= start ? pickedStart : start const nextEnd = pickedStart <= start ? endOfDay(start) : endOfDay(targetDate) return { ...prev, preset: 'custom', useAllTime: false, dateRange: { start: nextStart, end: nextEnd }, panelMonth: toMonthStart(targetDate) } }) setActiveBoundary('start') }, [activeBoundary, setRangeEnd, setRangeStart]) const isRangeModeActive = !draft.useAllTime const modeText = isRangeModeActive ? '当前导出模式:按时间范围导出' : '当前导出模式:全部时间导出,选择下方日期会切换为自定义时间范围' const isPresetActive = useCallback((preset: ExportDateRangePreset): boolean => { if (preset === 'all') return draft.useAllTime return !draft.useAllTime && draft.preset === preset }, [draft]) const calendarCells = useMemo(() => buildCalendarCells(draft.panelMonth), [draft.panelMonth]) const minPanelMonth = bounds ? toMonthStart(bounds.minDate) : null const maxPanelMonth = bounds ? toMonthStart(bounds.maxDate) : null const canShiftPrev = !minPanelMonth || draft.panelMonth.getTime() > minPanelMonth.getTime() const canShiftNext = !maxPanelMonth || draft.panelMonth.getTime() < maxPanelMonth.getTime() const isStartSelected = useCallback((date: Date) => ( !draft.useAllTime && isSameDay(date, draft.dateRange.start) ), [draft]) const isEndSelected = useCallback((date: Date) => ( !draft.useAllTime && isSameDay(date, draft.dateRange.end) ), [draft]) const isDateInRange = useCallback((date: Date) => ( !draft.useAllTime && startOfDay(date).getTime() >= startOfDay(draft.dateRange.start).getTime() && startOfDay(date).getTime() <= startOfDay(draft.dateRange.end).getTime() ), [draft]) const isDateSelectable = useCallback((date: Date) => { if (!bounds) return true const target = startOfDay(date).getTime() return target >= startOfDay(bounds.minDate).getTime() && target <= startOfDay(bounds.maxDate).getTime() }, [bounds]) const hintText = draft.useAllTime ? '选择开始或结束日期后,会自动切换为自定义时间范围' : (activeBoundary === 'start' ? '下一次点击将设置开始日期' : '下一次点击将设置结束日期') if (!open) return null return createPortal(
event.stopPropagation()}>

{title}

{EXPORT_DATE_RANGE_PRESETS.map((preset) => { const active = isPresetActive(preset.value) return ( ) })}
{modeText}
setActiveBoundary('start')} > 开始 { const nextValue = event.target.value setDateInput(prev => ({ ...prev, start: nextValue })) if (dateInputError.start) { setDateInputError(prev => ({ ...prev, start: false })) } }} onFocus={() => setActiveBoundary('start')} onClick={(event) => event.stopPropagation()} onKeyDown={(event) => { if (event.key !== 'Enter') return event.preventDefault() commitStartFromInput() }} onBlur={commitStartFromInput} />
setActiveBoundary('end')} > 结束 { const nextValue = event.target.value setDateInput(prev => ({ ...prev, end: nextValue })) if (dateInputError.end) { setDateInputError(prev => ({ ...prev, end: false })) } }} onFocus={() => setActiveBoundary('end')} onClick={(event) => event.stopPropagation()} onKeyDown={(event) => { if (event.key !== 'Enter') return event.preventDefault() commitEndFromInput() }} onBlur={commitEndFromInput} />
{hintText}
选择日期范围 {formatCalendarMonthTitle(draft.panelMonth)}
{WEEKDAY_SHORT_LABELS.map(label => ( {label} ))}
{calendarCells.map((cell) => { const startSelected = isStartSelected(cell.date) const endSelected = isEndSelected(cell.date) const inRange = isDateInRange(cell.date) const selectable = isDateSelectable(cell.date) return ( ) })}
, document.body ) }