From 01f774db54741b8c2a3d730907f8cdbe5bd25ca9 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Thu, 5 Mar 2026 11:36:56 +0800 Subject: [PATCH] feat(export): revamp time range dialog with dual calendars --- src/pages/ExportPage.scss | 119 ++++++++++++- src/pages/ExportPage.tsx | 350 +++++++++++++++++++++++++++----------- 2 files changed, 366 insertions(+), 103 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 3db772c..5428b95 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2349,7 +2349,7 @@ } .time-range-dialog { - width: min(520px, calc(100vw - 32px)); + width: min(920px, calc(100vw - 32px)); max-height: calc(100vh - 64px); overflow-y: auto; border-radius: 12px; @@ -2375,7 +2375,7 @@ .time-range-preset-list { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; } @@ -2400,10 +2400,117 @@ } } -.time-range-custom-row { +.time-range-calendar-grid { display: grid; grid-template-columns: 1fr 1fr; - gap: 10px; + gap: 12px; +} + +.time-range-calendar-panel { + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-secondary); + padding: 10px; +} + +.time-range-calendar-panel-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; +} + +.time-range-calendar-date-label { + display: flex; + flex-direction: column; + gap: 2px; + + span { + font-size: 11px; + color: var(--text-secondary); + } + + strong { + font-size: 13px; + color: var(--text-primary); + } +} + +.time-range-calendar-nav { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-primary); + + button { + width: 22px; + height: 22px; + border-radius: 6px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; + padding: 0; + line-height: 1; + } +} + +.time-range-calendar-weekdays { + margin-top: 8px; + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + + span { + text-align: center; + font-size: 11px; + color: var(--text-tertiary); + } +} + +.time-range-calendar-days { + margin-top: 6px; + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; +} + +.time-range-calendar-day { + border: 1px solid transparent; + border-radius: 8px; + min-height: 28px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 12px; + 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; + } +} + +.time-range-full-hint { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.45; + border-radius: 8px; + border: 1px dashed var(--border-color); + padding: 8px 10px; + background: var(--bg-secondary); +} + +.time-range-custom-row { + display: none; label { display: flex; @@ -2565,10 +2672,10 @@ } .time-range-preset-list { - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); } - .time-range-custom-row { + .time-range-calendar-grid { grid-template-columns: 1fr; } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 8f42aaf..5b9838b 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -50,6 +50,7 @@ type SessionLayout = 'shared' | 'per-session' type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname' type DateRangePreset = 'all' | 'today' | 'yesterday' | 'last3days' | 'last7days' | 'last30days' | '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' @@ -144,6 +145,14 @@ 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 contentTypeLabels: Record = { text: '聊天文本', @@ -469,17 +478,41 @@ const formatDateInputValue = (date: Date): string => { return `${y}-${m}-${d}` } -const parseDateInput = (value: string, endOfDay: boolean): Date => { - const [year, month, day] = value.split('-').map(v => Number(v)) - const date = new Date(year, month - 1, day) - if (endOfDay) { - date.setHours(23, 59, 59, 999) - } else { - date.setHours(0, 0, 0, 0) - } - return date +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' @@ -1157,6 +1190,7 @@ function ExportPage() { const [snsExportVideos, setSnsExportVideos] = useState(false) const [isTimeRangeDialogOpen, setIsTimeRangeDialogOpen] = useState(false) const [timeRangePreset, setTimeRangePreset] = useState('all') + const [timeRangeDialogDraft, setTimeRangeDialogDraft] = useState(null) const [options, setOptions] = useState({ format: 'json', @@ -2265,70 +2299,130 @@ function ExportPage() { const closeExportDialog = useCallback(() => { setExportDialog(prev => ({ ...prev, open: false })) setIsTimeRangeDialogOpen(false) + setTimeRangeDialogDraft(null) }, []) - const applyTimeRangePreset = useCallback((preset: Exclude) => { - setTimeRangePreset(preset) - if (preset === 'all') { - setOptions(prev => ({ - ...prev, - useAllTime: true, - dateRange: prev.dateRange ?? createDefaultDateRange() - })) - return + 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) } - const range = createDateRangeByPreset(preset) - setOptions(prev => ({ - ...prev, - useAllTime: false, - dateRange: range - })) + }, [options.dateRange, options.useAllTime, timeRangePreset]) + + const openTimeRangeDialog = useCallback(() => { + setTimeRangeDialogDraft(buildTimeRangeDialogDraft()) + setIsTimeRangeDialogOpen(true) + }, [buildTimeRangeDialogDraft]) + + const closeTimeRangeDialog = useCallback(() => { + setIsTimeRangeDialogOpen(false) + setTimeRangeDialogDraft(null) }, []) - const activateCustomTimeRange = useCallback(() => { - setTimeRangePreset('custom') - setOptions(prev => ({ - ...prev, - useAllTime: false, - dateRange: prev.dateRange ?? createDefaultDateRange() - })) - }, []) + const applyTimeRangePresetToDraft = useCallback((preset: Exclude) => { + setTimeRangeDialogDraft(prev => { + const base = prev ?? buildTimeRangeDialogDraft() + if (preset === 'all') { + return { + ...base, + preset, + useAllTime: true + } + } + 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 updateCustomDateRangeStart = useCallback((value: string) => { - const start = parseDateInput(value, false) - setTimeRangePreset('custom') - setOptions(prev => ({ - ...prev, - useAllTime: false, - dateRange: prev.dateRange - ? { - start, - end: prev.dateRange.end < start ? parseDateInput(value, true) : prev.dateRange.end - } - : { start, end: new Date() } - })) - }, []) + 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 updateCustomDateRangeEnd = useCallback((value: string) => { - const end = parseDateInput(value, true) - setTimeRangePreset('custom') + const updateTimeRangeDraftEnd = useCallback((targetDate: Date) => { + const end = endOfDay(targetDate) + setTimeRangeDialogDraft(prev => { + const base = prev ?? buildTimeRangeDialogDraft() + const nextEnd = end < base.dateRange.start ? endOfDay(base.dateRange.start) : end + return { + ...base, + preset: 'custom', + useAllTime: false, + dateRange: { + start: base.dateRange.start, + end: nextEnd + }, + endPanelMonth: toMonthStart(nextEnd) + } + }) + }, [buildTimeRangeDialogDraft]) + + 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: false, - dateRange: prev.dateRange - ? { - start: prev.dateRange.start > end ? parseDateInput(value, false) : prev.dateRange.start, - end - } - : { start: new Date(), end } + 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 '最近三天' + if (timeRangePreset === 'last3days') return '最近3天' if (timeRangePreset === 'last7days') return '最近一周' if (timeRangePreset === 'last30days') return '最近一个月' if (options.dateRange) { @@ -2337,10 +2431,22 @@ function ExportPage() { return '自定义时间范围' }, [options.useAllTime, options.dateRange, timeRangePreset]) + const activeTimeRangeDialogDraft = timeRangeDialogDraft ?? buildTimeRangeDialogDraft() + const isTimeRangePresetActive = useCallback((preset: DateRangePreset): boolean => { - if (preset === 'all') return options.useAllTime - return !options.useAllTime && timeRangePreset === preset - }, [options.useAllTime, timeRangePreset]) + 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] + ) useEffect(() => { const unsubscribe = onOpenSingleExport((payload) => { @@ -4478,7 +4584,7 @@ function ExportPage() { + {formatCalendarMonthTitle(activeTimeRangeDialogDraft.startPanelMonth)} + + + +
+ {WEEKDAY_SHORT_LABELS.map(label => ( + {label} + ))} +
+
+ {startPanelCells.map((cell) => { + const isSelected = isSameDay(cell.date, activeTimeRangeDialogDraft.dateRange.start) + return ( + + ) + })} +
+ + +
+
+
+ 截止日期 + {formatDateInputValue(activeTimeRangeDialogDraft.dateRange.end)} +
+
+ + {formatCalendarMonthTitle(activeTimeRangeDialogDraft.endPanelMonth)} + +
+
+
+ {WEEKDAY_SHORT_LABELS.map(label => ( + {label} + ))} +
+
+ {endPanelCells.map((cell) => { + const isSelected = isSameDay(cell.date, activeTimeRangeDialogDraft.dateRange.end) + return ( + + ) + })} +
+
+ + + {activeTimeRangeDialogDraft.useAllTime && ( +
+ 已选择“全部时间”,确认后会按默认导出全部时间;下方日期仅用于切换为自定义范围时预览。
)}
- +