diff --git a/src/components/Export/ExportDateRangeDialog.scss b/src/components/Export/ExportDateRangeDialog.scss index 9bb27c4..31565e9 100644 --- a/src/components/Export/ExportDateRangeDialog.scss +++ b/src/components/Export/ExportDateRangeDialog.scss @@ -13,13 +13,14 @@ width: min(480px, calc(100vw - 32px)); max-height: calc(100vh - 64px); overflow-y: auto; - border-radius: 12px; + border-radius: 16px; border: 1px solid var(--border-color); background: var(--bg-secondary-solid, var(--bg-primary)); - padding: 12px; + padding: 14px; display: flex; flex-direction: column; gap: 10px; + box-shadow: 0 22px 48px rgba(0, 0, 0, 0.16); } .export-date-range-dialog-header { @@ -83,8 +84,8 @@ } .export-date-range-mode-banner { - border-radius: 8px; - padding: 6px 8px; + border-radius: 10px; + padding: 7px 10px; font-size: 11px; line-height: 1.4; border: 1px solid var(--border-color); @@ -98,47 +99,92 @@ } } -.export-date-range-calendar-grid { +.export-date-range-boundary-row { display: grid; grid-template-columns: 1fr 1fr; 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 { border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--bg-secondary); - padding: 7px; + border-radius: 12px; + background: linear-gradient(180deg, rgba(var(--primary-rgb), 0.04), transparent 28%), var(--bg-secondary); + padding: 10px; + + &.single { + width: 100%; + } } .export-date-range-calendar-panel-header { display: flex; justify-content: space-between; - align-items: flex-start; + align-items: center; gap: 8px; } .export-date-range-calendar-date-label { display: flex; flex-direction: column; - gap: 2px; + gap: 3px; span { font-size: 11px; color: var(--text-secondary); } + + strong { + font-size: 13px; + color: var(--text-primary); + } } .export-date-range-date-input { width: 100%; min-width: 0; - border-radius: 6px; + border-radius: 8px; border: 1px solid var(--border-color); background: var(--bg-primary); color: var(--text-primary); - height: 24px; - padding: 0 7px; - font-size: 11px; + 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); + } &.invalid { border-color: #e84d4d; @@ -149,28 +195,31 @@ .export-date-range-calendar-nav { display: inline-flex; align-items: center; - gap: 4px; + gap: 6px; font-size: 11px; color: var(--text-primary); button { - width: 20px; - height: 20px; - border-radius: 5px; + width: 28px; + height: 28px; + border-radius: 8px; border: 1px solid var(--border-color); background: var(--bg-primary); color: var(--text-primary); cursor: pointer; padding: 0; line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; } } .export-date-range-calendar-weekdays { - margin-top: 6px; + margin-top: 10px; display: grid; grid-template-columns: repeat(7, 1fr); - gap: 2px; + gap: 4px; span { text-align: center; @@ -180,32 +229,49 @@ } .export-date-range-calendar-days { - margin-top: 4px; + margin-top: 6px; display: grid; grid-template-columns: repeat(7, 1fr); - gap: 2px; + gap: 4px; } .export-date-range-calendar-day { border: 1px solid transparent; - border-radius: 6px; - min-height: 20px; + border-radius: 10px; + min-height: 34px; background: var(--bg-primary); color: var(--text-primary); - font-size: 10px; + font-size: 12px; cursor: pointer; 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 { color: var(--text-quaternary); - opacity: 0.75; + opacity: 0.72; } - &.selected { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.14); + &.in-range { + background: rgba(var(--primary-rgb), 0.1); color: var(--primary); + } + + &.range-start, + &.range-end { + border-color: var(--primary); + background: var(--primary); + color: #fff; 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) { - .export-date-range-calendar-grid { +@media (max-width: 640px) { + .export-date-range-boundary-row { grid-template-columns: 1fr; } } diff --git a/src/components/Export/ExportDateRangeDialog.tsx b/src/components/Export/ExportDateRangeDialog.tsx index e6695f1..f450803 100644 --- a/src/components/Export/ExportDateRangeDialog.tsx +++ b/src/components/Export/ExportDateRangeDialog.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' -import { Check, X } from 'lucide-react' +import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react' import { EXPORT_DATE_RANGE_PRESETS, WEEKDAY_SHORT_LABELS, @@ -29,15 +29,15 @@ interface ExportDateRangeDialogProps { onConfirm: (value: ExportDateRangeSelection) => void } +type ActiveBoundary = 'start' | 'end' + interface ExportDateRangeDialogDraft extends ExportDateRangeSelection { - startPanelMonth: Date - endPanelMonth: Date + panelMonth: Date } const buildDialogDraft = (value: ExportDateRangeSelection): ExportDateRangeDialogDraft => ({ ...cloneExportDateRangeSelection(value), - startPanelMonth: toMonthStart(value.dateRange.start), - endPanelMonth: toMonthStart(value.dateRange.end) + panelMonth: toMonthStart(value.dateRange.start) }) export function ExportDateRangeDialog({ @@ -48,6 +48,7 @@ export function ExportDateRangeDialog({ onConfirm }: ExportDateRangeDialogProps) { const [draft, setDraft] = useState(() => buildDialogDraft(value)) + const [activeBoundary, setActiveBoundary] = useState('start') const [dateInput, setDateInput] = useState({ start: formatDateInputValue(value.dateRange.start), end: formatDateInputValue(value.dateRange.end) @@ -58,6 +59,7 @@ export function ExportDateRangeDialog({ if (!open) return const nextDraft = buildDialogDraft(value) setDraft(nextDraft) + setActiveBoundary('start') setDateInput({ start: formatDateInputValue(nextDraft.dateRange.start), end: formatDateInputValue(nextDraft.dateRange.end) @@ -74,32 +76,7 @@ export function ExportDateRangeDialog({ setDateInputError({ start: false, end: false }) }, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open]) - const applyPreset = useCallback((preset: Exclude) => { - 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 setRangeStart = useCallback((targetDate: Date) => { const start = startOfDay(targetDate) setDraft(prev => { const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end @@ -111,13 +88,12 @@ export function ExportDateRangeDialog({ start, end: nextEnd }, - startPanelMonth: toMonthStart(start), - endPanelMonth: toMonthStart(nextEnd) + panelMonth: toMonthStart(start) } }) }, []) - const updateDraftEnd = useCallback((targetDate: Date) => { + const setRangeEnd = useCallback((targetDate: Date) => { const end = endOfDay(targetDate) setDraft(prev => { const nextStart = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start @@ -130,12 +106,36 @@ export function ExportDateRangeDialog({ start: nextStart, end: nextEnd }, - startPanelMonth: toMonthStart(nextStart), - endPanelMonth: toMonthStart(nextEnd) + panelMonth: toMonthStart(targetDate) } }) }, []) + const applyPreset = useCallback((preset: Exclude) => { + 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 parsed = parseDateInputValue(dateInput.start) if (!parsed) { @@ -143,8 +143,8 @@ export function ExportDateRangeDialog({ return } setDateInputError(prev => ({ ...prev, start: false })) - updateDraftStart(parsed) - }, [dateInput.start, updateDraftStart]) + setRangeStart(parsed) + }, [dateInput.start, setRangeStart]) const commitEndFromInput = useCallback(() => { const parsed = parseDateInputValue(dateInput.end) @@ -153,29 +153,71 @@ export function ExportDateRangeDialog({ return } setDateInputError(prev => ({ ...prev, end: false })) - updateDraftEnd(parsed) - }, [dateInput.end, updateDraftEnd]) + setRangeEnd(parsed) + }, [dateInput.end, setRangeEnd]) - 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 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 startPanelCells = useMemo(() => buildCalendarCells(draft.startPanelMonth), [draft.startPanelMonth]) - const endPanelCells = useMemo(() => buildCalendarCells(draft.endPanelMonth), [draft.endPanelMonth]) + const calendarCells = useMemo(() => buildCalendarCells(draft.panelMonth), [draft.panelMonth]) + + 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 @@ -215,112 +257,112 @@ export function ExportDateRangeDialog({ {modeText} -
-
-
-
- 起始日期 - { - 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} - /> -
-
- - {formatCalendarMonthTitle(draft.startPanelMonth)} - -
-
-
- {WEEKDAY_SHORT_LABELS.map(label => ( - {label} - ))} -
-
- {startPanelCells.map((cell) => { - const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.start) - return ( - - ) - })} -
-
- -
-
-
- 截止日期 - { - 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} - /> -
-
- - {formatCalendarMonthTitle(draft.endPanelMonth)} - -
-
-
- {WEEKDAY_SHORT_LABELS.map(label => ( - {label} - ))} -
-
- {endPanelCells.map((cell) => { - const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.end) - return ( - - ) - })} -
-
+
+
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) + return ( + + ) + })} +
+
+