优化选择

This commit is contained in:
xuncha
2026-03-20 15:19:10 +08:00
parent a163ea377c
commit 7760358c02
4 changed files with 305 additions and 25 deletions

View File

@@ -212,6 +212,11 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
&:disabled {
cursor: not-allowed;
opacity: 0.45;
}
} }
} }
@@ -251,11 +256,23 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
&:disabled:hover {
border-color: transparent;
transform: none;
}
&.outside { &.outside {
color: var(--text-quaternary); color: var(--text-quaternary);
opacity: 0.72; opacity: 0.72;
} }
&.disabled {
cursor: not-allowed;
opacity: 0.35;
transform: none;
border-color: transparent;
}
&.in-range { &.in-range {
background: rgba(var(--primary-rgb), 0.1); background: rgba(var(--primary-rgb), 0.1);
color: var(--primary); color: var(--primary);

View File

@@ -25,6 +25,8 @@ interface ExportDateRangeDialogProps {
open: boolean open: boolean
value: ExportDateRangeSelection value: ExportDateRangeSelection
title?: string title?: string
minDate?: Date | null
maxDate?: Date | null
onClose: () => void onClose: () => void
onConfirm: (value: ExportDateRangeSelection) => void onConfirm: (value: ExportDateRangeSelection) => void
} }
@@ -35,19 +37,65 @@ interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
panelMonth: Date panelMonth: Date
} }
const buildDialogDraft = (value: ExportDateRangeSelection): ExportDateRangeDialogDraft => ({ const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => {
...cloneExportDateRangeSelection(value), if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null
panelMonth: toMonthStart(value.dateRange.start) if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null
}) const normalizedMin = startOfDay(minDate)
const normalizedMax = endOfDay(maxDate)
if (normalizedMin.getTime() > normalizedMax.getTime()) return null
return {
minDate: normalizedMin,
maxDate: normalizedMax
}
}
const clampSelectionToBounds = (
value: ExportDateRangeSelection,
minDate?: Date | null,
maxDate?: Date | null
): ExportDateRangeSelection => {
const bounds = resolveBounds(minDate, maxDate)
if (!bounds) return cloneExportDateRangeSelection(value)
const rawStart = value.useAllTime ? bounds.minDate : startOfDay(value.dateRange.start)
const rawEnd = value.useAllTime ? bounds.maxDate : endOfDay(value.dateRange.end)
const nextStart = new Date(Math.min(Math.max(rawStart.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
const nextEndCandidate = new Date(Math.min(Math.max(rawEnd.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate
const changed = nextStart.getTime() !== rawStart.getTime() || nextEnd.getTime() !== rawEnd.getTime()
return {
preset: value.useAllTime ? value.preset : (changed ? 'custom' : value.preset),
useAllTime: value.useAllTime,
dateRange: {
start: nextStart,
end: nextEnd
}
}
}
const buildDialogDraft = (
value: ExportDateRangeSelection,
minDate?: Date | null,
maxDate?: Date | null
): ExportDateRangeDialogDraft => {
const nextValue = clampSelectionToBounds(value, minDate, maxDate)
return {
...nextValue,
panelMonth: toMonthStart(nextValue.dateRange.start)
}
}
export function ExportDateRangeDialog({ export function ExportDateRangeDialog({
open, open,
value, value,
title = '时间范围设置', title = '时间范围设置',
minDate,
maxDate,
onClose, onClose,
onConfirm onConfirm
}: ExportDateRangeDialogProps) { }: ExportDateRangeDialogProps) {
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value)) const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value, minDate, maxDate))
const [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start') const [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start')
const [dateInput, setDateInput] = useState({ const [dateInput, setDateInput] = useState({
start: formatDateInputValue(value.dateRange.start), start: formatDateInputValue(value.dateRange.start),
@@ -57,7 +105,7 @@ export function ExportDateRangeDialog({
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const nextDraft = buildDialogDraft(value) const nextDraft = buildDialogDraft(value, minDate, maxDate)
setDraft(nextDraft) setDraft(nextDraft)
setActiveBoundary('start') setActiveBoundary('start')
setDateInput({ setDateInput({
@@ -65,7 +113,7 @@ export function ExportDateRangeDialog({
end: formatDateInputValue(nextDraft.dateRange.end) end: formatDateInputValue(nextDraft.dateRange.end)
}) })
setDateInputError({ start: false, end: false }) setDateInputError({ start: false, end: false })
}, [open, value]) }, [maxDate, minDate, open, value])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@@ -76,8 +124,24 @@ 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 setRangeStart = useCallback((targetDate: Date) => { const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate])
const clampStartDate = useCallback((targetDate: Date) => {
const start = startOfDay(targetDate) const start = startOfDay(targetDate)
if (!bounds) return start
if (start.getTime() < bounds.minDate.getTime()) return bounds.minDate
if (start.getTime() > bounds.maxDate.getTime()) return startOfDay(bounds.maxDate)
return start
}, [bounds])
const clampEndDate = useCallback((targetDate: Date) => {
const end = endOfDay(targetDate)
if (!bounds) return end
if (end.getTime() < bounds.minDate.getTime()) return endOfDay(bounds.minDate)
if (end.getTime() > bounds.maxDate.getTime()) return bounds.maxDate
return end
}, [bounds])
const setRangeStart = useCallback((targetDate: Date) => {
const start = clampStartDate(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
return { return {
@@ -91,12 +155,12 @@ export function ExportDateRangeDialog({
panelMonth: toMonthStart(start) panelMonth: toMonthStart(start)
} }
}) })
}, []) }, [clampStartDate])
const setRangeEnd = useCallback((targetDate: Date) => { const setRangeEnd = useCallback((targetDate: Date) => {
const end = endOfDay(targetDate) const end = clampEndDate(targetDate)
setDraft(prev => { setDraft(prev => {
const nextStart = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start
const nextEnd = end < nextStart ? endOfDay(nextStart) : end const nextEnd = end < nextStart ? endOfDay(nextStart) : end
return { return {
...prev, ...prev,
@@ -109,11 +173,13 @@ export function ExportDateRangeDialog({
panelMonth: toMonthStart(targetDate) panelMonth: toMonthStart(targetDate)
} }
}) })
}, []) }, [clampEndDate, clampStartDate])
const applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => { const applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => {
if (preset === 'all') { if (preset === 'all') {
const previewRange = createDefaultDateRange() const previewRange = bounds
? { start: bounds.minDate, end: bounds.maxDate }
: createDefaultDateRange()
setDraft(prev => ({ setDraft(prev => ({
...prev, ...prev,
preset, preset,
@@ -125,7 +191,11 @@ export function ExportDateRangeDialog({
return return
} }
const range = createDateRangeByPreset(preset) const range = clampSelectionToBounds({
preset,
useAllTime: false,
dateRange: createDateRangeByPreset(preset)
}, minDate, maxDate).dateRange
setDraft(prev => ({ setDraft(prev => ({
...prev, ...prev,
preset, preset,
@@ -134,7 +204,7 @@ export function ExportDateRangeDialog({
panelMonth: toMonthStart(range.start) panelMonth: toMonthStart(range.start)
})) }))
setActiveBoundary('start') setActiveBoundary('start')
}, []) }, [bounds, maxDate, minDate])
const commitStartFromInput = useCallback(() => { const commitStartFromInput = useCallback(() => {
const parsed = parseDateInputValue(dateInput.start) const parsed = parseDateInputValue(dateInput.start)
@@ -200,6 +270,10 @@ export function ExportDateRangeDialog({
}, [draft]) }, [draft])
const calendarCells = useMemo(() => buildCalendarCells(draft.panelMonth), [draft.panelMonth]) const calendarCells = useMemo(() => buildCalendarCells(draft.panelMonth), [draft.panelMonth])
const minPanelMonth = bounds ? toMonthStart(bounds.minDate) : null
const maxPanelMonth = bounds ? toMonthStart(bounds.maxDate) : null
const canShiftPrev = !minPanelMonth || draft.panelMonth.getTime() > minPanelMonth.getTime()
const canShiftNext = !maxPanelMonth || draft.panelMonth.getTime() < maxPanelMonth.getTime()
const isStartSelected = useCallback((date: Date) => ( const isStartSelected = useCallback((date: Date) => (
!draft.useAllTime && isSameDay(date, draft.dateRange.start) !draft.useAllTime && isSameDay(date, draft.dateRange.start)
@@ -215,6 +289,12 @@ export function ExportDateRangeDialog({
startOfDay(date).getTime() <= startOfDay(draft.dateRange.end).getTime() startOfDay(date).getTime() <= startOfDay(draft.dateRange.end).getTime()
), [draft]) ), [draft])
const isDateSelectable = useCallback((date: Date) => {
if (!bounds) return true
const target = startOfDay(date).getTime()
return target >= startOfDay(bounds.minDate).getTime() && target <= startOfDay(bounds.maxDate).getTime()
}, [bounds])
const hintText = draft.useAllTime const hintText = draft.useAllTime
? '选择开始或结束日期后,会自动切换为自定义时间范围' ? '选择开始或结束日期后,会自动切换为自定义时间范围'
: (activeBoundary === 'start' ? '下一次点击将设置开始日期' : '下一次点击将设置结束日期') : (activeBoundary === 'start' ? '下一次点击将设置开始日期' : '下一次点击将设置结束日期')
@@ -323,10 +403,10 @@ export function ExportDateRangeDialog({
<strong>{formatCalendarMonthTitle(draft.panelMonth)}</strong> <strong>{formatCalendarMonthTitle(draft.panelMonth)}</strong>
</div> </div>
<div className="export-date-range-calendar-nav"> <div className="export-date-range-calendar-nav">
<button type="button" onClick={() => shiftPanelMonth(-1)} aria-label="上个月"> <button type="button" onClick={() => shiftPanelMonth(-1)} aria-label="上个月" disabled={!canShiftPrev}>
<ChevronLeft size={14} /> <ChevronLeft size={14} />
</button> </button>
<button type="button" onClick={() => shiftPanelMonth(1)} aria-label="下个月"> <button type="button" onClick={() => shiftPanelMonth(1)} aria-label="下个月" disabled={!canShiftNext}>
<ChevronRight size={14} /> <ChevronRight size={14} />
</button> </button>
</div> </div>
@@ -341,13 +421,16 @@ export function ExportDateRangeDialog({
const startSelected = isStartSelected(cell.date) const startSelected = isStartSelected(cell.date)
const endSelected = isEndSelected(cell.date) const endSelected = isEndSelected(cell.date)
const inRange = isDateInRange(cell.date) const inRange = isDateInRange(cell.date)
const selectable = isDateSelectable(cell.date)
return ( return (
<button <button
key={cell.date.getTime()} key={cell.date.getTime()}
type="button" type="button"
disabled={!selectable}
className={[ className={[
'export-date-range-calendar-day', 'export-date-range-calendar-day',
cell.inCurrentMonth ? '' : 'outside', cell.inCurrentMonth ? '' : 'outside',
selectable ? '' : 'disabled',
inRange ? 'in-range' : '', inRange ? 'in-range' : '',
startSelected ? 'range-start' : '', startSelected ? 'range-start' : '',
endSelected ? 'range-end' : '', endSelected ? 'range-end' : '',

View File

@@ -52,10 +52,13 @@ import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '..
import type { SnsPost } from '../types/sns' import type { SnsPost } from '../types/sns'
import { import {
cloneExportDateRange, cloneExportDateRange,
cloneExportDateRangeSelection,
createDefaultDateRange, createDefaultDateRange,
createDefaultExportDateRangeSelection, createDefaultExportDateRangeSelection,
getExportDateRangeLabel, getExportDateRangeLabel,
resolveExportDateRangeConfig, resolveExportDateRangeConfig,
startOfDay,
endOfDay,
type ExportDateRangeSelection type ExportDateRangeSelection
} from '../utils/exportDateRange' } from '../utils/exportDateRange'
import './ExportPage.scss' import './ExportPage.scss'
@@ -830,6 +833,13 @@ interface SessionContentMetric {
transferMessages?: number transferMessages?: number
redPacketMessages?: number redPacketMessages?: number
callMessages?: number callMessages?: number
firstTimestamp?: number
lastTimestamp?: number
}
interface TimeRangeBounds {
minDate: Date
maxDate: Date
} }
interface SessionExportCacheMeta { interface SessionExportCacheMeta {
@@ -1049,27 +1059,74 @@ const normalizeMessageCount = (value: unknown): number | undefined => {
return Math.floor(parsed) return Math.floor(parsed)
} }
const normalizeTimestampSeconds = (value: unknown): number | undefined => {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed <= 0) return undefined
return Math.floor(parsed)
}
const clampExportSelectionToBounds = (
selection: ExportDateRangeSelection,
bounds: TimeRangeBounds | null
): ExportDateRangeSelection => {
if (!bounds) return cloneExportDateRangeSelection(selection)
const boundedStart = startOfDay(bounds.minDate)
const boundedEnd = endOfDay(bounds.maxDate)
const originalStart = selection.useAllTime ? boundedStart : startOfDay(selection.dateRange.start)
const originalEnd = selection.useAllTime ? boundedEnd : endOfDay(selection.dateRange.end)
const nextStart = new Date(Math.min(Math.max(originalStart.getTime(), boundedStart.getTime()), boundedEnd.getTime()))
const nextEndCandidate = new Date(Math.min(Math.max(originalEnd.getTime(), boundedStart.getTime()), boundedEnd.getTime()))
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate
const rangeChanged = nextStart.getTime() !== originalStart.getTime() || nextEnd.getTime() !== originalEnd.getTime()
return {
preset: selection.useAllTime ? selection.preset : (rangeChanged ? 'custom' : selection.preset),
useAllTime: selection.useAllTime,
dateRange: {
start: nextStart,
end: nextEnd
}
}
}
const areExportSelectionsEqual = (left: ExportDateRangeSelection, right: ExportDateRangeSelection): boolean => (
left.preset === right.preset &&
left.useAllTime === right.useAllTime &&
left.dateRange.start.getTime() === right.dateRange.start.getTime() &&
left.dateRange.end.getTime() === right.dateRange.end.getTime()
)
const pickSessionMediaMetric = ( const pickSessionMediaMetric = (
metricRaw: SessionExportMetric | SessionContentMetric | undefined metricRaw: SessionExportMetric | SessionContentMetric | undefined
): SessionContentMetric | null => { ): SessionContentMetric | null => {
if (!metricRaw) return null if (!metricRaw) return null
const totalMessages = normalizeMessageCount(metricRaw.totalMessages)
const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages) const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages)
const imageMessages = normalizeMessageCount(metricRaw.imageMessages) const imageMessages = normalizeMessageCount(metricRaw.imageMessages)
const videoMessages = normalizeMessageCount(metricRaw.videoMessages) const videoMessages = normalizeMessageCount(metricRaw.videoMessages)
const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages) const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages)
const firstTimestamp = normalizeTimestampSeconds(metricRaw.firstTimestamp)
const lastTimestamp = normalizeTimestampSeconds(metricRaw.lastTimestamp)
if ( if (
typeof totalMessages !== 'number' &&
typeof voiceMessages !== 'number' && typeof voiceMessages !== 'number' &&
typeof imageMessages !== 'number' && typeof imageMessages !== 'number' &&
typeof videoMessages !== 'number' && typeof videoMessages !== 'number' &&
typeof emojiMessages !== 'number' typeof emojiMessages !== 'number' &&
typeof firstTimestamp !== 'number' &&
typeof lastTimestamp !== 'number'
) { ) {
return null return null
} }
return { return {
totalMessages,
voiceMessages, voiceMessages,
imageMessages, imageMessages,
videoMessages, videoMessages,
emojiMessages emojiMessages,
firstTimestamp,
lastTimestamp
} }
} }
@@ -1520,6 +1577,8 @@ function ExportPage() {
const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false) const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false)
const [snsExportVideos, setSnsExportVideos] = useState(false) const [snsExportVideos, setSnsExportVideos] = useState(false)
const [isTimeRangeDialogOpen, setIsTimeRangeDialogOpen] = useState(false) const [isTimeRangeDialogOpen, setIsTimeRangeDialogOpen] = useState(false)
const [isResolvingTimeRangeBounds, setIsResolvingTimeRangeBounds] = useState(false)
const [timeRangeBounds, setTimeRangeBounds] = useState<TimeRangeBounds | null>(null)
const [isExportDefaultsModalOpen, setIsExportDefaultsModalOpen] = useState(false) const [isExportDefaultsModalOpen, setIsExportDefaultsModalOpen] = useState(false)
const [timeRangeSelection, setTimeRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection()) const [timeRangeSelection, setTimeRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
const [exportDefaultFormat, setExportDefaultFormat] = useState<TextExportFormat>('excel') const [exportDefaultFormat, setExportDefaultFormat] = useState<TextExportFormat>('excel')
@@ -2686,7 +2745,9 @@ function ExportPage() {
typeof emojiMessages !== 'number' && typeof emojiMessages !== 'number' &&
typeof transferMessages !== 'number' && typeof transferMessages !== 'number' &&
typeof redPacketMessages !== 'number' && typeof redPacketMessages !== 'number' &&
typeof callMessages !== 'number' typeof callMessages !== 'number' &&
typeof normalizeTimestampSeconds(metricRaw.firstTimestamp) !== 'number' &&
typeof normalizeTimestampSeconds(metricRaw.lastTimestamp) !== 'number'
) { ) {
continue continue
} }
@@ -2699,7 +2760,9 @@ function ExportPage() {
emojiMessages, emojiMessages,
transferMessages, transferMessages,
redPacketMessages, redPacketMessages,
callMessages callMessages,
firstTimestamp: normalizeTimestampSeconds(metricRaw.firstTimestamp),
lastTimestamp: normalizeTimestampSeconds(metricRaw.lastTimestamp)
} }
if (typeof totalMessages === 'number') { if (typeof totalMessages === 'number') {
nextMessageCounts[sessionId] = totalMessages nextMessageCounts[sessionId] = totalMessages
@@ -2743,7 +2806,9 @@ function ExportPage() {
previous.emojiMessages === nextMetric.emojiMessages && previous.emojiMessages === nextMetric.emojiMessages &&
previous.transferMessages === nextMetric.transferMessages && previous.transferMessages === nextMetric.transferMessages &&
previous.redPacketMessages === nextMetric.redPacketMessages && previous.redPacketMessages === nextMetric.redPacketMessages &&
previous.callMessages === nextMetric.callMessages previous.callMessages === nextMetric.callMessages &&
previous.firstTimestamp === nextMetric.firstTimestamp &&
previous.lastTimestamp === nextMetric.lastTimestamp
) { ) {
continue continue
} }
@@ -3898,6 +3963,7 @@ function ExportPage() {
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open'>) => { const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open'>) => {
setExportDialog({ open: true, ...payload }) setExportDialog({ open: true, ...payload })
setIsTimeRangeDialogOpen(false) setIsTimeRangeDialogOpen(false)
setTimeRangeBounds(null)
setTimeRangeSelection(exportDefaultDateRangeSelection) setTimeRangeSelection(exportDefaultDateRangeSelection)
setOptions(prev => { setOptions(prev => {
@@ -3960,11 +4026,108 @@ function ExportPage() {
const closeExportDialog = useCallback(() => { const closeExportDialog = useCallback(() => {
setExportDialog(prev => ({ ...prev, open: false })) setExportDialog(prev => ({ ...prev, open: false }))
setIsTimeRangeDialogOpen(false) setIsTimeRangeDialogOpen(false)
setTimeRangeBounds(null)
}, []) }, [])
const resolveChatExportTimeRangeBounds = useCallback(async (sessionIds: string[]): Promise<TimeRangeBounds | null> => {
const normalizedSessionIds = Array.from(new Set((sessionIds || []).map(id => String(id || '').trim()).filter(Boolean)))
if (normalizedSessionIds.length === 0) return null
let minTimestamp: number | undefined
let maxTimestamp: number | undefined
const resolvedSessionIds = new Set<string>()
const absorbMetric = (sessionId: string, metric?: { firstTimestamp?: number; lastTimestamp?: number } | null) => {
if (!metric) return
const firstTimestamp = normalizeTimestampSeconds(metric.firstTimestamp)
const lastTimestamp = normalizeTimestampSeconds(metric.lastTimestamp)
if (typeof firstTimestamp !== 'number' || typeof lastTimestamp !== 'number') return
resolvedSessionIds.add(sessionId)
if (minTimestamp === undefined || firstTimestamp < minTimestamp) minTimestamp = firstTimestamp
if (maxTimestamp === undefined || lastTimestamp > maxTimestamp) maxTimestamp = lastTimestamp
}
for (const sessionId of normalizedSessionIds) {
absorbMetric(sessionId, sessionContentMetrics[sessionId])
if (sessionDetail?.wxid === sessionId) {
absorbMetric(sessionId, {
firstTimestamp: sessionDetail.firstMessageTime,
lastTimestamp: sessionDetail.latestMessageTime
})
}
}
const applyStatsResult = (result?: {
success: boolean
data?: Record<string, SessionExportMetric>
} | null) => {
if (!result?.success || !result.data) return
applySessionMediaMetricsFromStats(result.data)
for (const sessionId of normalizedSessionIds) {
absorbMetric(sessionId, result.data[sessionId])
}
}
const missingSessionIds = () => normalizedSessionIds.filter(sessionId => !resolvedSessionIds.has(sessionId))
if (missingSessionIds().length > 0) {
applyStatsResult(await window.electronAPI.chat.getExportSessionStats(
missingSessionIds(),
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
))
}
if (missingSessionIds().length > 0) {
applyStatsResult(await window.electronAPI.chat.getExportSessionStats(
missingSessionIds(),
{ includeRelations: false, allowStaleCache: true }
))
}
if (resolvedSessionIds.size !== normalizedSessionIds.length) {
return null
}
if (typeof minTimestamp !== 'number' || typeof maxTimestamp !== 'number') {
return null
}
return {
minDate: new Date(minTimestamp * 1000),
maxDate: new Date(maxTimestamp * 1000)
}
}, [applySessionMediaMetricsFromStats, sessionContentMetrics, sessionDetail])
const openTimeRangeDialog = useCallback(() => { const openTimeRangeDialog = useCallback(() => {
void (async () => {
if (isResolvingTimeRangeBounds) return
setIsResolvingTimeRangeBounds(true)
try {
let nextBounds: TimeRangeBounds | null = null
if (exportDialog.scope !== 'sns') {
nextBounds = await resolveChatExportTimeRangeBounds(exportDialog.sessionIds)
}
setTimeRangeBounds(nextBounds)
if (nextBounds) {
const nextSelection = clampExportSelectionToBounds(timeRangeSelection, nextBounds)
if (!areExportSelectionsEqual(nextSelection, timeRangeSelection)) {
setTimeRangeSelection(nextSelection)
setOptions(prev => ({
...prev,
useAllTime: nextSelection.useAllTime,
dateRange: cloneExportDateRange(nextSelection.dateRange)
}))
}
}
setIsTimeRangeDialogOpen(true) setIsTimeRangeDialogOpen(true)
}, []) } catch (error) {
console.error('导出页解析时间范围边界失败', error)
setTimeRangeBounds(null)
setIsTimeRangeDialogOpen(true)
} finally {
setIsResolvingTimeRangeBounds(false)
}
})()
}, [exportDialog.scope, exportDialog.sessionIds, isResolvingTimeRangeBounds, resolveChatExportTimeRangeBounds, timeRangeSelection])
const closeTimeRangeDialog = useCallback(() => { const closeTimeRangeDialog = useCallback(() => {
setIsTimeRangeDialogOpen(false) setIsTimeRangeDialogOpen(false)
@@ -7753,8 +7916,9 @@ function ExportPage() {
type="button" type="button"
className="time-range-trigger" className="time-range-trigger"
onClick={openTimeRangeDialog} onClick={openTimeRangeDialog}
disabled={isResolvingTimeRangeBounds}
> >
<span>{timeRangeSummaryLabel}</span> <span>{isResolvingTimeRangeBounds ? '正在统计可选时间...' : timeRangeSummaryLabel}</span>
<span className="time-range-arrow">&gt;</span> <span className="time-range-arrow">&gt;</span>
</button> </button>
</div> </div>
@@ -7840,6 +8004,8 @@ function ExportPage() {
<ExportDateRangeDialog <ExportDateRangeDialog
open={isTimeRangeDialogOpen} open={isTimeRangeDialogOpen}
value={timeRangeSelection} value={timeRangeSelection}
minDate={timeRangeBounds?.minDate}
maxDate={timeRangeBounds?.maxDate}
onClose={closeTimeRangeDialog} onClose={closeTimeRangeDialog}
onConfirm={(nextSelection) => { onConfirm={(nextSelection) => {
setTimeRangeSelection(nextSelection) setTimeRangeSelection(nextSelection)

View File

@@ -580,6 +580,8 @@ export interface ExportSessionContentMetricCacheEntry {
imageMessages?: number imageMessages?: number
videoMessages?: number videoMessages?: number
emojiMessages?: number emojiMessages?: number
firstTimestamp?: number
lastTimestamp?: number
} }
export interface ExportSessionContentMetricCacheItem { export interface ExportSessionContentMetricCacheItem {
@@ -742,6 +744,12 @@ export async function getExportSessionContentMetricCache(scopeKey: string): Prom
if (typeof source.emojiMessages === 'number' && Number.isFinite(source.emojiMessages) && source.emojiMessages >= 0) { if (typeof source.emojiMessages === 'number' && Number.isFinite(source.emojiMessages) && source.emojiMessages >= 0) {
metric.emojiMessages = Math.floor(source.emojiMessages) metric.emojiMessages = Math.floor(source.emojiMessages)
} }
if (typeof source.firstTimestamp === 'number' && Number.isFinite(source.firstTimestamp) && source.firstTimestamp > 0) {
metric.firstTimestamp = Math.floor(source.firstTimestamp)
}
if (typeof source.lastTimestamp === 'number' && Number.isFinite(source.lastTimestamp) && source.lastTimestamp > 0) {
metric.lastTimestamp = Math.floor(source.lastTimestamp)
}
if (Object.keys(metric).length === 0) continue if (Object.keys(metric).length === 0) continue
metrics[sessionId] = metric metrics[sessionId] = metric
} }
@@ -781,6 +789,12 @@ export async function setExportSessionContentMetricCache(
if (typeof rawMetric.emojiMessages === 'number' && Number.isFinite(rawMetric.emojiMessages) && rawMetric.emojiMessages >= 0) { if (typeof rawMetric.emojiMessages === 'number' && Number.isFinite(rawMetric.emojiMessages) && rawMetric.emojiMessages >= 0) {
metric.emojiMessages = Math.floor(rawMetric.emojiMessages) metric.emojiMessages = Math.floor(rawMetric.emojiMessages)
} }
if (typeof rawMetric.firstTimestamp === 'number' && Number.isFinite(rawMetric.firstTimestamp) && rawMetric.firstTimestamp > 0) {
metric.firstTimestamp = Math.floor(rawMetric.firstTimestamp)
}
if (typeof rawMetric.lastTimestamp === 'number' && Number.isFinite(rawMetric.lastTimestamp) && rawMetric.lastTimestamp > 0) {
metric.lastTimestamp = Math.floor(rawMetric.lastTimestamp)
}
if (Object.keys(metric).length === 0) continue if (Object.keys(metric).length === 0) continue
normalized[sessionId] = metric normalized[sessionId] = metric
} }