feat(settings): unify export date range defaults

This commit is contained in:
aits2026
2026-03-06 12:29:32 +08:00
parent cf45ae30ac
commit 6e870ef300
7 changed files with 1058 additions and 561 deletions

View File

@@ -0,0 +1,254 @@
.export-date-range-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 1015;
}
.export-date-range-dialog {
width: min(480px, calc(100vw - 32px));
max-height: calc(100vh - 64px);
overflow-y: auto;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--bg-secondary-solid, var(--bg-primary));
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.export-date-range-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
h4 {
margin: 0;
font-size: 14px;
color: var(--text-primary);
}
}
.export-date-range-dialog-close-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 8px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-secondary);
}
.export-date-range-preset-list {
display: flex;
flex-wrap: nowrap;
gap: 4px;
overflow-x: auto;
padding-bottom: 2px;
&::-webkit-scrollbar {
height: 4px;
}
}
.export-date-range-preset-item {
flex: 0 0 auto;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-primary);
min-height: 30px;
padding: 0 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
font-size: 11px;
cursor: pointer;
white-space: nowrap;
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
color: var(--primary);
}
}
.export-date-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);
}
}
.export-date-range-calendar-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.export-date-range-calendar-panel {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
padding: 7px;
}
.export-date-range-calendar-panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.export-date-range-calendar-date-label {
display: flex;
flex-direction: column;
gap: 2px;
span {
font-size: 11px;
color: var(--text-secondary);
}
}
.export-date-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);
height: 24px;
padding: 0 7px;
font-size: 11px;
&.invalid {
border-color: #e84d4d;
box-shadow: 0 0 0 1px rgba(232, 77, 77, 0.2);
}
}
.export-date-range-calendar-nav {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-primary);
button {
width: 20px;
height: 20px;
border-radius: 5px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
padding: 0;
line-height: 1;
}
}
.export-date-range-calendar-weekdays {
margin-top: 6px;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
span {
text-align: center;
font-size: 10px;
color: var(--text-tertiary);
}
}
.export-date-range-calendar-days {
margin-top: 4px;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.export-date-range-calendar-day {
border: 1px solid transparent;
border-radius: 6px;
min-height: 20px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 10px;
cursor: pointer;
padding: 0;
&.outside {
color: var(--text-quaternary);
opacity: 0.75;
}
&.selected {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.14);
color: var(--primary);
font-weight: 600;
}
}
.export-date-range-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.export-date-range-dialog-btn {
border-radius: 8px;
padding: 7px 12px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--border-color);
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
&.primary {
border-color: var(--primary);
background: var(--primary);
color: #fff;
&:hover {
background: var(--primary-hover);
}
}
&.secondary {
background: var(--bg-secondary);
color: var(--text-primary);
&:hover {
border-color: var(--primary);
color: var(--primary);
}
}
}
@media (max-width: 860px) {
.export-date-range-calendar-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,340 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, 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
onClose: () => void
onConfirm: (value: ExportDateRangeSelection) => void
}
interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
startPanelMonth: Date
endPanelMonth: Date
}
const buildDialogDraft = (value: ExportDateRangeSelection): ExportDateRangeDialogDraft => ({
...cloneExportDateRangeSelection(value),
startPanelMonth: toMonthStart(value.dateRange.start),
endPanelMonth: toMonthStart(value.dateRange.end)
})
export function ExportDateRangeDialog({
open,
value,
title = '时间范围设置',
onClose,
onConfirm
}: ExportDateRangeDialogProps) {
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value))
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)
setDraft(nextDraft)
setDateInput({
start: formatDateInputValue(nextDraft.dateRange.start),
end: formatDateInputValue(nextDraft.dateRange.end)
})
setDateInputError({ start: false, end: false })
}, [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 applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => {
if (preset === 'all') {
const previewRange = createDefaultDateRange()
setDraft(prev => ({
...prev,
preset,
useAllTime: true,
dateRange: previewRange,
startPanelMonth: toMonthStart(previewRange.start),
endPanelMonth: toMonthStart(previewRange.end)
}))
return
}
const range = createDateRangeByPreset(preset)
setDraft(prev => ({
...prev,
preset,
useAllTime: false,
dateRange: range,
startPanelMonth: toMonthStart(range.start),
endPanelMonth: toMonthStart(range.end)
}))
}, [])
const updateDraftStart = useCallback((targetDate: Date) => {
const start = startOfDay(targetDate)
setDraft(prev => {
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
return {
...prev,
preset: 'custom',
useAllTime: false,
dateRange: {
start,
end: nextEnd
},
startPanelMonth: toMonthStart(start),
endPanelMonth: toMonthStart(nextEnd)
}
})
}, [])
const updateDraftEnd = useCallback((targetDate: Date) => {
const end = endOfDay(targetDate)
setDraft(prev => {
const nextStart = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
return {
...prev,
preset: 'custom',
useAllTime: false,
dateRange: {
start: nextStart,
end: nextEnd
},
startPanelMonth: toMonthStart(nextStart),
endPanelMonth: toMonthStart(nextEnd)
}
})
}, [])
const commitStartFromInput = useCallback(() => {
const parsed = parseDateInputValue(dateInput.start)
if (!parsed) {
setDateInputError(prev => ({ ...prev, start: true }))
return
}
setDateInputError(prev => ({ ...prev, start: false }))
updateDraftStart(parsed)
}, [dateInput.start, updateDraftStart])
const commitEndFromInput = useCallback(() => {
const parsed = parseDateInputValue(dateInput.end)
if (!parsed) {
setDateInputError(prev => ({ ...prev, end: true }))
return
}
setDateInputError(prev => ({ ...prev, end: false }))
updateDraftEnd(parsed)
}, [dateInput.end, updateDraftEnd])
const shiftPanelMonth = useCallback((panel: 'start' | 'end', delta: number) => {
setDraft(prev => (
panel === 'start'
? { ...prev, startPanelMonth: addMonths(prev.startPanelMonth, delta) }
: { ...prev, endPanelMonth: addMonths(prev.endPanelMonth, delta) }
))
}, [])
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 startPanelCells = useMemo(() => buildCalendarCells(draft.startPanelMonth), [draft.startPanelMonth])
const endPanelCells = useMemo(() => buildCalendarCells(draft.endPanelMonth), [draft.endPanelMonth])
if (!open) return null
return createPortal(
<div className="export-date-range-dialog-overlay" onClick={onClose}>
<div className="export-date-range-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
<div className="export-date-range-dialog-header">
<h4>{title}</h4>
<button
type="button"
className="export-date-range-dialog-close-btn"
onClick={onClose}
aria-label="关闭时间范围设置"
>
<X size={14} />
</button>
</div>
<div className="export-date-range-preset-list">
{EXPORT_DATE_RANGE_PRESETS.map((preset) => {
const active = isPresetActive(preset.value)
return (
<button
key={preset.value}
type="button"
className={`export-date-range-preset-item ${active ? 'active' : ''}`}
onClick={() => applyPreset(preset.value)}
>
<span>{preset.label}</span>
{active && <Check size={14} />}
</button>
)
})}
</div>
<div className={`export-date-range-mode-banner ${isRangeModeActive ? 'range' : 'all'}`}>
{modeText}
</div>
<div className="export-date-range-calendar-grid">
<section className="export-date-range-calendar-panel">
<div className="export-date-range-calendar-panel-header">
<div className="export-date-range-calendar-date-label">
<span></span>
<input
type="text"
className={`export-date-range-date-input ${dateInputError.start ? 'invalid' : ''}`}
value={dateInput.start}
placeholder="YYYY-MM-DD"
onChange={(event) => {
const nextValue = event.target.value
setDateInput(prev => ({ ...prev, start: nextValue }))
if (dateInputError.start) {
setDateInputError(prev => ({ ...prev, start: false }))
}
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
commitStartFromInput()
}}
onBlur={commitStartFromInput}
/>
</div>
<div className="export-date-range-calendar-nav">
<button type="button" onClick={() => shiftPanelMonth('start', -1)} aria-label="上个月"></button>
<span>{formatCalendarMonthTitle(draft.startPanelMonth)}</span>
<button type="button" onClick={() => shiftPanelMonth('start', 1)} aria-label="下个月"></button>
</div>
</div>
<div className="export-date-range-calendar-weekdays">
{WEEKDAY_SHORT_LABELS.map(label => (
<span key={`start-weekday-${label}`}>{label}</span>
))}
</div>
<div className="export-date-range-calendar-days">
{startPanelCells.map((cell) => {
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.start)
return (
<button
key={`start-${cell.date.getTime()}`}
type="button"
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
onClick={() => updateDraftStart(cell.date)}
>
{cell.date.getDate()}
</button>
)
})}
</div>
</section>
<section className="export-date-range-calendar-panel">
<div className="export-date-range-calendar-panel-header">
<div className="export-date-range-calendar-date-label">
<span></span>
<input
type="text"
className={`export-date-range-date-input ${dateInputError.end ? 'invalid' : ''}`}
value={dateInput.end}
placeholder="YYYY-MM-DD"
onChange={(event) => {
const nextValue = event.target.value
setDateInput(prev => ({ ...prev, end: nextValue }))
if (dateInputError.end) {
setDateInputError(prev => ({ ...prev, end: false }))
}
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
commitEndFromInput()
}}
onBlur={commitEndFromInput}
/>
</div>
<div className="export-date-range-calendar-nav">
<button type="button" onClick={() => shiftPanelMonth('end', -1)} aria-label="上个月"></button>
<span>{formatCalendarMonthTitle(draft.endPanelMonth)}</span>
<button type="button" onClick={() => shiftPanelMonth('end', 1)} aria-label="下个月"></button>
</div>
</div>
<div className="export-date-range-calendar-weekdays">
{WEEKDAY_SHORT_LABELS.map(label => (
<span key={`end-weekday-${label}`}>{label}</span>
))}
</div>
<div className="export-date-range-calendar-days">
{endPanelCells.map((cell) => {
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.end)
return (
<button
key={`end-${cell.date.getTime()}`}
type="button"
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
onClick={() => updateDraftEnd(cell.date)}
>
{cell.date.getDate()}
</button>
)
})}
</div>
</section>
</div>
<div className="export-date-range-dialog-actions">
<button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}>
</button>
<button
type="button"
className="export-date-range-dialog-btn primary"
onClick={() => onConfirm(cloneExportDateRangeSelection(draft))}
>
</button>
</div>
</div>
</div>,
document.body
)
}