feat(export): refine time range dialog mode switching

This commit is contained in:
tisonhuang
2026-03-05 12:10:07 +08:00
parent b5cb4051ab
commit b436bb63da
2 changed files with 208 additions and 58 deletions

View File

@@ -2349,7 +2349,7 @@
} }
.time-range-dialog { .time-range-dialog {
width: min(920px, calc(100vw - 32px)); width: min(480px, calc(100vw - 32px));
max-height: calc(100vh - 64px); max-height: calc(100vh - 64px);
overflow-y: auto; overflow-y: auto;
border-radius: 12px; border-radius: 12px;
@@ -2374,24 +2374,32 @@
} }
.time-range-preset-list { .time-range-preset-list {
display: grid; display: flex;
grid-template-columns: repeat(6, minmax(0, 1fr)); flex-wrap: nowrap;
gap: 8px; gap: 4px;
overflow-x: auto;
padding-bottom: 2px;
&::-webkit-scrollbar {
height: 4px;
}
} }
.time-range-preset-item { .time-range-preset-item {
flex: 0 0 auto;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 10px; border-radius: 8px;
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-primary); color: var(--text-primary);
min-height: 38px; min-height: 30px;
padding: 0 10px; padding: 0 8px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 4px;
font-size: 12px; font-size: 11px;
cursor: pointer; cursor: pointer;
white-space: nowrap;
&.active { &.active {
border-color: var(--primary); border-color: var(--primary);
@@ -2403,14 +2411,30 @@
.time-range-calendar-grid { .time-range-calendar-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 12px; gap: 8px;
}
.time-range-mode-banner {
border-radius: 8px;
padding: 6px 8px;
font-size: 11px;
line-height: 1.4;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
&.range {
border-color: rgba(var(--primary-rgb), 0.4);
background: rgba(var(--primary-rgb), 0.1);
color: var(--primary);
}
} }
.time-range-calendar-panel { .time-range-calendar-panel {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 10px; border-radius: 8px;
background: var(--bg-secondary); background: var(--bg-secondary);
padding: 10px; padding: 7px;
} }
.time-range-calendar-panel-header { .time-range-calendar-panel-header {
@@ -2430,23 +2454,40 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
strong { small {
font-size: 13px; font-size: 10px;
color: var(--text-tertiary);
}
}
.time-range-date-input {
width: 100%;
min-width: 0;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
height: 24px;
padding: 0 7px;
font-size: 11px;
&.invalid {
border-color: #e84d4d;
box-shadow: 0 0 0 1px rgba(232, 77, 77, 0.2);
} }
} }
.time-range-calendar-nav { .time-range-calendar-nav {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 4px;
font-size: 12px; font-size: 11px;
color: var(--text-primary); color: var(--text-primary);
button { button {
width: 22px; width: 20px;
height: 22px; height: 20px;
border-radius: 6px; border-radius: 5px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
@@ -2457,32 +2498,32 @@
} }
.time-range-calendar-weekdays { .time-range-calendar-weekdays {
margin-top: 8px; margin-top: 6px;
display: grid; display: grid;
grid-template-columns: repeat(7, 1fr); grid-template-columns: repeat(7, 1fr);
gap: 4px; gap: 2px;
span { span {
text-align: center; text-align: center;
font-size: 11px; font-size: 10px;
color: var(--text-tertiary); color: var(--text-tertiary);
} }
} }
.time-range-calendar-days { .time-range-calendar-days {
margin-top: 6px; margin-top: 4px;
display: grid; display: grid;
grid-template-columns: repeat(7, 1fr); grid-template-columns: repeat(7, 1fr);
gap: 4px; gap: 2px;
} }
.time-range-calendar-day { .time-range-calendar-day {
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 8px; border-radius: 6px;
min-height: 28px; min-height: 20px;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
font-size: 12px; font-size: 10px;
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
@@ -2499,16 +2540,6 @@
} }
} }
.time-range-full-hint {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.45;
border-radius: 8px;
border: 1px dashed var(--border-color);
padding: 8px 10px;
background: var(--bg-secondary);
}
.time-range-custom-row { .time-range-custom-row {
display: none; display: none;
@@ -2672,7 +2703,7 @@
} }
.time-range-preset-list { .time-range-preset-list {
grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px;
} }
.time-range-calendar-grid { .time-range-calendar-grid {

View File

@@ -499,6 +499,26 @@ const formatDateInputValue = (date: Date): string => {
return `${y}-${m}-${d}` return `${y}-${m}-${d}`
} }
const parseDateInputValue = (raw: string): Date | null => {
const text = String(raw || '').trim()
const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
if (!matched) return null
const year = Number(matched[1])
const month = Number(matched[2])
const day = Number(matched[3])
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null
if (month < 1 || month > 12 || day < 1 || day > 31) return null
const parsed = new Date(year, month - 1, day)
if (
parsed.getFullYear() !== year ||
parsed.getMonth() !== month - 1 ||
parsed.getDate() !== day
) {
return null
}
return parsed
}
const toMonthStart = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), 1) const toMonthStart = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), 1)
const addMonths = (date: Date, delta: number): Date => { const addMonths = (date: Date, delta: number): Date => {
@@ -1212,6 +1232,8 @@ function ExportPage() {
const [isTimeRangeDialogOpen, setIsTimeRangeDialogOpen] = useState(false) const [isTimeRangeDialogOpen, setIsTimeRangeDialogOpen] = useState(false)
const [timeRangePreset, setTimeRangePreset] = useState<DateRangePreset>('all') const [timeRangePreset, setTimeRangePreset] = useState<DateRangePreset>('all')
const [timeRangeDialogDraft, setTimeRangeDialogDraft] = useState<TimeRangeDialogDraft | null>(null) const [timeRangeDialogDraft, setTimeRangeDialogDraft] = useState<TimeRangeDialogDraft | null>(null)
const [timeRangeDateInput, setTimeRangeDateInput] = useState<{ start: string; end: string }>({ start: '', end: '' })
const [timeRangeDateInputError, setTimeRangeDateInputError] = useState<{ start: boolean; end: boolean }>({ start: false, end: false })
const [options, setOptions] = useState<ExportOptions>({ const [options, setOptions] = useState<ExportOptions>({
format: 'json', format: 'json',
@@ -2321,6 +2343,8 @@ function ExportPage() {
setExportDialog(prev => ({ ...prev, open: false })) setExportDialog(prev => ({ ...prev, open: false }))
setIsTimeRangeDialogOpen(false) setIsTimeRangeDialogOpen(false)
setTimeRangeDialogDraft(null) setTimeRangeDialogDraft(null)
setTimeRangeDateInput({ start: '', end: '' })
setTimeRangeDateInputError({ start: false, end: false })
}, []) }, [])
const buildTimeRangeDialogDraft = useCallback((): TimeRangeDialogDraft => { const buildTimeRangeDialogDraft = useCallback((): TimeRangeDialogDraft => {
@@ -2338,23 +2362,33 @@ function ExportPage() {
}, [options.dateRange, options.useAllTime, timeRangePreset]) }, [options.dateRange, options.useAllTime, timeRangePreset])
const openTimeRangeDialog = useCallback(() => { const openTimeRangeDialog = useCallback(() => {
setTimeRangeDialogDraft(buildTimeRangeDialogDraft()) const draft = buildTimeRangeDialogDraft()
setTimeRangeDialogDraft(draft)
setIsTimeRangeDialogOpen(true) setIsTimeRangeDialogOpen(true)
}, [buildTimeRangeDialogDraft]) }, [buildTimeRangeDialogDraft])
const closeTimeRangeDialog = useCallback(() => { const closeTimeRangeDialog = useCallback(() => {
setIsTimeRangeDialogOpen(false) setIsTimeRangeDialogOpen(false)
setTimeRangeDialogDraft(null) setTimeRangeDialogDraft(null)
setTimeRangeDateInput({ start: '', end: '' })
setTimeRangeDateInputError({ start: false, end: false })
}, []) }, [])
const applyTimeRangePresetToDraft = useCallback((preset: Exclude<DateRangePreset, 'custom'>) => { const applyTimeRangePresetToDraft = useCallback((preset: Exclude<DateRangePreset, 'custom'>) => {
setTimeRangeDialogDraft(prev => { setTimeRangeDialogDraft(prev => {
const base = prev ?? buildTimeRangeDialogDraft() const base = prev ?? buildTimeRangeDialogDraft()
if (preset === 'all') { if (preset === 'all') {
const previewRange = createDefaultDateRange()
return { return {
...base, ...base,
preset, preset,
useAllTime: true useAllTime: true,
dateRange: {
start: previewRange.start,
end: previewRange.end
},
startPanelMonth: toMonthStart(previewRange.start),
endPanelMonth: toMonthStart(previewRange.end)
} }
} }
const range = createDateRangeByPreset(preset) const range = createDateRangeByPreset(preset)
@@ -2372,6 +2406,10 @@ function ExportPage() {
}) })
}, [buildTimeRangeDialogDraft]) }, [buildTimeRangeDialogDraft])
const handleTimeRangePresetClick = useCallback((preset: Exclude<DateRangePreset, 'custom'>) => {
applyTimeRangePresetToDraft(preset)
}, [applyTimeRangePresetToDraft])
const updateTimeRangeDraftStart = useCallback((targetDate: Date) => { const updateTimeRangeDraftStart = useCallback((targetDate: Date) => {
const start = startOfDay(targetDate) const start = startOfDay(targetDate)
setTimeRangeDialogDraft(prev => { setTimeRangeDialogDraft(prev => {
@@ -2395,20 +2433,45 @@ function ExportPage() {
const end = endOfDay(targetDate) const end = endOfDay(targetDate)
setTimeRangeDialogDraft(prev => { setTimeRangeDialogDraft(prev => {
const base = prev ?? buildTimeRangeDialogDraft() const base = prev ?? buildTimeRangeDialogDraft()
const nextEnd = end < base.dateRange.start ? endOfDay(base.dateRange.start) : end const isAllTimeMode = base.useAllTime
const nextStart = isAllTimeMode
? startOfDay(targetDate)
: base.dateRange.start
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
return { return {
...base, ...base,
preset: 'custom', preset: 'custom',
useAllTime: false, useAllTime: false,
dateRange: { dateRange: {
start: base.dateRange.start, start: nextStart,
end: nextEnd end: nextEnd
}, },
startPanelMonth: toMonthStart(nextStart),
endPanelMonth: toMonthStart(nextEnd) endPanelMonth: toMonthStart(nextEnd)
} }
}) })
}, [buildTimeRangeDialogDraft]) }, [buildTimeRangeDialogDraft])
const commitTimeRangeStartFromInput = useCallback(() => {
const parsed = parseDateInputValue(timeRangeDateInput.start)
if (!parsed) {
setTimeRangeDateInputError(prev => ({ ...prev, start: true }))
return
}
setTimeRangeDateInputError(prev => ({ ...prev, start: false }))
updateTimeRangeDraftStart(parsed)
}, [timeRangeDateInput.start, updateTimeRangeDraftStart])
const commitTimeRangeEndFromInput = useCallback(() => {
const parsed = parseDateInputValue(timeRangeDateInput.end)
if (!parsed) {
setTimeRangeDateInputError(prev => ({ ...prev, end: true }))
return
}
setTimeRangeDateInputError(prev => ({ ...prev, end: false }))
updateTimeRangeDraftEnd(parsed)
}, [timeRangeDateInput.end, updateTimeRangeDraftEnd])
const shiftTimeRangePanelMonth = useCallback((panel: 'start' | 'end', delta: number) => { const shiftTimeRangePanelMonth = useCallback((panel: 'start' | 'end', delta: number) => {
setTimeRangeDialogDraft(prev => { setTimeRangeDialogDraft(prev => {
const base = prev ?? buildTimeRangeDialogDraft() const base = prev ?? buildTimeRangeDialogDraft()
@@ -2445,7 +2508,7 @@ function ExportPage() {
if (timeRangePreset === 'yesterday') return '昨天' if (timeRangePreset === 'yesterday') return '昨天'
if (timeRangePreset === 'last3days') return '最近3天' if (timeRangePreset === 'last3days') return '最近3天'
if (timeRangePreset === 'last7days') return '最近一周' if (timeRangePreset === 'last7days') return '最近一周'
if (timeRangePreset === 'last30days') return '最近一个月' if (timeRangePreset === 'last30days') return '最近30 天'
if (timeRangePreset === 'last1year') return '最近一年' if (timeRangePreset === 'last1year') return '最近一年'
if (timeRangePreset === 'last2years') return '最近两年' if (timeRangePreset === 'last2years') return '最近两年'
if (options.dateRange) { if (options.dateRange) {
@@ -2455,6 +2518,23 @@ function ExportPage() {
}, [options.useAllTime, options.dateRange, timeRangePreset]) }, [options.useAllTime, options.dateRange, timeRangePreset])
const activeTimeRangeDialogDraft = timeRangeDialogDraft ?? buildTimeRangeDialogDraft() const activeTimeRangeDialogDraft = timeRangeDialogDraft ?? buildTimeRangeDialogDraft()
const isRangeModeActive = !activeTimeRangeDialogDraft.useAllTime
const timeRangeModeText = isRangeModeActive
? '当前导出模式:按时间范围导出'
: '当前导出模式:全部时间导出(选择下方日期将切换为按时间范围导出)'
useEffect(() => {
if (!isTimeRangeDialogOpen) return
setTimeRangeDateInput({
start: formatDateInputValue(activeTimeRangeDialogDraft.dateRange.start),
end: formatDateInputValue(activeTimeRangeDialogDraft.dateRange.end)
})
setTimeRangeDateInputError({ start: false, end: false })
}, [
isTimeRangeDialogOpen,
activeTimeRangeDialogDraft.dateRange.start.getTime(),
activeTimeRangeDialogDraft.dateRange.end.getTime()
])
const isTimeRangePresetActive = useCallback((preset: DateRangePreset): boolean => { const isTimeRangePresetActive = useCallback((preset: DateRangePreset): boolean => {
if (preset === 'all') return activeTimeRangeDialogDraft.useAllTime if (preset === 'all') return activeTimeRangeDialogDraft.useAllTime
@@ -4696,9 +4776,8 @@ function ExportPage() {
{ value: 'yesterday', label: '昨天' }, { value: 'yesterday', label: '昨天' },
{ value: 'last3days', label: '最近3天' }, { value: 'last3days', label: '最近3天' },
{ value: 'last7days', label: '最近一周' }, { value: 'last7days', label: '最近一周' },
{ value: 'last30days', label: '最近一个月' }, { value: 'last30days', label: '最近30 天' },
{ value: 'last1year', label: '最近一年' }, { value: 'last1year', label: '最近一年' }
{ value: 'last2years', label: '最近两年' }
] as Array<{ value: Exclude<DateRangePreset, 'custom'>; label: string }>).map((preset) => { ] as Array<{ value: Exclude<DateRangePreset, 'custom'>; label: string }>).map((preset) => {
const isActive = isTimeRangePresetActive(preset.value) const isActive = isTimeRangePresetActive(preset.value)
return ( return (
@@ -4706,7 +4785,7 @@ function ExportPage() {
key={preset.value} key={preset.value}
type="button" type="button"
className={`time-range-preset-item ${isActive ? 'active' : ''}`} className={`time-range-preset-item ${isActive ? 'active' : ''}`}
onClick={() => applyTimeRangePresetToDraft(preset.value)} onClick={() => handleTimeRangePresetClick(preset.value)}
> >
<span>{preset.label}</span> <span>{preset.label}</span>
{isActive && <Check size={14} />} {isActive && <Check size={14} />}
@@ -4715,12 +4794,36 @@ function ExportPage() {
})} })}
</div> </div>
<div className={`time-range-mode-banner ${isRangeModeActive ? 'range' : 'all'}`}>
{timeRangeModeText}
</div>
<div className="time-range-calendar-grid"> <div className="time-range-calendar-grid">
<section className="time-range-calendar-panel"> <section className="time-range-calendar-panel">
<div className="time-range-calendar-panel-header"> <div className="time-range-calendar-panel-header">
<div className="time-range-calendar-date-label"> <div className="time-range-calendar-date-label">
<span></span> <span></span>
<strong>{formatDateInputValue(activeTimeRangeDialogDraft.dateRange.start)}</strong> <input
type="text"
className={`time-range-date-input ${timeRangeDateInputError.start ? 'invalid' : ''}`}
value={timeRangeDateInput.start}
placeholder="YYYY-MM-DD"
onChange={(event) => {
const nextValue = event.target.value
setTimeRangeDateInput(prev => ({ ...prev, start: nextValue }))
if (timeRangeDateInputError.start) {
setTimeRangeDateInputError(prev => ({ ...prev, start: false }))
}
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
commitTimeRangeStartFromInput()
}}
onBlur={() => {
commitTimeRangeStartFromInput()
}}
/>
</div> </div>
<div className="time-range-calendar-nav"> <div className="time-range-calendar-nav">
<button type="button" onClick={() => shiftTimeRangePanelMonth('start', -1)} aria-label="上个月"></button> <button type="button" onClick={() => shiftTimeRangePanelMonth('start', -1)} aria-label="上个月"></button>
@@ -4735,7 +4838,8 @@ function ExportPage() {
</div> </div>
<div className="time-range-calendar-days"> <div className="time-range-calendar-days">
{startPanelCells.map((cell) => { {startPanelCells.map((cell) => {
const isSelected = isSameDay(cell.date, activeTimeRangeDialogDraft.dateRange.start) const isSelected = !activeTimeRangeDialogDraft.useAllTime &&
isSameDay(cell.date, activeTimeRangeDialogDraft.dateRange.start)
return ( return (
<button <button
key={`start-${cell.date.getTime()}`} key={`start-${cell.date.getTime()}`}
@@ -4754,7 +4858,27 @@ function ExportPage() {
<div className="time-range-calendar-panel-header"> <div className="time-range-calendar-panel-header">
<div className="time-range-calendar-date-label"> <div className="time-range-calendar-date-label">
<span></span> <span></span>
<strong>{formatDateInputValue(activeTimeRangeDialogDraft.dateRange.end)}</strong> <input
type="text"
className={`time-range-date-input ${timeRangeDateInputError.end ? 'invalid' : ''}`}
value={timeRangeDateInput.end}
placeholder="YYYY-MM-DD"
onChange={(event) => {
const nextValue = event.target.value
setTimeRangeDateInput(prev => ({ ...prev, end: nextValue }))
if (timeRangeDateInputError.end) {
setTimeRangeDateInputError(prev => ({ ...prev, end: false }))
}
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
commitTimeRangeEndFromInput()
}}
onBlur={() => {
commitTimeRangeEndFromInput()
}}
/>
</div> </div>
<div className="time-range-calendar-nav"> <div className="time-range-calendar-nav">
<button type="button" onClick={() => shiftTimeRangePanelMonth('end', -1)} aria-label="上个月"></button> <button type="button" onClick={() => shiftTimeRangePanelMonth('end', -1)} aria-label="上个月"></button>
@@ -4769,7 +4893,8 @@ function ExportPage() {
</div> </div>
<div className="time-range-calendar-days"> <div className="time-range-calendar-days">
{endPanelCells.map((cell) => { {endPanelCells.map((cell) => {
const isSelected = isSameDay(cell.date, activeTimeRangeDialogDraft.dateRange.end) const isSelected = !activeTimeRangeDialogDraft.useAllTime &&
isSameDay(cell.date, activeTimeRangeDialogDraft.dateRange.end)
return ( return (
<button <button
key={`end-${cell.date.getTime()}`} key={`end-${cell.date.getTime()}`}
@@ -4785,12 +4910,6 @@ function ExportPage() {
</section> </section>
</div> </div>
{activeTimeRangeDialogDraft.useAllTime && (
<div className="time-range-full-hint">
</div>
)}
<div className="time-range-dialog-actions"> <div className="time-range-dialog-actions">
<button type="button" className="secondary-btn" onClick={closeTimeRangeDialog}> <button type="button" className="secondary-btn" onClick={closeTimeRangeDialog}>