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:
佘志高
2026-04-11 22:00:32 +08:00
parent 5bec4f3cd6
commit f2d6188c53
4 changed files with 279 additions and 73 deletions

View File

@@ -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;

View File

@@ -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>

View File

@@ -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
} }
} }
} }

View File

@@ -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
} }
} }
} }