From 6e870ef300e8d8e781b45773ed3d9bf68e680e06 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 12:29:32 +0800 Subject: [PATCH] feat(settings): unify export date range defaults --- .../Export/ExportDateRangeDialog.scss | 254 ++++++++ .../Export/ExportDateRangeDialog.tsx | 340 +++++++++++ src/pages/ExportPage.tsx | 550 ++---------------- src/pages/SettingsPage.scss | 47 +- src/pages/SettingsPage.tsx | 76 ++- src/services/config.ts | 11 +- src/utils/exportDateRange.ts | 341 +++++++++++ 7 files changed, 1058 insertions(+), 561 deletions(-) create mode 100644 src/components/Export/ExportDateRangeDialog.scss create mode 100644 src/components/Export/ExportDateRangeDialog.tsx create mode 100644 src/utils/exportDateRange.ts diff --git a/src/components/Export/ExportDateRangeDialog.scss b/src/components/Export/ExportDateRangeDialog.scss new file mode 100644 index 0000000..fc8bd95 --- /dev/null +++ b/src/components/Export/ExportDateRangeDialog.scss @@ -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; + } +} diff --git a/src/components/Export/ExportDateRangeDialog.tsx b/src/components/Export/ExportDateRangeDialog.tsx new file mode 100644 index 0000000..e6695f1 --- /dev/null +++ b/src/components/Export/ExportDateRangeDialog.tsx @@ -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(() => 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) => { + 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( +
+
event.stopPropagation()}> +
+

{title}

+ +
+ +
+ {EXPORT_DATE_RANGE_PRESETS.map((preset) => { + const active = isPresetActive(preset.value) + return ( + + ) + })} +
+ +
+ {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 ( + + ) + })} +
+
+
+ +
+ + +
+
+
, + document.body + ) +} diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 251006f..d92ab1e 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -40,7 +40,16 @@ import { import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' import { SnsPostItem } from '../components/Sns/SnsPostItem' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' +import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' import type { SnsPost } from '../types/sns' +import { + cloneExportDateRange, + createDefaultDateRange, + createDefaultExportDateRangeSelection, + getExportDateRangeLabel, + resolveExportDateRangeConfig, + type ExportDateRangeSelection +} from '../utils/exportDateRange' import './ExportPage.scss' type ConversationTab = 'private' | 'group' | 'official' | 'former_friend' @@ -53,17 +62,6 @@ type SnsRankMode = 'likes' | 'comments' type SessionLayout = 'shared' | 'per-session' type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname' -type DateRangePreset = - | 'all' - | 'today' - | 'yesterday' - | 'last3days' - | 'last7days' - | 'last30days' - | 'last1year' - | 'last2years' - | 'custom' -type CalendarCell = { date: Date; inCurrentMonth: boolean } type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson' @@ -158,14 +156,6 @@ interface ExportDialogState { title: string } -interface TimeRangeDialogDraft { - preset: DateRangePreset - useAllTime: boolean - dateRange: { start: Date; end: Date } - startPanelMonth: Date - endPanelMonth: Date -} - const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000 const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10 @@ -463,126 +453,6 @@ const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => return formatAbsoluteDate(timestamp) } -const startOfDay = (date: Date): Date => { - const next = new Date(date) - next.setHours(0, 0, 0, 0) - return next -} - -const endOfDay = (date: Date): Date => { - const next = new Date(date) - next.setHours(23, 59, 59, 999) - return next -} - -const createDefaultDateRange = (): { start: Date; end: Date } => { - const now = new Date() - return { - start: startOfDay(now), - end: now - } -} - -const createDateRangeByPreset = ( - preset: Exclude, - now = new Date() -): { start: Date; end: Date } => { - const end = new Date(now) - const baseStart = startOfDay(now) - - if (preset === 'today') { - return { start: baseStart, end } - } - - if (preset === 'yesterday') { - const yesterday = new Date(baseStart) - yesterday.setDate(yesterday.getDate() - 1) - return { - start: yesterday, - end: endOfDay(yesterday) - } - } - - if (preset === 'last1year' || preset === 'last2years') { - const yearsBack = preset === 'last1year' ? 1 : 2 - const start = new Date(baseStart) - const expectedMonth = start.getMonth() - start.setFullYear(start.getFullYear() - yearsBack) - // Handle leap-year fallback (e.g. Feb 29 -> Feb 28). - if (start.getMonth() !== expectedMonth) { - start.setDate(0) - } - return { start, end } - } - - const daysBack = preset === 'last3days' ? 2 : preset === 'last7days' ? 6 : 29 - const start = new Date(baseStart) - start.setDate(start.getDate() - daysBack) - return { start, end } -} - -const formatDateInputValue = (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}` -} - -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 addMonths = (date: Date, delta: number): Date => { - const next = new Date(date) - next.setMonth(next.getMonth() + delta) - return toMonthStart(next) -} - -const isSameDay = (left: Date, right: Date): boolean => ( - left.getFullYear() === right.getFullYear() && - left.getMonth() === right.getMonth() && - left.getDate() === right.getDate() -) - -const buildCalendarCells = (monthStart: Date): CalendarCell[] => { - const firstDay = new Date(monthStart.getFullYear(), monthStart.getMonth(), 1) - const startOffset = firstDay.getDay() - const gridStart = new Date(firstDay) - gridStart.setDate(gridStart.getDate() - startOffset) - const cells: CalendarCell[] = [] - for (let index = 0; index < 42; index += 1) { - const current = new Date(gridStart) - current.setDate(gridStart.getDate() + index) - cells.push({ - date: current, - inCurrentMonth: current.getMonth() === monthStart.getMonth() - }) - } - return cells -} - -const formatCalendarMonthTitle = (date: Date): string => `${date.getFullYear()}年${date.getMonth() + 1}月` - -const WEEKDAY_SHORT_LABELS = ['日', '一', '二', '三', '四', '五', '六'] - const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => { if (session.username.endsWith('@chatroom')) return 'group' if (session.username.startsWith('gh_')) return 'official' @@ -1412,10 +1282,8 @@ function ExportPage() { const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false) const [snsExportVideos, setSnsExportVideos] = useState(false) const [isTimeRangeDialogOpen, setIsTimeRangeDialogOpen] = useState(false) - const [timeRangePreset, setTimeRangePreset] = useState('all') - const [timeRangeDialogDraft, setTimeRangeDialogDraft] = useState(null) - const [timeRangeDateInput, setTimeRangeDateInput] = useState<{ start: string; end: string }>({ start: '', end: '' }) - const [timeRangeDateInputError, setTimeRangeDateInputError] = useState<{ start: boolean; end: boolean }>({ start: false, end: false }) + const [timeRangeSelection, setTimeRangeSelection] = useState(() => createDefaultExportDateRangeSelection()) + const [exportDefaultDateRangeSelection, setExportDefaultDateRangeSelection] = useState(() => createDefaultExportDateRangeSelection()) const [options, setOptions] = useState({ format: 'json', @@ -1917,7 +1785,7 @@ function ExportPage() { setIsBaseConfigLoading(true) let isReady = true try { - const [savedPath, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, exportCacheScope] = await Promise.all([ + const [savedPath, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([ configService.getExportPath(), configService.getExportDefaultMedia(), configService.getExportDefaultVoiceAsText(), @@ -1930,6 +1798,7 @@ function ExportPage() { configService.getExportLastSnsPostCount(), configService.getExportWriteLayout(), configService.getExportSessionNamePrefixEnabled(), + configService.getExportDefaultDateRange(), ensureExportCacheScope() ]) @@ -1948,6 +1817,9 @@ function ExportPage() { setLastExportByContent(savedContentMap) setExportRecordsBySession(savedSessionRecordMap) setLastSnsExportPostCount(savedSnsPostCount) + const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange) + setExportDefaultDateRangeSelection(resolvedDefaultDateRange) + setTimeRangeSelection(resolvedDefaultDateRange) await configService.setExportDefaultFormat('json') if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) { @@ -3313,14 +3185,14 @@ function ExportPage() { const openExportDialog = useCallback((payload: Omit) => { setExportDialog({ open: true, ...payload }) setIsTimeRangeDialogOpen(false) - setTimeRangePreset('all') + setTimeRangeSelection(exportDefaultDateRangeSelection) setOptions(prev => { - const nextDateRange = prev.dateRange ?? createDefaultDateRange() + const nextDateRange = cloneExportDateRange(exportDefaultDateRangeSelection.dateRange) const next: ExportOptions = { ...prev, - useAllTime: true, + useAllTime: exportDefaultDateRangeSelection.useAllTime, dateRange: nextDateRange } @@ -3348,219 +3220,22 @@ function ExportPage() { return next }) - }, []) + }, [exportDefaultDateRangeSelection]) const closeExportDialog = useCallback(() => { setExportDialog(prev => ({ ...prev, open: false })) setIsTimeRangeDialogOpen(false) - setTimeRangeDialogDraft(null) - setTimeRangeDateInput({ start: '', end: '' }) - setTimeRangeDateInputError({ start: false, end: false }) }, []) - const buildTimeRangeDialogDraft = useCallback((): TimeRangeDialogDraft => { - const dateRange = options.dateRange ?? createDefaultDateRange() - return { - preset: timeRangePreset, - useAllTime: options.useAllTime, - dateRange: { - start: new Date(dateRange.start), - end: new Date(dateRange.end) - }, - startPanelMonth: toMonthStart(dateRange.start), - endPanelMonth: toMonthStart(dateRange.end) - } - }, [options.dateRange, options.useAllTime, timeRangePreset]) - const openTimeRangeDialog = useCallback(() => { - const draft = buildTimeRangeDialogDraft() - setTimeRangeDialogDraft(draft) setIsTimeRangeDialogOpen(true) - }, [buildTimeRangeDialogDraft]) + }, []) const closeTimeRangeDialog = useCallback(() => { setIsTimeRangeDialogOpen(false) - setTimeRangeDialogDraft(null) - setTimeRangeDateInput({ start: '', end: '' }) - setTimeRangeDateInputError({ start: false, end: false }) }, []) - const applyTimeRangePresetToDraft = useCallback((preset: Exclude) => { - setTimeRangeDialogDraft(prev => { - const base = prev ?? buildTimeRangeDialogDraft() - if (preset === 'all') { - const previewRange = createDefaultDateRange() - return { - ...base, - preset, - useAllTime: true, - dateRange: { - start: previewRange.start, - end: previewRange.end - }, - startPanelMonth: toMonthStart(previewRange.start), - endPanelMonth: toMonthStart(previewRange.end) - } - } - const range = createDateRangeByPreset(preset) - return { - ...base, - preset, - useAllTime: false, - dateRange: { - start: range.start, - end: range.end - }, - startPanelMonth: toMonthStart(range.start), - endPanelMonth: toMonthStart(range.end) - } - }) - }, [buildTimeRangeDialogDraft]) - - const handleTimeRangePresetClick = useCallback((preset: Exclude) => { - applyTimeRangePresetToDraft(preset) - }, [applyTimeRangePresetToDraft]) - - const updateTimeRangeDraftStart = useCallback((targetDate: Date) => { - const start = startOfDay(targetDate) - setTimeRangeDialogDraft(prev => { - const base = prev ?? buildTimeRangeDialogDraft() - const nextEnd = base.dateRange.end < start ? endOfDay(start) : base.dateRange.end - return { - ...base, - preset: 'custom', - useAllTime: false, - dateRange: { - start, - end: nextEnd - }, - startPanelMonth: toMonthStart(start), - endPanelMonth: toMonthStart(nextEnd) - } - }) - }, [buildTimeRangeDialogDraft]) - - const updateTimeRangeDraftEnd = useCallback((targetDate: Date) => { - const end = endOfDay(targetDate) - setTimeRangeDialogDraft(prev => { - const base = prev ?? buildTimeRangeDialogDraft() - const isAllTimeMode = base.useAllTime - const nextStart = isAllTimeMode - ? startOfDay(targetDate) - : base.dateRange.start - const nextEnd = end < nextStart ? endOfDay(nextStart) : end - return { - ...base, - preset: 'custom', - useAllTime: false, - dateRange: { - start: nextStart, - end: nextEnd - }, - startPanelMonth: toMonthStart(nextStart), - endPanelMonth: toMonthStart(nextEnd) - } - }) - }, [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) => { - setTimeRangeDialogDraft(prev => { - const base = prev ?? buildTimeRangeDialogDraft() - if (panel === 'start') { - return { - ...base, - startPanelMonth: addMonths(base.startPanelMonth, delta) - } - } - return { - ...base, - endPanelMonth: addMonths(base.endPanelMonth, delta) - } - }) - }, [buildTimeRangeDialogDraft]) - - const commitTimeRangeDialogDraft = useCallback(() => { - const draft = timeRangeDialogDraft ?? buildTimeRangeDialogDraft() - setTimeRangePreset(draft.preset) - setOptions(prev => ({ - ...prev, - useAllTime: draft.useAllTime, - dateRange: { - start: new Date(draft.dateRange.start), - end: new Date(draft.dateRange.end) - } - })) - closeTimeRangeDialog() - }, [buildTimeRangeDialogDraft, closeTimeRangeDialog, timeRangeDialogDraft]) - - const timeRangeSummaryLabel = useMemo(() => { - if (options.useAllTime) return '默认导出全部时间' - if (timeRangePreset === 'today') return '今天' - if (timeRangePreset === 'yesterday') return '昨天' - if (timeRangePreset === 'last3days') return '最近3天' - if (timeRangePreset === 'last7days') return '最近一周' - if (timeRangePreset === 'last30days') return '最近30 天' - if (timeRangePreset === 'last1year') return '最近一年' - if (timeRangePreset === 'last2years') return '最近两年' - if (options.dateRange) { - return `${formatDateInputValue(options.dateRange.start)} 至 ${formatDateInputValue(options.dateRange.end)}` - } - return '自定义时间范围' - }, [options.useAllTime, options.dateRange, timeRangePreset]) - - 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 => { - if (preset === 'all') return activeTimeRangeDialogDraft.useAllTime - return !activeTimeRangeDialogDraft.useAllTime && activeTimeRangeDialogDraft.preset === preset - }, [activeTimeRangeDialogDraft]) - - const startPanelCells = useMemo( - () => buildCalendarCells(activeTimeRangeDialogDraft.startPanelMonth), - [activeTimeRangeDialogDraft.startPanelMonth] - ) - - const endPanelCells = useMemo( - () => buildCalendarCells(activeTimeRangeDialogDraft.endPanelMonth), - [activeTimeRangeDialogDraft.endPanelMonth] - ) + const timeRangeSummaryLabel = useMemo(() => getExportDateRangeLabel(timeRangeSelection), [timeRangeSelection]) useEffect(() => { const unsubscribe = onOpenSingleExport((payload) => { @@ -6377,173 +6052,20 @@ function ExportPage() { - {isTimeRangeDialogOpen && ( -
-
event.stopPropagation()}> -
-

时间范围设置

- -
- -
- {([ - { value: 'all', label: '全部时间' }, - { value: 'today', label: '今天' }, - { value: 'yesterday', label: '昨天' }, - { value: 'last3days', label: '最近3天' }, - { value: 'last7days', label: '最近一周' }, - { value: 'last30days', label: '最近30 天' }, - { value: 'last1year', label: '最近一年' } - ] as Array<{ value: Exclude; label: string }>).map((preset) => { - const isActive = isTimeRangePresetActive(preset.value) - return ( - - ) - })} -
- -
- {timeRangeModeText} -
- -
-
-
-
- 起始日期 - { - 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() - }} - /> -
-
- - {formatCalendarMonthTitle(activeTimeRangeDialogDraft.startPanelMonth)} - -
-
-
- {WEEKDAY_SHORT_LABELS.map(label => ( - {label} - ))} -
-
- {startPanelCells.map((cell) => { - const isSelected = !activeTimeRangeDialogDraft.useAllTime && - isSameDay(cell.date, activeTimeRangeDialogDraft.dateRange.start) - return ( - - ) - })} -
-
- -
-
-
- 截止日期 - { - 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() - }} - /> -
-
- - {formatCalendarMonthTitle(activeTimeRangeDialogDraft.endPanelMonth)} - -
-
-
- {WEEKDAY_SHORT_LABELS.map(label => ( - {label} - ))} -
-
- {endPanelCells.map((cell) => { - const isSelected = !activeTimeRangeDialogDraft.useAllTime && - isSameDay(cell.date, activeTimeRangeDialogDraft.dateRange.end) - return ( - - ) - })} -
-
-
- -
- - -
-
-
- )} + { + setTimeRangeSelection(nextSelection) + setOptions(prev => ({ + ...prev, + useAllTime: nextSelection.useAllTime, + dateRange: cloneExportDateRange(nextSelection.dateRange) + })) + closeTimeRangeDialog() + }} + /> , document.body diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index a27d74d..8dc2525 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -348,6 +348,51 @@ margin-bottom: 10px; } + .settings-time-range-field { + margin-bottom: 10px; + } + + .settings-time-range-trigger { + width: 100%; + padding: 10px 16px; + border: 1px solid var(--border-color); + border-radius: 9999px; + font-size: 14px; + background: var(--bg-primary); + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: rgba(var(--primary-rgb), 0.45); + color: var(--primary); + } + + &.open { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); + } + } + + .settings-time-range-value { + flex: 1; + min-width: 0; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .settings-time-range-arrow { + color: var(--text-tertiary); + font-weight: 700; + line-height: 1; + } + .select-trigger { width: 100%; padding: 10px 16px; @@ -2239,4 +2284,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 6161d23..59ddc09 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -12,6 +12,14 @@ import { ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2 } from 'lucide-react' import { Avatar } from '../components/Avatar' +import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' +import { + createDefaultExportDateRangeSelection, + getExportDateRangeLabel, + resolveExportDateRangeConfig, + serializeExportDateRangeConfig, + type ExportDateRangeSelection +} from '../utils/exportDateRange' import './SettingsPage.scss' type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'export' | 'cache' | 'api' | 'security' | 'about' | 'analytics' @@ -74,11 +82,9 @@ function SettingsPage() { const [wxidOptions, setWxidOptions] = useState([]) const [showWxidSelect, setShowWxidSelect] = useState(false) const [showExportFormatSelect, setShowExportFormatSelect] = useState(false) - const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false) const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) const [showExportConcurrencySelect, setShowExportConcurrencySelect] = useState(false) const exportFormatDropdownRef = useRef(null) - const exportDateRangeDropdownRef = useRef(null) const exportExcelColumnsDropdownRef = useRef(null) const exportConcurrencyDropdownRef = useRef(null) const [cachePath, setCachePath] = useState('') @@ -104,7 +110,8 @@ function SettingsPage() { const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false) const [transcribeLanguages, setTranscribeLanguages] = useState(['zh']) const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') - const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today') + const [exportDefaultDateRange, setExportDefaultDateRange] = useState(() => createDefaultExportDateRangeSelection()) + const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false) const [exportDefaultMedia, setExportDefaultMedia] = useState(false) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) @@ -209,9 +216,6 @@ function SettingsPage() { if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) { setShowExportFormatSelect(false) } - if (showExportDateRangeSelect && exportDateRangeDropdownRef.current && !exportDateRangeDropdownRef.current.contains(target)) { - setShowExportDateRangeSelect(false) - } if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) { setShowExportExcelColumnsSelect(false) } @@ -221,7 +225,7 @@ function SettingsPage() { } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect]) + }, [showExportFormatSelect, showExportExcelColumnsSelect, showExportConcurrencySelect]) useEffect(() => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => { @@ -331,7 +335,7 @@ function SettingsPage() { setAutoTranscribeVoice(savedAutoTranscribe) setTranscribeLanguages(savedTranscribeLanguages) setExportDefaultFormat(savedExportDefaultFormat || 'excel') - setExportDefaultDateRange(savedExportDefaultDateRange || 'today') + setExportDefaultDateRange(resolveExportDateRangeConfig(savedExportDefaultDateRange)) setExportDefaultMedia(savedExportDefaultMedia ?? false) setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? false) setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true) @@ -1557,13 +1561,6 @@ function SettingsPage() { { value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' }, { value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' } ] - const exportDateRangeOptions = [ - { value: 'today', label: '今天' }, - { value: '7d', label: '最近7天' }, - { value: '30d', label: '最近30天' }, - { value: '90d', label: '最近90天' }, - { value: 'all', label: '全部时间' } - ] const exportExcelColumnOptions = [ { value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' }, { value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' } @@ -1585,7 +1582,7 @@ function SettingsPage() { const renderExportTab = () => { const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full' const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat) - const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange) + const exportDateRangeLabel = getExportDateRangeLabel(exportDefaultDateRange) const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue) const exportConcurrencyLabel = String(exportDefaultConcurrency) @@ -1600,7 +1597,7 @@ function SettingsPage() { className={`select-trigger ${showExportFormatSelect ? 'open' : ''}`} onClick={() => { setShowExportFormatSelect(!showExportFormatSelect) - setShowExportDateRangeSelect(false) + setIsExportDateRangeDialogOpen(false) setShowExportExcelColumnsSelect(false) setShowExportConcurrencySelect(false) }} @@ -1634,42 +1631,35 @@ function SettingsPage() {
控制导出页面的默认时间选择 -
+
- {showExportDateRangeSelect && ( -
- {exportDateRangeOptions.map((option) => ( - - ))} -
- )}
+ setIsExportDateRangeDialogOpen(false)} + onConfirm={async (nextSelection) => { + setExportDefaultDateRange(nextSelection) + await configService.setExportDefaultDateRange(serializeExportDateRangeConfig(nextSelection)) + showMessage('已更新默认导出时间范围', true) + setIsExportDateRangeDialogOpen(false) + }} + /> +
控制图片/语音/表情的默认导出开关 @@ -1726,7 +1716,7 @@ function SettingsPage() { onClick={() => { setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect) setShowExportFormatSelect(false) - setShowExportDateRangeSelect(false) + setIsExportDateRangeDialogOpen(false) setShowExportConcurrencySelect(false) }} > @@ -1767,7 +1757,7 @@ function SettingsPage() { onClick={() => { setShowExportConcurrencySelect(!showExportConcurrencySelect) setShowExportFormatSelect(false) - setShowExportDateRangeSelect(false) + setIsExportDateRangeDialogOpen(false) setShowExportExcelColumnsSelect(false) }} > diff --git a/src/services/config.ts b/src/services/config.ts index 0f7a58c..f3ab875 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -1,5 +1,6 @@ // 配置服务 - 封装 Electron Store import { config } from './ipc' +import type { ExportDefaultDateRangeConfig } from '../utils/exportDateRange' // 配置键名 export const CONFIG_KEYS = { @@ -335,13 +336,17 @@ export async function setExportDefaultFormat(format: string): Promise { } // 获取导出默认时间范围 -export async function getExportDefaultDateRange(): Promise { +export async function getExportDefaultDateRange(): Promise { const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE) - return (value as string) || null + if (typeof value === 'string') return value + if (value && typeof value === 'object') { + return value as ExportDefaultDateRangeConfig + } + return null } // 设置导出默认时间范围 -export async function setExportDefaultDateRange(range: string): Promise { +export async function setExportDefaultDateRange(range: ExportDefaultDateRangeConfig | string): Promise { await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range) } diff --git a/src/utils/exportDateRange.ts b/src/utils/exportDateRange.ts new file mode 100644 index 0000000..e1f2def --- /dev/null +++ b/src/utils/exportDateRange.ts @@ -0,0 +1,341 @@ +export type ExportDateRangePreset = + | 'all' + | 'today' + | 'yesterday' + | 'last3days' + | 'last7days' + | 'last30days' + | 'last1year' + | 'last2years' + | 'custom' + +export type CalendarCell = { date: Date; inCurrentMonth: boolean } + +export interface ExportDateRange { + start: Date + end: Date +} + +export interface ExportDateRangeSelection { + preset: ExportDateRangePreset + useAllTime: boolean + dateRange: ExportDateRange +} + +export interface ExportDefaultDateRangeConfig { + version?: 1 + preset?: ExportDateRangePreset | string + useAllTime?: boolean + start?: string | number | Date | null + end?: string | number | Date | null +} + +export const EXPORT_DATE_RANGE_PRESETS: Array<{ + value: Exclude + label: string +}> = [ + { value: 'all', label: '全部时间' }, + { value: 'today', label: '今天' }, + { value: 'yesterday', label: '昨天' }, + { value: 'last3days', label: '最近3天' }, + { value: 'last7days', label: '最近一周' }, + { value: 'last30days', label: '最近30天' }, + { value: 'last1year', label: '最近一年' } +] + +const PRESET_LABELS: Record, string> = { + all: '全部时间', + today: '今天', + yesterday: '昨天', + last3days: '最近3天', + last7days: '最近一周', + last30days: '最近30天', + last1year: '最近一年', + last2years: '最近两年' +} + +const LEGACY_PRESET_MAP: Record | 'legacy90days'> = { + all: 'all', + today: 'today', + yesterday: 'yesterday', + last3days: 'last3days', + last7days: 'last7days', + last30days: 'last30days', + last1year: 'last1year', + last2years: 'last2years', + '7d': 'last7days', + '30d': 'last30days', + '90d': 'legacy90days' +} + +export const WEEKDAY_SHORT_LABELS = ['日', '一', '二', '三', '四', '五', '六'] + +export const startOfDay = (date: Date): Date => { + const next = new Date(date) + next.setHours(0, 0, 0, 0) + return next +} + +export const endOfDay = (date: Date): Date => { + const next = new Date(date) + next.setHours(23, 59, 59, 999) + return next +} + +export const createDefaultDateRange = (): ExportDateRange => { + const now = new Date() + return { + start: startOfDay(now), + end: now + } +} + +export const createDateRangeByPreset = ( + preset: Exclude, + now = new Date() +): ExportDateRange => { + const end = new Date(now) + const baseStart = startOfDay(now) + + if (preset === 'today') { + return { start: baseStart, end } + } + + if (preset === 'yesterday') { + const yesterday = new Date(baseStart) + yesterday.setDate(yesterday.getDate() - 1) + return { + start: yesterday, + end: endOfDay(yesterday) + } + } + + if (preset === 'last1year' || preset === 'last2years') { + const yearsBack = preset === 'last1year' ? 1 : 2 + const start = new Date(baseStart) + const expectedMonth = start.getMonth() + start.setFullYear(start.getFullYear() - yearsBack) + if (start.getMonth() !== expectedMonth) { + start.setDate(0) + } + return { start, end } + } + + const daysBack = preset === 'last3days' ? 2 : preset === 'last7days' ? 6 : 29 + const start = new Date(baseStart) + start.setDate(start.getDate() - daysBack) + return { start, end } +} + +export const createDateRangeByLastNDays = (days: number, now = new Date()): ExportDateRange => { + const end = new Date(now) + const start = startOfDay(now) + start.setDate(start.getDate() - Math.max(0, days - 1)) + return { start, end } +} + +export const formatDateInputValue = (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}` +} + +export 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 +} + +export const toMonthStart = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), 1) + +export const addMonths = (date: Date, delta: number): Date => { + const next = new Date(date) + next.setMonth(next.getMonth() + delta) + return toMonthStart(next) +} + +export const isSameDay = (left: Date, right: Date): boolean => ( + left.getFullYear() === right.getFullYear() && + left.getMonth() === right.getMonth() && + left.getDate() === right.getDate() +) + +export const buildCalendarCells = (monthStart: Date): CalendarCell[] => { + const firstDay = new Date(monthStart.getFullYear(), monthStart.getMonth(), 1) + const startOffset = firstDay.getDay() + const gridStart = new Date(firstDay) + gridStart.setDate(gridStart.getDate() - startOffset) + const cells: CalendarCell[] = [] + for (let index = 0; index < 42; index += 1) { + const current = new Date(gridStart) + current.setDate(gridStart.getDate() + index) + cells.push({ + date: current, + inCurrentMonth: current.getMonth() === monthStart.getMonth() + }) + } + return cells +} + +export const formatCalendarMonthTitle = (date: Date): string => `${date.getFullYear()}年${date.getMonth() + 1}月` + +export const cloneExportDateRange = (range: ExportDateRange): ExportDateRange => ({ + start: new Date(range.start), + end: new Date(range.end) +}) + +export const cloneExportDateRangeSelection = (selection: ExportDateRangeSelection): ExportDateRangeSelection => ({ + preset: selection.preset, + useAllTime: selection.useAllTime, + dateRange: cloneExportDateRange(selection.dateRange) +}) + +export const createExportDateRangeSelectionFromPreset = ( + preset: Exclude, + now = new Date() +): ExportDateRangeSelection => { + if (preset === 'all') { + return { + preset, + useAllTime: true, + dateRange: createDefaultDateRange() + } + } + + return { + preset, + useAllTime: false, + dateRange: createDateRangeByPreset(preset, now) + } +} + +export const createDefaultExportDateRangeSelection = (): ExportDateRangeSelection => ( + createExportDateRangeSelectionFromPreset('today') +) + +const parseStoredDate = (value: unknown): Date | null => { + if (value instanceof Date && !Number.isNaN(value.getTime())) { + return new Date(value) + } + if (typeof value === 'number' && Number.isFinite(value)) { + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? null : parsed + } + if (typeof value === 'string') { + const normalized = parseDateInputValue(value) + if (normalized) return normalized + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? null : parsed + } + return null +} + +const normalizePreset = (raw: unknown): Exclude | 'legacy90days' | null => { + if (typeof raw !== 'string') return null + const normalized = LEGACY_PRESET_MAP[raw] + return normalized ?? null +} + +export const resolveExportDateRangeConfig = ( + raw: ExportDefaultDateRangeConfig | string | null | undefined, + now = new Date() +): ExportDateRangeSelection => { + if (!raw) { + return createDefaultExportDateRangeSelection() + } + + if (typeof raw === 'string') { + const preset = normalizePreset(raw) + if (!preset) return createDefaultExportDateRangeSelection() + if (preset === 'legacy90days') { + return { + preset: 'custom', + useAllTime: false, + dateRange: createDateRangeByLastNDays(90, now) + } + } + return createExportDateRangeSelectionFromPreset(preset, now) + } + + const preset = normalizePreset(raw.preset) + if (raw.useAllTime || preset === 'all') { + return createExportDateRangeSelectionFromPreset('all', now) + } + if (preset && preset !== 'legacy90days') { + return createExportDateRangeSelectionFromPreset(preset, now) + } + + if (preset === 'legacy90days') { + return { + preset: 'custom', + useAllTime: false, + dateRange: createDateRangeByLastNDays(90, now) + } + } + + const parsedStart = parseStoredDate(raw.start) + const parsedEnd = parseStoredDate(raw.end) + if (parsedStart && parsedEnd) { + const start = startOfDay(parsedStart) + const end = endOfDay(parsedEnd) + return { + preset: 'custom', + useAllTime: false, + dateRange: { + start, + end: end < start ? endOfDay(start) : end + } + } + } + + return createDefaultExportDateRangeSelection() +} + +export const serializeExportDateRangeConfig = ( + selection: ExportDateRangeSelection +): ExportDefaultDateRangeConfig => { + if (selection.useAllTime) { + return { + version: 1, + preset: 'all', + useAllTime: true + } + } + + if (selection.preset === 'custom') { + return { + version: 1, + preset: 'custom', + useAllTime: false, + start: formatDateInputValue(selection.dateRange.start), + end: formatDateInputValue(selection.dateRange.end) + } + } + + return { + version: 1, + preset: selection.preset, + useAllTime: false + } +} + +export const getExportDateRangeLabel = (selection: ExportDateRangeSelection): string => { + if (selection.useAllTime) return PRESET_LABELS.all + if (selection.preset !== 'custom') return PRESET_LABELS[selection.preset] + return `${formatDateInputValue(selection.dateRange.start)} 至 ${formatDateInputValue(selection.dateRange.end)}` +}