diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 346362b..e69c5eb 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -6463,14 +6463,6 @@ class ExportService { failCount++ failedSessionIds.push(sessionId) console.error(`导出 ${sessionId} 失败:`, result.error) - onProgress?.({ - current: computeAggregateCurrent(), - total: sessionIds.length, - currentSession: sessionInfo.displayName, - currentSessionId: sessionId, - phase: 'complete', - phaseLabel: '导出失败' - }) } activeSessionRatios.delete(sessionId) @@ -6480,7 +6472,8 @@ class ExportService { total: sessionIds.length, currentSession: sessionInfo.displayName, currentSessionId: sessionId, - phase: 'exporting' + phase: 'complete', + phaseLabel: result.success ? '完成' : '导出失败' }) return 'done' } catch (error) { diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 799220a..3471090 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1901,6 +1901,56 @@ } } + .detail-record-empty { + padding: 10px 12px; + border-radius: 8px; + background: var(--bg-secondary); + font-size: 12px; + color: var(--text-secondary); + } + + .detail-record-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + .detail-record-item { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 8px 10px; + background: var(--bg-primary); + + .record-row { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + font-size: 12px; + + .label { + color: var(--text-secondary); + width: 56px; + flex-shrink: 0; + } + + .value { + color: var(--text-primary); + flex: 1; + text-align: right; + word-break: break-all; + + &.path { + text-align: left; + } + } + + .detail-inline-btn { + flex-shrink: 0; + } + } + } + .table-list { display: flex; flex-direction: column; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index ada1741..9ef8fec 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -151,6 +151,10 @@ const contentTypeLabels: Record = { emoji: '表情包' } +const getContentTypeLabel = (type: ContentType): string => { + return contentTypeLabels[type] || type +} + const formatOptions: Array<{ value: TextExportFormat; label: string; desc: string }> = [ { value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' }, { value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' }, @@ -385,6 +389,14 @@ const formatYmdHmDateTime = (timestamp?: number): string => { return `${y}-${m}-${day} ${h}:${min}` } +const formatPathBrief = (value: string, maxLength = 52): string => { + const normalized = String(value || '') + if (normalized.length <= maxLength) return normalized + const headLength = Math.max(10, Math.floor(maxLength * 0.55)) + const tailLength = Math.max(8, maxLength - headLength - 1) + return `${normalized.slice(0, headLength)}…${normalized.slice(-tailLength)}` +} + const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => { if (!timestamp) return '' const diff = Math.max(0, now - timestamp) @@ -931,6 +943,7 @@ function ExportPage() { const [tasks, setTasks] = useState([]) const [lastExportBySession, setLastExportBySession] = useState>({}) const [lastExportByContent, setLastExportByContent] = useState>({}) + const [exportRecordsBySession, setExportRecordsBySession] = useState>({}) const [lastSnsExportPostCount, setLastSnsExportPostCount] = useState(0) const [snsStats, setSnsStats] = useState<{ totalPosts: number; totalFriends: number }>({ totalPosts: 0, @@ -1331,7 +1344,7 @@ function ExportPage() { setIsBaseConfigLoading(true) let isReady = true try { - const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount, exportCacheScope] = await Promise.all([ + const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, exportCacheScope] = await Promise.all([ configService.getExportPath(), configService.getExportDefaultFormat(), configService.getExportDefaultMedia(), @@ -1342,6 +1355,7 @@ function ExportPage() { configService.getExportWriteLayout(), configService.getExportLastSessionRunMap(), configService.getExportLastContentRunMap(), + configService.getExportSessionRecordMap(), configService.getExportLastSnsPostCount(), ensureExportCacheScope() ]) @@ -1358,6 +1372,7 @@ function ExportPage() { setWriteLayout(savedWriteLayout) setLastExportBySession(savedSessionMap) setLastExportByContent(savedContentMap) + setExportRecordsBySession(savedSessionRecordMap) setLastSnsExportPostCount(savedSnsPostCount) if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) { @@ -2252,6 +2267,67 @@ function ExportPage() { }) }, []) + const resolveTaskExportContentLabel = useCallback((payload: ExportTaskPayload): string => { + if (payload.scope === 'content' && payload.contentType) { + return getContentTypeLabel(payload.contentType) + } + if (payload.scope === 'sns') return '朋友圈' + + const labels: string[] = ['聊天文本'] + const opts = payload.options + if (opts?.exportMedia) { + if (opts.exportImages) labels.push('图片') + if (opts.exportVoices) labels.push('语音') + if (opts.exportVideos) labels.push('视频') + if (opts.exportEmojis) labels.push('表情包') + } + return Array.from(new Set(labels)).join('、') + }, []) + + const markSessionExportRecords = useCallback(( + sessionIds: string[], + content: string, + outputDir: string, + exportTime: number + ) => { + const normalizedContent = String(content || '').trim() + const normalizedOutputDir = String(outputDir || '').trim() + const normalizedExportTime = Number.isFinite(exportTime) ? Math.max(0, Math.floor(exportTime)) : Date.now() + if (!normalizedContent || !normalizedOutputDir) return + if (!Array.isArray(sessionIds) || sessionIds.length === 0) return + + setExportRecordsBySession(prev => { + const next: Record = { ...prev } + let changed = false + + for (const rawSessionId of sessionIds) { + const sessionId = String(rawSessionId || '').trim() + if (!sessionId) continue + const existingList = Array.isArray(next[sessionId]) ? [...next[sessionId]] : [] + const lastRecord = existingList[existingList.length - 1] + if ( + lastRecord && + lastRecord.content === normalizedContent && + lastRecord.outputDir === normalizedOutputDir && + Math.abs(Number(lastRecord.exportTime || 0) - normalizedExportTime) <= 2000 + ) { + continue + } + existingList.push({ + exportTime: normalizedExportTime, + content: normalizedContent, + outputDir: normalizedOutputDir + }) + next[sessionId] = existingList.slice(-80) + changed = true + } + + if (!changed) return prev + void configService.setExportSessionRecordMap(next) + return next + }) + }, []) + const inferContentTypesFromOptions = (opts: ElectronExportOptions): ContentType[] => { const types: ContentType[] = ['text'] if (opts.exportMedia) { @@ -2286,6 +2362,7 @@ function ExportPage() { ? (task.performance || createEmptyTaskPerformance()) : task.performance })) + const taskExportContentLabel = resolveTaskExportContentLabel(next.payload) progressUnsubscribeRef.current?.() const settledSessionIdsFromProgress = new Set() @@ -2323,6 +2400,7 @@ function ExportPage() { if (contentTypes.length > 0) { markContentExported([currentSessionId], contentTypes, now) } + markSessionExportRecords([currentSessionId], taskExportContentLabel, next.payload.outputDir, now) } } @@ -2427,8 +2505,14 @@ function ExportPage() { ? result.successSessionIds : [] if (successSessionIds.length > 0) { - markSessionExported(successSessionIds, doneAt) - markContentExported(successSessionIds, contentTypes, doneAt) + const unsettledSuccessSessionIds = successSessionIds.filter((sessionId) => !settledSessionIdsFromProgress.has(sessionId)) + if (unsettledSuccessSessionIds.length > 0) { + markSessionExported(unsettledSuccessSessionIds, doneAt) + markSessionExportRecords(unsettledSuccessSessionIds, taskExportContentLabel, next.payload.outputDir, doneAt) + if (contentTypes.length > 0) { + markContentExported(unsettledSuccessSessionIds, contentTypes, doneAt) + } + } } updateTask(next.id, task => ({ @@ -2462,7 +2546,15 @@ function ExportPage() { runningTaskIdRef.current = null void runNextTask() } - }, [updateTask, markSessionExported, markContentExported, loadSnsStats, lastSnsExportPostCount]) + }, [ + updateTask, + markSessionExported, + markSessionExportRecords, + markContentExported, + resolveTaskExportContentLabel, + loadSnsStats, + lastSnsExportPostCount + ]) useEffect(() => { void runNextTask() @@ -2945,6 +3037,15 @@ function ExportPage() { return map }, [contactsList]) + const currentSessionExportRecords = useMemo(() => { + const sessionId = String(sessionDetail?.wxid || '').trim() + if (!sessionId) return [] as configService.ExportSessionRecordEntry[] + const records = Array.isArray(exportRecordsBySession[sessionId]) ? exportRecordsBySession[sessionId] : [] + return [...records] + .sort((a, b) => Number(b.exportTime || 0) - Number(a.exportTime || 0)) + .slice(0, 20) + }, [sessionDetail?.wxid, exportRecordsBySession]) + const applySessionDetailStats = useCallback(( sessionId: string, metric: SessionExportMetric, @@ -4253,6 +4354,42 @@ function ExportPage() { )} +
+
+ + 导出记录(最近 20 条) +
+ {currentSessionExportRecords.length === 0 ? ( +
暂无导出记录
+ ) : ( +
+ {currentSessionExportRecords.map((record, index) => ( +
+
+ 导出时间 + {formatYmdHmDateTime(record.exportTime)} +
+
+ 导出内容 + {record.content} +
+
+ 导出目录 + {formatPathBrief(record.outputDir)} + +
+
+ ))} +
+ )} +
+
diff --git a/src/services/config.ts b/src/services/config.ts index 7dbe239..b77cebe 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -35,6 +35,7 @@ export const CONFIG_KEYS = { EXPORT_WRITE_LAYOUT: 'exportWriteLayout', EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap', EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap', + EXPORT_SESSION_RECORD_MAP: 'exportSessionRecordMap', EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount', EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap', EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap', @@ -443,6 +444,44 @@ export async function setExportLastContentRunMap(map: Record): P await config.set(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP, map) } +export interface ExportSessionRecordEntry { + exportTime: number + content: string + outputDir: string +} + +export async function getExportSessionRecordMap(): Promise> { + const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_RECORD_MAP) + if (!value || typeof value !== 'object') return {} + const map: Record = {} + const entries = Object.entries(value as Record) + for (const [sessionId, rawList] of entries) { + if (!Array.isArray(rawList)) continue + const normalizedList: ExportSessionRecordEntry[] = [] + for (const rawItem of rawList) { + if (!rawItem || typeof rawItem !== 'object') continue + const exportTime = Number((rawItem as Record).exportTime) + const content = String((rawItem as Record).content || '').trim() + const outputDir = String((rawItem as Record).outputDir || '').trim() + if (!Number.isFinite(exportTime) || exportTime <= 0) continue + if (!content || !outputDir) continue + normalizedList.push({ + exportTime: Math.floor(exportTime), + content, + outputDir + }) + } + if (normalizedList.length > 0) { + map[sessionId] = normalizedList + } + } + return map +} + +export async function setExportSessionRecordMap(map: Record): Promise { + await config.set(CONFIG_KEYS.EXPORT_SESSION_RECORD_MAP, map) +} + export async function getExportLastSnsPostCount(): Promise { const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT) if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {