This commit is contained in:
cc
2026-04-24 18:21:09 +08:00
parent c923327112
commit 255b857e67
8 changed files with 465 additions and 89 deletions

View File

@@ -65,6 +65,7 @@ import type { SnsPost } from '../types/sns'
import {
cloneExportDateRange,
cloneExportDateRangeSelection,
createExportDateRangeSelectionFromPreset,
createDateRangeByLastNDays,
createDefaultDateRange,
createDefaultExportDateRangeSelection,
@@ -1599,6 +1600,19 @@ const areExportSelectionsEqual = (left: ExportDateRangeSelection, right: ExportD
left.dateRange.end.getTime() === right.dateRange.end.getTime()
)
const resolveDynamicExportSelection = (
selection: ExportDateRangeSelection,
now = new Date()
): ExportDateRangeSelection => {
if (selection.useAllTime) {
return cloneExportDateRangeSelection(selection)
}
if (selection.preset === 'custom') {
return cloneExportDateRangeSelection(selection)
}
return createExportDateRangeSelectionFromPreset(selection.preset, now)
}
const pickSessionMediaMetric = (
metricRaw: SessionExportMetric | SessionContentMetric | undefined
): SessionContentMetric | null => {
@@ -4790,19 +4804,20 @@ function ExportPage() {
const clearSelection = () => setSelectedSessions(new Set())
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open' | 'intent'> & { intent?: ExportDialogState['intent'] }) => {
const dynamicDefaultRangeSelection = resolveDynamicExportSelection(exportDefaultDateRangeSelection, new Date())
setExportDialog({ open: true, intent: payload.intent || 'manual', ...payload })
setIsTimeRangeDialogOpen(false)
setTimeRangeBounds(null)
setTimeRangeSelection(exportDefaultDateRangeSelection)
setTimeRangeSelection(dynamicDefaultRangeSelection)
setOptions(prev => {
const nextDateRange = cloneExportDateRange(exportDefaultDateRangeSelection.dateRange)
const nextDateRange = cloneExportDateRange(dynamicDefaultRangeSelection.dateRange)
const next: ExportOptions = {
...prev,
format: exportDefaultFormat,
exportAvatars: exportDefaultAvatars,
useAllTime: exportDefaultDateRangeSelection.useAllTime,
useAllTime: dynamicDefaultRangeSelection.useAllTime,
dateRange: nextDateRange,
exportMedia: Boolean(
exportDefaultMedia.images ||
@@ -4863,9 +4878,13 @@ function ExportPage() {
setTimeRangeBounds(null)
}, [])
const resolveChatExportTimeRangeBounds = useCallback(async (sessionIds: string[]): Promise<TimeRangeBounds | null> => {
const resolveChatExportTimeRangeBounds = useCallback(async (
sessionIds: string[],
options?: { forceRefresh?: boolean }
): Promise<TimeRangeBounds | null> => {
const normalizedSessionIds = Array.from(new Set((sessionIds || []).map(id => String(id || '').trim()).filter(Boolean)))
if (normalizedSessionIds.length === 0) return null
const forceRefresh = options?.forceRefresh === true
const sessionRowMap = new Map<string, SessionRow>()
for (const session of sessions) {
@@ -4928,29 +4947,36 @@ function ExportPage() {
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) {
if (forceRefresh) {
applyStatsResult(await window.electronAPI.chat.getExportSessionStats(
sessionsNeedingFreshStats,
{ includeRelations: false }
normalizedSessionIds,
{ includeRelations: false, forceRefresh: true }
))
} else {
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) {
@@ -4971,14 +4997,26 @@ function ExportPage() {
if (isResolvingTimeRangeBounds) return
setIsResolvingTimeRangeBounds(true)
try {
const liveSelection = resolveDynamicExportSelection(timeRangeSelection, new Date())
if (!areExportSelectionsEqual(liveSelection, timeRangeSelection)) {
setTimeRangeSelection(liveSelection)
setOptions(prev => ({
...prev,
useAllTime: liveSelection.useAllTime,
dateRange: cloneExportDateRange(liveSelection.dateRange)
}))
}
let nextBounds: TimeRangeBounds | null = null
if (exportDialog.scope !== 'sns') {
nextBounds = await resolveChatExportTimeRangeBounds(exportDialog.sessionIds)
nextBounds = await resolveChatExportTimeRangeBounds(exportDialog.sessionIds, {
forceRefresh: exportDialog.scope === 'single'
})
}
setTimeRangeBounds(nextBounds)
if (nextBounds) {
const nextSelection = clampExportSelectionToBounds(timeRangeSelection, nextBounds)
if (!areExportSelectionsEqual(nextSelection, timeRangeSelection)) {
const nextSelection = clampExportSelectionToBounds(liveSelection, nextBounds)
if (!areExportSelectionsEqual(nextSelection, liveSelection)) {
setTimeRangeSelection(nextSelection)
setOptions(prev => ({
...prev,
@@ -5056,47 +5094,51 @@ function ExportPage() {
return unsubscribe
}, [loadBaseConfig, openExportDialog])
const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => {
const buildExportOptions = (
scope: TaskScope,
contentType?: ContentType,
sourceOptions: ExportOptions = options
): ElectronExportOptions => {
const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared'
const exportMediaEnabled = Boolean(
options.exportImages ||
options.exportVoices ||
options.exportVideos ||
options.exportEmojis ||
options.exportFiles
sourceOptions.exportImages ||
sourceOptions.exportVoices ||
sourceOptions.exportVideos ||
sourceOptions.exportEmojis ||
sourceOptions.exportFiles
)
const base: ElectronExportOptions = {
format: options.format,
exportAvatars: options.exportAvatars,
format: sourceOptions.format,
exportAvatars: sourceOptions.exportAvatars,
exportMedia: exportMediaEnabled,
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
excelCompactColumns: options.excelCompactColumns,
txtColumns: options.txtColumns,
displayNamePreference: options.displayNamePreference,
exportConcurrency: options.exportConcurrency,
exportImages: sourceOptions.exportImages,
exportVoices: sourceOptions.exportVoices,
exportVideos: sourceOptions.exportVideos,
exportEmojis: sourceOptions.exportEmojis,
exportFiles: sourceOptions.exportFiles,
maxFileSizeMb: sourceOptions.maxFileSizeMb,
exportVoiceAsText: sourceOptions.exportVoiceAsText,
excelCompactColumns: sourceOptions.excelCompactColumns,
txtColumns: sourceOptions.txtColumns,
displayNamePreference: sourceOptions.displayNamePreference,
exportConcurrency: sourceOptions.exportConcurrency,
fileNamingMode: exportDefaultFileNamingMode,
sessionLayout,
sessionNameWithTypePrefix,
dateRange: options.useAllTime
dateRange: sourceOptions.useAllTime
? null
: options.dateRange
: sourceOptions.dateRange
? {
start: Math.floor(options.dateRange.start.getTime() / 1000),
end: Math.floor(options.dateRange.end.getTime() / 1000)
start: Math.floor(sourceOptions.dateRange.start.getTime() / 1000),
end: Math.floor(sourceOptions.dateRange.end.getTime() / 1000)
}
: null
}
if (scope === 'content' && contentType) {
if (contentType === 'text') {
const textExportConcurrency = Math.min(2, Math.max(1, base.exportConcurrency ?? options.exportConcurrency))
const textExportConcurrency = Math.min(2, Math.max(1, base.exportConcurrency ?? sourceOptions.exportConcurrency))
return {
...base,
contentType,
@@ -5127,14 +5169,14 @@ function ExportPage() {
return base
}
const buildSnsExportOptions = () => {
const buildSnsExportOptions = (sourceOptions: ExportOptions = options) => {
const format: SnsTimelineExportFormat = snsExportFormat
const dateRange = options.useAllTime
const dateRange = sourceOptions.useAllTime
? null
: options.dateRange
: sourceOptions.dateRange
? {
startTime: Math.floor(options.dateRange.start.getTime() / 1000),
endTime: Math.floor(options.dateRange.end.getTime() / 1000)
startTime: Math.floor(sourceOptions.dateRange.start.getTime() / 1000),
endTime: Math.floor(sourceOptions.dateRange.end.getTime() / 1000)
}
: null
@@ -5946,12 +5988,27 @@ function ExportPage() {
if (!exportDialog.open || !exportFolder) return
if (exportDialog.scope !== 'sns' && exportDialog.sessionIds.length === 0) return
const effectiveRangeSelection = resolveDynamicExportSelection(timeRangeSelection, new Date())
if (!areExportSelectionsEqual(effectiveRangeSelection, timeRangeSelection)) {
setTimeRangeSelection(effectiveRangeSelection)
}
const effectiveOptionsState: ExportOptions = {
...options,
useAllTime: effectiveRangeSelection.useAllTime,
dateRange: cloneExportDateRange(effectiveRangeSelection.dateRange)
}
setOptions(prev => ({
...prev,
useAllTime: effectiveOptionsState.useAllTime,
dateRange: cloneExportDateRange(effectiveRangeSelection.dateRange)
}))
const isAutomationCreateIntent = exportDialog.intent === 'automation-create'
const exportOptions = exportDialog.scope === 'sns'
? undefined
: buildExportOptions(exportDialog.scope, exportDialog.contentType)
: buildExportOptions(exportDialog.scope, exportDialog.contentType, effectiveOptionsState)
const snsOptions = exportDialog.scope === 'sns'
? buildSnsExportOptions()
? buildSnsExportOptions(effectiveOptionsState)
: undefined
const title =
exportDialog.scope === 'single'
@@ -5968,7 +6025,7 @@ function ExportPage() {
return
}
const { dateRange: _discard, ...optionTemplate } = exportOptions
const normalizedRangeSelection = cloneExportDateRangeSelection(timeRangeSelection)
const normalizedRangeSelection = cloneExportDateRangeSelection(effectiveRangeSelection)
const scope = exportDialog.scope === 'single'
? 'single'
: exportDialog.scope === 'content'