mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
优化选择
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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' : '',
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
setIsTimeRangeDialogOpen(true)
|
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)
|
||||||
|
} 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">></span>
|
<span className="time-range-arrow">></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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user