Merge remote-tracking branch 'upstream/dev'

This commit is contained in:
H3CoF6
2026-03-21 00:27:33 +08:00
13 changed files with 1625 additions and 246 deletions

View File

@@ -13,13 +13,14 @@
width: min(480px, calc(100vw - 32px));
max-height: calc(100vh - 64px);
overflow-y: auto;
border-radius: 12px;
border-radius: 16px;
border: 1px solid var(--border-color);
background: var(--bg-secondary-solid, var(--bg-primary));
padding: 12px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.16);
}
.export-date-range-dialog-header {
@@ -83,8 +84,8 @@
}
.export-date-range-mode-banner {
border-radius: 8px;
padding: 6px 8px;
border-radius: 10px;
padding: 7px 10px;
font-size: 11px;
line-height: 1.4;
border: 1px solid var(--border-color);
@@ -98,47 +99,92 @@
}
}
.export-date-range-calendar-grid {
.export-date-range-boundary-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.export-date-range-boundary-card {
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
padding: 8px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
}
.boundary-label {
font-size: 11px;
color: var(--text-secondary);
}
}
.export-date-range-selection-hint {
font-size: 11px;
color: var(--text-secondary);
padding: 0 2px;
}
.export-date-range-calendar-panel {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
padding: 7px;
border-radius: 12px;
background: linear-gradient(180deg, rgba(var(--primary-rgb), 0.04), transparent 28%), var(--bg-secondary);
padding: 10px;
&.single {
width: 100%;
}
}
.export-date-range-calendar-panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
align-items: center;
gap: 8px;
}
.export-date-range-calendar-date-label {
display: flex;
flex-direction: column;
gap: 2px;
gap: 3px;
span {
font-size: 11px;
color: var(--text-secondary);
}
strong {
font-size: 13px;
color: var(--text-primary);
}
}
.export-date-range-date-input {
width: 100%;
min-width: 0;
border-radius: 6px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
height: 24px;
padding: 0 7px;
font-size: 11px;
height: 30px;
padding: 0 9px;
font-size: 12px;
&:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
}
&.invalid {
border-color: #e84d4d;
@@ -149,28 +195,36 @@
.export-date-range-calendar-nav {
display: inline-flex;
align-items: center;
gap: 4px;
gap: 6px;
font-size: 11px;
color: var(--text-primary);
button {
width: 20px;
height: 20px;
border-radius: 5px;
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
padding: 0;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
&:disabled {
cursor: not-allowed;
opacity: 0.45;
}
}
}
.export-date-range-calendar-weekdays {
margin-top: 6px;
margin-top: 10px;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
gap: 4px;
span {
text-align: center;
@@ -180,32 +234,61 @@
}
.export-date-range-calendar-days {
margin-top: 4px;
margin-top: 6px;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
gap: 4px;
}
.export-date-range-calendar-day {
border: 1px solid transparent;
border-radius: 6px;
min-height: 20px;
border-radius: 10px;
min-height: 34px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 10px;
font-size: 12px;
cursor: pointer;
padding: 0;
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease, transform 0.15s ease;
&:hover {
border-color: rgba(var(--primary-rgb), 0.28);
transform: translateY(-1px);
}
&:disabled:hover {
border-color: transparent;
transform: none;
}
&.outside {
color: var(--text-quaternary);
opacity: 0.75;
opacity: 0.72;
}
&.selected {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.14);
&.disabled {
cursor: not-allowed;
opacity: 0.35;
transform: none;
border-color: transparent;
}
&.in-range {
background: rgba(var(--primary-rgb), 0.1);
color: var(--primary);
}
&.range-start,
&.range-end {
border-color: var(--primary);
background: var(--primary);
color: #fff;
font-weight: 600;
opacity: 1;
}
&.active-boundary {
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.22);
}
}
@@ -247,8 +330,8 @@
}
}
@media (max-width: 860px) {
.export-date-range-calendar-grid {
@media (max-width: 640px) {
.export-date-range-boundary-row {
grid-template-columns: 1fr;
}
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, X } from 'lucide-react'
import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react'
import {
EXPORT_DATE_RANGE_PRESETS,
WEEKDAY_SHORT_LABELS,
@@ -25,29 +25,78 @@ interface ExportDateRangeDialogProps {
open: boolean
value: ExportDateRangeSelection
title?: string
minDate?: Date | null
maxDate?: Date | null
onClose: () => void
onConfirm: (value: ExportDateRangeSelection) => void
}
type ActiveBoundary = 'start' | 'end'
interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
startPanelMonth: Date
endPanelMonth: Date
panelMonth: Date
}
const buildDialogDraft = (value: ExportDateRangeSelection): ExportDateRangeDialogDraft => ({
...cloneExportDateRangeSelection(value),
startPanelMonth: toMonthStart(value.dateRange.start),
endPanelMonth: toMonthStart(value.dateRange.end)
})
const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => {
if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null
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({
open,
value,
title = '时间范围设置',
minDate,
maxDate,
onClose,
onConfirm
}: ExportDateRangeDialogProps) {
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value))
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value, minDate, maxDate))
const [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start')
const [dateInput, setDateInput] = useState({
start: formatDateInputValue(value.dateRange.start),
end: formatDateInputValue(value.dateRange.end)
@@ -56,14 +105,15 @@ export function ExportDateRangeDialog({
useEffect(() => {
if (!open) return
const nextDraft = buildDialogDraft(value)
const nextDraft = buildDialogDraft(value, minDate, maxDate)
setDraft(nextDraft)
setActiveBoundary('start')
setDateInput({
start: formatDateInputValue(nextDraft.dateRange.start),
end: formatDateInputValue(nextDraft.dateRange.end)
})
setDateInputError({ start: false, end: false })
}, [open, value])
}, [maxDate, minDate, open, value])
useEffect(() => {
if (!open) return
@@ -74,33 +124,24 @@ export function ExportDateRangeDialog({
setDateInputError({ start: false, end: false })
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
const applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => {
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 bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate])
const clampStartDate = useCallback((targetDate: Date) => {
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 => {
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
return {
@@ -111,16 +152,15 @@ export function ExportDateRangeDialog({
start,
end: nextEnd
},
startPanelMonth: toMonthStart(start),
endPanelMonth: toMonthStart(nextEnd)
panelMonth: toMonthStart(start)
}
})
}, [])
}, [clampStartDate])
const updateDraftEnd = useCallback((targetDate: Date) => {
const end = endOfDay(targetDate)
const setRangeEnd = useCallback((targetDate: Date) => {
const end = clampEndDate(targetDate)
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
return {
...prev,
@@ -130,11 +170,41 @@ export function ExportDateRangeDialog({
start: nextStart,
end: nextEnd
},
startPanelMonth: toMonthStart(nextStart),
endPanelMonth: toMonthStart(nextEnd)
panelMonth: toMonthStart(targetDate)
}
})
}, [])
}, [clampEndDate, clampStartDate])
const applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => {
if (preset === 'all') {
const previewRange = bounds
? { start: bounds.minDate, end: bounds.maxDate }
: createDefaultDateRange()
setDraft(prev => ({
...prev,
preset,
useAllTime: true,
dateRange: previewRange,
panelMonth: toMonthStart(previewRange.start)
}))
setActiveBoundary('start')
return
}
const range = clampSelectionToBounds({
preset,
useAllTime: false,
dateRange: createDateRangeByPreset(preset)
}, minDate, maxDate).dateRange
setDraft(prev => ({
...prev,
preset,
useAllTime: false,
dateRange: range,
panelMonth: toMonthStart(range.start)
}))
setActiveBoundary('start')
}, [bounds, maxDate, minDate])
const commitStartFromInput = useCallback(() => {
const parsed = parseDateInputValue(dateInput.start)
@@ -143,8 +213,8 @@ export function ExportDateRangeDialog({
return
}
setDateInputError(prev => ({ ...prev, start: false }))
updateDraftStart(parsed)
}, [dateInput.start, updateDraftStart])
setRangeStart(parsed)
}, [dateInput.start, setRangeStart])
const commitEndFromInput = useCallback(() => {
const parsed = parseDateInputValue(dateInput.end)
@@ -153,29 +223,81 @@ export function ExportDateRangeDialog({
return
}
setDateInputError(prev => ({ ...prev, end: false }))
updateDraftEnd(parsed)
}, [dateInput.end, updateDraftEnd])
setRangeEnd(parsed)
}, [dateInput.end, setRangeEnd])
const shiftPanelMonth = useCallback((panel: 'start' | 'end', delta: number) => {
setDraft(prev => (
panel === 'start'
? { ...prev, startPanelMonth: addMonths(prev.startPanelMonth, delta) }
: { ...prev, endPanelMonth: addMonths(prev.endPanelMonth, delta) }
))
const shiftPanelMonth = useCallback((delta: number) => {
setDraft(prev => ({
...prev,
panelMonth: addMonths(prev.panelMonth, delta)
}))
}, [])
const handleCalendarSelect = useCallback((targetDate: Date) => {
if (activeBoundary === 'start') {
setRangeStart(targetDate)
setActiveBoundary('end')
return
}
setDraft(prev => {
const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
const pickedStart = startOfDay(targetDate)
const nextStart = pickedStart <= start ? pickedStart : start
const nextEnd = pickedStart <= start ? endOfDay(start) : endOfDay(targetDate)
return {
...prev,
preset: 'custom',
useAllTime: false,
dateRange: {
start: nextStart,
end: nextEnd
},
panelMonth: toMonthStart(targetDate)
}
})
setActiveBoundary('start')
}, [activeBoundary, setRangeEnd, setRangeStart])
const isRangeModeActive = !draft.useAllTime
const modeText = isRangeModeActive
? '当前导出模式:按时间范围导出'
: '当前导出模式:全部时间导出选择下方日期切换为时间范围导出)'
: '当前导出模式:全部时间导出选择下方日期切换为自定义时间范围'
const isPresetActive = useCallback((preset: ExportDateRangePreset): boolean => {
if (preset === 'all') return draft.useAllTime
return !draft.useAllTime && draft.preset === preset
}, [draft])
const startPanelCells = useMemo(() => buildCalendarCells(draft.startPanelMonth), [draft.startPanelMonth])
const endPanelCells = useMemo(() => buildCalendarCells(draft.endPanelMonth), [draft.endPanelMonth])
const calendarCells = useMemo(() => buildCalendarCells(draft.panelMonth), [draft.panelMonth])
const 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) => (
!draft.useAllTime && isSameDay(date, draft.dateRange.start)
), [draft])
const isEndSelected = useCallback((date: Date) => (
!draft.useAllTime && isSameDay(date, draft.dateRange.end)
), [draft])
const isDateInRange = useCallback((date: Date) => (
!draft.useAllTime &&
startOfDay(date).getTime() >= startOfDay(draft.dateRange.start).getTime() &&
startOfDay(date).getTime() <= startOfDay(draft.dateRange.end).getTime()
), [draft])
const 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
? '选择开始或结束日期后,会自动切换为自定义时间范围'
: (activeBoundary === 'start' ? '下一次点击将设置开始日期' : '下一次点击将设置结束日期')
if (!open) return null
@@ -215,112 +337,115 @@ export function ExportDateRangeDialog({
{modeText}
</div>
<div className="export-date-range-calendar-grid">
<section className="export-date-range-calendar-panel">
<div className="export-date-range-calendar-panel-header">
<div className="export-date-range-calendar-date-label">
<span></span>
<input
type="text"
className={`export-date-range-date-input ${dateInputError.start ? 'invalid' : ''}`}
value={dateInput.start}
placeholder="YYYY-MM-DD"
onChange={(event) => {
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}
/>
</div>
<div className="export-date-range-calendar-nav">
<button type="button" onClick={() => shiftPanelMonth('start', -1)} aria-label="上个月"></button>
<span>{formatCalendarMonthTitle(draft.startPanelMonth)}</span>
<button type="button" onClick={() => shiftPanelMonth('start', 1)} aria-label="下个月"></button>
</div>
</div>
<div className="export-date-range-calendar-weekdays">
{WEEKDAY_SHORT_LABELS.map(label => (
<span key={`start-weekday-${label}`}>{label}</span>
))}
</div>
<div className="export-date-range-calendar-days">
{startPanelCells.map((cell) => {
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.start)
return (
<button
key={`start-${cell.date.getTime()}`}
type="button"
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
onClick={() => updateDraftStart(cell.date)}
>
{cell.date.getDate()}
</button>
)
})}
</div>
</section>
<section className="export-date-range-calendar-panel">
<div className="export-date-range-calendar-panel-header">
<div className="export-date-range-calendar-date-label">
<span></span>
<input
type="text"
className={`export-date-range-date-input ${dateInputError.end ? 'invalid' : ''}`}
value={dateInput.end}
placeholder="YYYY-MM-DD"
onChange={(event) => {
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}
/>
</div>
<div className="export-date-range-calendar-nav">
<button type="button" onClick={() => shiftPanelMonth('end', -1)} aria-label="上个月"></button>
<span>{formatCalendarMonthTitle(draft.endPanelMonth)}</span>
<button type="button" onClick={() => shiftPanelMonth('end', 1)} aria-label="下个月"></button>
</div>
</div>
<div className="export-date-range-calendar-weekdays">
{WEEKDAY_SHORT_LABELS.map(label => (
<span key={`end-weekday-${label}`}>{label}</span>
))}
</div>
<div className="export-date-range-calendar-days">
{endPanelCells.map((cell) => {
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.end)
return (
<button
key={`end-${cell.date.getTime()}`}
type="button"
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
onClick={() => updateDraftEnd(cell.date)}
>
{cell.date.getDate()}
</button>
)
})}
</div>
</section>
<div className="export-date-range-boundary-row">
<div
className={`export-date-range-boundary-card ${activeBoundary === 'start' ? 'active' : ''}`}
onClick={() => setActiveBoundary('start')}
>
<span className="boundary-label"></span>
<input
type="text"
className={`export-date-range-date-input ${dateInputError.start ? 'invalid' : ''}`}
value={dateInput.start}
placeholder="YYYY-MM-DD"
onChange={(event) => {
const nextValue = event.target.value
setDateInput(prev => ({ ...prev, start: nextValue }))
if (dateInputError.start) {
setDateInputError(prev => ({ ...prev, start: false }))
}
}}
onFocus={() => setActiveBoundary('start')}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
commitStartFromInput()
}}
onBlur={commitStartFromInput}
/>
</div>
<div
className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`}
onClick={() => setActiveBoundary('end')}
>
<span className="boundary-label"></span>
<input
type="text"
className={`export-date-range-date-input ${dateInputError.end ? 'invalid' : ''}`}
value={dateInput.end}
placeholder="YYYY-MM-DD"
onChange={(event) => {
const nextValue = event.target.value
setDateInput(prev => ({ ...prev, end: nextValue }))
if (dateInputError.end) {
setDateInputError(prev => ({ ...prev, end: false }))
}
}}
onFocus={() => setActiveBoundary('end')}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
commitEndFromInput()
}}
onBlur={commitEndFromInput}
/>
</div>
</div>
<div className="export-date-range-selection-hint">{hintText}</div>
<section className="export-date-range-calendar-panel single">
<div className="export-date-range-calendar-panel-header">
<div className="export-date-range-calendar-date-label">
<span></span>
<strong>{formatCalendarMonthTitle(draft.panelMonth)}</strong>
</div>
<div className="export-date-range-calendar-nav">
<button type="button" onClick={() => shiftPanelMonth(-1)} aria-label="上个月" disabled={!canShiftPrev}>
<ChevronLeft size={14} />
</button>
<button type="button" onClick={() => shiftPanelMonth(1)} aria-label="下个月" disabled={!canShiftNext}>
<ChevronRight size={14} />
</button>
</div>
</div>
<div className="export-date-range-calendar-weekdays">
{WEEKDAY_SHORT_LABELS.map(label => (
<span key={`weekday-${label}`}>{label}</span>
))}
</div>
<div className="export-date-range-calendar-days">
{calendarCells.map((cell) => {
const startSelected = isStartSelected(cell.date)
const endSelected = isEndSelected(cell.date)
const inRange = isDateInRange(cell.date)
const selectable = isDateSelectable(cell.date)
return (
<button
key={cell.date.getTime()}
type="button"
disabled={!selectable}
className={[
'export-date-range-calendar-day',
cell.inCurrentMonth ? '' : 'outside',
selectable ? '' : 'disabled',
inRange ? 'in-range' : '',
startSelected ? 'range-start' : '',
endSelected ? 'range-end' : '',
activeBoundary === 'start' && startSelected ? 'active-boundary' : '',
activeBoundary === 'end' && endSelected ? 'active-boundary' : ''
].filter(Boolean).join(' ')}
onClick={() => handleCalendarSelect(cell.date)}
>
{cell.date.getDate()}
</button>
)
})}
</div>
</section>
<div className="export-date-range-dialog-actions">
<button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}>

View File

@@ -585,6 +585,263 @@ interface GroupPanelMember {
messageCountStatus: GroupMessageCountStatus
}
const QUOTED_SENDER_CACHE_TTL_MS = 10 * 60 * 1000
const quotedSenderDisplayCache = new Map<string, { displayName: string; updatedAt: number }>()
const quotedSenderDisplayLoading = new Map<string, Promise<string | undefined>>()
const quotedGroupMembersCache = new Map<string, { members: GroupPanelMember[]; updatedAt: number }>()
const quotedGroupMembersLoading = new Map<string, Promise<GroupPanelMember[]>>()
function buildQuotedSenderCacheKey(
sessionId: string,
senderUsername: string,
isGroupChat: boolean
): string {
const normalizedSessionId = normalizeSearchIdentityText(sessionId) || String(sessionId || '').trim()
const normalizedSender = normalizeSearchIdentityText(senderUsername) || String(senderUsername || '').trim()
return `${isGroupChat ? 'group' : 'direct'}::${normalizedSessionId}::${normalizedSender}`
}
function isSameQuotedSenderIdentity(left?: string | null, right?: string | null): boolean {
const leftCandidates = buildSearchIdentityCandidates(left)
const rightCandidates = buildSearchIdentityCandidates(right)
if (leftCandidates.length === 0 || rightCandidates.length === 0) {
return false
}
for (const leftCandidate of leftCandidates) {
for (const rightCandidate of rightCandidates) {
if (leftCandidate === rightCandidate) return true
if (leftCandidate.startsWith(rightCandidate + '_')) return true
if (rightCandidate.startsWith(leftCandidate + '_')) return true
}
}
return false
}
function normalizeQuotedGroupMember(member: Partial<GroupPanelMember> | null | undefined): GroupPanelMember | null {
const username = String(member?.username || '').trim()
if (!username) return null
const displayName = String(member?.displayName || '').trim()
const nickname = String(member?.nickname || '').trim()
const remark = String(member?.remark || '').trim()
const alias = String(member?.alias || '').trim()
const groupNickname = String(member?.groupNickname || '').trim()
return {
username,
displayName: displayName || groupNickname || remark || nickname || alias || username,
avatarUrl: member?.avatarUrl,
nickname,
alias,
remark,
groupNickname,
isOwner: Boolean(member?.isOwner),
isFriend: Boolean(member?.isFriend),
messageCount: Number.isFinite(member?.messageCount) ? Math.max(0, Math.floor(member?.messageCount as number)) : 0,
messageCountStatus: 'ready'
}
}
function resolveQuotedSenderFallbackDisplayName(
sessionId: string,
senderUsername?: string | null,
fallbackDisplayName?: string | null
): string | undefined {
const resolved = resolveSearchSenderDisplayName(fallbackDisplayName, senderUsername, sessionId)
if (resolved) return resolved
return resolveSearchSenderUsernameFallback(senderUsername)
}
function resolveQuotedSenderUsername(
fromusr?: string | null,
chatusr?: string | null
): string {
const normalizedChatUsr = String(chatusr || '').trim()
const normalizedFromUsr = String(fromusr || '').trim()
if (normalizedChatUsr) {
return normalizedChatUsr
}
if (normalizedFromUsr.endsWith('@chatroom')) {
return ''
}
return normalizedFromUsr
}
function resolveQuotedGroupMemberDisplayName(member: GroupPanelMember): string | undefined {
const remark = normalizeSearchIdentityText(member.remark)
if (remark) return remark
const groupNickname = normalizeSearchIdentityText(member.groupNickname)
if (groupNickname) return groupNickname
const nickname = normalizeSearchIdentityText(member.nickname)
if (nickname) return nickname
const displayName = resolveSearchSenderDisplayName(member.displayName, member.username)
if (displayName) return displayName
const alias = normalizeSearchIdentityText(member.alias)
if (alias) return alias
return resolveSearchSenderUsernameFallback(member.username)
}
function resolveQuotedPrivateDisplayName(contact: any): string | undefined {
const remark = normalizeSearchIdentityText(contact?.remark)
if (remark) return remark
const nickname = normalizeSearchIdentityText(
contact?.nickName || contact?.nick_name || contact?.nickname
)
if (nickname) return nickname
const alias = normalizeSearchIdentityText(contact?.alias)
if (alias) return alias
return undefined
}
async function getQuotedGroupMembers(chatroomId: string): Promise<GroupPanelMember[]> {
const normalizedChatroomId = String(chatroomId || '').trim()
if (!normalizedChatroomId || !normalizedChatroomId.includes('@chatroom')) {
return []
}
const cached = quotedGroupMembersCache.get(normalizedChatroomId)
if (cached && Date.now() - cached.updatedAt < QUOTED_SENDER_CACHE_TTL_MS) {
return cached.members
}
const pending = quotedGroupMembersLoading.get(normalizedChatroomId)
if (pending) return pending
const request = window.electronAPI.groupAnalytics.getGroupMembersPanelData(
normalizedChatroomId,
{ forceRefresh: false, includeMessageCounts: false }
).then((result) => {
const members = Array.isArray(result.data)
? result.data
.map((member) => normalizeQuotedGroupMember(member as Partial<GroupPanelMember>))
.filter((member): member is GroupPanelMember => Boolean(member))
: []
if (members.length > 0) {
quotedGroupMembersCache.set(normalizedChatroomId, {
members,
updatedAt: Date.now()
})
return members
}
return cached?.members || []
}).catch(() => cached?.members || []).finally(() => {
quotedGroupMembersLoading.delete(normalizedChatroomId)
})
quotedGroupMembersLoading.set(normalizedChatroomId, request)
return request
}
async function resolveQuotedSenderDisplayName(options: {
sessionId: string
senderUsername?: string | null
fallbackDisplayName?: string | null
isGroupChat?: boolean
myWxid?: string | null
}): Promise<string | undefined> {
const normalizedSessionId = String(options.sessionId || '').trim()
const normalizedSender = String(options.senderUsername || '').trim()
const fallbackDisplayName = resolveQuotedSenderFallbackDisplayName(
normalizedSessionId,
normalizedSender,
options.fallbackDisplayName
)
if (!normalizedSender) {
return fallbackDisplayName
}
const cacheKey = buildQuotedSenderCacheKey(normalizedSessionId, normalizedSender, Boolean(options.isGroupChat))
const cached = quotedSenderDisplayCache.get(cacheKey)
if (cached && Date.now() - cached.updatedAt < QUOTED_SENDER_CACHE_TTL_MS) {
return cached.displayName
}
const pending = quotedSenderDisplayLoading.get(cacheKey)
if (pending) return pending
const request = (async (): Promise<string | undefined> => {
if (options.isGroupChat) {
const members = await getQuotedGroupMembers(normalizedSessionId)
const matchedMember = members.find((member) => isSameQuotedSenderIdentity(member.username, normalizedSender))
const groupDisplayName = matchedMember ? resolveQuotedGroupMemberDisplayName(matchedMember) : undefined
if (groupDisplayName) {
quotedSenderDisplayCache.set(cacheKey, {
displayName: groupDisplayName,
updatedAt: Date.now()
})
return groupDisplayName
}
}
if (isCurrentUserSearchIdentity(normalizedSender, options.myWxid)) {
const selfDisplayName = fallbackDisplayName || '我'
quotedSenderDisplayCache.set(cacheKey, {
displayName: selfDisplayName,
updatedAt: Date.now()
})
return selfDisplayName
}
try {
const contact = await window.electronAPI.chat.getContact(normalizedSender)
const contactDisplayName = resolveQuotedPrivateDisplayName(contact)
if (contactDisplayName) {
quotedSenderDisplayCache.set(cacheKey, {
displayName: contactDisplayName,
updatedAt: Date.now()
})
return contactDisplayName
}
} catch {
// ignore contact lookup failures and fall back below
}
try {
const profile = await window.electronAPI.chat.getContactAvatar(normalizedSender)
const profileDisplayName = normalizeSearchIdentityText(profile?.displayName)
if (profileDisplayName && !isWxidLikeSearchIdentity(profileDisplayName)) {
quotedSenderDisplayCache.set(cacheKey, {
displayName: profileDisplayName,
updatedAt: Date.now()
})
return profileDisplayName
}
} catch {
// ignore avatar lookup failures and keep fallback usable
}
if (fallbackDisplayName) {
quotedSenderDisplayCache.set(cacheKey, {
displayName: fallbackDisplayName,
updatedAt: Date.now()
})
}
return fallbackDisplayName
})().finally(() => {
quotedSenderDisplayLoading.delete(cacheKey)
})
quotedSenderDisplayLoading.set(cacheKey, request)
return request
}
interface SessionListCachePayload {
updatedAt: number
sessions: ChatSession[]
@@ -2394,6 +2651,10 @@ function ChatPage(props: ChatPageProps) {
const handleAccountChanged = useCallback(async () => {
senderAvatarCache.clear()
senderAvatarLoading.clear()
quotedSenderDisplayCache.clear()
quotedSenderDisplayLoading.clear()
quotedGroupMembersCache.clear()
quotedGroupMembersLoading.clear()
sessionContactProfileCacheRef.current.clear()
pendingSessionContactEnrichRef.current.clear()
sessionContactEnrichAttemptAtRef.current.clear()
@@ -5660,6 +5921,7 @@ function ChatPage(props: ChatPageProps) {
session={currentSession!}
showTime={!showDateDivider && showTime}
myAvatarUrl={myAvatarUrl}
myWxid={myWxid}
isGroupChat={isCurrentSessionGroup}
autoTranscribeVoiceEnabled={autoTranscribeVoiceEnabled}
onRequireModelDownload={handleRequireModelDownload}
@@ -5678,6 +5940,7 @@ function ChatPage(props: ChatPageProps) {
formatDateDivider,
currentSession,
myAvatarUrl,
myWxid,
isCurrentSessionGroup,
autoTranscribeVoiceEnabled,
handleRequireModelDownload,
@@ -7258,6 +7521,7 @@ function MessageBubble({
session,
showTime,
myAvatarUrl,
myWxid,
isGroupChat,
autoTranscribeVoiceEnabled,
onRequireModelDownload,
@@ -7271,6 +7535,7 @@ function MessageBubble({
session: ChatSession;
showTime?: boolean;
myAvatarUrl?: string;
myWxid?: string;
isGroupChat?: boolean;
autoTranscribeVoiceEnabled?: boolean;
onRequireModelDownload?: (sessionId: string, messageId: string) => void;
@@ -7290,6 +7555,7 @@ function MessageBubble({
const isSent = message.isSend === 1
const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined)
const [senderName, setSenderName] = useState<string | undefined>(undefined)
const [quotedSenderName, setQuotedSenderName] = useState<string | undefined>(undefined)
const senderProfileRequestSeqRef = useRef(0)
const [emojiError, setEmojiError] = useState(false)
const [emojiLoading, setEmojiLoading] = useState(false)
@@ -8235,6 +8501,53 @@ function MessageBubble({
appMsgTextCache.set(selector, value)
return value
}, [appMsgDoc, appMsgTextCache])
const quotedSenderUsername = resolveQuotedSenderUsername(
queryAppMsgText('refermsg > fromusr'),
queryAppMsgText('refermsg > chatusr')
)
const quotedContent = message.quotedContent || queryAppMsgText('refermsg > content') || ''
const quotedSenderFallbackName = useMemo(
() => resolveQuotedSenderFallbackDisplayName(
session.username,
quotedSenderUsername,
message.quotedSender || queryAppMsgText('refermsg > displayname') || ''
),
[message.quotedSender, queryAppMsgText, quotedSenderUsername, session.username]
)
useEffect(() => {
let cancelled = false
const nextFallbackName = quotedSenderFallbackName || undefined
setQuotedSenderName(nextFallbackName)
if (!quotedContent || !quotedSenderUsername) {
return () => {
cancelled = true
}
}
void resolveQuotedSenderDisplayName({
sessionId: session.username,
senderUsername: quotedSenderUsername,
fallbackDisplayName: nextFallbackName,
isGroupChat,
myWxid
}).then((resolvedName) => {
if (cancelled) return
setQuotedSenderName(resolvedName || nextFallbackName)
})
return () => {
cancelled = true
}
}, [
quotedContent,
quotedSenderFallbackName,
quotedSenderUsername,
session.username,
isGroupChat,
myWxid
])
const locationMessageMeta = useMemo(() => {
if (message.localType !== 48) return null
@@ -8269,7 +8582,8 @@ function MessageBubble({
: (isGroupChat ? resolvedSenderAvatarUrl : session.avatarUrl)
// 是否有引用消息
const hasQuote = message.quotedContent && message.quotedContent.length > 0
const hasQuote = quotedContent.length > 0
const displayQuotedSenderName = quotedSenderName || quotedSenderFallbackName
const handlePlayVideo = useCallback(async () => {
if (!videoInfo?.videoUrl) return
@@ -8680,7 +8994,6 @@ function MessageBubble({
if (xmlType === '57') {
const replyText = q('title') || cleanedParsedContent || ''
const referContent = q('refermsg > content') || ''
const referSender = q('refermsg > displayname') || ''
const referType = q('refermsg > type') || ''
// 根据被引用消息类型渲染对应内容
@@ -8712,7 +9025,7 @@ function MessageBubble({
return (
<div className="bubble-content">
<div className="quoted-message">
{referSender && <span className="quoted-sender">{referSender}</span>}
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
<span className="quoted-text">{renderReferContent()}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
@@ -8808,11 +9121,10 @@ function MessageBubble({
// 引用回复消息appMsgKind='quote'xmlType=57
const replyText = message.linkTitle || q('title') || cleanedParsedContent || ''
const referContent = message.quotedContent || q('refermsg > content') || ''
const referSender = message.quotedSender || q('refermsg > displayname') || ''
return (
<div className="bubble-content">
<div className="quoted-message">
{referSender && <span className="quoted-sender">{referSender}</span>}
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(referContent))}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
@@ -9003,7 +9315,6 @@ function MessageBubble({
if (appMsgType === '57') {
const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanedParsedContent || ''
const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || ''
const referSender = parsedDoc?.querySelector('refermsg > displayname')?.textContent?.trim() || ''
const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || ''
const renderReferContent2 = () => {
@@ -9029,7 +9340,7 @@ function MessageBubble({
return (
<div className="bubble-content">
<div className="quoted-message">
{referSender && <span className="quoted-sender">{referSender}</span>}
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
<span className="quoted-text">{renderReferContent2()}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
@@ -9315,8 +9626,8 @@ function MessageBubble({
return (
<div className="bubble-content">
<div className="quoted-message">
{message.quotedSender && <span className="quoted-sender">{message.quotedSender}</span>}
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(message.quotedContent || ''))}</span>
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(quotedContent))}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanedParsedContent)}</div>
</div>
@@ -9444,6 +9755,7 @@ const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => {
if (prevProps.messageKey !== nextProps.messageKey) return false
if (prevProps.showTime !== nextProps.showTime) return false
if (prevProps.myAvatarUrl !== nextProps.myAvatarUrl) return false
if (prevProps.myWxid !== nextProps.myWxid) return false
if (prevProps.isGroupChat !== nextProps.isGroupChat) return false
if (prevProps.autoTranscribeVoiceEnabled !== nextProps.autoTranscribeVoiceEnabled) return false
if (prevProps.isSelectionMode !== nextProps.isSelectionMode) return false

View File

@@ -52,10 +52,13 @@ import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '..
import type { SnsPost } from '../types/sns'
import {
cloneExportDateRange,
cloneExportDateRangeSelection,
createDefaultDateRange,
createDefaultExportDateRangeSelection,
getExportDateRangeLabel,
resolveExportDateRangeConfig,
startOfDay,
endOfDay,
type ExportDateRangeSelection
} from '../utils/exportDateRange'
import './ExportPage.scss'
@@ -830,6 +833,13 @@ interface SessionContentMetric {
transferMessages?: number
redPacketMessages?: number
callMessages?: number
firstTimestamp?: number
lastTimestamp?: number
}
interface TimeRangeBounds {
minDate: Date
maxDate: Date
}
interface SessionExportCacheMeta {
@@ -1049,27 +1059,74 @@ const normalizeMessageCount = (value: unknown): number | undefined => {
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 = (
metricRaw: SessionExportMetric | SessionContentMetric | undefined
): SessionContentMetric | null => {
if (!metricRaw) return null
const totalMessages = normalizeMessageCount(metricRaw.totalMessages)
const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages)
const imageMessages = normalizeMessageCount(metricRaw.imageMessages)
const videoMessages = normalizeMessageCount(metricRaw.videoMessages)
const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages)
const firstTimestamp = normalizeTimestampSeconds(metricRaw.firstTimestamp)
const lastTimestamp = normalizeTimestampSeconds(metricRaw.lastTimestamp)
if (
typeof totalMessages !== 'number' &&
typeof voiceMessages !== 'number' &&
typeof imageMessages !== 'number' &&
typeof videoMessages !== 'number' &&
typeof emojiMessages !== 'number'
typeof emojiMessages !== 'number' &&
typeof firstTimestamp !== 'number' &&
typeof lastTimestamp !== 'number'
) {
return null
}
return {
totalMessages,
voiceMessages,
imageMessages,
videoMessages,
emojiMessages
emojiMessages,
firstTimestamp,
lastTimestamp
}
}
@@ -1520,6 +1577,8 @@ function ExportPage() {
const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false)
const [snsExportVideos, setSnsExportVideos] = 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 [timeRangeSelection, setTimeRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
const [exportDefaultFormat, setExportDefaultFormat] = useState<TextExportFormat>('excel')
@@ -2686,7 +2745,9 @@ function ExportPage() {
typeof emojiMessages !== 'number' &&
typeof transferMessages !== 'number' &&
typeof redPacketMessages !== 'number' &&
typeof callMessages !== 'number'
typeof callMessages !== 'number' &&
typeof normalizeTimestampSeconds(metricRaw.firstTimestamp) !== 'number' &&
typeof normalizeTimestampSeconds(metricRaw.lastTimestamp) !== 'number'
) {
continue
}
@@ -2699,7 +2760,9 @@ function ExportPage() {
emojiMessages,
transferMessages,
redPacketMessages,
callMessages
callMessages,
firstTimestamp: normalizeTimestampSeconds(metricRaw.firstTimestamp),
lastTimestamp: normalizeTimestampSeconds(metricRaw.lastTimestamp)
}
if (typeof totalMessages === 'number') {
nextMessageCounts[sessionId] = totalMessages
@@ -2733,7 +2796,9 @@ function ExportPage() {
emojiMessages: typeof metric.emojiMessages === 'number' ? metric.emojiMessages : previous.emojiMessages,
transferMessages: typeof metric.transferMessages === 'number' ? metric.transferMessages : previous.transferMessages,
redPacketMessages: typeof metric.redPacketMessages === 'number' ? metric.redPacketMessages : previous.redPacketMessages,
callMessages: typeof metric.callMessages === 'number' ? metric.callMessages : previous.callMessages
callMessages: typeof metric.callMessages === 'number' ? metric.callMessages : previous.callMessages,
firstTimestamp: typeof metric.firstTimestamp === 'number' ? metric.firstTimestamp : previous.firstTimestamp,
lastTimestamp: typeof metric.lastTimestamp === 'number' ? metric.lastTimestamp : previous.lastTimestamp
}
if (
previous.totalMessages === nextMetric.totalMessages &&
@@ -2743,7 +2808,9 @@ function ExportPage() {
previous.emojiMessages === nextMetric.emojiMessages &&
previous.transferMessages === nextMetric.transferMessages &&
previous.redPacketMessages === nextMetric.redPacketMessages &&
previous.callMessages === nextMetric.callMessages
previous.callMessages === nextMetric.callMessages &&
previous.firstTimestamp === nextMetric.firstTimestamp &&
previous.lastTimestamp === nextMetric.lastTimestamp
) {
continue
}
@@ -3898,6 +3965,7 @@ function ExportPage() {
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open'>) => {
setExportDialog({ open: true, ...payload })
setIsTimeRangeDialogOpen(false)
setTimeRangeBounds(null)
setTimeRangeSelection(exportDefaultDateRangeSelection)
setOptions(prev => {
@@ -3960,11 +4028,143 @@ function ExportPage() {
const closeExportDialog = useCallback(() => {
setExportDialog(prev => ({ ...prev, open: 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
const sessionRowMap = new Map<string, SessionRow>()
for (const session of sessions) {
sessionRowMap.set(session.username, session)
}
let minTimestamp: number | undefined
let maxTimestamp: number | undefined
const resolvedSessionBounds = new Map<string, { hasMin: boolean; hasMax: boolean }>()
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
const previous = resolvedSessionBounds.get(sessionId) || { hasMin: false, hasMax: false }
const nextState = {
hasMin: previous.hasMin || typeof firstTimestamp === 'number',
hasMax: previous.hasMax || typeof lastTimestamp === 'number'
}
resolvedSessionBounds.set(sessionId, nextState)
if (typeof firstTimestamp === 'number' && (minTimestamp === undefined || firstTimestamp < minTimestamp)) {
minTimestamp = firstTimestamp
}
if (typeof lastTimestamp === 'number' && (maxTimestamp === undefined || lastTimestamp > maxTimestamp)) {
maxTimestamp = lastTimestamp
}
}
for (const sessionId of normalizedSessionIds) {
const sessionRow = sessionRowMap.get(sessionId)
absorbMetric(sessionId, {
firstTimestamp: undefined,
lastTimestamp: sessionRow?.sortTimestamp || sessionRow?.lastTimestamp
})
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 => {
const resolved = resolvedSessionBounds.get(sessionId)
return !resolved?.hasMin || !resolved?.hasMax
})
const staleSessionIds = new Set<string>()
if (missingSessionIds().length > 0) {
const cacheResult = await window.electronAPI.chat.getExportSessionStats(
missingSessionIds(),
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
)
applyStatsResult(cacheResult)
for (const sessionId of cacheResult?.needsRefresh || []) {
staleSessionIds.add(String(sessionId || '').trim())
}
}
const sessionsNeedingFreshStats = Array.from(new Set([
...missingSessionIds(),
...Array.from(staleSessionIds).filter(Boolean)
]))
if (sessionsNeedingFreshStats.length > 0) {
applyStatsResult(await window.electronAPI.chat.getExportSessionStats(
sessionsNeedingFreshStats,
{ includeRelations: false }
))
}
if (missingSessionIds().length > 0) {
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, sessions])
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(() => {
setIsTimeRangeDialogOpen(false)
@@ -7753,8 +7953,9 @@ function ExportPage() {
type="button"
className="time-range-trigger"
onClick={openTimeRangeDialog}
disabled={isResolvingTimeRangeBounds}
>
<span>{timeRangeSummaryLabel}</span>
<span>{isResolvingTimeRangeBounds ? '正在统计可选时间...' : timeRangeSummaryLabel}</span>
<span className="time-range-arrow">&gt;</span>
</button>
</div>
@@ -7840,6 +8041,8 @@ function ExportPage() {
<ExportDateRangeDialog
open={isTimeRangeDialogOpen}
value={timeRangeSelection}
minDate={timeRangeBounds?.minDate}
maxDate={timeRangeBounds?.maxDate}
onClose={closeTimeRangeDialog}
onConfirm={(nextSelection) => {
setTimeRangeSelection(nextSelection)

View File

@@ -176,6 +176,8 @@ export default function SnsPage() {
const selectedContactUsernamesRef = useRef<string[]>(selectedContactUsernames)
const cacheScopeKeyRef = useRef('')
const snsUserPostCountsCacheScopeKeyRef = useRef('')
const activeContactsLoadTaskIdRef = useRef<string | null>(null)
const activeContactsCountTaskIdRef = useRef<string | null>(null)
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
const pendingResetFeedRef = useRef(false)
const contactsLoadTokenRef = useRef(0)
@@ -750,6 +752,12 @@ export default function SnsPage() {
window.clearTimeout(contactsCountBatchTimerRef.current)
contactsCountBatchTimerRef.current = null
}
if (activeContactsCountTaskIdRef.current) {
finishBackgroundTask(activeContactsCountTaskIdRef.current, 'canceled', {
detail: '已停止后续联系人朋友圈条数补算'
})
activeContactsCountTaskIdRef.current = null
}
if (resetProgress) {
setContactsCountProgress({
resolved: 0,
@@ -814,31 +822,56 @@ export default function SnsPage() {
cancelable: true
})
activeContactsCountTaskIdRef.current = taskId
let normalizedCounts: Record<string, number> = {}
try {
const result = await window.electronAPI.sns.getUserPostCounts()
if (isBackgroundTaskCancelRequested(taskId)) {
if (activeContactsCountTaskIdRef.current === taskId) {
activeContactsCountTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,当前计数查询结束后不再继续分批写入'
})
return
}
if (runToken !== contactsCountHydrationTokenRef.current) return
if (runToken !== contactsCountHydrationTokenRef.current) {
if (activeContactsCountTaskIdRef.current === taskId) {
activeContactsCountTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: '页面状态已刷新,本次联系人朋友圈条数补算已过期'
})
return
}
if (result.success && result.counts) {
normalizedCounts = Object.fromEntries(
Object.entries(result.counts).map(([username, value]) => [username, normalizePostCount(value)])
)
normalizedCounts = pendingTargets.reduce<Record<string, number>>((acc, username) => {
acc[username] = normalizePostCount(result.counts?.[username])
return acc
}, {})
void (async () => {
try {
const scopeKey = await ensureSnsUserPostCountsCacheScopeKey()
await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts)
const currentCache = await configService.getExportSnsUserPostCountsCache(scopeKey)
await configService.setExportSnsUserPostCountsCache(scopeKey, {
...(currentCache?.counts || {}),
...normalizedCounts
})
} catch (cacheError) {
console.error('Failed to persist SNS user post counts cache:', cacheError)
}
})()
} else {
normalizedCounts = pendingTargets.reduce<Record<string, number>>((acc, username) => {
acc[username] = 0
return acc
}, {})
}
} catch (error) {
console.error('Failed to load contact post counts:', error)
if (activeContactsCountTaskIdRef.current === taskId) {
activeContactsCountTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'failed', {
detail: String(error)
})
@@ -848,8 +881,19 @@ export default function SnsPage() {
let resolved = preResolved
let cursor = 0
const applyBatch = () => {
if (runToken !== contactsCountHydrationTokenRef.current) return
if (runToken !== contactsCountHydrationTokenRef.current) {
if (activeContactsCountTaskIdRef.current === taskId) {
activeContactsCountTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: '页面状态已刷新,本次联系人朋友圈条数补算已过期'
})
return
}
if (isBackgroundTaskCancelRequested(taskId)) {
if (activeContactsCountTaskIdRef.current === taskId) {
activeContactsCountTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: `已停止后续加载,已完成 ${resolved}/${totalTargets}`
})
@@ -870,6 +914,9 @@ export default function SnsPage() {
running: false
})
contactsCountBatchTimerRef.current = null
if (activeContactsCountTaskIdRef.current === taskId) {
activeContactsCountTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'completed', {
detail: '联系人朋友圈条数补算完成',
progressText: `${totalTargets}/${totalTargets}`
@@ -910,6 +957,18 @@ export default function SnsPage() {
contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS)
} else {
contactsCountBatchTimerRef.current = null
setContactsCountProgress({
resolved: totalTargets,
total: totalTargets,
running: false
})
if (activeContactsCountTaskIdRef.current === taskId) {
activeContactsCountTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'completed', {
detail: '鑱旂郴浜烘湅鍙嬪湀鏉℃暟琛ョ畻瀹屾垚',
progressText: `${totalTargets}/${totalTargets}`
})
}
}
@@ -918,6 +977,12 @@ export default function SnsPage() {
// Load Contacts先按最近会话显示联系人再异步统计朋友圈条数并增量排序
const loadContacts = useCallback(async () => {
if (activeContactsLoadTaskIdRef.current) {
finishBackgroundTask(activeContactsLoadTaskIdRef.current, 'canceled', {
detail: '新一轮联系人列表加载已开始,旧任务已取消'
})
activeContactsLoadTaskIdRef.current = null
}
const requestToken = ++contactsLoadTokenRef.current
const taskId = registerBackgroundTask({
sourcePage: 'sns',
@@ -926,6 +991,7 @@ export default function SnsPage() {
progressText: '初始化',
cancelable: true
})
activeContactsLoadTaskIdRef.current = taskId
stopContactsCountHydration(true)
setContactsLoading(true)
try {
@@ -955,7 +1021,15 @@ export default function SnsPage() {
}
})
if (requestToken !== contactsLoadTokenRef.current) return
if (requestToken !== contactsLoadTokenRef.current) {
if (activeContactsLoadTaskIdRef.current === taskId) {
activeContactsLoadTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: '页面状态已刷新,本次联系人列表加载已过期'
})
return
}
if (cachedContacts.length > 0) {
const cachedContactsSorted = sortContactsForRanking(cachedContacts)
setContacts(cachedContactsSorted)
@@ -977,6 +1051,9 @@ export default function SnsPage() {
window.electronAPI.chat.getSessions()
])
if (isBackgroundTaskCancelRequested(taskId)) {
if (activeContactsLoadTaskIdRef.current === taskId) {
activeContactsLoadTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,当前联系人查询结束后未继续补齐'
})
@@ -1021,7 +1098,15 @@ export default function SnsPage() {
}
let contactsList = sortContactsForRanking(Array.from(contactMap.values()))
if (requestToken !== contactsLoadTokenRef.current) return
if (requestToken !== contactsLoadTokenRef.current) {
if (activeContactsLoadTaskIdRef.current === taskId) {
activeContactsLoadTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: '页面状态已刷新,本次联系人列表加载已过期'
})
return
}
setContacts(contactsList)
const readyUsernames = new Set(
contactsList
@@ -1043,6 +1128,9 @@ export default function SnsPage() {
})
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
if (isBackgroundTaskCancelRequested(taskId)) {
if (activeContactsLoadTaskIdRef.current === taskId) {
activeContactsLoadTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,联系人补齐未继续写入'
})
@@ -1058,7 +1146,15 @@ export default function SnsPage() {
avatarUrl: extra.avatarUrl || contact.avatarUrl
}
})
if (requestToken !== contactsLoadTokenRef.current) return
if (requestToken !== contactsLoadTokenRef.current) {
if (activeContactsLoadTaskIdRef.current === taskId) {
activeContactsLoadTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: '页面状态已刷新,本次联系人列表加载已过期'
})
return
}
setContacts((prev) => {
const prevMap = new Map(prev.map((contact) => [contact.username, contact]))
const merged = contactsList.map((contact) => {
@@ -1074,18 +1170,35 @@ export default function SnsPage() {
})
}
}
if (activeContactsLoadTaskIdRef.current === taskId) {
activeContactsLoadTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'completed', {
detail: `朋友圈联系人列表加载完成,共 ${contactsList.length}`,
progressText: `${contactsList.length}`
})
} catch (error) {
if (requestToken !== contactsLoadTokenRef.current) return
if (requestToken !== contactsLoadTokenRef.current) {
if (activeContactsLoadTaskIdRef.current === taskId) {
activeContactsLoadTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: '页面状态已刷新,本次联系人列表加载已过期'
})
return
}
console.error('Failed to load contacts:', error)
stopContactsCountHydration(true)
if (activeContactsLoadTaskIdRef.current === taskId) {
activeContactsLoadTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'failed', {
detail: String(error)
})
} finally {
if (activeContactsLoadTaskIdRef.current === taskId && requestToken !== contactsLoadTokenRef.current) {
activeContactsLoadTaskIdRef.current = null
}
if (requestToken === contactsLoadTokenRef.current) {
setContactsLoading(false)
}
@@ -1185,6 +1298,18 @@ export default function SnsPage() {
window.clearTimeout(contactsCountBatchTimerRef.current)
contactsCountBatchTimerRef.current = null
}
if (activeContactsCountTaskIdRef.current) {
finishBackgroundTask(activeContactsCountTaskIdRef.current, 'canceled', {
detail: '已离开朋友圈页,联系人朋友圈条数补算已取消'
})
activeContactsCountTaskIdRef.current = null
}
if (activeContactsLoadTaskIdRef.current) {
finishBackgroundTask(activeContactsLoadTaskIdRef.current, 'canceled', {
detail: '已离开朋友圈页,联系人列表加载已取消'
})
activeContactsLoadTaskIdRef.current = null
}
}
}, [])

View File

@@ -580,6 +580,8 @@ export interface ExportSessionContentMetricCacheEntry {
imageMessages?: number
videoMessages?: number
emojiMessages?: number
firstTimestamp?: number
lastTimestamp?: number
}
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) {
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
metrics[sessionId] = metric
}
@@ -781,6 +789,12 @@ export async function setExportSessionContentMetricCache(
if (typeof rawMetric.emojiMessages === 'number' && Number.isFinite(rawMetric.emojiMessages) && rawMetric.emojiMessages >= 0) {
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
normalized[sessionId] = metric
}