导出时 日历只有一个

This commit is contained in:
xuncha
2026-03-20 15:12:13 +08:00
parent 3fabf961e5
commit a163ea377c
2 changed files with 292 additions and 184 deletions

View File

@@ -13,13 +13,14 @@
width: min(480px, 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: 16px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
background: var(--bg-secondary-solid, var(--bg-primary)); background: var(--bg-secondary-solid, var(--bg-primary));
padding: 12px; padding: 14px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.16);
} }
.export-date-range-dialog-header { .export-date-range-dialog-header {
@@ -83,8 +84,8 @@
} }
.export-date-range-mode-banner { .export-date-range-mode-banner {
border-radius: 8px; border-radius: 10px;
padding: 6px 8px; padding: 7px 10px;
font-size: 11px; font-size: 11px;
line-height: 1.4; line-height: 1.4;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -98,47 +99,92 @@
} }
} }
.export-date-range-calendar-grid { .export-date-range-boundary-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 8px; gap: 8px;
} }
.export-date-range-boundary-card {
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
padding: 8px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
}
.boundary-label {
font-size: 11px;
color: var(--text-secondary);
}
}
.export-date-range-selection-hint {
font-size: 11px;
color: var(--text-secondary);
padding: 0 2px;
}
.export-date-range-calendar-panel { .export-date-range-calendar-panel {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 12px;
background: var(--bg-secondary); background: linear-gradient(180deg, rgba(var(--primary-rgb), 0.04), transparent 28%), var(--bg-secondary);
padding: 7px; padding: 10px;
&.single {
width: 100%;
}
} }
.export-date-range-calendar-panel-header { .export-date-range-calendar-panel-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: center;
gap: 8px; gap: 8px;
} }
.export-date-range-calendar-date-label { .export-date-range-calendar-date-label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 3px;
span { span {
font-size: 11px; font-size: 11px;
color: var(--text-secondary); color: var(--text-secondary);
} }
strong {
font-size: 13px;
color: var(--text-primary);
}
} }
.export-date-range-date-input { .export-date-range-date-input {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
border-radius: 6px; border-radius: 8px;
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);
height: 24px; height: 30px;
padding: 0 7px; padding: 0 9px;
font-size: 11px; font-size: 12px;
&:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
}
&.invalid { &.invalid {
border-color: #e84d4d; border-color: #e84d4d;
@@ -149,28 +195,31 @@
.export-date-range-calendar-nav { .export-date-range-calendar-nav {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 6px;
font-size: 11px; font-size: 11px;
color: var(--text-primary); color: var(--text-primary);
button { button {
width: 20px; width: 28px;
height: 20px; height: 28px;
border-radius: 5px; border-radius: 8px;
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);
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
line-height: 1; line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
} }
} }
.export-date-range-calendar-weekdays { .export-date-range-calendar-weekdays {
margin-top: 6px; margin-top: 10px;
display: grid; display: grid;
grid-template-columns: repeat(7, 1fr); grid-template-columns: repeat(7, 1fr);
gap: 2px; gap: 4px;
span { span {
text-align: center; text-align: center;
@@ -180,32 +229,49 @@
} }
.export-date-range-calendar-days { .export-date-range-calendar-days {
margin-top: 4px; margin-top: 6px;
display: grid; display: grid;
grid-template-columns: repeat(7, 1fr); grid-template-columns: repeat(7, 1fr);
gap: 2px; gap: 4px;
} }
.export-date-range-calendar-day { .export-date-range-calendar-day {
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 6px; border-radius: 10px;
min-height: 20px; min-height: 34px;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
font-size: 10px; font-size: 12px;
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease, transform 0.15s ease;
&:hover {
border-color: rgba(var(--primary-rgb), 0.28);
transform: translateY(-1px);
}
&.outside { &.outside {
color: var(--text-quaternary); color: var(--text-quaternary);
opacity: 0.75; opacity: 0.72;
} }
&.selected { &.in-range {
border-color: var(--primary); background: rgba(var(--primary-rgb), 0.1);
background: rgba(var(--primary-rgb), 0.14);
color: var(--primary); color: var(--primary);
}
&.range-start,
&.range-end {
border-color: var(--primary);
background: var(--primary);
color: #fff;
font-weight: 600; font-weight: 600;
opacity: 1;
}
&.active-boundary {
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.22);
} }
} }
@@ -247,8 +313,8 @@
} }
} }
@media (max-width: 860px) { @media (max-width: 640px) {
.export-date-range-calendar-grid { .export-date-range-boundary-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Check, X } from 'lucide-react' import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react'
import { import {
EXPORT_DATE_RANGE_PRESETS, EXPORT_DATE_RANGE_PRESETS,
WEEKDAY_SHORT_LABELS, WEEKDAY_SHORT_LABELS,
@@ -29,15 +29,15 @@ interface ExportDateRangeDialogProps {
onConfirm: (value: ExportDateRangeSelection) => void onConfirm: (value: ExportDateRangeSelection) => void
} }
type ActiveBoundary = 'start' | 'end'
interface ExportDateRangeDialogDraft extends ExportDateRangeSelection { interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
startPanelMonth: Date panelMonth: Date
endPanelMonth: Date
} }
const buildDialogDraft = (value: ExportDateRangeSelection): ExportDateRangeDialogDraft => ({ const buildDialogDraft = (value: ExportDateRangeSelection): ExportDateRangeDialogDraft => ({
...cloneExportDateRangeSelection(value), ...cloneExportDateRangeSelection(value),
startPanelMonth: toMonthStart(value.dateRange.start), panelMonth: toMonthStart(value.dateRange.start)
endPanelMonth: toMonthStart(value.dateRange.end)
}) })
export function ExportDateRangeDialog({ export function ExportDateRangeDialog({
@@ -48,6 +48,7 @@ export function ExportDateRangeDialog({
onConfirm onConfirm
}: ExportDateRangeDialogProps) { }: ExportDateRangeDialogProps) {
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value)) const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value))
const [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start')
const [dateInput, setDateInput] = useState({ const [dateInput, setDateInput] = useState({
start: formatDateInputValue(value.dateRange.start), start: formatDateInputValue(value.dateRange.start),
end: formatDateInputValue(value.dateRange.end) end: formatDateInputValue(value.dateRange.end)
@@ -58,6 +59,7 @@ export function ExportDateRangeDialog({
if (!open) return if (!open) return
const nextDraft = buildDialogDraft(value) const nextDraft = buildDialogDraft(value)
setDraft(nextDraft) setDraft(nextDraft)
setActiveBoundary('start')
setDateInput({ setDateInput({
start: formatDateInputValue(nextDraft.dateRange.start), start: formatDateInputValue(nextDraft.dateRange.start),
end: formatDateInputValue(nextDraft.dateRange.end) end: formatDateInputValue(nextDraft.dateRange.end)
@@ -74,32 +76,7 @@ export function ExportDateRangeDialog({
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 applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => { const setRangeStart = useCallback((targetDate: Date) => {
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) const start = startOfDay(targetDate)
setDraft(prev => { setDraft(prev => {
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
@@ -111,13 +88,12 @@ export function ExportDateRangeDialog({
start, start,
end: nextEnd end: nextEnd
}, },
startPanelMonth: toMonthStart(start), panelMonth: toMonthStart(start)
endPanelMonth: toMonthStart(nextEnd)
} }
}) })
}, []) }, [])
const updateDraftEnd = useCallback((targetDate: Date) => { const setRangeEnd = useCallback((targetDate: Date) => {
const end = endOfDay(targetDate) const end = endOfDay(targetDate)
setDraft(prev => { setDraft(prev => {
const nextStart = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start const nextStart = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
@@ -130,12 +106,36 @@ export function ExportDateRangeDialog({
start: nextStart, start: nextStart,
end: nextEnd end: nextEnd
}, },
startPanelMonth: toMonthStart(nextStart), panelMonth: toMonthStart(targetDate)
endPanelMonth: toMonthStart(nextEnd)
} }
}) })
}, []) }, [])
const applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => {
if (preset === 'all') {
const previewRange = createDefaultDateRange()
setDraft(prev => ({
...prev,
preset,
useAllTime: true,
dateRange: previewRange,
panelMonth: toMonthStart(previewRange.start)
}))
setActiveBoundary('start')
return
}
const range = createDateRangeByPreset(preset)
setDraft(prev => ({
...prev,
preset,
useAllTime: false,
dateRange: range,
panelMonth: toMonthStart(range.start)
}))
setActiveBoundary('start')
}, [])
const commitStartFromInput = useCallback(() => { const commitStartFromInput = useCallback(() => {
const parsed = parseDateInputValue(dateInput.start) const parsed = parseDateInputValue(dateInput.start)
if (!parsed) { if (!parsed) {
@@ -143,8 +143,8 @@ export function ExportDateRangeDialog({
return return
} }
setDateInputError(prev => ({ ...prev, start: false })) setDateInputError(prev => ({ ...prev, start: false }))
updateDraftStart(parsed) setRangeStart(parsed)
}, [dateInput.start, updateDraftStart]) }, [dateInput.start, setRangeStart])
const commitEndFromInput = useCallback(() => { const commitEndFromInput = useCallback(() => {
const parsed = parseDateInputValue(dateInput.end) const parsed = parseDateInputValue(dateInput.end)
@@ -153,29 +153,71 @@ export function ExportDateRangeDialog({
return return
} }
setDateInputError(prev => ({ ...prev, end: false })) setDateInputError(prev => ({ ...prev, end: false }))
updateDraftEnd(parsed) setRangeEnd(parsed)
}, [dateInput.end, updateDraftEnd]) }, [dateInput.end, setRangeEnd])
const shiftPanelMonth = useCallback((panel: 'start' | 'end', delta: number) => { const shiftPanelMonth = useCallback((delta: number) => {
setDraft(prev => ( setDraft(prev => ({
panel === 'start' ...prev,
? { ...prev, startPanelMonth: addMonths(prev.startPanelMonth, delta) } panelMonth: addMonths(prev.panelMonth, delta)
: { ...prev, endPanelMonth: addMonths(prev.endPanelMonth, 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 isRangeModeActive = !draft.useAllTime
const modeText = isRangeModeActive const modeText = isRangeModeActive
? '当前导出模式:按时间范围导出' ? '当前导出模式:按时间范围导出'
: '当前导出模式:全部时间导出选择下方日期切换为时间范围导出)' : '当前导出模式:全部时间导出选择下方日期切换为自定义时间范围'
const isPresetActive = useCallback((preset: ExportDateRangePreset): boolean => { const isPresetActive = useCallback((preset: ExportDateRangePreset): boolean => {
if (preset === 'all') return draft.useAllTime if (preset === 'all') return draft.useAllTime
return !draft.useAllTime && draft.preset === preset return !draft.useAllTime && draft.preset === preset
}, [draft]) }, [draft])
const startPanelCells = useMemo(() => buildCalendarCells(draft.startPanelMonth), [draft.startPanelMonth]) const calendarCells = useMemo(() => buildCalendarCells(draft.panelMonth), [draft.panelMonth])
const endPanelCells = useMemo(() => buildCalendarCells(draft.endPanelMonth), [draft.endPanelMonth])
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 hintText = draft.useAllTime
? '选择开始或结束日期后,会自动切换为自定义时间范围'
: (activeBoundary === 'start' ? '下一次点击将设置开始日期' : '下一次点击将设置结束日期')
if (!open) return null if (!open) return null
@@ -215,11 +257,12 @@ export function ExportDateRangeDialog({
{modeText} {modeText}
</div> </div>
<div className="export-date-range-calendar-grid"> <div className="export-date-range-boundary-row">
<section className="export-date-range-calendar-panel"> <div
<div className="export-date-range-calendar-panel-header"> className={`export-date-range-boundary-card ${activeBoundary === 'start' ? 'active' : ''}`}
<div className="export-date-range-calendar-date-label"> onClick={() => setActiveBoundary('start')}
<span></span> >
<span className="boundary-label"></span>
<input <input
type="text" type="text"
className={`export-date-range-date-input ${dateInputError.start ? 'invalid' : ''}`} className={`export-date-range-date-input ${dateInputError.start ? 'invalid' : ''}`}
@@ -232,6 +275,8 @@ export function ExportDateRangeDialog({
setDateInputError(prev => ({ ...prev, start: false })) setDateInputError(prev => ({ ...prev, start: false }))
} }
}} }}
onFocus={() => setActiveBoundary('start')}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key !== 'Enter') return if (event.key !== 'Enter') return
event.preventDefault() event.preventDefault()
@@ -240,38 +285,11 @@ export function ExportDateRangeDialog({
onBlur={commitStartFromInput} onBlur={commitStartFromInput}
/> />
</div> </div>
<div className="export-date-range-calendar-nav"> <div
<button type="button" onClick={() => shiftPanelMonth('start', -1)} aria-label="上个月"></button> className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`}
<span>{formatCalendarMonthTitle(draft.startPanelMonth)}</span> onClick={() => setActiveBoundary('end')}
<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()} <span className="boundary-label"></span>
</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 <input
type="text" type="text"
className={`export-date-range-date-input ${dateInputError.end ? 'invalid' : ''}`} className={`export-date-range-date-input ${dateInputError.end ? 'invalid' : ''}`}
@@ -284,6 +302,8 @@ export function ExportDateRangeDialog({
setDateInputError(prev => ({ ...prev, end: false })) setDateInputError(prev => ({ ...prev, end: false }))
} }
}} }}
onFocus={() => setActiveBoundary('end')}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key !== 'Enter') return if (event.key !== 'Enter') return
event.preventDefault() event.preventDefault()
@@ -292,26 +312,49 @@ export function ExportDateRangeDialog({
onBlur={commitEndFromInput} onBlur={commitEndFromInput}
/> />
</div> </div>
</div>
<div className="export-date-range-selection-hint">{hintText}</div>
<section className="export-date-range-calendar-panel single">
<div className="export-date-range-calendar-panel-header">
<div className="export-date-range-calendar-date-label">
<span></span>
<strong>{formatCalendarMonthTitle(draft.panelMonth)}</strong>
</div>
<div className="export-date-range-calendar-nav"> <div className="export-date-range-calendar-nav">
<button type="button" onClick={() => shiftPanelMonth('end', -1)} aria-label="上个月"></button> <button type="button" onClick={() => shiftPanelMonth(-1)} aria-label="上个月">
<span>{formatCalendarMonthTitle(draft.endPanelMonth)}</span> <ChevronLeft size={14} />
<button type="button" onClick={() => shiftPanelMonth('end', 1)} aria-label="下个月"></button> </button>
<button type="button" onClick={() => shiftPanelMonth(1)} aria-label="下个月">
<ChevronRight size={14} />
</button>
</div> </div>
</div> </div>
<div className="export-date-range-calendar-weekdays"> <div className="export-date-range-calendar-weekdays">
{WEEKDAY_SHORT_LABELS.map(label => ( {WEEKDAY_SHORT_LABELS.map(label => (
<span key={`end-weekday-${label}`}>{label}</span> <span key={`weekday-${label}`}>{label}</span>
))} ))}
</div> </div>
<div className="export-date-range-calendar-days"> <div className="export-date-range-calendar-days">
{endPanelCells.map((cell) => { {calendarCells.map((cell) => {
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.end) const startSelected = isStartSelected(cell.date)
const endSelected = isEndSelected(cell.date)
const inRange = isDateInRange(cell.date)
return ( return (
<button <button
key={`end-${cell.date.getTime()}`} key={cell.date.getTime()}
type="button" type="button"
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`} className={[
onClick={() => updateDraftEnd(cell.date)} 'export-date-range-calendar-day',
cell.inCurrentMonth ? '' : 'outside',
inRange ? 'in-range' : '',
startSelected ? 'range-start' : '',
endSelected ? 'range-end' : '',
activeBoundary === 'start' && startSelected ? 'active-boundary' : '',
activeBoundary === 'end' && endSelected ? 'active-boundary' : ''
].filter(Boolean).join(' ')}
onClick={() => handleCalendarSelect(cell.date)}
> >
{cell.date.getDate()} {cell.date.getDate()}
</button> </button>
@@ -319,7 +362,6 @@ export function ExportDateRangeDialog({
})} })}
</div> </div>
</section> </section>
</div>
<div className="export-date-range-dialog-actions"> <div className="export-date-range-dialog-actions">
<button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}> <button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}>