From 5945942acd7aa8497f65212949767e055eeef42c Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Thu, 5 Mar 2026 14:43:03 +0800 Subject: [PATCH 01/97] =?UTF-8?q?=E4=BF=AE=E5=A4=8Ddev=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E8=BF=98=E6=9C=89=E8=BF=9B=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/electron/main.ts b/electron/main.ts index 4ec00b7..b91f2ad 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2372,6 +2372,13 @@ app.whenReady().then(async () => { }) }) +app.on('before-quit', async () => { + // 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出 + try { await httpService.stop() } catch {} + // 终止 wcdb Worker 线程,避免线程阻止进程退出 + try { wcdbService.shutdown() } catch {} +}) + app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() From 2a9f0f24fd9e4bbafdc6902d2665cf38a5678ecf Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 15:39:59 +0800 Subject: [PATCH 02/97] perf(export): speed up session detail stats loading --- electron/services/chatService.ts | 49 ++++++++-------- src/pages/ExportPage.tsx | 99 +++++++++++--------------------- 2 files changed, 56 insertions(+), 92 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index e50817e..4c5e43e 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -5209,39 +5209,36 @@ class ChatService { return { success: true, detail: cachedDetail.detail } } - const [tableStatsResult, statsResult] = await Promise.allSettled([ - wcdbService.getMessageTableStats(normalizedSessionId), - (async (): Promise => { - const cachedStats = this.getSessionStatsCacheEntry(normalizedSessionId) - if (cachedStats && this.supportsRequestedRelation(cachedStats.entry, false)) { - return this.fromSessionStatsCacheStats(cachedStats.entry.stats) - } - const myWxid = this.configService.get('myWxid') || '' - const selfIdentitySet = new Set(this.buildIdentityKeys(myWxid)) - const stats = await this.getOrComputeSessionExportStats(normalizedSessionId, false, selfIdentitySet) - this.setSessionStatsCacheEntry(normalizedSessionId, stats, false) - return stats - })() - ]) - - const statsSnapshot = statsResult.status === 'fulfilled' - ? statsResult.value - : null - const firstMessageTime = statsSnapshot && Number.isFinite(statsSnapshot.firstTimestamp) - ? Math.max(0, Math.floor(statsSnapshot.firstTimestamp as number)) - : undefined - const latestMessageTime = statsSnapshot && Number.isFinite(statsSnapshot.lastTimestamp) - ? Math.max(0, Math.floor(statsSnapshot.lastTimestamp as number)) - : undefined + const tableStatsResult = await wcdbService.getMessageTableStats(normalizedSessionId) const messageTables: { dbName: string; tableName: string; count: number }[] = [] - if (tableStatsResult.status === 'fulfilled' && tableStatsResult.value.success && tableStatsResult.value.tables) { - for (const row of tableStatsResult.value.tables) { + let firstMessageTime: number | undefined + let latestMessageTime: number | undefined + if (tableStatsResult.success && tableStatsResult.tables) { + for (const row of tableStatsResult.tables) { messageTables.push({ dbName: basename(row.db_path || ''), tableName: row.table_name || '', count: parseInt(row.count || '0', 10) }) + + const firstTs = this.getRowInt( + row, + ['first_timestamp', 'firstTimestamp', 'first_time', 'firstTime', 'min_create_time', 'minCreateTime'], + 0 + ) + if (firstTs > 0 && (firstMessageTime === undefined || firstTs < firstMessageTime)) { + firstMessageTime = firstTs + } + + const lastTs = this.getRowInt( + row, + ['last_timestamp', 'lastTimestamp', 'last_time', 'lastTime', 'max_create_time', 'maxCreateTime'], + 0 + ) + if (lastTs > 0 && (latestMessageTime === undefined || lastTs > latestMessageTime)) { + latestMessageTime = lastTs + } } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 3b56b13..01e219f 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -3514,7 +3514,7 @@ function ExportPage() { window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - { includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } + { includeRelations: false, allowStaleCache: true } ) ]) @@ -3535,59 +3535,56 @@ function ExportPage() { } } - let refreshIncludeRelations = false - let shouldRefreshStats = false if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) { const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined - refreshIncludeRelations = Boolean(cacheMeta?.includeRelations) if (metric) { - applySessionDetailStats(normalizedSessionId, metric, cacheMeta, refreshIncludeRelations) + applySessionDetailStats(normalizedSessionId, metric, cacheMeta, false) } else if (cacheMeta) { setSessionDetail((prev) => { if (!prev || prev.wxid !== normalizedSessionId) return prev return { ...prev, - relationStatsLoaded: refreshIncludeRelations || prev.relationStatsLoaded, statsUpdatedAt: cacheMeta.updatedAt, statsStale: cacheMeta.stale } }) } - shouldRefreshStats = Array.isArray(statsResultSettled.value.needsRefresh) && - statsResultSettled.value.needsRefresh.includes(normalizedSessionId) } - if (shouldRefreshStats) { - setIsRefreshingSessionDetailStats(true) - void (async () => { - try { - const freshResult = await window.electronAPI.chat.getExportSessionStats( - [normalizedSessionId], - { includeRelations: refreshIncludeRelations, forceRefresh: true, preferAccurateSpecialTypes: true } - ) - if (requestSeq !== detailRequestSeqRef.current) return - if (freshResult.success && freshResult.data) { - const metric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined - const cacheMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined - if (metric) { - applySessionDetailStats( - normalizedSessionId, - metric, - cacheMeta, - refreshIncludeRelations ? true : undefined - ) - } - } - } catch (error) { - console.error('导出页刷新会话统计失败:', error) - } finally { - if (requestSeq === detailRequestSeqRef.current) { - setIsRefreshingSessionDetailStats(false) + setIsRefreshingSessionDetailStats(true) + void (async () => { + try { + // 后台精确补算三类重字段(转账/红包/通话),不阻塞首屏基础统计显示。 + const freshResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (freshResult.success && freshResult.data) { + const metric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined + const cacheMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (metric) { + applySessionDetailStats(normalizedSessionId, metric, cacheMeta, false) + } else if (cacheMeta) { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + statsUpdatedAt: cacheMeta.updatedAt, + statsStale: cacheMeta.stale + } + }) } } - })() - } + } catch (error) { + console.error('导出页刷新会话统计失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsRefreshingSessionDetailStats(false) + } + } + })() } catch (error) { console.error('导出页加载会话详情补充统计失败:', error) } finally { @@ -3619,36 +3616,6 @@ function ExportPage() { if (metric) { applySessionDetailStats(normalizedSessionId, metric, cacheMeta, true) } - - const needRefresh = relationResult.success && - Array.isArray(relationResult.needsRefresh) && - relationResult.needsRefresh.includes(normalizedSessionId) - - if (needRefresh) { - setIsRefreshingSessionDetailStats(true) - void (async () => { - try { - const freshResult = await window.electronAPI.chat.getExportSessionStats( - [normalizedSessionId], - { includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true } - ) - if (requestSeq !== detailRequestSeqRef.current) return - if (freshResult.success && freshResult.data) { - const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined - const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined - if (freshMetric) { - applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true) - } - } - } catch (error) { - console.error('导出页刷新会话关系统计失败:', error) - } finally { - if (requestSeq === detailRequestSeqRef.current) { - setIsRefreshingSessionDetailStats(false) - } - } - })() - } } catch (error) { console.error('导出页加载会话关系统计失败:', error) } finally { From a5358b82f67221c6fc184b2c71e694c49b08b865 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 16:05:58 +0800 Subject: [PATCH 03/97] perf(export): further optimize detail loading and prioritize session stats --- electron/main.ts | 51 ++++++++-- electron/preload.ts | 12 ++- electron/services/chatService.ts | 48 ++++----- src/App.tsx | 6 +- src/pages/ChatPage.tsx | 21 ++-- src/pages/ExportPage.tsx | 162 +++++++++++++++++++------------ src/types/electron.d.ts | 14 ++- 7 files changed, 205 insertions(+), 109 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index b91f2ad..7770856 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -87,6 +87,7 @@ let onboardingWindow: BrowserWindow | null = null // Splash 启动窗口 let splashWindow: BrowserWindow | null = null const sessionChatWindows = new Map() +const sessionChatWindowSources = new Map() const keyService = new KeyService() let mainWindowReady = false @@ -123,6 +124,32 @@ interface AnnualReportYearsTaskState { updatedAt: number } +interface OpenSessionChatWindowOptions { + source?: 'chat' | 'export' +} + +const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => { + return String(source || '').trim().toLowerCase() === 'export' ? 'export' : 'chat' +} + +const loadSessionChatWindowContent = ( + win: BrowserWindow, + sessionId: string, + source: 'chat' | 'export' +) => { + const query = new URLSearchParams({ + sessionId, + source + }).toString() + if (process.env.VITE_DEV_SERVER_URL) { + win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${query}`) + return + } + win.loadFile(join(__dirname, '../dist/index.html'), { + hash: `/chat-window?${query}` + }) +} + const annualReportYearsLoadTasks = new Map() const annualReportYearsTaskByCacheKey = new Map() const annualReportYearsSnapshotCache = new Map() @@ -688,12 +715,18 @@ function createChatHistoryWindow(sessionId: string, messageId: number) { /** * 创建独立的会话聊天窗口(单会话,复用聊天页右侧消息区域) */ -function createSessionChatWindow(sessionId: string) { +function createSessionChatWindow(sessionId: string, options?: OpenSessionChatWindowOptions) { const normalizedSessionId = String(sessionId || '').trim() if (!normalizedSessionId) return null + const normalizedSource = normalizeSessionChatWindowSource(options?.source) const existing = sessionChatWindows.get(normalizedSessionId) if (existing && !existing.isDestroyed()) { + const trackedSource = sessionChatWindowSources.get(normalizedSessionId) || 'chat' + if (trackedSource !== normalizedSource) { + loadSessionChatWindowContent(existing, normalizedSessionId, normalizedSource) + sessionChatWindowSources.set(normalizedSessionId, normalizedSource) + } if (existing.isMinimized()) { existing.restore() } @@ -730,10 +763,9 @@ function createSessionChatWindow(sessionId: string) { autoHideMenuBar: true }) - const sessionParam = `sessionId=${encodeURIComponent(normalizedSessionId)}` - if (process.env.VITE_DEV_SERVER_URL) { - win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${sessionParam}`) + loadSessionChatWindowContent(win, normalizedSessionId, normalizedSource) + if (process.env.VITE_DEV_SERVER_URL) { win.webContents.on('before-input-event', (event, input) => { if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { if (win.webContents.isDevToolsOpened()) { @@ -744,10 +776,6 @@ function createSessionChatWindow(sessionId: string) { event.preventDefault() } }) - } else { - win.loadFile(join(__dirname, '../dist/index.html'), { - hash: `/chat-window?${sessionParam}` - }) } win.once('ready-to-show', () => { @@ -759,10 +787,12 @@ function createSessionChatWindow(sessionId: string) { const tracked = sessionChatWindows.get(normalizedSessionId) if (tracked === win) { sessionChatWindows.delete(normalizedSessionId) + sessionChatWindowSources.delete(normalizedSessionId) } }) sessionChatWindows.set(normalizedSessionId, win) + sessionChatWindowSources.set(normalizedSessionId, normalizedSource) return win } @@ -1071,8 +1101,8 @@ function registerIpcHandlers() { }) // 打开会话聊天窗口(同会话仅保留一个窗口并聚焦) - ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string) => { - const win = createSessionChatWindow(sessionId) + ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string, options?: OpenSessionChatWindowOptions) => { + const win = createSessionChatWindow(sessionId, options) return Boolean(win) }) @@ -1410,6 +1440,7 @@ function registerIpcHandlers() { forceRefresh?: boolean allowStaleCache?: boolean preferAccurateSpecialTypes?: boolean + cacheOnly?: boolean }) => { return chatService.getExportSessionStats(sessionIds, options) }) diff --git a/electron/preload.ts b/electron/preload.ts index c173d10..3c51ee3 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -99,8 +99,8 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath), openChatHistoryWindow: (sessionId: string, messageId: number) => ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId), - openSessionChatWindow: (sessionId: string) => - ipcRenderer.invoke('window:openSessionChatWindow', sessionId) + openSessionChatWindow: (sessionId: string, options?: { source?: 'chat' | 'export' }) => + ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options) }, // 数据库路径 @@ -174,7 +174,13 @@ contextBridge.exposeInMainWorld('electronAPI', { getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId), getExportSessionStats: ( sessionIds: string[], - options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean } + options?: { + includeRelations?: boolean + forceRefresh?: boolean + allowStaleCache?: boolean + preferAccurateSpecialTypes?: boolean + cacheOnly?: boolean + } ) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options), getGroupMyMessageCountHint: (chatroomId: string) => ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 4c5e43e..68cb598 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -164,6 +164,7 @@ interface ExportSessionStatsOptions { forceRefresh?: boolean allowStaleCache?: boolean preferAccurateSpecialTypes?: boolean + cacheOnly?: boolean } interface ExportSessionStatsCacheMeta { @@ -5354,6 +5355,7 @@ class ChatService { const forceRefresh = options.forceRefresh === true const allowStaleCache = options.allowStaleCache === true const preferAccurateSpecialTypes = options.preferAccurateSpecialTypes === true + const cacheOnly = options.cacheOnly === true const normalizedSessionIds = Array.from( new Set( @@ -5377,32 +5379,34 @@ class ChatService { ? this.getGroupMyMessageCountHintEntry(sessionId) : null const cachedResult = this.getSessionStatsCacheEntry(sessionId) - if (!forceRefresh && !preferAccurateSpecialTypes) { - if (cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) { - const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs - if (!stale || allowStaleCache) { - resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats) - if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) { - resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount - } - cacheMeta[sessionId] = { - updatedAt: cachedResult.entry.updatedAt, - stale, - includeRelations: cachedResult.entry.includeRelations, - source: cachedResult.source - } - if (stale) { - needsRefreshSet.add(sessionId) - } - continue + const canUseCache = cacheOnly || (!forceRefresh && !preferAccurateSpecialTypes) + if (canUseCache && cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) { + const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs + if (!stale || allowStaleCache || cacheOnly) { + resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats) + if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) { + resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount + } + cacheMeta[sessionId] = { + updatedAt: cachedResult.entry.updatedAt, + stale, + includeRelations: cachedResult.entry.includeRelations, + source: cachedResult.source + } + if (stale) { + needsRefreshSet.add(sessionId) } - } - // allowStaleCache 仅对“已有缓存”生效;无缓存会话仍需进入计算流程。 - if (allowStaleCache && cachedResult) { - needsRefreshSet.add(sessionId) continue } } + // allowStaleCache/cacheOnly 仅对“已有缓存”生效;无缓存会话不会直接算重查询。 + if (canUseCache && allowStaleCache && cachedResult) { + needsRefreshSet.add(sessionId) + continue + } + if (cacheOnly) { + continue + } pendingSessionIds.push(sessionId) } diff --git a/src/App.tsx b/src/App.tsx index 9d040d2..05d1990 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -402,8 +402,10 @@ function App() { // 独立会话聊天窗口(仅显示聊天内容区域) if (isStandaloneChatWindow) { - const sessionId = new URLSearchParams(location.search).get('sessionId') || '' - return + const params = new URLSearchParams(location.search) + const sessionId = params.get('sessionId') || '' + const standaloneSource = params.get('source') + return } // 独立通知窗口 diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 90df9aa..56d7ba5 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -204,6 +204,7 @@ function formatYmdHmDateTime(timestamp?: number): string { interface ChatPageProps { standaloneSessionWindow?: boolean initialSessionId?: string | null + standaloneSource?: string | null } @@ -408,8 +409,10 @@ const SessionItem = React.memo(function SessionItem({ function ChatPage(props: ChatPageProps) { - const { standaloneSessionWindow = false, initialSessionId = null } = props + const { standaloneSessionWindow = false, initialSessionId = null, standaloneSource = null } = props const normalizedInitialSessionId = useMemo(() => String(initialSessionId || '').trim(), [initialSessionId]) + const normalizedStandaloneSource = useMemo(() => String(standaloneSource || '').trim().toLowerCase(), [standaloneSource]) + const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export' const navigate = useNavigate() const { @@ -3863,13 +3866,15 @@ function ChatPage(props: ChatPageProps) { > - + {!shouldHideStandaloneDetailButton && ( + + )} diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 01e219f..0615f8b 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -163,6 +163,7 @@ interface TimeRangeDialogDraft { } const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] +const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000 const contentTypeLabels: Record = { text: '聊天文本', voice: '语音', @@ -1307,6 +1308,8 @@ function ExportPage() { const hasBaseConfigReadyRef = useRef(false) const sessionCountRequestIdRef = useRef(0) const activeTabRef = useRef('private') + const detailStatsPriorityRef = useRef(false) + const sessionPreciseRefreshAtRef = useRef>({}) const ensureExportCacheScope = useCallback(async (): Promise => { if (exportCacheScopeReadyRef.current) { @@ -1894,6 +1897,9 @@ function ExportPage() { setIsLoadingSessionCounts(true) try { + if (detailStatsPriorityRef.current) { + return { ...accumulatedCounts } + } if (prioritizedSessionIds.length > 0) { const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds) if (isStale()) return { ...accumulatedCounts } @@ -1902,6 +1908,9 @@ function ExportPage() { } } + if (detailStatsPriorityRef.current) { + return { ...accumulatedCounts } + } if (remainingSessionIds.length > 0) { const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds) if (isStale()) return { ...accumulatedCounts } @@ -1930,6 +1939,7 @@ function ExportPage() { const loadToken = Date.now() sessionLoadTokenRef.current = loadToken sessionsHydratedAtRef.current = 0 + sessionPreciseRefreshAtRef.current = {} setIsLoading(true) setIsSessionEnriching(false) sessionCountRequestIdRef.current += 1 @@ -2027,12 +2037,14 @@ function ExportPage() { setIsSessionEnriching(true) void (async () => { try { + if (detailStatsPriorityRef.current) return let contactMap = { ...cachedContactMap } let avatarEntries = { ...cachedAvatarEntries } let hasFreshNetworkData = false let hasNetworkContactsSnapshot = false if (isStale()) return + if (detailStatsPriorityRef.current) return const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) if (isStale()) return @@ -2091,6 +2103,7 @@ function ExportPage() { if (needsEnrichment.length > 0) { for (let i = 0; i < needsEnrichment.length; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) { if (isStale()) return + if (detailStatsPriorityRef.current) return const batch = needsEnrichment.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE) if (batch.length === 0) continue try { @@ -3399,6 +3412,11 @@ function ExportPage() { const loadSessionDetail = useCallback(async (sessionId: string) => { const normalizedSessionId = String(sessionId || '').trim() if (!normalizedSessionId) return + const preciseCacheKey = `${exportCacheScopeRef.current}::${normalizedSessionId}` + + detailStatsPriorityRef.current = true + sessionCountRequestIdRef.current += 1 + setIsLoadingSessionCounts(false) const requestSeq = ++detailRequestSeqRef.current const mappedSession = sessionRowByUsername.get(normalizedSessionId) @@ -3510,19 +3528,13 @@ function ExportPage() { } try { - const [extraResultSettled, statsResultSettled] = await Promise.allSettled([ - window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), - window.electronAPI.chat.getExportSessionStats( - [normalizedSessionId], - { includeRelations: false, allowStaleCache: true } - ) - ]) - - if (requestSeq !== detailRequestSeqRef.current) return - - if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) { - const detail = extraResultSettled.value.detail - if (detail) { + const extraPromise = window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId) + void (async () => { + try { + const extraResult = await extraPromise + if (requestSeq !== detailRequestSeqRef.current) return + if (!extraResult.success || !extraResult.detail) return + const detail = extraResult.detail setSessionDetail((prev) => { if (!prev || prev.wxid !== normalizedSessionId) return prev return { @@ -3532,62 +3544,86 @@ function ExportPage() { messageTables: Array.isArray(detail.messageTables) ? detail.messageTables : [] } }) - } - } - - if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) { - const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined - const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined - if (metric) { - applySessionDetailStats(normalizedSessionId, metric, cacheMeta, false) - } else if (cacheMeta) { - setSessionDetail((prev) => { - if (!prev || prev.wxid !== normalizedSessionId) return prev - return { - ...prev, - statsUpdatedAt: cacheMeta.updatedAt, - statsStale: cacheMeta.stale - } - }) - } - } - - setIsRefreshingSessionDetailStats(true) - void (async () => { - try { - // 后台精确补算三类重字段(转账/红包/通话),不阻塞首屏基础统计显示。 - const freshResult = await window.electronAPI.chat.getExportSessionStats( - [normalizedSessionId], - { includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } - ) - if (requestSeq !== detailRequestSeqRef.current) return - if (freshResult.success && freshResult.data) { - const metric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined - const cacheMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined - if (metric) { - applySessionDetailStats(normalizedSessionId, metric, cacheMeta, false) - } else if (cacheMeta) { - setSessionDetail((prev) => { - if (!prev || prev.wxid !== normalizedSessionId) return prev - return { - ...prev, - statsUpdatedAt: cacheMeta.updatedAt, - statsStale: cacheMeta.stale - } - }) - } - } } catch (error) { - console.error('导出页刷新会话统计失败:', error) + console.error('导出页加载会话详情补充信息失败:', error) } finally { if (requestSeq === detailRequestSeqRef.current) { - setIsRefreshingSessionDetailStats(false) + setIsLoadingSessionDetailExtra(false) } } })() + + let quickMetric: SessionExportMetric | undefined + let quickCacheMeta: SessionExportCacheMeta | undefined + try { + const quickStatsResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: false, allowStaleCache: true, cacheOnly: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (quickStatsResult.success) { + quickMetric = quickStatsResult.data?.[normalizedSessionId] as SessionExportMetric | undefined + quickCacheMeta = quickStatsResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (quickMetric) { + applySessionDetailStats(normalizedSessionId, quickMetric, quickCacheMeta, false) + } else if (quickCacheMeta) { + const cacheMeta = quickCacheMeta + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + statsUpdatedAt: cacheMeta.updatedAt, + statsStale: cacheMeta.stale + } + }) + } + } + } catch (error) { + console.error('导出页读取会话统计缓存失败:', error) + } + + const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0 + const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS + const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale)) + + if (shouldRunPreciseRefresh) { + setIsRefreshingSessionDetailStats(true) + void (async () => { + try { + // 后台精确补算三类重字段(转账/红包/通话),不阻塞首屏基础统计显示。 + const freshResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (freshResult.success && freshResult.data) { + const metric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined + const cacheMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (metric) { + applySessionDetailStats(normalizedSessionId, metric, cacheMeta, false) + sessionPreciseRefreshAtRef.current[preciseCacheKey] = Date.now() + } else if (cacheMeta) { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + statsUpdatedAt: cacheMeta.updatedAt, + statsStale: cacheMeta.stale + } + }) + } + } + } catch (error) { + console.error('导出页刷新会话统计失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsRefreshingSessionDetailStats(false) + } + } + })() + } } catch (error) { console.error('导出页加载会话详情补充统计失败:', error) - } finally { if (requestSeq === detailRequestSeqRef.current) { setIsLoadingSessionDetailExtra(false) } @@ -3627,6 +3663,7 @@ function ExportPage() { const closeSessionDetailPanel = useCallback(() => { detailRequestSeqRef.current += 1 + detailStatsPriorityRef.current = false setShowSessionDetailPanel(false) setIsLoadingSessionDetail(false) setIsLoadingSessionDetailExtra(false) @@ -3636,6 +3673,7 @@ function ExportPage() { const openSessionDetail = useCallback((sessionId: string) => { if (!sessionId) return + detailStatsPriorityRef.current = true setShowSessionDetailPanel(true) void loadSessionDetail(sessionId) }, [loadSessionDetail]) @@ -3827,7 +3865,7 @@ function ExportPage() { title={canExport ? '在新窗口打开该会话' : '该联系人暂无会话记录'} onClick={() => { if (!canExport) return - void window.electronAPI.window.openSessionChatWindow(contact.username) + void window.electronAPI.window.openSessionChatWindow(contact.username, { source: 'export' }) }} > diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index a3abc38..079d30f 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -1,5 +1,9 @@ import type { ChatSession, Message, Contact, ContactInfo } from './models' +export interface SessionChatWindowOpenOptions { + source?: 'chat' | 'export' +} + export interface ElectronAPI { window: { minimize: () => void @@ -13,7 +17,7 @@ export interface ElectronAPI { resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise openChatHistoryWindow: (sessionId: string, messageId: number) => Promise - openSessionChatWindow: (sessionId: string) => Promise + openSessionChatWindow: (sessionId: string, options?: SessionChatWindowOpenOptions) => Promise } config: { get: (key: string) => Promise @@ -250,7 +254,13 @@ export interface ElectronAPI { }> getExportSessionStats: ( sessionIds: string[], - options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean } + options?: { + includeRelations?: boolean + forceRefresh?: boolean + allowStaleCache?: boolean + preferAccurateSpecialTypes?: boolean + cacheOnly?: boolean + } ) => Promise<{ success: boolean data?: Record Date: Thu, 5 Mar 2026 16:18:00 +0800 Subject: [PATCH 04/97] feat(export): move open-chat action below message count --- src/pages/ExportPage.scss | 57 ++++++++++++++++++++------------------- src/pages/ExportPage.tsx | 23 +++++++++------- 2 files changed, 44 insertions(+), 36 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 270f0c0..481ae45 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1347,8 +1347,10 @@ width: var(--contacts-message-col-width); min-width: var(--contacts-message-col-width); display: flex; + flex-direction: column; align-items: center; justify-content: center; + gap: 4px; flex-shrink: 0; text-align: center; } @@ -1397,6 +1399,30 @@ .row-message-stat.total .row-message-count-value { font-size: 13px; } + + .row-open-chat-link { + border: none; + padding: 0; + margin: 0; + background: transparent; + color: var(--primary); + font-size: 12px; + line-height: 1.2; + font-weight: 600; + cursor: pointer; + + &:hover { + color: var(--primary-hover); + text-decoration: underline; + text-underline-offset: 2px; + } + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 30%, transparent); + outline-offset: 2px; + border-radius: 4px; + } + } } .table-virtuoso { @@ -1546,33 +1572,6 @@ gap: 6px; } - .row-open-chat-btn { - border: 1px solid color-mix(in srgb, var(--primary) 38%, var(--border-color)); - border-radius: 8px; - padding: 7px 10px; - background: color-mix(in srgb, var(--primary) 12%, var(--bg-secondary)); - color: var(--primary); - font-size: 12px; - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 5px; - white-space: nowrap; - - &:hover:not(:disabled) { - background: color-mix(in srgb, var(--primary) 18%, var(--bg-secondary)); - border-color: color-mix(in srgb, var(--primary) 55%, var(--border-color)); - } - - &:disabled { - opacity: 0.65; - cursor: not-allowed; - color: var(--text-tertiary); - border-color: var(--border-color); - background: var(--bg-secondary); - } - } - .row-detail-btn { border: 1px solid var(--border-color); border-radius: 8px; @@ -2704,6 +2703,10 @@ font-size: 12px; } + .table-wrap .row-open-chat-link { + font-size: 11px; + } + .export-dialog-overlay { padding: 10px; } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 0615f8b..a9f4ee8 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -3825,6 +3825,11 @@ function ExportPage() { : typeof displayedMessageCount === 'number' ? displayedMessageCount.toLocaleString('zh-CN') : '获取中' + const openChatLabel = contact.type === 'friend' + ? '打开私聊' + : contact.type === 'group' + ? '打开群聊' + : '打开对话' return (
@@ -3856,21 +3861,21 @@ function ExportPage() { {messageCountLabel}
-
-
-
+ {canExport && ( + )} +
+
+
)}
+
+ {emojiLabel} +
+
+ {voiceLabel} +
+
+ {imageLabel} +
+
+ {videoLabel} +
)} - {isGroupChatSession(currentSession.username) && ( + {isCurrentSessionGroup && (
- {decodeHtmlEntities(post.nickname)} + {formatTime(post.createTime)}
diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index bc52b0c..013eaae 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -179,6 +179,30 @@ flex-shrink: 0; } +.author-trigger-btn { + background: transparent; + border: none; + padding: 0; + margin: 0; + color: inherit; + cursor: pointer; +} + +.avatar-trigger { + border-radius: 12px; + transition: transform 0.15s ease, box-shadow 0.15s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.1); + } + + &:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + } +} + .post-content-col { flex: 1; min-width: 0; @@ -206,6 +230,30 @@ margin-bottom: 2px; } + .author-name-trigger { + align-self: flex-start; + border-radius: 6px; + margin-bottom: 2px; + + .author-name { + transition: color 0.15s ease, text-decoration-color 0.15s ease; + text-decoration: underline; + text-decoration-color: transparent; + text-underline-offset: 2px; + margin-bottom: 0; + } + + &:hover .author-name { + color: var(--primary); + text-decoration-color: currentColor; + } + + &:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + } + } + .post-time { font-size: 12px; color: var(--text-tertiary); @@ -1317,6 +1365,116 @@ } } +.author-timeline-dialog { + background: var(--sns-card-bg); + border-radius: var(--sns-border-radius-lg); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); + width: min(860px, 94vw); + max-height: 86vh; + display: flex; + flex-direction: column; + border: 1px solid var(--border-color); + overflow: hidden; + animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +.author-timeline-header { + padding: 14px 18px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); + display: flex; + align-items: flex-start; + justify-content: space-between; + + .close-btn { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 6px; + border-radius: 6px; + display: flex; + + &:hover { + background: var(--bg-primary); + color: var(--text-primary); + } + } +} + +.author-timeline-meta { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.author-timeline-meta-text { + min-width: 0; + + h3 { + margin: 0; + font-size: 16px; + color: var(--text-primary); + } +} + +.author-timeline-username { + margin-top: 2px; + font-size: 12px; + color: var(--text-secondary); +} + +.author-timeline-stats { + margin-top: 4px; + font-size: 12px; + color: var(--text-secondary); +} + +.author-timeline-body { + padding: 16px; + overflow-y: auto; + min-height: 180px; + max-height: calc(86vh - 96px); +} + +.author-timeline-posts-list { + gap: 16px; +} + +.author-timeline-loading { + margin-top: 12px; +} + +.author-timeline-empty { + padding: 42px 10px 30px; + text-align: center; + font-size: 14px; + color: var(--text-secondary); +} + +.author-timeline-load-more { + display: block; + margin: 12px auto 2px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + border-radius: 999px; + padding: 7px 16px; + font-size: 13px; + cursor: pointer; + + &:hover:not(:disabled) { + color: var(--primary); + border-color: var(--primary); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + @keyframes slide-up-fade { from { opacity: 0; diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index e7caad5..53d34ad 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -5,6 +5,7 @@ import './SnsPage.scss' import { SnsPost } from '../types/sns' import { SnsPostItem } from '../components/Sns/SnsPostItem' import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel' +import { Avatar } from '../components/Avatar' import * as configService from '../services/config' const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000 @@ -28,6 +29,12 @@ interface SnsOverviewStats { type OverviewStatsStatus = 'loading' | 'ready' | 'error' +interface AuthorTimelineTarget { + username: string + nickname: string + avatarUrl?: string +} + export default function SnsPage() { const [posts, setPosts] = useState([]) const [loading, setLoading] = useState(false) @@ -55,6 +62,11 @@ export default function SnsPage() { // UI states const [showJumpDialog, setShowJumpDialog] = useState(false) const [debugPost, setDebugPost] = useState(null) + const [authorTimelineTarget, setAuthorTimelineTarget] = useState(null) + const [authorTimelinePosts, setAuthorTimelinePosts] = useState([]) + const [authorTimelineLoading, setAuthorTimelineLoading] = useState(false) + const [authorTimelineLoadingMore, setAuthorTimelineLoadingMore] = useState(false) + const [authorTimelineHasMore, setAuthorTimelineHasMore] = useState(false) // 导出相关状态 const [showExportDialog, setShowExportDialog] = useState(false) @@ -89,6 +101,9 @@ export default function SnsPage() { const cacheScopeKeyRef = useRef('') const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null) const contactsLoadTokenRef = useRef(0) + const authorTimelinePostsRef = useRef([]) + const authorTimelineLoadingRef = useRef(false) + const authorTimelineRequestTokenRef = useRef(0) // Sync posts ref useEffect(() => { @@ -109,6 +124,9 @@ export default function SnsPage() { useEffect(() => { jumpTargetDateRef.current = jumpTargetDate }, [jumpTargetDate]) + useEffect(() => { + authorTimelinePostsRef.current = authorTimelinePosts + }, [authorTimelinePosts]) // 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动 useLayoutEffect(() => { const snapshot = scrollAdjustmentRef.current; @@ -132,6 +150,18 @@ export default function SnsPage() { return `${year}-${month}-${day}` } + const decodeHtmlEntities = (text: string): string => { + if (!text) return '' + return text + .replace(//g, '$1') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .trim() + } + const isDefaultViewNow = useCallback(() => { return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current }, []) @@ -445,6 +475,117 @@ export default function SnsPage() { } }, []) + const closeAuthorTimeline = useCallback(() => { + authorTimelineRequestTokenRef.current += 1 + authorTimelineLoadingRef.current = false + setAuthorTimelineTarget(null) + setAuthorTimelinePosts([]) + setAuthorTimelineLoading(false) + setAuthorTimelineLoadingMore(false) + setAuthorTimelineHasMore(false) + }, []) + + const loadAuthorTimelinePosts = useCallback(async (target: AuthorTimelineTarget, options: { reset?: boolean } = {}) => { + const { reset = false } = options + if (authorTimelineLoadingRef.current) return + + authorTimelineLoadingRef.current = true + if (reset) { + setAuthorTimelineLoading(true) + setAuthorTimelineLoadingMore(false) + setAuthorTimelineHasMore(false) + } else { + setAuthorTimelineLoadingMore(true) + } + + const requestToken = ++authorTimelineRequestTokenRef.current + + try { + const limit = 20 + let endTs: number | undefined = undefined + + if (!reset && authorTimelinePostsRef.current.length > 0) { + endTs = authorTimelinePostsRef.current[authorTimelinePostsRef.current.length - 1].createTime - 1 + } + + const result = await window.electronAPI.sns.getTimeline( + limit, + 0, + [target.username], + '', + undefined, + endTs + ) + + if (requestToken !== authorTimelineRequestTokenRef.current) return + if (!result.success || !result.timeline) { + if (reset) { + setAuthorTimelinePosts([]) + setAuthorTimelineHasMore(false) + } + return + } + + if (reset) { + const sorted = [...result.timeline].sort((a, b) => b.createTime - a.createTime) + setAuthorTimelinePosts(sorted) + setAuthorTimelineHasMore(result.timeline.length >= limit) + return + } + + const existingIds = new Set(authorTimelinePostsRef.current.map((p) => p.id)) + const uniqueOlder = result.timeline.filter((p) => !existingIds.has(p.id)) + if (uniqueOlder.length > 0) { + const merged = [...authorTimelinePostsRef.current, ...uniqueOlder].sort((a, b) => b.createTime - a.createTime) + setAuthorTimelinePosts(merged) + } + if (result.timeline.length < limit) { + setAuthorTimelineHasMore(false) + } + } catch (error) { + console.error('Failed to load author timeline:', error) + if (requestToken === authorTimelineRequestTokenRef.current && reset) { + setAuthorTimelinePosts([]) + setAuthorTimelineHasMore(false) + } + } finally { + if (requestToken === authorTimelineRequestTokenRef.current) { + authorTimelineLoadingRef.current = false + setAuthorTimelineLoading(false) + setAuthorTimelineLoadingMore(false) + } + } + }, []) + + const openAuthorTimeline = useCallback((post: SnsPost) => { + authorTimelineRequestTokenRef.current += 1 + authorTimelineLoadingRef.current = false + const target = { + username: post.username, + nickname: post.nickname, + avatarUrl: post.avatarUrl + } + setAuthorTimelineTarget(target) + setAuthorTimelinePosts([]) + setAuthorTimelineHasMore(false) + void loadAuthorTimelinePosts(target, { reset: true }) + }, [loadAuthorTimelinePosts]) + + const loadMoreAuthorTimeline = useCallback(() => { + if (!authorTimelineTarget || authorTimelineLoading || authorTimelineLoadingMore || !authorTimelineHasMore) return + void loadAuthorTimelinePosts(authorTimelineTarget, { reset: false }) + }, [authorTimelineHasMore, authorTimelineLoading, authorTimelineLoadingMore, authorTimelineTarget, loadAuthorTimelinePosts]) + + const handlePostDelete = useCallback((postId: string) => { + setPosts(prev => { + const next = prev.filter(p => p.id !== postId) + void persistSnsPageCache({ posts: next }) + return next + }) + setAuthorTimelinePosts(prev => prev.filter(p => p.id !== postId)) + void loadOverviewStats() + }, [loadOverviewStats, persistSnsPageCache]) + // Initial Load & Listeners useEffect(() => { void hydrateSnsPageCache() @@ -474,6 +615,17 @@ export default function SnsPage() { return () => clearTimeout(timer) }, [selectedUsernames, searchKeyword, jumpTargetDate, loadPosts]) + useEffect(() => { + if (!authorTimelineTarget) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeAuthorTimeline() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [authorTimelineTarget, closeAuthorTimeline]) + const handleScroll = (e: React.UIEvent) => { const { scrollTop, clientHeight, scrollHeight } = e.currentTarget if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) { @@ -492,6 +644,22 @@ export default function SnsPage() { } } + const handleAuthorTimelineScroll = (e: React.UIEvent) => { + const { scrollTop, clientHeight, scrollHeight } = e.currentTarget + if (scrollHeight - scrollTop - clientHeight < 260) { + loadMoreAuthorTimeline() + } + } + + const renderAuthorTimelineStats = () => { + if (authorTimelineLoading) return '加载中...' + if (authorTimelinePosts.length === 0) return '暂无朋友圈' + const latest = authorTimelinePosts[0]?.createTime ?? null + const earliest = authorTimelinePosts[authorTimelinePosts.length - 1]?.createTime ?? null + const loadedLabel = authorTimelineHasMore ? `已加载 ${authorTimelinePosts.length} 条` : `共 ${authorTimelinePosts.length} 条` + return `${loadedLabel} | ${formatDateOnly(earliest)} ~ ${formatDateOnly(latest)}` + } + return (
@@ -578,14 +746,8 @@ export default function SnsPage() { } }} onDebug={(p) => setDebugPost(p)} - onDelete={(postId) => { - setPosts(prev => { - const next = prev.filter(p => p.id !== postId) - void persistSnsPageCache({ posts: next }) - return next - }) - loadOverviewStats() - }} + onDelete={handlePostDelete} + onOpenAuthorPosts={openAuthorTimeline} /> ))}
@@ -657,6 +819,76 @@ export default function SnsPage() { currentDate={jumpTargetDate || new Date()} /> + {authorTimelineTarget && ( +
+
e.stopPropagation()}> +
+
+ +
+

{decodeHtmlEntities(authorTimelineTarget.nickname)}

+
@{authorTimelineTarget.username}
+
{renderAuthorTimelineStats()}
+
+
+ +
+ +
+ {authorTimelinePosts.length > 0 && ( +
+ {authorTimelinePosts.map(post => ( + { + if (isVideo) { + void window.electronAPI.window.openVideoPlayerWindow(src) + } else { + void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined) + } + }} + onDebug={(p) => setDebugPost(p)} + onDelete={handlePostDelete} + onOpenAuthorPosts={openAuthorTimeline} + /> + ))} +
+ )} + + {authorTimelineLoading && ( +
+ + 正在加载该用户朋友圈... +
+ )} + + {!authorTimelineLoading && authorTimelinePosts.length === 0 && ( +
该用户暂无朋友圈
+ )} + + {!authorTimelineLoading && authorTimelineHasMore && ( + + )} +
+
+
+ )} + {debugPost && (
setDebugPost(null)}>
e.stopPropagation()}> From e6942bc2015f236bd593d654b7ea16df7c9b3682 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 17:11:04 +0800 Subject: [PATCH 09/97] feat(export): add session load detail modal with typed progress --- src/pages/ExportPage.scss | 145 +++++++++++++++ src/pages/ExportPage.tsx | 382 +++++++++++++++++++++++++++++++++++++- 2 files changed, 523 insertions(+), 4 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 8192e23..7c227ea 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -27,6 +27,27 @@ gap: 6px; } +.session-load-detail-entry { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 5px 10px; + font-size: 12px; + color: var(--text-secondary); + background: var(--bg-secondary); + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease; + + &:hover { + border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color)); + color: var(--text-primary); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary)); + } +} + .export-section-title { margin: 0; font-size: 15px; @@ -87,6 +108,126 @@ } } +.session-load-detail-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.42); + display: flex; + align-items: center; + justify-content: center; + z-index: 2200; + padding: 20px; +} + +.session-load-detail-modal { + width: min(760px, 100%); + max-height: min(78vh, 860px); + overflow: hidden; + border-radius: 14px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28); + display: flex; + flex-direction: column; +} + +.session-load-detail-header { + padding: 14px 16px 10px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + + h4 { + margin: 0; + font-size: 15px; + color: var(--text-primary); + } + + p { + margin: 4px 0 0; + font-size: 12px; + color: var(--text-tertiary); + } +} + +.session-load-detail-close { + border: 1px solid var(--border-color); + border-radius: 8px; + width: 28px; + height: 28px; + background: var(--bg-secondary); + color: var(--text-secondary); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + + &:hover { + color: var(--text-primary); + border-color: var(--text-tertiary); + } +} + +.session-load-detail-body { + padding: 12px 16px 16px; + overflow: auto; + display: flex; + flex-direction: column; + gap: 14px; +} + +.session-load-detail-block { + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--card-bg); + + h5 { + margin: 0; + padding: 10px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + font-size: 13px; + color: var(--text-primary); + } +} + +.session-load-detail-table { + display: flex; + flex-direction: column; + overflow-x: auto; +} + +.session-load-detail-row { + display: grid; + grid-template-columns: 1.1fr 1fr 0.8fr 0.8fr; + gap: 10px; + align-items: center; + padding: 9px 12px; + font-size: 12px; + color: var(--text-secondary); + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 66%, transparent); + min-width: 540px; + + &:last-child { + border-bottom: none; + } + + > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &.header { + font-size: 11px; + color: var(--text-tertiary); + font-weight: 600; + background: color-mix(in srgb, var(--bg-secondary) 75%, transparent); + } +} + .global-export-controls { background: var(--card-bg); border: 1px solid var(--border-color); @@ -2696,6 +2837,10 @@ font-size: 14px; } + .session-load-detail-entry { + margin-left: 0; + } + .table-wrap { --contacts-message-col-width: 104px; --contacts-media-col-width: 62px; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 5bc9d8a..34c1960 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -177,6 +177,13 @@ const contentTypeLabels: Record = { emoji: '表情包' } +const conversationTabLabels: Record = { + private: '私聊', + group: '群聊', + official: '公众号', + former_friend: '曾经的好友' +} + const getContentTypeLabel = (type: ContentType): string => { return contentTypeLabels[type] || type } @@ -690,6 +697,20 @@ interface SessionExportCacheMeta { source: 'memory' | 'disk' | 'fresh' } +type SessionLoadStageStatus = 'pending' | 'loading' | 'done' | 'failed' + +interface SessionLoadStageState { + status: SessionLoadStageStatus + startedAt?: number + finishedAt?: number + error?: string +} + +interface SessionLoadTraceState { + messageCount: SessionLoadStageState + mediaMetrics: SessionLoadStageState +} + const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null try { @@ -909,6 +930,13 @@ const hasCompleteSessionMediaMetric = (metricRaw: SessionContentMetric | undefin ) } +const createDefaultSessionLoadStage = (): SessionLoadStageState => ({ status: 'pending' }) + +const createDefaultSessionLoadTrace = (): SessionLoadTraceState => ({ + messageCount: createDefaultSessionLoadStage(), + mediaMetrics: createDefaultSessionLoadStage() +}) + const WriteLayoutSelector = memo(function WriteLayoutSelector({ writeLayout, onChange, @@ -1250,12 +1278,14 @@ function ExportPage() { const [isLoadingSessionCounts, setIsLoadingSessionCounts] = useState(false) const [isSessionCountStageReady, setIsSessionCountStageReady] = useState(false) const [sessionContentMetrics, setSessionContentMetrics] = useState>({}) + const [sessionLoadTraceMap, setSessionLoadTraceMap] = useState>({}) const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) const [contactsLoadSession, setContactsLoadSession] = useState(null) const [contactsLoadIssue, setContactsLoadIssue] = useState(null) const [showContactsDiagnostics, setShowContactsDiagnostics] = useState(false) const [contactsDiagnosticTick, setContactsDiagnosticTick] = useState(Date.now()) const [showSessionDetailPanel, setShowSessionDetailPanel] = useState(false) + const [showSessionLoadDetailModal, setShowSessionLoadDetailModal] = useState(false) const [sessionDetail, setSessionDetail] = useState(null) const [isLoadingSessionDetail, setIsLoadingSessionDetail] = useState(false) const [isLoadingSessionDetailExtra, setIsLoadingSessionDetailExtra] = useState(false) @@ -1422,6 +1452,96 @@ function ExportPage() { sessionContentMetricsRef.current = sessionContentMetrics }, [sessionContentMetrics]) + const patchSessionLoadTraceStage = useCallback(( + sessionIds: string[], + stageKey: keyof SessionLoadTraceState, + status: SessionLoadStageStatus, + options?: { force?: boolean; error?: string } + ) => { + if (sessionIds.length === 0) return + const now = Date.now() + setSessionLoadTraceMap(prev => { + let changed = false + const next = { ...prev } + for (const sessionIdRaw of sessionIds) { + const sessionId = String(sessionIdRaw || '').trim() + if (!sessionId) continue + const prevTrace = next[sessionId] || createDefaultSessionLoadTrace() + const prevStage = prevTrace[stageKey] || createDefaultSessionLoadStage() + if (!options?.force && prevStage.status === 'done' && status !== 'done') { + continue + } + let stageChanged = false + const nextStage: SessionLoadStageState = { ...prevStage } + if (nextStage.status !== status) { + nextStage.status = status + stageChanged = true + } + if (status === 'loading') { + if (!nextStage.startedAt) { + nextStage.startedAt = now + stageChanged = true + } + if (nextStage.finishedAt) { + nextStage.finishedAt = undefined + stageChanged = true + } + if (nextStage.error) { + nextStage.error = undefined + stageChanged = true + } + } else if (status === 'done') { + if (!nextStage.startedAt) { + nextStage.startedAt = now + stageChanged = true + } + if (!nextStage.finishedAt) { + nextStage.finishedAt = now + stageChanged = true + } + if (nextStage.error) { + nextStage.error = undefined + stageChanged = true + } + } else if (status === 'failed') { + if (!nextStage.startedAt) { + nextStage.startedAt = now + stageChanged = true + } + if (!nextStage.finishedAt) { + nextStage.finishedAt = now + stageChanged = true + } + const nextError = options?.error || '加载失败' + if (nextStage.error !== nextError) { + nextStage.error = nextError + stageChanged = true + } + } else if (status === 'pending') { + if (nextStage.startedAt !== undefined) { + nextStage.startedAt = undefined + stageChanged = true + } + if (nextStage.finishedAt !== undefined) { + nextStage.finishedAt = undefined + stageChanged = true + } + if (nextStage.error !== undefined) { + nextStage.error = undefined + stageChanged = true + } + } + if (!stageChanged) continue + next[sessionId] = { + ...prevTrace, + [stageKey]: nextStage + } + changed = true + } + return changed ? next : prev + }) + }, []) + const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => { const scopeKey = options?.scopeKey || await ensureExportCacheScope() const loadVersion = contactsLoadVersionRef.current + 1 @@ -1953,12 +2073,13 @@ function ExportPage() { incoming.push(sessionId) } if (incoming.length === 0) return + patchSessionLoadTraceStage(incoming, 'mediaMetrics', 'pending') if (front) { sessionMediaMetricQueueRef.current = [...incoming, ...sessionMediaMetricQueueRef.current] } else { sessionMediaMetricQueueRef.current.push(...incoming) } - }, [isSessionMediaMetricReady]) + }, [isSessionMediaMetricReady, patchSessionLoadTraceStage]) const applySessionMediaMetricsFromStats = useCallback((data?: Record) => { if (!data) return @@ -2011,6 +2132,7 @@ function ExportPage() { if (batchSessionIds.length === 0) { continue } + patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'loading') try { const cacheResult = await window.electronAPI.chat.getExportSessionStats( @@ -2035,13 +2157,21 @@ function ExportPage() { } } catch (error) { console.error('导出页加载会话媒体统计失败:', error) + patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'failed', { + error: String(error) + }) } finally { + const completedSessionIds: string[] = [] for (const sessionId of batchSessionIds) { sessionMediaMetricLoadingSetRef.current.delete(sessionId) if (isSessionMediaMetricReady(sessionId)) { sessionMediaMetricReadySetRef.current.add(sessionId) + completedSessionIds.push(sessionId) } } + if (completedSessionIds.length > 0) { + patchSessionLoadTraceStage(completedSessionIds, 'mediaMetrics', 'done') + } } await new Promise(resolve => window.setTimeout(resolve, 0)) @@ -2052,7 +2182,7 @@ function ExportPage() { void runSessionMediaMetricWorker(runId) } } - }, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady]) + }, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage]) const scheduleSessionMediaMetricWorker = useCallback(() => { if (!isSessionCountStageReady) return @@ -2076,6 +2206,9 @@ function ExportPage() { setIsSessionCountStageReady(false) const exportableSessions = sourceSessions.filter(session => session.hasSession) + const exportableSessionIds = exportableSessions.map(session => session.username) + const exportableSessionIdSet = new Set(exportableSessionIds) + patchSessionLoadTraceStage(exportableSessionIds, 'messageCount', 'pending', { force: true }) const seededHintCounts = exportableSessions.reduce>((acc, session) => { const nextCount = normalizeMessageCount(session.messageCountHint) if (typeof nextCount === 'number') { @@ -2084,12 +2217,17 @@ function ExportPage() { return acc }, {}) const seededPersistentCounts = Object.entries(options?.seededCounts || {}).reduce>((acc, [sessionId, countRaw]) => { + if (!exportableSessionIdSet.has(sessionId)) return acc const nextCount = normalizeMessageCount(countRaw) if (typeof nextCount === 'number') { acc[sessionId] = nextCount } return acc }, {}) + const seededPersistentSessionIds = Object.keys(seededPersistentCounts) + if (seededPersistentSessionIds.length > 0) { + patchSessionLoadTraceStage(seededPersistentSessionIds, 'messageCount', 'done') + } const seededCounts = { ...seededHintCounts, ...seededPersistentCounts } const accumulatedCounts: Record = { ...seededCounts } setSessionMessageCounts(seededCounts) @@ -2146,10 +2284,19 @@ function ExportPage() { return { ...accumulatedCounts } } if (prioritizedSessionIds.length > 0) { + patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'loading') const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds) if (isStale()) return { ...accumulatedCounts } if (priorityResult.success) { applyCounts(priorityResult.counts) + patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'done') + } else { + patchSessionLoadTraceStage( + prioritizedSessionIds, + 'messageCount', + 'failed', + { error: priorityResult.error || '总消息数加载失败' } + ) } } @@ -2157,14 +2304,26 @@ function ExportPage() { return { ...accumulatedCounts } } if (remainingSessionIds.length > 0) { + patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'loading') const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds) if (isStale()) return { ...accumulatedCounts } if (remainingResult.success) { applyCounts(remainingResult.counts) + patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'done') + } else { + patchSessionLoadTraceStage( + remainingSessionIds, + 'messageCount', + 'failed', + { error: remainingResult.error || '总消息数加载失败' } + ) } } } catch (error) { console.error('导出页加载会话消息总数失败:', error) + patchSessionLoadTraceStage(exportableSessionIds, 'messageCount', 'failed', { + error: String(error) + }) } finally { if (!isStale()) { setIsLoadingSessionCounts(false) @@ -2179,7 +2338,7 @@ function ExportPage() { } } return { ...accumulatedCounts } - }, [mergeSessionContentMetrics]) + }, [mergeSessionContentMetrics, patchSessionLoadTraceStage]) const loadSessions = useCallback(async () => { const loadToken = Date.now() @@ -2192,6 +2351,7 @@ function ExportPage() { sessionCountRequestIdRef.current += 1 setSessionMessageCounts({}) setSessionContentMetrics({}) + setSessionLoadTraceMap({}) setIsLoadingSessionCounts(false) setIsSessionCountStageReady(false) @@ -2270,6 +2430,10 @@ function ExportPage() { } return acc }, {}) + const cachedContentMetricSessionIds = Object.keys(cachedContentMetrics) + if (cachedContentMetricSessionIds.length > 0) { + patchSessionLoadTraceStage(cachedContentMetricSessionIds, 'mediaMetrics', 'done') + } if (isStale()) return if (Object.keys(cachedMessageCounts).length > 0) { @@ -2481,7 +2645,7 @@ function ExportPage() { } finally { if (!isStale()) setIsLoading(false) } - }, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, resetSessionMediaMetricLoader, syncContactTypeCounts]) + }, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, resetSessionMediaMetricLoader, syncContactTypeCounts]) useEffect(() => { if (!isExportRoute) return @@ -3612,6 +3776,105 @@ function ExportPage() { return indexedContacts.map(item => item.contact) }, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername]) + const keywordMatchedContactUsernameSet = useMemo(() => { + const keyword = searchKeyword.trim().toLowerCase() + const matched = new Set() + for (const contact of contactsList) { + if (!contact?.username) continue + if (!keyword) { + matched.add(contact.username) + continue + } + if ( + (contact.displayName || '').toLowerCase().includes(keyword) || + (contact.remark || '').toLowerCase().includes(keyword) || + (contact.nickname || '').toLowerCase().includes(keyword) || + (contact.alias || '').toLowerCase().includes(keyword) || + contact.username.toLowerCase().includes(keyword) + ) { + matched.add(contact.username) + } + } + return matched + }, [contactsList, searchKeyword]) + + const loadDetailTargetsByTab = useMemo(() => { + const targets: Record = { + private: [], + group: [], + official: [], + former_friend: [] + } + for (const session of sessions) { + if (!session.hasSession) continue + if (!keywordMatchedContactUsernameSet.has(session.username)) continue + targets[session.kind].push(session.username) + } + return targets + }, [keywordMatchedContactUsernameSet, sessions]) + + const formatLoadDetailTime = useCallback((value?: number): string => { + if (!value || !Number.isFinite(value)) return '--' + return new Date(value).toLocaleTimeString('zh-CN', { hour12: false }) + }, []) + + const getLoadDetailStatusLabel = useCallback((loaded: number, total: number, hasStarted: boolean): string => { + if (total <= 0) return '待加载' + if (loaded >= total) return `已完成 ${total}` + if (hasStarted) return `加载中 ${loaded}/${total}` + return '待加载' + }, []) + + const summarizeLoadTraceForTab = useCallback(( + sessionIds: string[], + stageKey: keyof SessionLoadTraceState + ) => { + const total = sessionIds.length + let loaded = 0 + let hasStarted = false + let earliestStart: number | undefined + let latestFinish: number | undefined + for (const sessionId of sessionIds) { + const stage = sessionLoadTraceMap[sessionId]?.[stageKey] + if (stage?.status === 'done') { + loaded += 1 + } + if (stage?.status === 'loading' || stage?.status === 'failed' || typeof stage?.startedAt === 'number') { + hasStarted = true + } + if (typeof stage?.startedAt === 'number') { + earliestStart = earliestStart === undefined + ? stage.startedAt + : Math.min(earliestStart, stage.startedAt) + } + if (typeof stage?.finishedAt === 'number') { + latestFinish = latestFinish === undefined + ? stage.finishedAt + : Math.max(latestFinish, stage.finishedAt) + } + } + return { + total, + loaded, + statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted), + startedAt: earliestStart, + finishedAt: latestFinish + } + }, [getLoadDetailStatusLabel, sessionLoadTraceMap]) + + const sessionLoadDetailRows = useMemo(() => { + const tabOrder: ConversationTab[] = ['private', 'group', 'official', 'former_friend'] + return tabOrder.map((tab) => { + const sessionIds = loadDetailTargetsByTab[tab] || [] + return { + tab, + label: conversationTabLabels[tab], + messageCount: summarizeLoadTraceForTab(sessionIds, 'messageCount'), + mediaMetrics: summarizeLoadTraceForTab(sessionIds, 'mediaMetrics') + } + }) + }, [loadDetailTargetsByTab, summarizeLoadTraceForTab]) + useEffect(() => { contactsVirtuosoRef.current?.scrollToIndex({ index: 0, align: 'start' }) setIsContactsListAtTop(true) @@ -4053,6 +4316,17 @@ function ExportPage() { return () => window.removeEventListener('keydown', handleKeyDown) }, [closeSessionDetailPanel, showSessionDetailPanel]) + useEffect(() => { + if (!showSessionLoadDetailModal) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setShowSessionLoadDetailModal(false) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [showSessionLoadDetailModal]) + const handleCopyDetailField = useCallback(async (text: string, field: string) => { try { await navigator.clipboard.writeText(text) @@ -4167,6 +4441,21 @@ function ExportPage() { const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length const hasFilteredContacts = filteredContacts.length > 0 + const sessionLoadDetailUpdatedAt = useMemo(() => { + let latest = 0 + for (const row of sessionLoadDetailRows) { + const candidateTimes = [ + row.messageCount.finishedAt || row.messageCount.startedAt || 0, + row.mediaMetrics.finishedAt || row.mediaMetrics.startedAt || 0 + ] + for (const candidate of candidateTimes) { + if (candidate > latest) { + latest = candidate + } + } + } + return latest + }, [sessionLoadDetailRows]) const closeTaskCenter = useCallback(() => { setIsTaskCenterOpen(false) setExpandedPerfTaskId(null) @@ -4517,6 +4806,14 @@ function ExportPage() { '你可以先在列表中筛选目标会话,再批量导出,结果会保留每个会话的结构与时间线。' ]} /> +
@@ -4672,6 +4969,83 @@ function ExportPage() { )}
+ {showSessionLoadDetailModal && ( +
setShowSessionLoadDetailModal(false)} + > +
event.stopPropagation()} + > +
+
+

数据加载详情

+

+ 更新时间: + {sessionLoadDetailUpdatedAt > 0 + ? new Date(sessionLoadDetailUpdatedAt).toLocaleString('zh-CN') + : '暂无'} +

+
+ +
+ +
+
+
总消息数
+
+
+ 会话类型 + 加载状态 + 开始时间 + 完成时间 +
+ {sessionLoadDetailRows.map((row) => ( +
+ {row.label} + {row.messageCount.statusLabel} + {formatLoadDetailTime(row.messageCount.startedAt)} + {formatLoadDetailTime(row.messageCount.finishedAt)} +
+ ))} +
+
+ +
+
多媒体统计(表情包/图片/视频/语音)
+
+
+ 会话类型 + 加载状态 + 开始时间 + 完成时间 +
+ {sessionLoadDetailRows.map((row) => ( +
+ {row.label} + {row.mediaMetrics.statusLabel} + {formatLoadDetailTime(row.mediaMetrics.startedAt)} + {formatLoadDetailTime(row.mediaMetrics.finishedAt)} +
+ ))} +
+
+
+
+
+ )} + {showSessionDetailPanel && (
Date: Thu, 5 Mar 2026 17:15:33 +0800 Subject: [PATCH 10/97] feat(export): show spinner in load detail in-progress status --- src/pages/ExportPage.scss | 13 +++++++++++++ src/pages/ExportPage.tsx | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 7c227ea..78cb3a1 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -228,6 +228,19 @@ } } +.session-load-detail-status-cell { + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: 6px; + min-width: 0; +} + +.session-load-detail-status-icon { + color: var(--text-tertiary); + flex-shrink: 0; +} + .global-export-controls { background: var(--card-bg); border: 1px solid var(--border-color); diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 34c1960..b005bb1 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -5014,7 +5014,12 @@ function ExportPage() { {sessionLoadDetailRows.map((row) => (
{row.label} - {row.messageCount.statusLabel} + + {row.messageCount.statusLabel} + {row.messageCount.statusLabel.startsWith('加载中') && ( + + )} + {formatLoadDetailTime(row.messageCount.startedAt)} {formatLoadDetailTime(row.messageCount.finishedAt)}
@@ -5034,7 +5039,12 @@ function ExportPage() { {sessionLoadDetailRows.map((row) => (
{row.label} - {row.mediaMetrics.statusLabel} + + {row.mediaMetrics.statusLabel} + {row.mediaMetrics.statusLabel.startsWith('加载中') && ( + + )} + {formatLoadDetailTime(row.mediaMetrics.startedAt)} {formatLoadDetailTime(row.mediaMetrics.finishedAt)}
From 7cc2961538079706115b48b5f54d0d63132e3d26 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 17:24:14 +0800 Subject: [PATCH 11/97] fix(export): hide finish time until grouped load completes --- src/pages/ExportPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index b005bb1..bd33b92 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -3858,7 +3858,7 @@ function ExportPage() { loaded, statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted), startedAt: earliestStart, - finishedAt: latestFinish + finishedAt: loaded >= total ? latestFinish : undefined } }, [getLoadDetailStatusLabel, sessionLoadTraceMap]) From db0ebc6c33a8bda63ee5753ac17925f27aabdb62 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 17:24:28 +0800 Subject: [PATCH 12/97] feat(sns): show loaded vs total posts in author timeline --- electron/main.ts | 4 ++ electron/preload.ts | 1 + electron/services/snsService.ts | 52 +++++++++++++++++++++++ src/components/Sns/SnsPostItem.tsx | 65 ++++++++++++++++------------- src/pages/SnsPage.scss | 7 ++++ src/pages/SnsPage.tsx | 66 ++++++++++++++++++++++++++---- src/types/electron.d.ts | 1 + 7 files changed, 160 insertions(+), 36 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 207d3b6..6ca4580 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1517,6 +1517,10 @@ function registerIpcHandlers() { return snsService.getExportStatsFast() }) + ipcMain.handle('sns:getUserPostStats', async (_, username: string) => { + return snsService.getUserPostStats(username) + }) + ipcMain.handle('sns:debugResource', async (_, url: string) => { return snsService.debugResource(url) }) diff --git a/electron/preload.ts b/electron/preload.ts index a323f4c..feb594b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -355,6 +355,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'), getExportStats: () => ipcRenderer.invoke('sns:getExportStats'), + getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload), downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload), diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 1e0be35..9b30dfb 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -506,6 +506,10 @@ class SnsService { return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0 } + private escapeSqlString(value: string): string { + return value.replace(/'/g, "''") + } + private pickTimelineUsername(post: any): string { const raw = post?.username ?? post?.user_name ?? post?.userName ?? '' if (typeof raw !== 'string') return '' @@ -864,6 +868,54 @@ class SnsService { }) } + async getUserPostStats(username: string): Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> { + const normalizedUsername = this.toOptionalString(username) + if (!normalizedUsername) { + return { success: false, error: '用户名不能为空' } + } + + const escapedUsername = this.escapeSqlString(normalizedUsername) + const primaryResult = await wcdbService.execQuery( + 'sns', + null, + `SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = '${escapedUsername}'` + ) + + if (primaryResult.success) { + const totalPosts = primaryResult.rows && primaryResult.rows.length > 0 + ? this.parseCountValue(primaryResult.rows[0]) + : 0 + return { + success: true, + data: { + username: normalizedUsername, + totalPosts + } + } + } + + const fallbackResult = await wcdbService.execQuery( + 'sns', + null, + `SELECT COUNT(1) AS total FROM SnsTimeLine WHERE userName = '${escapedUsername}'` + ) + + if (fallbackResult.success) { + const totalPosts = fallbackResult.rows && fallbackResult.rows.length > 0 + ? this.parseCountValue(fallbackResult.rows[0]) + : 0 + return { + success: true, + data: { + username: normalizedUsername, + totalPosts + } + } + } + + return { success: false, error: primaryResult.error || fallbackResult.error || '统计单个好友朋友圈失败' } + } + // 安装朋友圈删除拦截 async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { return wcdbService.installSnsBlockDeleteTrigger() diff --git a/src/components/Sns/SnsPostItem.tsx b/src/components/Sns/SnsPostItem.tsx index 980377f..7498b36 100644 --- a/src/components/Sns/SnsPostItem.tsx +++ b/src/components/Sns/SnsPostItem.tsx @@ -243,11 +243,12 @@ interface SnsPostItemProps { post: SnsPost onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void onDebug: (post: SnsPost) => void - onDelete?: (postId: string) => void + onDelete?: (postId: string, username: string) => void onOpenAuthorPosts?: (post: SnsPost) => void + hideAuthorMeta?: boolean } -export const SnsPostItem: React.FC = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts }) => { +export const SnsPostItem: React.FC = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts, hideAuthorMeta = false }) => { const [mediaDeleted, setMediaDeleted] = useState(false) const [dbDeleted, setDbDeleted] = useState(false) const [deleting, setDeleting] = useState(false) @@ -300,7 +301,7 @@ export const SnsPostItem: React.FC = ({ post, onPreview, onDeb const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id) if (r.success) { setDbDeleted(true) - onDelete?.(post.id) + onDelete?.(post.id, post.username) } } finally { setDeleting(false) @@ -315,35 +316,41 @@ export const SnsPostItem: React.FC = ({ post, onPreview, onDeb return ( <>
-
- -
+ {!hideAuthorMeta && ( +
+ +
+ )}
-
- - {formatTime(post.createTime)} -
+ {hideAuthorMeta ? ( + {formatTime(post.createTime)} + ) : ( +
+ + {formatTime(post.createTime)} +
+ )}
{(mediaDeleted || dbDeleted) && ( diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 013eaae..854cc28 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -267,6 +267,13 @@ flex-shrink: 0; } + .post-time-standalone { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.2; + padding-top: 2px; + } + .debug-btn { opacity: 0; transition: opacity 0.2s; diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 53d34ad..b3459f3 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -67,6 +67,8 @@ export default function SnsPage() { const [authorTimelineLoading, setAuthorTimelineLoading] = useState(false) const [authorTimelineLoadingMore, setAuthorTimelineLoadingMore] = useState(false) const [authorTimelineHasMore, setAuthorTimelineHasMore] = useState(false) + const [authorTimelineTotalPosts, setAuthorTimelineTotalPosts] = useState(null) + const [authorTimelineStatsLoading, setAuthorTimelineStatsLoading] = useState(false) // 导出相关状态 const [showExportDialog, setShowExportDialog] = useState(false) @@ -104,6 +106,7 @@ export default function SnsPage() { const authorTimelinePostsRef = useRef([]) const authorTimelineLoadingRef = useRef(false) const authorTimelineRequestTokenRef = useRef(0) + const authorTimelineStatsTokenRef = useRef(0) // Sync posts ref useEffect(() => { @@ -477,12 +480,41 @@ export default function SnsPage() { const closeAuthorTimeline = useCallback(() => { authorTimelineRequestTokenRef.current += 1 + authorTimelineStatsTokenRef.current += 1 authorTimelineLoadingRef.current = false setAuthorTimelineTarget(null) setAuthorTimelinePosts([]) setAuthorTimelineLoading(false) setAuthorTimelineLoadingMore(false) setAuthorTimelineHasMore(false) + setAuthorTimelineTotalPosts(null) + setAuthorTimelineStatsLoading(false) + }, []) + + const loadAuthorTimelineTotalPosts = useCallback(async (target: AuthorTimelineTarget) => { + const requestToken = ++authorTimelineStatsTokenRef.current + setAuthorTimelineStatsLoading(true) + setAuthorTimelineTotalPosts(null) + + try { + const result = await window.electronAPI.sns.getUserPostStats(target.username) + if (requestToken !== authorTimelineStatsTokenRef.current) return + + if (result.success && result.data) { + setAuthorTimelineTotalPosts(Math.max(0, Number(result.data.totalPosts || 0))) + } else { + setAuthorTimelineTotalPosts(null) + } + } catch (error) { + console.error('Failed to load author timeline total posts:', error) + if (requestToken === authorTimelineStatsTokenRef.current) { + setAuthorTimelineTotalPosts(null) + } + } finally { + if (requestToken === authorTimelineStatsTokenRef.current) { + setAuthorTimelineStatsLoading(false) + } + } }, []) const loadAuthorTimelinePosts = useCallback(async (target: AuthorTimelineTarget, options: { reset?: boolean } = {}) => { @@ -568,23 +600,28 @@ export default function SnsPage() { setAuthorTimelineTarget(target) setAuthorTimelinePosts([]) setAuthorTimelineHasMore(false) + setAuthorTimelineTotalPosts(null) void loadAuthorTimelinePosts(target, { reset: true }) - }, [loadAuthorTimelinePosts]) + void loadAuthorTimelineTotalPosts(target) + }, [loadAuthorTimelinePosts, loadAuthorTimelineTotalPosts]) const loadMoreAuthorTimeline = useCallback(() => { if (!authorTimelineTarget || authorTimelineLoading || authorTimelineLoadingMore || !authorTimelineHasMore) return void loadAuthorTimelinePosts(authorTimelineTarget, { reset: false }) }, [authorTimelineHasMore, authorTimelineLoading, authorTimelineLoadingMore, authorTimelineTarget, loadAuthorTimelinePosts]) - const handlePostDelete = useCallback((postId: string) => { + const handlePostDelete = useCallback((postId: string, username: string) => { setPosts(prev => { const next = prev.filter(p => p.id !== postId) void persistSnsPageCache({ posts: next }) return next }) setAuthorTimelinePosts(prev => prev.filter(p => p.id !== postId)) + if (authorTimelineTarget && authorTimelineTarget.username === username) { + setAuthorTimelineTotalPosts(prev => prev === null ? null : Math.max(0, prev - 1)) + } void loadOverviewStats() - }, [loadOverviewStats, persistSnsPageCache]) + }, [authorTimelineTarget, loadOverviewStats, persistSnsPageCache]) // Initial Load & Listeners useEffect(() => { @@ -626,6 +663,13 @@ export default function SnsPage() { return () => window.removeEventListener('keydown', handleKeyDown) }, [authorTimelineTarget, closeAuthorTimeline]) + useEffect(() => { + if (authorTimelineTotalPosts === null) return + if (authorTimelinePosts.length >= authorTimelineTotalPosts) { + setAuthorTimelineHasMore(false) + } + }, [authorTimelinePosts.length, authorTimelineTotalPosts]) + const handleScroll = (e: React.UIEvent) => { const { scrollTop, clientHeight, scrollHeight } = e.currentTarget if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) { @@ -652,12 +696,19 @@ export default function SnsPage() { } const renderAuthorTimelineStats = () => { - if (authorTimelineLoading) return '加载中...' - if (authorTimelinePosts.length === 0) return '暂无朋友圈' + const loadedCount = authorTimelinePosts.length + const loadPart = authorTimelineStatsLoading + ? `已加载 ${loadedCount} / 总数统计中...` + : authorTimelineTotalPosts === null + ? `已加载 ${loadedCount} 条` + : `已加载 ${loadedCount} / 共 ${authorTimelineTotalPosts} 条` + + if (authorTimelineLoading && loadedCount === 0) return `${loadPart} | 加载中...` + if (loadedCount === 0) return loadPart + const latest = authorTimelinePosts[0]?.createTime ?? null const earliest = authorTimelinePosts[authorTimelinePosts.length - 1]?.createTime ?? null - const loadedLabel = authorTimelineHasMore ? `已加载 ${authorTimelinePosts.length} 条` : `共 ${authorTimelinePosts.length} 条` - return `${loadedLabel} | ${formatDateOnly(earliest)} ~ ${formatDateOnly(latest)}` + return `${loadPart} | ${formatDateOnly(earliest)} ~ ${formatDateOnly(latest)}` } return ( @@ -858,6 +909,7 @@ export default function SnsPage() { onDebug={(p) => setDebugPost(p)} onDelete={handlePostDelete} onOpenAuthorPosts={openAuthorTimeline} + hideAuthorMeta /> ))}
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 00ce172..0d05be4 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -791,6 +791,7 @@ export interface ElectronAPI { getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }> getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> + getUserPostStats: (username: string) => Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }> checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }> From 38af8de4693589f2d98b02c4806e6ac7ad3bc8a3 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 17:33:41 +0800 Subject: [PATCH 13/97] fix(sns): fallback to userName when user_name count is zero --- electron/services/snsService.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 9b30dfb..a05bb38 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -881,15 +881,15 @@ class SnsService { `SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = '${escapedUsername}'` ) - if (primaryResult.success) { - const totalPosts = primaryResult.rows && primaryResult.rows.length > 0 - ? this.parseCountValue(primaryResult.rows[0]) - : 0 + const primaryTotal = (primaryResult.success && primaryResult.rows && primaryResult.rows.length > 0) + ? this.parseCountValue(primaryResult.rows[0]) + : 0 + if (primaryResult.success && primaryTotal > 0) { return { success: true, data: { username: normalizedUsername, - totalPosts + totalPosts: primaryTotal } } } @@ -901,14 +901,24 @@ class SnsService { ) if (fallbackResult.success) { - const totalPosts = fallbackResult.rows && fallbackResult.rows.length > 0 + const fallbackTotal = fallbackResult.rows && fallbackResult.rows.length > 0 ? this.parseCountValue(fallbackResult.rows[0]) : 0 return { success: true, data: { username: normalizedUsername, - totalPosts + totalPosts: Math.max(primaryTotal, fallbackTotal) + } + } + } + + if (primaryResult.success) { + return { + success: true, + data: { + username: normalizedUsername, + totalPosts: primaryTotal } } } From 3388b7a1221a6fc76553b1435cc6e761599a0c52 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 17:44:24 +0800 Subject: [PATCH 14/97] fix(sns): derive per-user totals from timeline counts map --- electron/main.ts | 4 ++ electron/preload.ts | 1 + electron/services/snsService.ts | 117 +++++++++++++++++++------------- src/pages/SnsPage.tsx | 7 +- src/types/electron.d.ts | 1 + 5 files changed, 80 insertions(+), 50 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 6ca4580..21f63a4 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1509,6 +1509,10 @@ function registerIpcHandlers() { return snsService.getSnsUsernames() }) + ipcMain.handle('sns:getUserPostCounts', async () => { + return snsService.getUserPostCounts() + }) + ipcMain.handle('sns:getExportStats', async () => { return snsService.getExportStats() }) diff --git a/electron/preload.ts b/electron/preload.ts index feb594b..41039e2 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -353,6 +353,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), + getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'), getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'), getExportStats: () => ipcRenderer.invoke('sns:getExportStats'), getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username), diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index a05bb38..2bb2908 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -292,7 +292,9 @@ class SnsService { private contactCache: ContactCacheService private imageCache = new Map() private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null + private userPostCountsCache: { counts: Record; updatedAt: number } | null = null private readonly exportStatsCacheTtlMs = 5 * 60 * 1000 + private readonly userPostCountsCacheTtlMs = 5 * 60 * 1000 private lastTimelineFallbackAt = 0 private readonly timelineFallbackCooldownMs = 3 * 60 * 1000 @@ -506,10 +508,6 @@ class SnsService { return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0 } - private escapeSqlString(value: string): string { - return value.replace(/'/g, "''") - } - private pickTimelineUsername(post: any): string { const raw = post?.username ?? post?.user_name ?? post?.userName ?? '' if (typeof raw !== 'string') return '' @@ -868,62 +866,82 @@ class SnsService { }) } + private async getUserPostCountsFromTimeline(): Promise> { + const pageSize = 500 + const counts: Record = {} + let offset = 0 + + for (let round = 0; round < 2000; round++) { + const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0) + if (!result.success || !Array.isArray(result.timeline)) { + throw new Error(result.error || '获取朋友圈用户总条数失败') + } + + const rows = result.timeline + if (rows.length === 0) break + + for (const row of rows) { + const username = this.pickTimelineUsername(row) + if (!username) continue + counts[username] = (counts[username] || 0) + 1 + } + + if (rows.length < pageSize) break + offset += rows.length + } + + return counts + } + + async getUserPostCounts(options?: { + preferCache?: boolean + }): Promise<{ success: boolean; counts?: Record; error?: string }> { + const preferCache = options?.preferCache ?? true + const now = Date.now() + + try { + if ( + preferCache && + this.userPostCountsCache && + now - this.userPostCountsCache.updatedAt <= this.userPostCountsCacheTtlMs + ) { + return { success: true, counts: this.userPostCountsCache.counts } + } + + const counts = await this.getUserPostCountsFromTimeline() + this.userPostCountsCache = { + counts, + updatedAt: Date.now() + } + return { success: true, counts } + } catch (error) { + console.error('[SnsService] getUserPostCounts failed:', error) + if (this.userPostCountsCache) { + return { success: true, counts: this.userPostCountsCache.counts } + } + return { success: false, error: String(error) } + } + } + async getUserPostStats(username: string): Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> { const normalizedUsername = this.toOptionalString(username) if (!normalizedUsername) { return { success: false, error: '用户名不能为空' } } - const escapedUsername = this.escapeSqlString(normalizedUsername) - const primaryResult = await wcdbService.execQuery( - 'sns', - null, - `SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = '${escapedUsername}'` - ) - - const primaryTotal = (primaryResult.success && primaryResult.rows && primaryResult.rows.length > 0) - ? this.parseCountValue(primaryResult.rows[0]) - : 0 - if (primaryResult.success && primaryTotal > 0) { + const countsResult = await this.getUserPostCounts({ preferCache: true }) + if (countsResult.success) { + const totalPosts = countsResult.counts?.[normalizedUsername] ?? 0 return { success: true, data: { username: normalizedUsername, - totalPosts: primaryTotal + totalPosts: Math.max(0, Number(totalPosts || 0)) } } } - const fallbackResult = await wcdbService.execQuery( - 'sns', - null, - `SELECT COUNT(1) AS total FROM SnsTimeLine WHERE userName = '${escapedUsername}'` - ) - - if (fallbackResult.success) { - const fallbackTotal = fallbackResult.rows && fallbackResult.rows.length > 0 - ? this.parseCountValue(fallbackResult.rows[0]) - : 0 - return { - success: true, - data: { - username: normalizedUsername, - totalPosts: Math.max(primaryTotal, fallbackTotal) - } - } - } - - if (primaryResult.success) { - return { - success: true, - data: { - username: normalizedUsername, - totalPosts: primaryTotal - } - } - } - - return { success: false, error: primaryResult.error || fallbackResult.error || '统计单个好友朋友圈失败' } + return { success: false, error: countsResult.error || '统计单个好友朋友圈失败' } } // 安装朋友圈删除拦截 @@ -943,7 +961,12 @@ class SnsService { // 从数据库直接删除朋友圈记录 async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> { - return wcdbService.deleteSnsPost(postId) + const result = await wcdbService.deleteSnsPost(postId) + if (result.success) { + this.userPostCountsCache = null + this.exportStatsCache = null + } + return result } /** diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index b3459f3..5edb5b6 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -497,11 +497,12 @@ export default function SnsPage() { setAuthorTimelineTotalPosts(null) try { - const result = await window.electronAPI.sns.getUserPostStats(target.username) + const result = await window.electronAPI.sns.getUserPostCounts() if (requestToken !== authorTimelineStatsTokenRef.current) return - if (result.success && result.data) { - setAuthorTimelineTotalPosts(Math.max(0, Number(result.data.totalPosts || 0))) + if (result.success && result.counts) { + const totalPosts = result.counts[target.username] ?? 0 + setAuthorTimelineTotalPosts(Math.max(0, Number(totalPosts || 0))) } else { setAuthorTimelineTotalPosts(null) } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 0d05be4..72aaa57 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -789,6 +789,7 @@ export interface ElectronAPI { onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }> getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }> + getUserPostCounts: () => Promise<{ success: boolean; counts?: Record; error?: string }> getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> getUserPostStats: (username: string) => Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> From 9dd5ee236522c64c9dce4a20482c3a52f6e5cdc9 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 17:44:32 +0800 Subject: [PATCH 15/97] fix(export): align media load progress with visible loaded state --- src/pages/ExportPage.scss | 8 ++ src/pages/ExportPage.tsx | 165 ++++++++++++++++++++++++++++++-------- 2 files changed, 140 insertions(+), 33 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 78cb3a1..4ac3e47 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -241,6 +241,14 @@ flex-shrink: 0; } +.session-load-detail-progress-pulse { + color: var(--text-tertiary); + font-size: 11px; + font-variant-numeric: tabular-nums; + letter-spacing: 0.1px; + flex-shrink: 0; +} + .global-export-controls { background: var(--card-bg); border: 1px solid var(--border-color); diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index bd33b92..c079795 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -711,6 +711,15 @@ interface SessionLoadTraceState { mediaMetrics: SessionLoadStageState } +interface SessionLoadStageSummary { + total: number + loaded: number + statusLabel: string + startedAt?: number + finishedAt?: number + latestProgressAt?: number +} + const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null try { @@ -1279,6 +1288,7 @@ function ExportPage() { const [isSessionCountStageReady, setIsSessionCountStageReady] = useState(false) const [sessionContentMetrics, setSessionContentMetrics] = useState>({}) const [sessionLoadTraceMap, setSessionLoadTraceMap] = useState>({}) + const [sessionLoadProgressPulseMap, setSessionLoadProgressPulseMap] = useState>({}) const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) const [contactsLoadSession, setContactsLoadSession] = useState(null) const [contactsLoadIssue, setContactsLoadIssue] = useState(null) @@ -1382,6 +1392,7 @@ function ExportPage() { const activeTabRef = useRef('private') const detailStatsPriorityRef = useRef(false) const sessionPreciseRefreshAtRef = useRef>({}) + const sessionLoadProgressSnapshotRef = useRef>({}) const sessionMediaMetricQueueRef = useRef([]) const sessionMediaMetricQueuedSetRef = useRef>(new Set()) const sessionMediaMetricLoadingSetRef = useRef>(new Set()) @@ -2352,6 +2363,8 @@ function ExportPage() { setSessionMessageCounts({}) setSessionContentMetrics({}) setSessionLoadTraceMap({}) + setSessionLoadProgressPulseMap({}) + sessionLoadProgressSnapshotRef.current = {} setIsLoadingSessionCounts(false) setIsSessionCountStageReady(false) @@ -2430,9 +2443,11 @@ function ExportPage() { } return acc }, {}) - const cachedContentMetricSessionIds = Object.keys(cachedContentMetrics) - if (cachedContentMetricSessionIds.length > 0) { - patchSessionLoadTraceStage(cachedContentMetricSessionIds, 'mediaMetrics', 'done') + const cachedContentMetricReadySessionIds = Object.entries(cachedContentMetrics) + .filter(([, metric]) => hasCompleteSessionMediaMetric(metric)) + .map(([sessionId]) => sessionId) + if (cachedContentMetricReadySessionIds.length > 0) { + patchSessionLoadTraceStage(cachedContentMetricReadySessionIds, 'mediaMetrics', 'done') } if (isStale()) return @@ -3828,16 +3843,22 @@ function ExportPage() { const summarizeLoadTraceForTab = useCallback(( sessionIds: string[], stageKey: keyof SessionLoadTraceState - ) => { + ): SessionLoadStageSummary => { const total = sessionIds.length let loaded = 0 let hasStarted = false let earliestStart: number | undefined let latestFinish: number | undefined + let latestProgressAt: number | undefined for (const sessionId of sessionIds) { const stage = sessionLoadTraceMap[sessionId]?.[stageKey] if (stage?.status === 'done') { loaded += 1 + if (typeof stage.finishedAt === 'number') { + latestProgressAt = latestProgressAt === undefined + ? stage.finishedAt + : Math.max(latestProgressAt, stage.finishedAt) + } } if (stage?.status === 'loading' || stage?.status === 'failed' || typeof stage?.startedAt === 'number') { hasStarted = true @@ -3858,7 +3879,8 @@ function ExportPage() { loaded, statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted), startedAt: earliestStart, - finishedAt: loaded >= total ? latestFinish : undefined + finishedAt: loaded >= total ? latestFinish : undefined, + latestProgressAt } }, [getLoadDetailStatusLabel, sessionLoadTraceMap]) @@ -3875,6 +3897,67 @@ function ExportPage() { }) }, [loadDetailTargetsByTab, summarizeLoadTraceForTab]) + const formatLoadDetailPulseTime = useCallback((value?: number): string => { + if (!value || !Number.isFinite(value)) return '--' + return new Date(value).toLocaleTimeString('zh-CN', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + }, []) + + useEffect(() => { + const previousSnapshot = sessionLoadProgressSnapshotRef.current + const nextSnapshot: Record = {} + const resetKeys: string[] = [] + const updates: Array<{ key: string; at: number; delta: number }> = [] + const stageKeys: Array = ['messageCount', 'mediaMetrics'] + + for (const row of sessionLoadDetailRows) { + for (const stageKey of stageKeys) { + const summary = row[stageKey] + const key = `${stageKey}:${row.tab}` + const loaded = Number.isFinite(summary.loaded) ? Math.max(0, Math.floor(summary.loaded)) : 0 + const total = Number.isFinite(summary.total) ? Math.max(0, Math.floor(summary.total)) : 0 + nextSnapshot[key] = { loaded, total } + + const previous = previousSnapshot[key] + if (!previous || previous.total !== total || loaded < previous.loaded) { + resetKeys.push(key) + continue + } + if (loaded > previous.loaded) { + updates.push({ + key, + at: summary.latestProgressAt || Date.now(), + delta: loaded - previous.loaded + }) + } + } + } + + sessionLoadProgressSnapshotRef.current = nextSnapshot + if (resetKeys.length === 0 && updates.length === 0) return + + setSessionLoadProgressPulseMap(prev => { + let changed = false + const next = { ...prev } + for (const key of resetKeys) { + if (!(key in next)) continue + delete next[key] + changed = true + } + for (const update of updates) { + const previous = next[update.key] + if (previous && previous.at === update.at && previous.delta === update.delta) continue + next[update.key] = { at: update.at, delta: update.delta } + changed = true + } + return changed ? next : prev + }) + }, [sessionLoadDetailRows]) + useEffect(() => { contactsVirtuosoRef.current?.scrollToIndex({ index: 0, align: 'start' }) setIsContactsListAtTop(true) @@ -4482,7 +4565,6 @@ function ExportPage() { const metricToDisplay = (value: unknown): { state: 'value'; text: string } | { state: 'loading' } | { state: 'na'; text: '--' } => { const normalized = normalizeMessageCount(value) if (!canExport) return { state: 'na', text: '--' } - if (!isSessionCountStageReady) return { state: 'loading' } if (typeof normalized === 'number') { return { state: 'value', text: normalized.toLocaleString('zh-CN') } } @@ -4619,7 +4701,6 @@ function ExportPage() { sessionMessageCounts, sessionRowByUsername, showSessionDetailPanel, - isSessionCountStageReady, toggleSelectSession ]) const handleContactsListWheelCapture = useCallback((event: WheelEvent) => { @@ -5011,19 +5092,28 @@ function ExportPage() { 开始时间 完成时间
- {sessionLoadDetailRows.map((row) => ( -
- {row.label} - - {row.messageCount.statusLabel} - {row.messageCount.statusLabel.startsWith('加载中') && ( - - )} - - {formatLoadDetailTime(row.messageCount.startedAt)} - {formatLoadDetailTime(row.messageCount.finishedAt)} -
- ))} + {sessionLoadDetailRows.map((row) => { + const pulse = sessionLoadProgressPulseMap[`messageCount:${row.tab}`] + const isLoading = row.messageCount.statusLabel.startsWith('加载中') + return ( +
+ {row.label} + + {row.messageCount.statusLabel} + {isLoading && ( + + )} + {isLoading && pulse && pulse.delta > 0 && ( + + {formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}条 + + )} + + {formatLoadDetailTime(row.messageCount.startedAt)} + {formatLoadDetailTime(row.messageCount.finishedAt)} +
+ ) + })}
@@ -5036,19 +5126,28 @@ function ExportPage() { 开始时间 完成时间
- {sessionLoadDetailRows.map((row) => ( -
- {row.label} - - {row.mediaMetrics.statusLabel} - {row.mediaMetrics.statusLabel.startsWith('加载中') && ( - - )} - - {formatLoadDetailTime(row.mediaMetrics.startedAt)} - {formatLoadDetailTime(row.mediaMetrics.finishedAt)} -
- ))} + {sessionLoadDetailRows.map((row) => { + const pulse = sessionLoadProgressPulseMap[`mediaMetrics:${row.tab}`] + const isLoading = row.mediaMetrics.statusLabel.startsWith('加载中') + return ( +
+ {row.label} + + {row.mediaMetrics.statusLabel} + {isLoading && ( + + )} + {isLoading && pulse && pulse.delta > 0 && ( + + {formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}条 + + )} + + {formatLoadDetailTime(row.mediaMetrics.startedAt)} + {formatLoadDetailTime(row.mediaMetrics.finishedAt)} +
+ ) + })}
From c301f36912a9acd9dbc1abd638f087eff38b5c57 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 18:08:09 +0800 Subject: [PATCH 16/97] feat(export): add sns count and timeline popup in session detail --- src/pages/ExportPage.scss | 253 +++++++++++++++++++++++- src/pages/ExportPage.tsx | 393 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 643 insertions(+), 3 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 4ac3e47..034cd9b 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -120,7 +120,7 @@ } .session-load-detail-modal { - width: min(760px, 100%); + width: min(820px, 100%); max-height: min(78vh, 860px); overflow: hidden; border-radius: 14px; @@ -200,14 +200,14 @@ .session-load-detail-row { display: grid; - grid-template-columns: 1.1fr 1fr 0.8fr 0.8fr; + grid-template-columns: minmax(76px, 0.78fr) minmax(260px, 1.55fr) minmax(84px, 0.74fr) minmax(84px, 0.74fr); gap: 10px; align-items: center; padding: 9px 12px; font-size: 12px; color: var(--text-secondary); border-bottom: 1px solid color-mix(in srgb, var(--border-color) 66%, transparent); - min-width: 540px; + min-width: 620px; &:last-child { border-bottom: none; @@ -230,10 +230,14 @@ .session-load-detail-status-cell { display: inline-flex; + flex-wrap: wrap; align-items: center; justify-content: flex-start; gap: 6px; min-width: 0; + overflow: visible !important; + text-overflow: clip !important; + white-space: normal !important; } .session-load-detail-status-icon { @@ -245,6 +249,7 @@ color: var(--text-tertiary); font-size: 11px; font-variant-numeric: tabular-nums; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; letter-spacing: 0.1px; flex-shrink: 0; } @@ -2038,6 +2043,10 @@ } } + .detail-sns-entry-btn { + white-space: nowrap; + } + .copy-btn { display: flex; align-items: center; @@ -2149,6 +2158,218 @@ } } +.export-session-sns-overlay { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 24px 16px; + background: rgba(15, 23, 42, 0.38); +} + +.export-session-sns-dialog { + width: min(760px, 100%); + max-height: min(86vh, 860px); + border-radius: 14px; + border: 1px solid var(--border-color); + background: var(--bg-secondary-solid, #ffffff); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.24); + display: flex; + flex-direction: column; + overflow: hidden; + + .sns-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-color); + } + + .sns-dialog-header-main { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + } + + .sns-dialog-avatar { + width: 42px; + height: 42px; + border-radius: 10px; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + overflow: hidden; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: #fff; + font-size: 14px; + font-weight: 600; + } + } + + .sns-dialog-meta { + min-width: 0; + + h4 { + margin: 0; + font-size: 15px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .sns-dialog-username { + margin-top: 2px; + font-size: 12px; + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .sns-dialog-stats { + margin-top: 4px; + font-size: 12px; + color: var(--text-secondary); + } + + .close-btn { + border: none; + background: transparent; + color: var(--text-secondary); + width: 28px; + height: 28px; + border-radius: 7px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .sns-dialog-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 12px 14px 14px; + } + + .sns-post-list { + display: flex; + flex-direction: column; + gap: 12px; + } + + .sns-post-card { + border: 1px solid var(--border-color); + background: var(--bg-primary); + border-radius: 10px; + padding: 10px 11px; + } + + .sns-post-time { + font-size: 12px; + color: var(--text-tertiary); + margin-bottom: 6px; + } + + .sns-post-content { + white-space: pre-wrap; + word-break: break-word; + font-size: 13px; + color: var(--text-primary); + line-height: 1.55; + } + + .sns-post-media-grid { + margin-top: 8px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + } + + .sns-post-media-item { + border: none; + padding: 0; + border-radius: 8px; + overflow: hidden; + background: var(--bg-secondary); + position: relative; + cursor: pointer; + aspect-ratio: 1 / 1; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + } + + .sns-post-media-video-tag { + position: absolute; + right: 6px; + bottom: 6px; + background: rgba(0, 0, 0, 0.64); + color: #fff; + border-radius: 5px; + font-size: 11px; + line-height: 1; + padding: 3px 5px; + } + + .sns-dialog-status { + padding: 16px 0; + text-align: center; + color: var(--text-secondary); + font-size: 13px; + + &.empty { + color: var(--text-tertiary); + } + } + + .sns-dialog-load-more { + display: block; + margin: 12px auto 0; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + border-radius: 8px; + padding: 8px 16px; + font-size: 13px; + cursor: pointer; + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + background: var(--bg-hover); + } + } +} + .table-state { display: flex; align-items: center; @@ -2862,6 +3083,15 @@ margin-left: 0; } + .session-load-detail-modal { + width: min(94vw, 820px); + } + + .session-load-detail-row { + grid-template-columns: minmax(68px, 0.72fr) minmax(232px, 1.6fr) minmax(80px, 0.72fr) minmax(80px, 0.72fr); + min-width: 560px; + } + .table-wrap { --contacts-message-col-width: 104px; --contacts-media-col-width: 62px; @@ -2961,4 +3191,21 @@ .export-session-detail-panel { width: calc(100vw - 12px); } + + .export-session-sns-overlay { + padding: 12px 8px; + } + + .export-session-sns-dialog { + width: min(100vw - 16px, 760px); + max-height: calc(100vh - 24px); + + .sns-dialog-header { + padding: 12px; + } + + .sns-dialog-body { + padding: 10px 10px 12px; + } + } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index c079795..21d9f82 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -38,6 +38,7 @@ import { onOpenSingleExport } from '../services/exportBridge' import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' +import type { SnsPost } from '../types/sns' import './ExportPage.scss' type ConversationTab = 'private' | 'group' | 'official' | 'former_friend' @@ -422,6 +423,20 @@ const formatYmdHmDateTime = (timestamp?: number): string => { return `${y}-${m}-${day} ${h}:${min}` } +const isSingleContactSession = (sessionId: string): boolean => { + const normalized = String(sessionId || '').trim() + if (!normalized) return false + if (normalized.includes('@chatroom')) return false + if (normalized.startsWith('gh_')) return false + return true +} + +const isSnsVideoMediaUrl = (url?: string): boolean => { + if (!url) return false + const lower = url.toLowerCase() + return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb') +} + const formatPathBrief = (value: string, maxLength = 52): string => { const normalized = String(value || '') if (normalized.length <= maxLength) return normalized @@ -661,6 +676,12 @@ interface SessionDetail { messageTables: { dbName: string; tableName: string; count: number }[] } +interface SessionSnsTimelineTarget { + username: string + displayName: string + avatarUrl?: string +} + interface SessionExportMetric { totalMessages: number voiceMessages: number @@ -1302,6 +1323,15 @@ function ExportPage() { const [isRefreshingSessionDetailStats, setIsRefreshingSessionDetailStats] = useState(false) const [isLoadingSessionRelationStats, setIsLoadingSessionRelationStats] = useState(false) const [copiedDetailField, setCopiedDetailField] = useState(null) + const [snsUserPostCounts, setSnsUserPostCounts] = useState>({}) + const [snsUserPostCountsStatus, setSnsUserPostCountsStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle') + const [sessionSnsTimelineTarget, setSessionSnsTimelineTarget] = useState(null) + const [sessionSnsTimelinePosts, setSessionSnsTimelinePosts] = useState([]) + const [sessionSnsTimelineLoading, setSessionSnsTimelineLoading] = useState(false) + const [sessionSnsTimelineLoadingMore, setSessionSnsTimelineLoadingMore] = useState(false) + const [sessionSnsTimelineHasMore, setSessionSnsTimelineHasMore] = useState(false) + const [sessionSnsTimelineTotalPosts, setSessionSnsTimelineTotalPosts] = useState(null) + const [sessionSnsTimelineStatsLoading, setSessionSnsTimelineStatsLoading] = useState(false) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('B') @@ -1391,6 +1421,9 @@ function ExportPage() { const isLoadingSessionCountsRef = useRef(false) const activeTabRef = useRef('private') const detailStatsPriorityRef = useRef(false) + const sessionSnsTimelinePostsRef = useRef([]) + const sessionSnsTimelineLoadingRef = useRef(false) + const sessionSnsTimelineRequestTokenRef = useRef(0) const sessionPreciseRefreshAtRef = useRef>({}) const sessionLoadProgressSnapshotRef = useRef>({}) const sessionMediaMetricQueueRef = useRef([]) @@ -1774,6 +1807,10 @@ function ExportPage() { hasSeededSnsStatsRef.current = hasSeededSnsStats }, [hasSeededSnsStats]) + useEffect(() => { + sessionSnsTimelinePostsRef.current = sessionSnsTimelinePosts + }, [sessionSnsTimelinePosts]) + const preselectSessionIds = useMemo(() => { const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null const rawList = Array.isArray(state?.preselectSessionIds) @@ -1919,6 +1956,177 @@ function ExportPage() { } }, []) + const loadSnsUserPostCounts = useCallback(async (options?: { force?: boolean }) => { + if (snsUserPostCountsStatus === 'loading') return + if (!options?.force && snsUserPostCountsStatus === 'ready') return + + setSnsUserPostCountsStatus('loading') + try { + const result = await window.electronAPI.sns.getUserPostCounts() + if (result.success && result.counts) { + const normalized: Record = {} + for (const [rawUsername, rawCount] of Object.entries(result.counts)) { + const username = String(rawUsername || '').trim() + if (!username) continue + const value = Number(rawCount) + normalized[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 + } + setSnsUserPostCounts(normalized) + setSnsUserPostCountsStatus('ready') + return + } + + setSnsUserPostCountsStatus('error') + } catch (error) { + console.error('加载朋友圈用户条数失败:', error) + setSnsUserPostCountsStatus('error') + } + }, [snsUserPostCountsStatus]) + + const loadSessionSnsTimelinePosts = useCallback(async (target: SessionSnsTimelineTarget, options?: { reset?: boolean }) => { + const reset = Boolean(options?.reset) + if (sessionSnsTimelineLoadingRef.current) return + + sessionSnsTimelineLoadingRef.current = true + if (reset) { + setSessionSnsTimelineLoading(true) + setSessionSnsTimelineLoadingMore(false) + setSessionSnsTimelineHasMore(false) + } else { + setSessionSnsTimelineLoadingMore(true) + } + + const requestToken = ++sessionSnsTimelineRequestTokenRef.current + + try { + const limit = 20 + let endTime: number | undefined + if (!reset && sessionSnsTimelinePostsRef.current.length > 0) { + endTime = sessionSnsTimelinePostsRef.current[sessionSnsTimelinePostsRef.current.length - 1].createTime - 1 + } + + const result = await window.electronAPI.sns.getTimeline(limit, 0, [target.username], '', undefined, endTime) + if (requestToken !== sessionSnsTimelineRequestTokenRef.current) return + + if (!result.success || !Array.isArray(result.timeline)) { + if (reset) { + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineHasMore(false) + } + return + } + + const timeline = [...(result.timeline as SnsPost[])].sort((a, b) => b.createTime - a.createTime) + if (reset) { + setSessionSnsTimelinePosts(timeline) + setSessionSnsTimelineHasMore(timeline.length >= limit) + return + } + + const existingIds = new Set(sessionSnsTimelinePostsRef.current.map((post) => post.id)) + const uniqueOlder = timeline.filter((post) => !existingIds.has(post.id)) + if (uniqueOlder.length > 0) { + const merged = [...sessionSnsTimelinePostsRef.current, ...uniqueOlder].sort((a, b) => b.createTime - a.createTime) + setSessionSnsTimelinePosts(merged) + } + if (timeline.length < limit) { + setSessionSnsTimelineHasMore(false) + } + } catch (error) { + console.error('加载联系人朋友圈失败:', error) + if (requestToken === sessionSnsTimelineRequestTokenRef.current && reset) { + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineHasMore(false) + } + } finally { + if (requestToken === sessionSnsTimelineRequestTokenRef.current) { + sessionSnsTimelineLoadingRef.current = false + setSessionSnsTimelineLoading(false) + setSessionSnsTimelineLoadingMore(false) + } + } + }, []) + + const closeSessionSnsTimeline = useCallback(() => { + sessionSnsTimelineRequestTokenRef.current += 1 + sessionSnsTimelineLoadingRef.current = false + setSessionSnsTimelineTarget(null) + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineLoading(false) + setSessionSnsTimelineLoadingMore(false) + setSessionSnsTimelineHasMore(false) + setSessionSnsTimelineTotalPosts(null) + setSessionSnsTimelineStatsLoading(false) + }, []) + + const openSessionSnsTimeline = useCallback(() => { + const normalizedSessionId = String(sessionDetail?.wxid || '').trim() + if (!isSingleContactSession(normalizedSessionId) || !sessionDetail) return + + const target: SessionSnsTimelineTarget = { + username: normalizedSessionId, + displayName: sessionDetail.displayName || sessionDetail.remark || sessionDetail.nickName || normalizedSessionId, + avatarUrl: sessionDetail.avatarUrl + } + + setSessionSnsTimelineTarget(target) + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineHasMore(false) + setSessionSnsTimelineLoadingMore(false) + setSessionSnsTimelineLoading(false) + + if (snsUserPostCountsStatus === 'ready') { + const count = Number(snsUserPostCounts[normalizedSessionId] || 0) + setSessionSnsTimelineTotalPosts(Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0) + setSessionSnsTimelineStatsLoading(false) + } else { + setSessionSnsTimelineTotalPosts(null) + setSessionSnsTimelineStatsLoading(true) + } + + void loadSessionSnsTimelinePosts(target, { reset: true }) + void loadSnsUserPostCounts() + }, [ + loadSessionSnsTimelinePosts, + loadSnsUserPostCounts, + sessionDetail, + snsUserPostCounts, + snsUserPostCountsStatus + ]) + + const loadMoreSessionSnsTimeline = useCallback(() => { + if (!sessionSnsTimelineTarget || sessionSnsTimelineLoading || sessionSnsTimelineLoadingMore || !sessionSnsTimelineHasMore) return + void loadSessionSnsTimelinePosts(sessionSnsTimelineTarget, { reset: false }) + }, [ + loadSessionSnsTimelinePosts, + sessionSnsTimelineHasMore, + sessionSnsTimelineLoading, + sessionSnsTimelineLoadingMore, + sessionSnsTimelineTarget + ]) + + const renderSessionSnsTimelineStats = useCallback((): string => { + const loadedCount = sessionSnsTimelinePosts.length + const loadPart = sessionSnsTimelineStatsLoading + ? `已加载 ${loadedCount} / 总数统计中...` + : sessionSnsTimelineTotalPosts === null + ? `已加载 ${loadedCount} 条` + : `已加载 ${loadedCount} / 共 ${sessionSnsTimelineTotalPosts} 条` + + if (sessionSnsTimelineLoading && loadedCount === 0) return `${loadPart} | 加载中...` + if (loadedCount === 0) return loadPart + + const latest = sessionSnsTimelinePosts[0]?.createTime + const earliest = sessionSnsTimelinePosts[sessionSnsTimelinePosts.length - 1]?.createTime + const rangeText = `${formatYmdDateFromSeconds(earliest)} ~ ${formatYmdDateFromSeconds(latest)}` + return `${loadPart} | ${rangeText}` + }, [ + sessionSnsTimelineLoading, + sessionSnsTimelinePosts, + sessionSnsTimelineStatsLoading, + sessionSnsTimelineTotalPosts + ]) + const mergeSessionContentMetrics = useCallback((input: Record) => { const entries = Object.entries(input) if (entries.length === 0) return @@ -4081,6 +4289,27 @@ function ExportPage() { .slice(0, 20) }, [sessionDetail?.wxid, exportRecordsBySession]) + const sessionDetailSupportsSnsTimeline = useMemo(() => { + const sessionId = String(sessionDetail?.wxid || '').trim() + return isSingleContactSession(sessionId) + }, [sessionDetail?.wxid]) + + const sessionDetailSnsCountLabel = useMemo(() => { + const sessionId = String(sessionDetail?.wxid || '').trim() + if (!sessionId || !sessionDetailSupportsSnsTimeline) return '朋友圈:0条' + + if (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle') { + return '朋友圈:统计中...' + } + if (snsUserPostCountsStatus === 'error') { + return '朋友圈:统计失败' + } + + const count = Number(snsUserPostCounts[sessionId] || 0) + const normalized = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0 + return `朋友圈:${normalized}条` + }, [sessionDetail?.wxid, sessionDetailSupportsSnsTimeline, snsUserPostCounts, snsUserPostCountsStatus]) + const applySessionDetailStats = useCallback(( sessionId: string, metric: SessionExportMetric, @@ -4371,14 +4600,58 @@ function ExportPage() { } }, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid]) + useEffect(() => { + if (!showSessionDetailPanel || !sessionDetailSupportsSnsTimeline) return + if (snsUserPostCountsStatus === 'idle') { + void loadSnsUserPostCounts() + } + }, [ + loadSnsUserPostCounts, + sessionDetailSupportsSnsTimeline, + showSessionDetailPanel, + snsUserPostCountsStatus + ]) + + useEffect(() => { + if (!sessionSnsTimelineTarget) return + if (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle') { + setSessionSnsTimelineStatsLoading(true) + return + } + if (snsUserPostCountsStatus === 'ready') { + const total = Number(snsUserPostCounts[sessionSnsTimelineTarget.username] || 0) + setSessionSnsTimelineTotalPosts(Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0) + setSessionSnsTimelineStatsLoading(false) + return + } + setSessionSnsTimelineTotalPosts(null) + setSessionSnsTimelineStatsLoading(false) + }, [sessionSnsTimelineTarget, snsUserPostCounts, snsUserPostCountsStatus]) + + useEffect(() => { + if (sessionSnsTimelineTotalPosts === null) return + if (sessionSnsTimelinePosts.length >= sessionSnsTimelineTotalPosts) { + setSessionSnsTimelineHasMore(false) + } + }, [sessionSnsTimelinePosts.length, sessionSnsTimelineTotalPosts]) + const closeSessionDetailPanel = useCallback(() => { detailRequestSeqRef.current += 1 detailStatsPriorityRef.current = false + sessionSnsTimelineRequestTokenRef.current += 1 + sessionSnsTimelineLoadingRef.current = false setShowSessionDetailPanel(false) setIsLoadingSessionDetail(false) setIsLoadingSessionDetailExtra(false) setIsRefreshingSessionDetailStats(false) setIsLoadingSessionRelationStats(false) + setSessionSnsTimelineTarget(null) + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineLoading(false) + setSessionSnsTimelineLoadingMore(false) + setSessionSnsTimelineHasMore(false) + setSessionSnsTimelineTotalPosts(null) + setSessionSnsTimelineStatsLoading(false) }, []) const openSessionDetail = useCallback((sessionId: string) => { @@ -4410,6 +4683,17 @@ function ExportPage() { return () => window.removeEventListener('keydown', handleKeyDown) }, [showSessionLoadDetailModal]) + useEffect(() => { + if (!sessionSnsTimelineTarget) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeSessionSnsTimeline() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [closeSessionSnsTimeline, sessionSnsTimelineTarget]) + const handleCopyDetailField = useCallback(async (text: string, field: string) => { try { await navigator.clipboard.writeText(text) @@ -5228,6 +5512,21 @@ function ExportPage() {
)} + {sessionDetailSupportsSnsTimeline && ( +
+ + 朋友圈 + + + +
+ )}
@@ -5454,6 +5753,100 @@ function ExportPage() {
)} + + {sessionSnsTimelineTarget && ( +
+
event.stopPropagation()} + > +
+
+
+ {sessionSnsTimelineTarget.avatarUrl ? ( + + ) : ( + {getAvatarLetter(sessionSnsTimelineTarget.displayName || sessionSnsTimelineTarget.username)} + )} +
+
+

{sessionSnsTimelineTarget.displayName}

+
@{sessionSnsTimelineTarget.username}
+
{renderSessionSnsTimelineStats()}
+
+
+ +
+ +
+ {sessionSnsTimelinePosts.length > 0 && ( +
+ {sessionSnsTimelinePosts.map((post) => ( +
+
{formatYmdHmDateTime(post.createTime * 1000)}
+ {post.contentDesc &&
{post.contentDesc}
} + {Array.isArray(post.media) && post.media.length > 0 && ( +
+ {post.media.slice(0, 9).map((media, mediaIndex) => { + const mediaUrl = String(media?.url || media?.thumb || '') + const previewUrl = String(media?.thumb || media?.url || '') + if (!mediaUrl || !previewUrl) return null + const isVideo = isSnsVideoMediaUrl(mediaUrl) + return ( + + ) + })} +
+ )} +
+ ))} +
+ )} + + {sessionSnsTimelineLoading && ( +
正在加载该联系人的朋友圈...
+ )} + + {!sessionSnsTimelineLoading && sessionSnsTimelinePosts.length === 0 && ( +
该联系人暂无朋友圈
+ )} + + {!sessionSnsTimelineLoading && sessionSnsTimelineHasMore && ( + + )} +
+
+
+ )}
From d5dbcd3f8077a83e2e786e7e02fa1ee72098b36f Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 18:27:30 +0800 Subject: [PATCH 17/97] fix(export): align sns timeline dialog with sns page rendering --- src/pages/ExportPage.scss | 8 +++++ src/pages/ExportPage.tsx | 63 +++++++++++++-------------------------- 2 files changed, 28 insertions(+), 43 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 034cd9b..80cc66e 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2273,6 +2273,14 @@ padding: 12px 14px 14px; } + .export-session-sns-posts-list { + gap: 14px; + } + + .post-header-actions { + display: none; + } + .sns-post-list { display: flex; flex-direction: column; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 21d9f82..c32f5e4 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -38,6 +38,7 @@ import { onOpenSingleExport } from '../services/exportBridge' import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' +import { SnsPostItem } from '../components/Sns/SnsPostItem' import type { SnsPost } from '../types/sns' import './ExportPage.scss' @@ -431,12 +432,6 @@ const isSingleContactSession = (sessionId: string): boolean => { return true } -const isSnsVideoMediaUrl = (url?: string): boolean => { - if (!url) return false - const lower = url.toLowerCase() - return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb') -} - const formatPathBrief = (value: string, maxLength = 52): string => { const normalized = String(value || '') if (normalized.length <= maxLength) return normalized @@ -2085,7 +2080,7 @@ function ExportPage() { } void loadSessionSnsTimelinePosts(target, { reset: true }) - void loadSnsUserPostCounts() + void loadSnsUserPostCounts({ force: true }) }, [ loadSessionSnsTimelinePosts, loadSnsUserPostCounts, @@ -4658,8 +4653,11 @@ function ExportPage() { if (!sessionId) return detailStatsPriorityRef.current = true setShowSessionDetailPanel(true) + if (isSingleContactSession(sessionId)) { + void loadSnsUserPostCounts({ force: true }) + } void loadSessionDetail(sessionId) - }, [loadSessionDetail]) + }, [loadSessionDetail, loadSnsUserPostCounts]) useEffect(() => { if (!showSessionDetailPanel) return @@ -5785,42 +5783,21 @@ function ExportPage() {
{sessionSnsTimelinePosts.length > 0 && ( -
+
{sessionSnsTimelinePosts.map((post) => ( -
-
{formatYmdHmDateTime(post.createTime * 1000)}
- {post.contentDesc &&
{post.contentDesc}
} - {Array.isArray(post.media) && post.media.length > 0 && ( -
- {post.media.slice(0, 9).map((media, mediaIndex) => { - const mediaUrl = String(media?.url || media?.thumb || '') - const previewUrl = String(media?.thumb || media?.url || '') - if (!mediaUrl || !previewUrl) return null - const isVideo = isSnsVideoMediaUrl(mediaUrl) - return ( - - ) - })} -
- )} -
+ { + if (isVideo) { + void window.electronAPI.window.openVideoPlayerWindow(src) + } else { + void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined) + } + }} + onDebug={() => {}} + hideAuthorMeta + /> ))}
)} From 63fd42ff052afeca3396440d95806ba262e53a36 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 18:50:46 +0800 Subject: [PATCH 18/97] feat(sns): incremental contact post-count ranking in filter list --- src/components/Sns/SnsFilterPanel.tsx | 36 ++++- src/pages/SnsPage.scss | 42 +++++ src/pages/SnsPage.tsx | 224 ++++++++++++++++++++++++-- 3 files changed, 289 insertions(+), 13 deletions(-) diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index 35e23fe..788ac3d 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react' -import { Search, Calendar, User, X, Filter, Check } from 'lucide-react' +import React from 'react' +import { Search, Calendar, User, X, Loader2 } from 'lucide-react' import { Avatar } from '../Avatar' // import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved @@ -7,6 +7,14 @@ interface Contact { username: string displayName: string avatarUrl?: string + postCount?: number + postCountStatus?: 'idle' | 'loading' | 'ready' +} + +interface ContactsCountProgress { + resolved: number + total: number + running: boolean } interface SnsFilterPanelProps { @@ -21,6 +29,7 @@ interface SnsFilterPanelProps { contactSearch: string setContactSearch: (val: string) => void loading?: boolean + contactsCountProgress?: ContactsCountProgress } export const SnsFilterPanel: React.FC = ({ @@ -34,11 +43,12 @@ export const SnsFilterPanel: React.FC = ({ contacts, contactSearch, setContactSearch, - loading + loading, + contactsCountProgress }) => { const filteredContacts = contacts.filter(c => - c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) || + (c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) || c.username.toLowerCase().includes(contactSearch.toLowerCase()) ) @@ -152,8 +162,17 @@ export const SnsFilterPanel: React.FC = ({ )}
+ {contactsCountProgress && contactsCountProgress.total > 0 && ( +
+ {contactsCountProgress.running + ? `朋友圈条数统计中 ${contactsCountProgress.resolved}/${contactsCountProgress.total}` + : `朋友圈条数已统计 ${contactsCountProgress.total}/${contactsCountProgress.total}`} +
+ )} +
{filteredContacts.map(contact => { + const isPostCountReady = contact.postCountStatus === 'ready' return (
= ({
{contact.displayName}
+
+ {isPostCountReady ? ( + {Math.max(0, Math.floor(Number(contact.postCount || 0)))}条 + ) : ( + + + + )} +
) })} diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 854cc28..34febde 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -1098,6 +1098,14 @@ } } + .contact-count-progress { + padding: 8px 16px 10px; + font-size: 11px; + color: var(--text-tertiary); + border-bottom: 1px dashed color-mix(in srgb, var(--border-color) 70%, transparent); + font-variant-numeric: tabular-nums; + } + .contact-list-scroll { flex: 1; overflow-y: auto; @@ -1175,6 +1183,40 @@ text-overflow: ellipsis; } } + + .contact-post-count-wrap { + margin-left: 8px; + min-width: 46px; + display: flex; + justify-content: flex-end; + align-items: center; + flex-shrink: 0; + } + + .contact-post-count { + font-size: 12px; + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .contact-post-count-loading { + color: var(--text-tertiary); + display: inline-flex; + align-items: center; + justify-content: center; + + .spinning { + animation: spin 0.8s linear infinite; + } + } + + &.selected { + .contact-post-count { + color: var(--primary); + font-weight: 600; + } + } } } diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 5edb5b6..4cb4a2b 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -11,12 +11,25 @@ import * as configService from '../services/config' const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000 const SNS_PAGE_CACHE_POST_LIMIT = 200 const SNS_PAGE_CACHE_SCOPE_FALLBACK = '__default__' +const CONTACT_COUNT_SORT_DEBOUNCE_MS = 200 +const CONTACT_COUNT_BATCH_SIZE = 10 + +type ContactPostCountStatus = 'idle' | 'loading' | 'ready' interface Contact { username: string displayName: string avatarUrl?: string type?: 'friend' | 'former_friend' | 'sns_only' + lastSessionTimestamp?: number + postCount?: number + postCountStatus?: ContactPostCountStatus +} + +interface ContactsCountProgress { + resolved: number + total: number + running: boolean } interface SnsOverviewStats { @@ -58,6 +71,11 @@ export default function SnsPage() { const [contacts, setContacts] = useState([]) const [contactSearch, setContactSearch] = useState('') const [contactsLoading, setContactsLoading] = useState(false) + const [contactsCountProgress, setContactsCountProgress] = useState({ + resolved: 0, + total: 0, + running: false + }) // UI states const [showJumpDialog, setShowJumpDialog] = useState(false) @@ -103,6 +121,8 @@ export default function SnsPage() { const cacheScopeKeyRef = useRef('') const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null) const contactsLoadTokenRef = useRef(0) + const contactsCountHydrationTokenRef = useRef(0) + const contactsCountBatchTimerRef = useRef(null) const authorTimelinePostsRef = useRef([]) const authorTimelineLoadingRef = useRef(false) const authorTimelineRequestTokenRef = useRef(0) @@ -165,6 +185,31 @@ export default function SnsPage() { .trim() } + const normalizePostCount = useCallback((value: unknown): number => { + const numeric = Number(value) + if (!Number.isFinite(numeric)) return 0 + return Math.max(0, Math.floor(numeric)) + }, []) + + const compareContactsForRanking = useCallback((a: Contact, b: Contact): number => { + const aReady = a.postCountStatus === 'ready' + const bReady = b.postCountStatus === 'ready' + if (aReady && bReady) { + const countDiff = normalizePostCount(b.postCount) - normalizePostCount(a.postCount) + if (countDiff !== 0) return countDiff + } else if (aReady !== bReady) { + return aReady ? -1 : 1 + } + + const tsDiff = Number(b.lastSessionTimestamp || 0) - Number(a.lastSessionTimestamp || 0) + if (tsDiff !== 0) return tsDiff + return (a.displayName || a.username).localeCompare((b.displayName || b.username), 'zh-Hans-CN') + }, [normalizePostCount]) + + const sortContactsForRanking = useCallback((input: Contact[]): Contact[] => { + return [...input].sort(compareContactsForRanking) + }, [compareContactsForRanking]) + const isDefaultViewNow = useCallback(() => { return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current }, []) @@ -423,13 +468,145 @@ export default function SnsPage() { } }, [jumpTargetDate, persistSnsPageCache, searchKeyword, selectedUsernames]) - // Load Contacts(仅加载好友/曾经好友,不再统计朋友圈条数) + const stopContactsCountHydration = useCallback((resetProgress = false) => { + contactsCountHydrationTokenRef.current += 1 + if (contactsCountBatchTimerRef.current) { + window.clearTimeout(contactsCountBatchTimerRef.current) + contactsCountBatchTimerRef.current = null + } + if (resetProgress) { + setContactsCountProgress({ + resolved: 0, + total: 0, + running: false + }) + } else { + setContactsCountProgress((prev) => ({ ...prev, running: false })) + } + }, []) + + const hydrateContactPostCounts = useCallback(async (usernames: string[]) => { + const targets = usernames + .map((username) => String(username || '').trim()) + .filter(Boolean) + stopContactsCountHydration(true) + if (targets.length === 0) return + + const runToken = ++contactsCountHydrationTokenRef.current + const totalTargets = targets.length + const targetSet = new Set(targets) + + setContacts((prev) => { + let changed = false + const next = prev.map((contact) => { + if (!targetSet.has(contact.username)) return contact + if (contact.postCountStatus === 'loading' && typeof contact.postCount !== 'number') return contact + changed = true + return { + ...contact, + postCount: undefined, + postCountStatus: 'loading' as ContactPostCountStatus + } + }) + return changed ? sortContactsForRanking(next) : prev + }) + setContactsCountProgress({ + resolved: 0, + total: totalTargets, + running: true + }) + + let normalizedCounts: Record = {} + try { + const result = await window.electronAPI.sns.getUserPostCounts() + if (runToken !== contactsCountHydrationTokenRef.current) return + if (result.success && result.counts) { + normalizedCounts = Object.fromEntries( + Object.entries(result.counts).map(([username, value]) => [username, normalizePostCount(value)]) + ) + } + } catch (error) { + console.error('Failed to load contact post counts:', error) + } + + let resolved = 0 + let cursor = 0 + const applyBatch = () => { + if (runToken !== contactsCountHydrationTokenRef.current) return + + const batch = targets.slice(cursor, cursor + CONTACT_COUNT_BATCH_SIZE) + if (batch.length === 0) { + setContactsCountProgress({ + resolved: totalTargets, + total: totalTargets, + running: false + }) + contactsCountBatchTimerRef.current = null + return + } + + const batchSet = new Set(batch) + setContacts((prev) => { + let changed = false + const next = prev.map((contact) => { + if (!batchSet.has(contact.username)) return contact + const nextCount = normalizePostCount(normalizedCounts[contact.username]) + if (contact.postCountStatus === 'ready' && contact.postCount === nextCount) return contact + changed = true + return { + ...contact, + postCount: nextCount, + postCountStatus: 'ready' as ContactPostCountStatus + } + }) + return changed ? sortContactsForRanking(next) : prev + }) + + resolved += batch.length + cursor += batch.length + setContactsCountProgress({ + resolved, + total: totalTargets, + running: resolved < totalTargets + }) + + if (cursor < totalTargets) { + contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS) + } else { + contactsCountBatchTimerRef.current = null + } + } + + applyBatch() + }, [normalizePostCount, sortContactsForRanking, stopContactsCountHydration]) + + // Load Contacts(先按最近会话显示联系人,再异步统计朋友圈条数并增量排序) const loadContacts = useCallback(async () => { const requestToken = ++contactsLoadTokenRef.current + stopContactsCountHydration(true) setContactsLoading(true) try { - const contactsResult = await window.electronAPI.chat.getContacts() + const [contactsResult, sessionsResult] = await Promise.all([ + window.electronAPI.chat.getContacts(), + window.electronAPI.chat.getSessions() + ]) const contactMap = new Map() + const sessionTimestampMap = new Map() + + if (sessionsResult.success && Array.isArray(sessionsResult.sessions)) { + for (const session of sessionsResult.sessions) { + const username = String(session?.username || '').trim() + if (!username) continue + const ts = Math.max( + Number(session?.sortTimestamp || 0), + Number(session?.lastTimestamp || 0) + ) + const prevTs = Number(sessionTimestampMap.get(username) || 0) + if (ts > prevTs) { + sessionTimestampMap.set(username, ts) + } + } + } if (contactsResult.success && contactsResult.contacts) { for (const c of contactsResult.contacts) { @@ -438,16 +615,19 @@ export default function SnsPage() { username: c.username, displayName: c.displayName, avatarUrl: c.avatarUrl, - type: c.type === 'former_friend' ? 'former_friend' : 'friend' + type: c.type === 'former_friend' ? 'former_friend' : 'friend', + lastSessionTimestamp: Number(sessionTimestampMap.get(c.username) || 0), + postCount: undefined, + postCountStatus: 'idle' }) } } } - let contactsList = Array.from(contactMap.values()) - + let contactsList = sortContactsForRanking(Array.from(contactMap.values())) if (requestToken !== contactsLoadTokenRef.current) return setContacts(contactsList) + void hydrateContactPostCounts(contactsList.map(contact => contact.username)) const allUsernames = contactsList.map(c => c.username) @@ -455,7 +635,7 @@ export default function SnsPage() { if (allUsernames.length > 0) { const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames) if (enriched.success && enriched.contacts) { - contactsList = contactsList.map(contact => { + contactsList = contactsList.map((contact) => { const extra = enriched.contacts?.[contact.username] if (!extra) return contact return { @@ -465,18 +645,31 @@ export default function SnsPage() { } }) if (requestToken !== contactsLoadTokenRef.current) return - setContacts(contactsList) + setContacts((prev) => { + const prevMap = new Map(prev.map((contact) => [contact.username, contact])) + const merged = contactsList.map((contact) => { + const previous = prevMap.get(contact.username) + return { + ...contact, + lastSessionTimestamp: previous?.lastSessionTimestamp ?? contact.lastSessionTimestamp, + postCount: previous?.postCount, + postCountStatus: previous?.postCountStatus ?? contact.postCountStatus + } + }) + return sortContactsForRanking(merged) + }) } } } catch (error) { if (requestToken !== contactsLoadTokenRef.current) return console.error('Failed to load contacts:', error) + stopContactsCountHydration(true) } finally { if (requestToken === contactsLoadTokenRef.current) { setContactsLoading(false) } } - }, []) + }, [hydrateContactPostCounts, sortContactsForRanking, stopContactsCountHydration]) const closeAuthorTimeline = useCallback(() => { authorTimelineRequestTokenRef.current += 1 @@ -631,10 +824,22 @@ export default function SnsPage() { loadOverviewStats() }, [hydrateSnsPageCache, loadContacts, loadOverviewStats]) + useEffect(() => { + return () => { + contactsCountHydrationTokenRef.current += 1 + if (contactsCountBatchTimerRef.current) { + window.clearTimeout(contactsCountBatchTimerRef.current) + contactsCountBatchTimerRef.current = null + } + } + }, []) + useEffect(() => { const handleChange = () => { cacheScopeKeyRef.current = '' // wxid changed, reset everything + stopContactsCountHydration(true) + setContacts([]) setPosts([]); setHasMore(true); setHasNewer(false); setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined); void hydrateSnsPageCache() @@ -644,7 +849,7 @@ export default function SnsPage() { } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) - }, [hydrateSnsPageCache, loadContacts, loadOverviewStats, loadPosts]) + }, [hydrateSnsPageCache, loadContacts, loadOverviewStats, loadPosts, stopContactsCountHydration]) useEffect(() => { const timer = setTimeout(() => { @@ -858,6 +1063,7 @@ export default function SnsPage() { contactSearch={contactSearch} setContactSearch={setContactSearch} loading={contactsLoading} + contactsCountProgress={contactsCountProgress} /> {/* Dialogs and Overlays */} From d07e4c8ecda38fde22de00a8e87e8c58ed7d11d6 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 19:02:38 +0800 Subject: [PATCH 19/97] chore(sns): remove my-posts segment from overview stats line --- src/pages/SnsPage.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 4cb4a2b..8f6e733 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -353,8 +353,7 @@ export default function SnsPage() { if (overviewStatsStatus === 'loading') { return '统计中...' } - const myPostsLabel = overviewStats.myPosts === null ? '--' : String(overviewStats.myPosts) - return `共 ${overviewStats.totalPosts} 条 | 我的朋友圈 ${myPostsLabel} 条 | ${formatDateOnly(overviewStats.earliestTime)} ~ ${formatDateOnly(overviewStats.latestTime)} | ${overviewStats.totalFriends} 位好友` + return `共 ${overviewStats.totalPosts} 条 | ${formatDateOnly(overviewStats.earliestTime)} ~ ${formatDateOnly(overviewStats.latestTime)} | ${overviewStats.totalFriends} 位好友` } const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => { From 4e0038c81397a4f8ec8c65882df530b56cc28ca4 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 19:07:13 +0800 Subject: [PATCH 20/97] feat(export): include sns count loading progress in load detail --- src/pages/ExportPage.tsx | 182 ++++++++++++++++++++++++++++++++++----- 1 file changed, 162 insertions(+), 20 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index c32f5e4..98ca8a2 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -171,6 +171,8 @@ const SESSION_MEDIA_METRIC_BATCH_SIZE = 12 const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48 const SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS = 120 const SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS = 1200 +const SNS_USER_POST_COUNT_BATCH_SIZE = 12 +const SNS_USER_POST_COUNT_BATCH_INTERVAL_MS = 120 const contentTypeLabels: Record = { text: '聊天文本', voice: '语音', @@ -725,6 +727,7 @@ interface SessionLoadStageState { interface SessionLoadTraceState { messageCount: SessionLoadStageState mediaMetrics: SessionLoadStageState + snsPostCounts: SessionLoadStageState } interface SessionLoadStageSummary { @@ -959,7 +962,8 @@ const createDefaultSessionLoadStage = (): SessionLoadStageState => ({ status: 'p const createDefaultSessionLoadTrace = (): SessionLoadTraceState => ({ messageCount: createDefaultSessionLoadStage(), - mediaMetrics: createDefaultSessionLoadStage() + mediaMetrics: createDefaultSessionLoadStage(), + snsPostCounts: createDefaultSessionLoadStage() }) const WriteLayoutSelector = memo(function WriteLayoutSelector({ @@ -1419,6 +1423,8 @@ function ExportPage() { const sessionSnsTimelinePostsRef = useRef([]) const sessionSnsTimelineLoadingRef = useRef(false) const sessionSnsTimelineRequestTokenRef = useRef(0) + const snsUserPostCountsHydrationTokenRef = useRef(0) + const snsUserPostCountsBatchTimerRef = useRef(null) const sessionPreciseRefreshAtRef = useRef>({}) const sessionLoadProgressSnapshotRef = useRef>({}) const sessionMediaMetricQueueRef = useRef([]) @@ -1955,28 +1961,86 @@ function ExportPage() { if (snsUserPostCountsStatus === 'loading') return if (!options?.force && snsUserPostCountsStatus === 'ready') return + const targetSessionIds = sessionsRef.current + .filter((session) => session.hasSession && isSingleContactSession(session.username)) + .map((session) => session.username) + + snsUserPostCountsHydrationTokenRef.current += 1 + const runToken = snsUserPostCountsHydrationTokenRef.current + if (snsUserPostCountsBatchTimerRef.current) { + window.clearTimeout(snsUserPostCountsBatchTimerRef.current) + snsUserPostCountsBatchTimerRef.current = null + } + + if (targetSessionIds.length === 0) { + setSnsUserPostCountsStatus('ready') + return + } + + patchSessionLoadTraceStage(targetSessionIds, 'snsPostCounts', 'pending', { force: true }) + patchSessionLoadTraceStage(targetSessionIds, 'snsPostCounts', 'loading') setSnsUserPostCountsStatus('loading') + + let normalizedCounts: Record = {} try { const result = await window.electronAPI.sns.getUserPostCounts() - if (result.success && result.counts) { - const normalized: Record = {} - for (const [rawUsername, rawCount] of Object.entries(result.counts)) { - const username = String(rawUsername || '').trim() - if (!username) continue - const value = Number(rawCount) - normalized[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 - } - setSnsUserPostCounts(normalized) - setSnsUserPostCountsStatus('ready') + if (runToken !== snsUserPostCountsHydrationTokenRef.current) return + + if (!result.success || !result.counts) { + patchSessionLoadTraceStage(targetSessionIds, 'snsPostCounts', 'failed', { + error: result.error || '朋友圈条数统计失败' + }) + setSnsUserPostCountsStatus('error') return } - setSnsUserPostCountsStatus('error') + for (const [rawUsername, rawCount] of Object.entries(result.counts)) { + const username = String(rawUsername || '').trim() + if (!username) continue + const value = Number(rawCount) + normalizedCounts[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 + } } catch (error) { console.error('加载朋友圈用户条数失败:', error) + if (runToken !== snsUserPostCountsHydrationTokenRef.current) return + patchSessionLoadTraceStage(targetSessionIds, 'snsPostCounts', 'failed', { + error: String(error) + }) setSnsUserPostCountsStatus('error') + return } - }, [snsUserPostCountsStatus]) + + let cursor = 0 + const applyBatch = () => { + if (runToken !== snsUserPostCountsHydrationTokenRef.current) return + + const batchSessionIds = targetSessionIds.slice(cursor, cursor + SNS_USER_POST_COUNT_BATCH_SIZE) + if (batchSessionIds.length === 0) { + setSnsUserPostCountsStatus('ready') + snsUserPostCountsBatchTimerRef.current = null + return + } + + const batchCounts: Record = {} + for (const sessionId of batchSessionIds) { + const nextCount = normalizedCounts[sessionId] + batchCounts[sessionId] = Number.isFinite(nextCount) ? Math.max(0, Math.floor(nextCount)) : 0 + } + + setSnsUserPostCounts(prev => ({ ...prev, ...batchCounts })) + patchSessionLoadTraceStage(batchSessionIds, 'snsPostCounts', 'done') + + cursor += batchSessionIds.length + if (cursor < targetSessionIds.length) { + snsUserPostCountsBatchTimerRef.current = window.setTimeout(applyBatch, SNS_USER_POST_COUNT_BATCH_INTERVAL_MS) + } else { + setSnsUserPostCountsStatus('ready') + snsUserPostCountsBatchTimerRef.current = null + } + } + + applyBatch() + }, [patchSessionLoadTraceStage, snsUserPostCountsStatus]) const loadSessionSnsTimelinePosts = useCallback(async (target: SessionSnsTimelineTarget, options?: { reset?: boolean }) => { const reset = Boolean(options?.reset) @@ -2080,7 +2144,7 @@ function ExportPage() { } void loadSessionSnsTimelinePosts(target, { reset: true }) - void loadSnsUserPostCounts({ force: true }) + void loadSnsUserPostCounts() }, [ loadSessionSnsTimelinePosts, loadSnsUserPostCounts, @@ -2568,6 +2632,13 @@ function ExportPage() { setSessionLoadTraceMap({}) setSessionLoadProgressPulseMap({}) sessionLoadProgressSnapshotRef.current = {} + snsUserPostCountsHydrationTokenRef.current += 1 + if (snsUserPostCountsBatchTimerRef.current) { + window.clearTimeout(snsUserPostCountsBatchTimerRef.current) + snsUserPostCountsBatchTimerRef.current = null + } + setSnsUserPostCounts({}) + setSnsUserPostCountsStatus('idle') setIsLoadingSessionCounts(false) setIsSessionCountStageReady(false) @@ -2895,8 +2966,14 @@ function ExportPage() { // 导出页隐藏时停止后台联系人补齐请求,避免与通讯录页面查询抢占。 sessionLoadTokenRef.current = Date.now() sessionCountRequestIdRef.current += 1 + snsUserPostCountsHydrationTokenRef.current += 1 + if (snsUserPostCountsBatchTimerRef.current) { + window.clearTimeout(snsUserPostCountsBatchTimerRef.current) + snsUserPostCountsBatchTimerRef.current = null + } setIsSessionEnriching(false) setIsLoadingSessionCounts(false) + setSnsUserPostCountsStatus('idle') }, [isExportRoute]) useEffect(() => { @@ -4087,18 +4164,31 @@ function ExportPage() { } }, [getLoadDetailStatusLabel, sessionLoadTraceMap]) + const createNotApplicableLoadSummary = useCallback((): SessionLoadStageSummary => { + return { + total: 0, + loaded: 0, + statusLabel: '不适用' + } + }, []) + const sessionLoadDetailRows = useMemo(() => { const tabOrder: ConversationTab[] = ['private', 'group', 'official', 'former_friend'] return tabOrder.map((tab) => { const sessionIds = loadDetailTargetsByTab[tab] || [] + const snsSessionIds = sessionIds.filter((sessionId) => isSingleContactSession(sessionId)) + const snsPostCounts = tab === 'private' || tab === 'former_friend' + ? summarizeLoadTraceForTab(snsSessionIds, 'snsPostCounts') + : createNotApplicableLoadSummary() return { tab, label: conversationTabLabels[tab], messageCount: summarizeLoadTraceForTab(sessionIds, 'messageCount'), - mediaMetrics: summarizeLoadTraceForTab(sessionIds, 'mediaMetrics') + mediaMetrics: summarizeLoadTraceForTab(sessionIds, 'mediaMetrics'), + snsPostCounts } }) - }, [loadDetailTargetsByTab, summarizeLoadTraceForTab]) + }, [createNotApplicableLoadSummary, loadDetailTargetsByTab, summarizeLoadTraceForTab]) const formatLoadDetailPulseTime = useCallback((value?: number): string => { if (!value || !Number.isFinite(value)) return '--' @@ -4115,7 +4205,7 @@ function ExportPage() { const nextSnapshot: Record = {} const resetKeys: string[] = [] const updates: Array<{ key: string; at: number; delta: number }> = [] - const stageKeys: Array = ['messageCount', 'mediaMetrics'] + const stageKeys: Array = ['messageCount', 'mediaMetrics', 'snsPostCounts'] for (const row of sessionLoadDetailRows) { for (const stageKey of stageKeys) { @@ -4255,6 +4345,11 @@ function ExportPage() { useEffect(() => { return () => { + snsUserPostCountsHydrationTokenRef.current += 1 + if (snsUserPostCountsBatchTimerRef.current) { + window.clearTimeout(snsUserPostCountsBatchTimerRef.current) + snsUserPostCountsBatchTimerRef.current = null + } if (sessionMediaMetricBackgroundFeedTimerRef.current) { window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current) sessionMediaMetricBackgroundFeedTimerRef.current = null @@ -4607,6 +4702,15 @@ function ExportPage() { snsUserPostCountsStatus ]) + useEffect(() => { + if (!isExportRoute || !isSessionCountStageReady) return + if (snsUserPostCountsStatus !== 'idle') return + const timer = window.setTimeout(() => { + void loadSnsUserPostCounts() + }, 260) + return () => window.clearTimeout(timer) + }, [isExportRoute, isSessionCountStageReady, loadSnsUserPostCounts, snsUserPostCountsStatus]) + useEffect(() => { if (!sessionSnsTimelineTarget) return if (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle') { @@ -4654,7 +4758,7 @@ function ExportPage() { detailStatsPriorityRef.current = true setShowSessionDetailPanel(true) if (isSingleContactSession(sessionId)) { - void loadSnsUserPostCounts({ force: true }) + void loadSnsUserPostCounts() } void loadSessionDetail(sessionId) }, [loadSessionDetail, loadSnsUserPostCounts]) @@ -4672,6 +4776,9 @@ function ExportPage() { useEffect(() => { if (!showSessionLoadDetailModal) return + if (snsUserPostCountsStatus === 'idle') { + void loadSnsUserPostCounts() + } const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setShowSessionLoadDetailModal(false) @@ -4679,7 +4786,7 @@ function ExportPage() { } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [showSessionLoadDetailModal]) + }, [loadSnsUserPostCounts, showSessionLoadDetailModal, snsUserPostCountsStatus]) useEffect(() => { if (!sessionSnsTimelineTarget) return @@ -4811,7 +4918,8 @@ function ExportPage() { for (const row of sessionLoadDetailRows) { const candidateTimes = [ row.messageCount.finishedAt || row.messageCount.startedAt || 0, - row.mediaMetrics.finishedAt || row.mediaMetrics.startedAt || 0 + row.mediaMetrics.finishedAt || row.mediaMetrics.startedAt || 0, + row.snsPostCounts.finishedAt || row.snsPostCounts.startedAt || 0 ] for (const candidate of candidateTimes) { if (candidate > latest) { @@ -5432,6 +5540,40 @@ function ExportPage() { })}
+ +
+
朋友圈条数统计
+
+
+ 会话类型 + 加载状态 + 开始时间 + 完成时间 +
+ {sessionLoadDetailRows.map((row) => { + const pulse = sessionLoadProgressPulseMap[`snsPostCounts:${row.tab}`] + const isLoading = row.snsPostCounts.statusLabel.startsWith('加载中') + return ( +
+ {row.label} + + {row.snsPostCounts.statusLabel} + {isLoading && ( + + )} + {isLoading && pulse && pulse.delta > 0 && ( + + {formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}条 + + )} + + {formatLoadDetailTime(row.snsPostCounts.startedAt)} + {formatLoadDetailTime(row.snsPostCounts.finishedAt)} +
+ ) + })} +
+
From 7ead55d80196b96ef325f86eabbf2508f9c1e33b Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 19:21:37 +0800 Subject: [PATCH 21/97] fix(export,sns): share sns user count cache across pages --- src/pages/ExportPage.tsx | 13 +++++- src/pages/SnsPage.tsx | 88 ++++++++++++++++++++++++++++++---------- src/services/config.ts | 58 ++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 23 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 98ca8a2..539ee92 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -2000,6 +2000,17 @@ function ExportPage() { const value = Number(rawCount) normalizedCounts[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 } + + void (async () => { + try { + const scopeKey = exportCacheScopeReadyRef.current + ? exportCacheScopeRef.current + : await ensureExportCacheScope() + await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts) + } catch (cacheError) { + console.error('写入导出页朋友圈条数缓存失败:', cacheError) + } + })() } catch (error) { console.error('加载朋友圈用户条数失败:', error) if (runToken !== snsUserPostCountsHydrationTokenRef.current) return @@ -2040,7 +2051,7 @@ function ExportPage() { } applyBatch() - }, [patchSessionLoadTraceStage, snsUserPostCountsStatus]) + }, [ensureExportCacheScope, patchSessionLoadTraceStage, snsUserPostCountsStatus]) const loadSessionSnsTimelinePosts = useCallback(async (target: SessionSnsTimelineTarget, options?: { reset?: boolean }) => { const reset = Boolean(options?.reset) diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 8f6e733..b51ce85 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -113,12 +113,14 @@ export default function SnsPage() { const [hasNewer, setHasNewer] = useState(false) const [loadingNewer, setLoadingNewer] = useState(false) const postsRef = useRef([]) + const contactsRef = useRef([]) const overviewStatsRef = useRef(overviewStats) const overviewStatsStatusRef = useRef(overviewStatsStatus) const selectedUsernamesRef = useRef(selectedUsernames) const searchKeywordRef = useRef(searchKeyword) const jumpTargetDateRef = useRef(jumpTargetDate) const cacheScopeKeyRef = useRef('') + const snsUserPostCountsCacheScopeKeyRef = useRef('') const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null) const contactsLoadTokenRef = useRef(0) const contactsCountHydrationTokenRef = useRef(0) @@ -132,6 +134,9 @@ export default function SnsPage() { useEffect(() => { postsRef.current = posts }, [posts]) + useEffect(() => { + contactsRef.current = contacts + }, [contacts]) useEffect(() => { overviewStatsRef.current = overviewStats }, [overviewStats]) @@ -222,6 +227,21 @@ export default function SnsPage() { return scopeKey }, []) + const ensureSnsUserPostCountsCacheScopeKey = useCallback(async () => { + if (snsUserPostCountsCacheScopeKeyRef.current) return snsUserPostCountsCacheScopeKeyRef.current + const [wxidRaw, dbPathRaw] = await Promise.all([ + configService.getMyWxid(), + configService.getDbPath() + ]) + const wxid = String(wxidRaw || '').trim() + const dbPath = String(dbPathRaw || '').trim() + const scopeKey = (dbPath || wxid) + ? `${dbPath}::${wxid}` + : 'default' + snsUserPostCountsCacheScopeKeyRef.current = scopeKey + return scopeKey + }, []) + const persistSnsPageCache = useCallback(async (patch?: { posts?: SnsPost[]; overviewStats?: SnsOverviewStats }) => { if (!isDefaultViewNow()) return try { @@ -484,36 +504,47 @@ export default function SnsPage() { } }, []) - const hydrateContactPostCounts = useCallback(async (usernames: string[]) => { + const hydrateContactPostCounts = useCallback(async (usernames: string[], options?: { force?: boolean }) => { + const force = options?.force === true const targets = usernames .map((username) => String(username || '').trim()) .filter(Boolean) stopContactsCountHydration(true) if (targets.length === 0) return + const readySet = new Set( + contactsRef.current + .filter((contact) => contact.postCountStatus === 'ready' && typeof contact.postCount === 'number') + .map((contact) => contact.username) + ) + const pendingTargets = force ? targets : targets.filter((username) => !readySet.has(username)) const runToken = ++contactsCountHydrationTokenRef.current const totalTargets = targets.length - const targetSet = new Set(targets) + const targetSet = new Set(pendingTargets) - setContacts((prev) => { - let changed = false - const next = prev.map((contact) => { - if (!targetSet.has(contact.username)) return contact - if (contact.postCountStatus === 'loading' && typeof contact.postCount !== 'number') return contact - changed = true - return { - ...contact, - postCount: undefined, - postCountStatus: 'loading' as ContactPostCountStatus - } + if (pendingTargets.length > 0) { + setContacts((prev) => { + let changed = false + const next = prev.map((contact) => { + if (!targetSet.has(contact.username)) return contact + if (contact.postCountStatus === 'loading' && typeof contact.postCount !== 'number') return contact + changed = true + return { + ...contact, + postCount: force ? undefined : contact.postCount, + postCountStatus: 'loading' as ContactPostCountStatus + } + }) + return changed ? sortContactsForRanking(next) : prev }) - return changed ? sortContactsForRanking(next) : prev - }) + } + const preResolved = Math.max(0, totalTargets - pendingTargets.length) setContactsCountProgress({ - resolved: 0, + resolved: preResolved, total: totalTargets, - running: true + running: pendingTargets.length > 0 }) + if (pendingTargets.length === 0) return let normalizedCounts: Record = {} try { @@ -523,17 +554,25 @@ export default function SnsPage() { normalizedCounts = Object.fromEntries( Object.entries(result.counts).map(([username, value]) => [username, normalizePostCount(value)]) ) + void (async () => { + try { + const scopeKey = await ensureSnsUserPostCountsCacheScopeKey() + await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts) + } catch (cacheError) { + console.error('Failed to persist SNS user post counts cache:', cacheError) + } + })() } } catch (error) { console.error('Failed to load contact post counts:', error) } - let resolved = 0 + let resolved = preResolved let cursor = 0 const applyBatch = () => { if (runToken !== contactsCountHydrationTokenRef.current) return - const batch = targets.slice(cursor, cursor + CONTACT_COUNT_BATCH_SIZE) + const batch = pendingTargets.slice(cursor, cursor + CONTACT_COUNT_BATCH_SIZE) if (batch.length === 0) { setContactsCountProgress({ resolved: totalTargets, @@ -585,6 +624,9 @@ export default function SnsPage() { stopContactsCountHydration(true) setContactsLoading(true) try { + const snsPostCountsScopeKey = await ensureSnsUserPostCountsCacheScopeKey() + const cachedPostCountsItem = await configService.getExportSnsUserPostCountsCache(snsPostCountsScopeKey) + const cachedPostCounts = cachedPostCountsItem?.counts || {} const [contactsResult, sessionsResult] = await Promise.all([ window.electronAPI.chat.getContacts(), window.electronAPI.chat.getSessions() @@ -610,14 +652,16 @@ export default function SnsPage() { if (contactsResult.success && contactsResult.contacts) { for (const c of contactsResult.contacts) { if (c.type === 'friend' || c.type === 'former_friend') { + const cachedCount = cachedPostCounts[c.username] + const hasCachedCount = typeof cachedCount === 'number' && Number.isFinite(cachedCount) contactMap.set(c.username, { username: c.username, displayName: c.displayName, avatarUrl: c.avatarUrl, type: c.type === 'former_friend' ? 'former_friend' : 'friend', lastSessionTimestamp: Number(sessionTimestampMap.get(c.username) || 0), - postCount: undefined, - postCountStatus: 'idle' + postCount: hasCachedCount ? Math.max(0, Math.floor(cachedCount)) : undefined, + postCountStatus: hasCachedCount ? 'ready' : 'idle' }) } } @@ -668,7 +712,7 @@ export default function SnsPage() { setContactsLoading(false) } } - }, [hydrateContactPostCounts, sortContactsForRanking, stopContactsCountHydration]) + }, [ensureSnsUserPostCountsCacheScopeKey, hydrateContactPostCounts, sortContactsForRanking, stopContactsCountHydration]) const closeAuthorTimeline = useCallback(() => { authorTimelineRequestTokenRef.current += 1 diff --git a/src/services/config.ts b/src/services/config.ts index b5a25c8..b644ca0 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -41,6 +41,7 @@ export const CONFIG_KEYS = { EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap', EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap', EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap', + EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap', SNS_PAGE_CACHE_MAP: 'snsPageCacheMap', CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs', CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap', @@ -533,6 +534,11 @@ export interface ExportSnsStatsCacheItem { totalFriends: number } +export interface ExportSnsUserPostCountsCacheItem { + updatedAt: number + counts: Record +} + export interface SnsPageOverviewCache { totalPosts: number totalFriends: number @@ -740,6 +746,58 @@ export async function setExportSnsStatsCache( await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map) } +export async function getExportSnsUserPostCountsCache(scopeKey: string): Promise { + if (!scopeKey) return null + const value = await config.get(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP) + if (!value || typeof value !== 'object') return null + const rawMap = value as Record + const rawItem = rawMap[scopeKey] + if (!rawItem || typeof rawItem !== 'object') return null + + const raw = rawItem as Record + const rawCounts = raw.counts + if (!rawCounts || typeof rawCounts !== 'object') return null + + const counts: Record = {} + for (const [rawUsername, rawCount] of Object.entries(rawCounts as Record)) { + const username = String(rawUsername || '').trim() + if (!username) continue + const valueNum = Number(rawCount) + counts[username] = Number.isFinite(valueNum) ? Math.max(0, Math.floor(valueNum)) : 0 + } + + const updatedAt = typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) + ? raw.updatedAt + : 0 + return { updatedAt, counts } +} + +export async function setExportSnsUserPostCountsCache( + scopeKey: string, + counts: Record +): Promise { + if (!scopeKey) return + const current = await config.get(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP) + const map = current && typeof current === 'object' + ? { ...(current as Record) } + : {} + + const normalized: Record = {} + for (const [rawUsername, rawCount] of Object.entries(counts || {})) { + const username = String(rawUsername || '').trim() + if (!username) continue + const valueNum = Number(rawCount) + normalized[username] = Number.isFinite(valueNum) ? Math.max(0, Math.floor(valueNum)) : 0 + } + + map[scopeKey] = { + updatedAt: Date.now(), + counts: normalized + } + + await config.set(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP, map) +} + export async function getSnsPageCache(scopeKey: string): Promise { if (!scopeKey) return null const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP) From 2140a220e255a71f292f5eac1150287cdc75bc7a Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 19:28:28 +0800 Subject: [PATCH 22/97] fix(electron): ensure app quits after main window close in dev --- electron/main.ts | 21 ++++++++++++++++++++- electron/windows/notificationWindow.ts | 22 ++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/electron/main.ts b/electron/main.ts index 21f63a4..f4ccb98 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -24,7 +24,7 @@ import { windowsHelloService } from './services/windowsHelloService' import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService' import { cloudControlService } from './services/cloudControlService' -import { registerNotificationHandlers, showNotification } from './windows/notificationWindow' +import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow' import { httpService } from './services/httpService' @@ -92,6 +92,7 @@ const keyService = new KeyService() let mainWindowReady = false let shouldShowMain = true +let isAppQuitting = false // 更新下载状态管理(Issue #294 修复) let isDownloadInProgress = false @@ -332,6 +333,21 @@ function createWindow(options: { autoShow?: boolean } = {}) { callback(false) }) + win.on('closed', () => { + if (mainWindow !== win) return + + mainWindow = null + mainWindowReady = false + + if (process.platform !== 'darwin' && !isAppQuitting) { + // 隐藏通知窗也是 BrowserWindow,必须销毁,否则会阻止应用退出。 + destroyNotificationWindow() + if (BrowserWindow.getAllWindows().length === 0) { + app.quit() + } + } + }) + return win } @@ -2427,6 +2443,9 @@ app.whenReady().then(async () => { }) app.on('before-quit', async () => { + isAppQuitting = true + // 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。 + destroyNotificationWindow() // 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出 try { await httpService.stop() } catch {} // 终止 wcdb Worker 线程,避免线程阻止进程退出 diff --git a/electron/windows/notificationWindow.ts b/electron/windows/notificationWindow.ts index ec58eac..1642924 100644 --- a/electron/windows/notificationWindow.ts +++ b/electron/windows/notificationWindow.ts @@ -5,6 +5,28 @@ import { ConfigService } from '../services/config' let notificationWindow: BrowserWindow | null = null let closeTimer: NodeJS.Timeout | null = null +export function destroyNotificationWindow() { + if (closeTimer) { + clearTimeout(closeTimer) + closeTimer = null + } + lastNotificationData = null + + if (!notificationWindow || notificationWindow.isDestroyed()) { + notificationWindow = null + return + } + + const win = notificationWindow + notificationWindow = null + + try { + win.destroy() + } catch (error) { + console.warn('[NotificationWindow] Failed to destroy window:', error) + } +} + export function createNotificationWindow() { if (notificationWindow && !notificationWindow.isDestroyed()) { return notificationWindow From c625756ab4e848c23b154bd5e8d875e88604e897 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 19:35:42 +0800 Subject: [PATCH 23/97] fix(export,sns): preserve sns load state across route switches --- src/pages/ExportPage.tsx | 47 +++++++++++++++++++++++++++------- src/pages/SnsPage.tsx | 54 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 539ee92..5e89b9b 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1977,8 +1977,40 @@ function ExportPage() { return } - patchSessionLoadTraceStage(targetSessionIds, 'snsPostCounts', 'pending', { force: true }) - patchSessionLoadTraceStage(targetSessionIds, 'snsPostCounts', 'loading') + const scopeKey = exportCacheScopeReadyRef.current + ? exportCacheScopeRef.current + : await ensureExportCacheScope() + const targetSet = new Set(targetSessionIds) + let cachedCounts: Record = {} + try { + const cached = await configService.getExportSnsUserPostCountsCache(scopeKey) + cachedCounts = cached?.counts || {} + } catch (cacheError) { + console.error('读取导出页朋友圈条数缓存失败:', cacheError) + } + + const cachedTargetCounts = Object.entries(cachedCounts).reduce>((acc, [sessionId, countRaw]) => { + if (!targetSet.has(sessionId)) return acc + const nextCount = Number(countRaw) + acc[sessionId] = Number.isFinite(nextCount) ? Math.max(0, Math.floor(nextCount)) : 0 + return acc + }, {}) + const cachedReadySessionIds = Object.keys(cachedTargetCounts) + if (cachedReadySessionIds.length > 0) { + setSnsUserPostCounts(prev => ({ ...prev, ...cachedTargetCounts })) + patchSessionLoadTraceStage(cachedReadySessionIds, 'snsPostCounts', 'done') + } + + const pendingSessionIds = options?.force + ? targetSessionIds + : targetSessionIds.filter((sessionId) => !(sessionId in cachedTargetCounts)) + if (pendingSessionIds.length === 0) { + setSnsUserPostCountsStatus('ready') + return + } + + patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'pending', { force: true }) + patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'loading') setSnsUserPostCountsStatus('loading') let normalizedCounts: Record = {} @@ -1987,7 +2019,7 @@ function ExportPage() { if (runToken !== snsUserPostCountsHydrationTokenRef.current) return if (!result.success || !result.counts) { - patchSessionLoadTraceStage(targetSessionIds, 'snsPostCounts', 'failed', { + patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'failed', { error: result.error || '朋友圈条数统计失败' }) setSnsUserPostCountsStatus('error') @@ -2003,9 +2035,6 @@ function ExportPage() { void (async () => { try { - const scopeKey = exportCacheScopeReadyRef.current - ? exportCacheScopeRef.current - : await ensureExportCacheScope() await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts) } catch (cacheError) { console.error('写入导出页朋友圈条数缓存失败:', cacheError) @@ -2014,7 +2043,7 @@ function ExportPage() { } catch (error) { console.error('加载朋友圈用户条数失败:', error) if (runToken !== snsUserPostCountsHydrationTokenRef.current) return - patchSessionLoadTraceStage(targetSessionIds, 'snsPostCounts', 'failed', { + patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'failed', { error: String(error) }) setSnsUserPostCountsStatus('error') @@ -2025,7 +2054,7 @@ function ExportPage() { const applyBatch = () => { if (runToken !== snsUserPostCountsHydrationTokenRef.current) return - const batchSessionIds = targetSessionIds.slice(cursor, cursor + SNS_USER_POST_COUNT_BATCH_SIZE) + const batchSessionIds = pendingSessionIds.slice(cursor, cursor + SNS_USER_POST_COUNT_BATCH_SIZE) if (batchSessionIds.length === 0) { setSnsUserPostCountsStatus('ready') snsUserPostCountsBatchTimerRef.current = null @@ -2984,7 +3013,7 @@ function ExportPage() { } setIsSessionEnriching(false) setIsLoadingSessionCounts(false) - setSnsUserPostCountsStatus('idle') + setSnsUserPostCountsStatus(prev => (prev === 'loading' ? 'idle' : prev)) }, [isExportRoute]) useEffect(() => { diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index b51ce85..f088e76 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -504,7 +504,10 @@ export default function SnsPage() { } }, []) - const hydrateContactPostCounts = useCallback(async (usernames: string[], options?: { force?: boolean }) => { + const hydrateContactPostCounts = useCallback(async ( + usernames: string[], + options?: { force?: boolean; readyUsernames?: Set } + ) => { const force = options?.force === true const targets = usernames .map((username) => String(username || '').trim()) @@ -512,7 +515,7 @@ export default function SnsPage() { stopContactsCountHydration(true) if (targets.length === 0) return - const readySet = new Set( + const readySet = options?.readyUsernames || new Set( contactsRef.current .filter((contact) => contact.postCountStatus === 'ready' && typeof contact.postCount === 'number') .map((contact) => contact.username) @@ -625,8 +628,42 @@ export default function SnsPage() { setContactsLoading(true) try { const snsPostCountsScopeKey = await ensureSnsUserPostCountsCacheScopeKey() - const cachedPostCountsItem = await configService.getExportSnsUserPostCountsCache(snsPostCountsScopeKey) + const [cachedPostCountsItem, cachedContactsItem, cachedAvatarItem] = await Promise.all([ + configService.getExportSnsUserPostCountsCache(snsPostCountsScopeKey), + configService.getContactsListCache(snsPostCountsScopeKey), + configService.getContactsAvatarCache(snsPostCountsScopeKey) + ]) const cachedPostCounts = cachedPostCountsItem?.counts || {} + const cachedAvatarMap = cachedAvatarItem?.avatars || {} + const cachedContacts = (cachedContactsItem?.contacts || []) + .filter((contact) => contact.type === 'friend' || contact.type === 'former_friend') + .map((contact) => { + const cachedCount = cachedPostCounts[contact.username] + const hasCachedCount = typeof cachedCount === 'number' && Number.isFinite(cachedCount) + return { + username: contact.username, + displayName: contact.displayName || contact.username, + avatarUrl: cachedAvatarMap[contact.username]?.avatarUrl, + type: (contact.type === 'former_friend' ? 'former_friend' : 'friend') as 'friend' | 'former_friend', + lastSessionTimestamp: 0, + postCount: hasCachedCount ? Math.max(0, Math.floor(cachedCount)) : undefined, + postCountStatus: hasCachedCount ? 'ready' as ContactPostCountStatus : 'idle' as ContactPostCountStatus + } + }) + + if (requestToken !== contactsLoadTokenRef.current) return + if (cachedContacts.length > 0) { + const cachedContactsSorted = sortContactsForRanking(cachedContacts) + setContacts(cachedContactsSorted) + setContactsLoading(false) + const cachedReadyCount = cachedContactsSorted.filter(contact => contact.postCountStatus === 'ready').length + setContactsCountProgress({ + resolved: cachedReadyCount, + total: cachedContactsSorted.length, + running: cachedReadyCount < cachedContactsSorted.length + }) + } + const [contactsResult, sessionsResult] = await Promise.all([ window.electronAPI.chat.getContacts(), window.electronAPI.chat.getSessions() @@ -670,7 +707,15 @@ export default function SnsPage() { let contactsList = sortContactsForRanking(Array.from(contactMap.values())) if (requestToken !== contactsLoadTokenRef.current) return setContacts(contactsList) - void hydrateContactPostCounts(contactsList.map(contact => contact.username)) + const readyUsernames = new Set( + contactsList + .filter((contact) => contact.postCountStatus === 'ready' && typeof contact.postCount === 'number') + .map((contact) => contact.username) + ) + void hydrateContactPostCounts( + contactsList.map(contact => contact.username), + { readyUsernames } + ) const allUsernames = contactsList.map(c => c.username) @@ -880,6 +925,7 @@ export default function SnsPage() { useEffect(() => { const handleChange = () => { cacheScopeKeyRef.current = '' + snsUserPostCountsCacheScopeKeyRef.current = '' // wxid changed, reset everything stopContactsCountHydration(true) setContacts([]) From 2eff82891e32303f236f870dbeed3f62be32d684 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 19:43:11 +0800 Subject: [PATCH 24/97] feat(export): add clickable sns count column in session list --- src/pages/ExportPage.scss | 32 ++++++++++++++++++ src/pages/ExportPage.tsx | 69 ++++++++++++++++++++++++++++++++------- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 80cc66e..c22a09c 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1559,6 +1559,38 @@ color: var(--text-tertiary); } + .row-sns-metric-btn { + border: none; + background: transparent; + margin: 0; + padding: 0; + min-height: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + line-height: 1.2; + color: var(--primary); + font-variant-numeric: tabular-nums; + cursor: pointer; + + &:hover { + color: var(--primary-hover); + text-decoration: underline; + text-underline-offset: 2px; + } + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 48%, transparent); + outline-offset: 2px; + border-radius: 4px; + } + + &.loading { + color: var(--text-tertiary); + } + } + .row-message-stats { width: 100%; display: flex; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 5e89b9b..259c7e0 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -2158,16 +2158,7 @@ function ExportPage() { setSessionSnsTimelineStatsLoading(false) }, []) - const openSessionSnsTimeline = useCallback(() => { - const normalizedSessionId = String(sessionDetail?.wxid || '').trim() - if (!isSingleContactSession(normalizedSessionId) || !sessionDetail) return - - const target: SessionSnsTimelineTarget = { - username: normalizedSessionId, - displayName: sessionDetail.displayName || sessionDetail.remark || sessionDetail.nickName || normalizedSessionId, - avatarUrl: sessionDetail.avatarUrl - } - + const openSessionSnsTimelineByTarget = useCallback((target: SessionSnsTimelineTarget) => { setSessionSnsTimelineTarget(target) setSessionSnsTimelinePosts([]) setSessionSnsTimelineHasMore(false) @@ -2175,7 +2166,7 @@ function ExportPage() { setSessionSnsTimelineLoading(false) if (snsUserPostCountsStatus === 'ready') { - const count = Number(snsUserPostCounts[normalizedSessionId] || 0) + const count = Number(snsUserPostCounts[target.username] || 0) setSessionSnsTimelineTotalPosts(Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0) setSessionSnsTimelineStatsLoading(false) } else { @@ -2188,11 +2179,33 @@ function ExportPage() { }, [ loadSessionSnsTimelinePosts, loadSnsUserPostCounts, - sessionDetail, snsUserPostCounts, snsUserPostCountsStatus ]) + const openSessionSnsTimeline = useCallback(() => { + const normalizedSessionId = String(sessionDetail?.wxid || '').trim() + if (!isSingleContactSession(normalizedSessionId) || !sessionDetail) return + + const target: SessionSnsTimelineTarget = { + username: normalizedSessionId, + displayName: sessionDetail.displayName || sessionDetail.remark || sessionDetail.nickName || normalizedSessionId, + avatarUrl: sessionDetail.avatarUrl + } + + openSessionSnsTimelineByTarget(target) + }, [openSessionSnsTimelineByTarget, sessionDetail]) + + const openContactSnsTimeline = useCallback((contact: ContactInfo) => { + const normalizedSessionId = String(contact?.username || '').trim() + if (!isSingleContactSession(normalizedSessionId)) return + openSessionSnsTimelineByTarget({ + username: normalizedSessionId, + displayName: contact.displayName || contact.remark || contact.nickname || normalizedSessionId, + avatarUrl: contact.avatarUrl + }) + }, [openSessionSnsTimelineByTarget]) + const loadMoreSessionSnsTimeline = useCallback(() => { if (!sessionSnsTimelineTarget || sessionSnsTimelineLoading || sessionSnsTimelineLoadingMore || !sessionSnsTimelineHasMore) return void loadSessionSnsTimelinePosts(sessionSnsTimelineTarget, { reset: false }) @@ -4058,6 +4071,9 @@ function ExportPage() { if (activeTab === 'former_friend') return '曾经的好友' return '公众号' }, [activeTab]) + const shouldShowSnsColumn = useMemo(() => ( + activeTab === 'private' || activeTab === 'former_friend' + ), [activeTab]) const sessionRowByUsername = useMemo(() => { const map = new Map() @@ -5004,6 +5020,10 @@ function ExportPage() { const voiceMetric = metricToDisplay(mediaMetric?.voiceMessages) const imageMetric = metricToDisplay(mediaMetric?.imageMessages) const videoMetric = metricToDisplay(mediaMetric?.videoMessages) + const isSnsCountLoading = snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle' + const snsRawCount = Number(snsUserPostCounts[contact.username] || 0) + const snsCount = Number.isFinite(snsRawCount) ? Math.max(0, Math.floor(snsRawCount)) : 0 + const supportsSnsTimeline = isSingleContactSession(contact.username) const openChatLabel = contact.type === 'friend' ? '打开私聊' : contact.type === 'group' @@ -5086,6 +5106,24 @@ function ExportPage() { : videoMetric.text}
+ {shouldShowSnsColumn && ( +
+ {supportsSnsTimeline ? ( + + ) : ( + -- + )} +
+ )}
- @@ -5635,7 +5641,9 @@ function ExportPage() { 开始时间 完成时间
- {sessionLoadDetailRows.map((row) => { + {sessionLoadDetailRows + .filter((row) => row.tab === 'private') + .map((row) => { const pulse = sessionLoadProgressPulseMap[`snsPostCounts:${row.tab}`] const isLoading = row.snsPostCounts.statusLabel.startsWith('加载中') return ( From edaef537126b57ab8218f22425cecfb5a51a6014 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 19:52:23 +0800 Subject: [PATCH 26/97] feat(export): add sns detail sync tip between header and list --- src/pages/ExportPage.scss | 15 +++++++++++++++ src/pages/ExportPage.tsx | 4 ++++ 2 files changed, 19 insertions(+) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index c22a09c..93a49e5 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2298,6 +2298,16 @@ } } + .sns-dialog-tip { + padding: 10px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent); + background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary)); + font-size: 12px; + line-height: 1.6; + color: var(--text-secondary); + word-break: break-word; + } + .sns-dialog-body { flex: 1; min-height: 0; @@ -3244,6 +3254,11 @@ padding: 12px; } + .sns-dialog-tip { + padding: 10px 12px; + line-height: 1.55; + } + .sns-dialog-body { padding: 10px 10px 12px; } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 0b45ed0..6fe00e2 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -6016,6 +6016,10 @@ function ExportPage() {
+
+ 在微信桌面客户端中打开这个人的朋友圈浏览,可快速把其朋友圈同步到这里。若你在乎这个人,一定要试试~ +
+
{sessionSnsTimelinePosts.length > 0 && (
From 3e004867bee9d59a62722ad728a3d42b0fdd2eb2 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 19:55:17 +0800 Subject: [PATCH 27/97] fix(export): show sns counts per-session as soon as loaded --- src/pages/ExportPage.tsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 6fe00e2..4db0498 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -5029,10 +5029,21 @@ function ExportPage() { const voiceMetric = metricToDisplay(mediaMetric?.voiceMessages) const imageMetric = metricToDisplay(mediaMetric?.imageMessages) const videoMetric = metricToDisplay(mediaMetric?.videoMessages) - const isSnsCountLoading = snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle' + const supportsSnsTimeline = isSingleContactSession(contact.username) + const hasSnsCount = Object.prototype.hasOwnProperty.call(snsUserPostCounts, contact.username) + const snsStageStatus = sessionLoadTraceMap[contact.username]?.snsPostCounts?.status + const isSnsCountLoading = ( + supportsSnsTimeline && + !hasSnsCount && + ( + snsStageStatus === 'pending' || + snsStageStatus === 'loading' || + snsUserPostCountsStatus === 'loading' || + snsUserPostCountsStatus === 'idle' + ) + ) const snsRawCount = Number(snsUserPostCounts[contact.username] || 0) const snsCount = Number.isFinite(snsRawCount) ? Math.max(0, Math.floor(snsRawCount)) : 0 - const supportsSnsTimeline = isSingleContactSession(contact.username) const openChatLabel = contact.type === 'friend' ? '打开私聊' : contact.type === 'group' @@ -5126,7 +5137,9 @@ function ExportPage() { > {isSnsCountLoading ? - : `${snsCount.toLocaleString('zh-CN')} 条`} + : hasSnsCount + ? `${snsCount.toLocaleString('zh-CN')} 条` + : '--'} ) : ( -- @@ -5176,6 +5189,7 @@ function ExportPage() { selectedSessions, sessionDetail?.wxid, sessionContentMetrics, + sessionLoadTraceMap, sessionMessageCounts, sessionRowByUsername, showSessionDetailPanel, From ba2cdbf8cfaeb637429f39b683fa9c5a60a36382 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 20:02:14 +0800 Subject: [PATCH 28/97] feat(export): add sns like/comment ranking strip in detail header --- src/pages/ExportPage.scss | 112 ++++++++++++++++++++++++++++++++++++++ src/pages/ExportPage.tsx | 108 +++++++++++++++++++++++++++++++++++- 2 files changed, 217 insertions(+), 3 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 93a49e5..35a0472 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2280,6 +2280,103 @@ color: var(--text-secondary); } + .sns-dialog-header-actions { + display: flex; + align-items: flex-start; + gap: 8px; + flex-shrink: 0; + } + + .sns-dialog-rank-switch { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + } + + .sns-dialog-rank-btn { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-secondary); + height: 28px; + padding: 0 10px; + font-size: 12px; + line-height: 1; + cursor: pointer; + white-space: nowrap; + + &:hover { + color: var(--text-primary); + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + } + + &.active { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 52%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); + } + } + + .sns-dialog-rank-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 248px; + max-height: 220px; + overflow-y: auto; + border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color)); + border-radius: 10px; + background: var(--bg-primary-solid, #fff); + box-shadow: 0 14px 26px rgba(0, 0, 0, 0.18); + padding: 8px; + z-index: 12; + } + + .sns-dialog-rank-empty { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.5; + text-align: center; + padding: 6px 0; + } + + .sns-dialog-rank-row { + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-height: 28px; + padding: 0 4px; + border-radius: 7px; + + &:hover { + background: var(--bg-hover); + } + } + + .sns-dialog-rank-index { + font-size: 12px; + color: var(--text-tertiary); + text-align: right; + font-variant-numeric: tabular-nums; + } + + .sns-dialog-rank-name { + font-size: 12px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .sns-dialog-rank-count { + font-size: 12px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + .close-btn { border: none; background: transparent; @@ -3254,6 +3351,21 @@ padding: 12px; } + .sns-dialog-header-actions { + gap: 6px; + } + + .sns-dialog-rank-btn { + height: 26px; + padding: 0 8px; + font-size: 11px; + } + + .sns-dialog-rank-panel { + width: min(78vw, 232px); + max-height: 190px; + } + .sns-dialog-tip { padding: 10px 12px; line-height: 1.55; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 4db0498..4026c5a 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -47,6 +47,7 @@ type TaskStatus = 'queued' | 'running' | 'success' | 'error' type TaskScope = 'single' | 'multi' | 'content' | 'sns' type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' type ContentCardType = ContentType | 'sns' +type SnsRankMode = 'likes' | 'comments' type SessionLayout = 'shared' | 'per-session' @@ -1335,6 +1336,7 @@ function ExportPage() { const [sessionSnsTimelineHasMore, setSessionSnsTimelineHasMore] = useState(false) const [sessionSnsTimelineTotalPosts, setSessionSnsTimelineTotalPosts] = useState(null) const [sessionSnsTimelineStatsLoading, setSessionSnsTimelineStatsLoading] = useState(false) + const [sessionSnsRankMode, setSessionSnsRankMode] = useState(null) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('B') @@ -2153,6 +2155,7 @@ function ExportPage() { const closeSessionSnsTimeline = useCallback(() => { sessionSnsTimelineRequestTokenRef.current += 1 sessionSnsTimelineLoadingRef.current = false + setSessionSnsRankMode(null) setSessionSnsTimelineTarget(null) setSessionSnsTimelinePosts([]) setSessionSnsTimelineLoading(false) @@ -2163,6 +2166,7 @@ function ExportPage() { }, []) const openSessionSnsTimelineByTarget = useCallback((target: SessionSnsTimelineTarget) => { + setSessionSnsRankMode(null) setSessionSnsTimelineTarget(target) setSessionSnsTimelinePosts([]) setSessionSnsTimelineHasMore(false) @@ -2243,6 +2247,62 @@ function ExportPage() { sessionSnsTimelineTotalPosts ]) + const sessionSnsLikeRankings = useMemo(() => { + const rankMap = new Map() + for (const post of sessionSnsTimelinePosts) { + const createTime = Number(post?.createTime) || 0 + const likes = Array.isArray(post?.likes) ? post.likes : [] + for (const likeNameRaw of likes) { + const name = String(likeNameRaw || '').trim() || '未知用户' + const current = rankMap.get(name) + if (current) { + current.count += 1 + if (createTime > current.latestTime) current.latestTime = createTime + continue + } + rankMap.set(name, { name, count: 1, latestTime: createTime }) + } + } + return [...rankMap.values()].sort((a, b) => { + if (b.count !== a.count) return b.count - a.count + if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime + return a.name.localeCompare(b.name, 'zh-CN') + }) + }, [sessionSnsTimelinePosts]) + + const sessionSnsCommentRankings = useMemo(() => { + const rankMap = new Map() + for (const post of sessionSnsTimelinePosts) { + const createTime = Number(post?.createTime) || 0 + const comments = Array.isArray(post?.comments) ? post.comments : [] + for (const comment of comments) { + const name = String(comment?.nickname || '').trim() || '未知用户' + const current = rankMap.get(name) + if (current) { + current.count += 1 + if (createTime > current.latestTime) current.latestTime = createTime + continue + } + rankMap.set(name, { name, count: 1, latestTime: createTime }) + } + } + return [...rankMap.values()].sort((a, b) => { + if (b.count !== a.count) return b.count - a.count + if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime + return a.name.localeCompare(b.name, 'zh-CN') + }) + }, [sessionSnsTimelinePosts]) + + const toggleSessionSnsRankMode = useCallback((mode: SnsRankMode) => { + setSessionSnsRankMode((prev) => (prev === mode ? null : mode)) + }, []) + + const sessionSnsActiveRankings = useMemo(() => { + if (sessionSnsRankMode === 'likes') return sessionSnsLikeRankings + if (sessionSnsRankMode === 'comments') return sessionSnsCommentRankings + return [] + }, [sessionSnsCommentRankings, sessionSnsLikeRankings, sessionSnsRankMode]) + const mergeSessionContentMetrics = useCallback((input: Record) => { const entries = Object.entries(input) if (entries.length === 0) return @@ -6025,9 +6085,51 @@ function ExportPage() {
{renderSessionSnsTimelineStats()}
- +
+
+ + + {sessionSnsRankMode && ( +
+ {sessionSnsActiveRankings.length === 0 ? ( +
+ {sessionSnsRankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'} +
+ ) : ( + sessionSnsActiveRankings.slice(0, 10).map((item, index) => ( +
+ {index + 1} + {item.name} + + {item.count.toLocaleString('zh-CN')} + {sessionSnsRankMode === 'likes' ? '次' : '条'} + +
+ )) + )} +
+ )} +
+ +
From 7cea8b4fb376399820cd5eba284c2b6a540e5bbf Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 20:10:47 +0800 Subject: [PATCH 29/97] fix(export): tune sns rank strip size and theme compatibility --- src/pages/ExportPage.scss | 99 +++++++++++++++++++++++++++++---------- src/pages/ExportPage.tsx | 45 +++++++++--------- 2 files changed, 98 insertions(+), 46 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 35a0472..4606e21 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1794,7 +1794,7 @@ } } - .row-action-cell { +.row-action-cell { display: flex; flex-direction: column; align-items: flex-end; @@ -1804,7 +1804,7 @@ .row-action-main { display: inline-flex; - align-items: center; + align-items: flex-start; gap: 6px; } @@ -1831,47 +1831,95 @@ } } - .row-export-btn { + .row-export-action-stack { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 2px; + min-width: 84px; + } + + .row-export-link { border: none; - border-radius: 8px; - padding: 7px 10px; - background: var(--primary); - color: #fff; + padding: 0; + margin: 0; + background: transparent; + color: var(--primary); font-size: 12px; cursor: pointer; - display: flex; - align-items: center; - gap: 5px; + line-height: 1.2; + font-weight: 600; + white-space: nowrap; &:hover:not(:disabled) { - background: var(--primary-hover); + color: var(--primary-hover); + text-decoration: underline; + text-underline-offset: 2px; } &:disabled { - opacity: 0.75; cursor: not-allowed; } - &.running { - background: color-mix(in srgb, var(--primary) 80%, #000); + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 30%, transparent); + outline-offset: 2px; + border-radius: 4px; } - &.paused { - background: rgba(250, 173, 20, 0.16); - color: #d48806; - border: 1px solid rgba(250, 173, 20, 0.38); + &.state-running { + cursor: progress; } - &.no-session { - background: var(--bg-secondary); + &.state-disabled { color: var(--text-tertiary); - border: 1px dashed var(--border-color); + text-decoration: none; } } + .row-export-meta { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 1px; + } + + .row-export-meta-label { + font-size: 10px; + line-height: 1.2; + color: var(--text-tertiary); + font-weight: 500; + } + .row-export-time { font-size: 11px; + line-height: 1.2; color: var(--text-tertiary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + text-align: center; + } + + .row-export-link.state-running + .row-export-meta .row-export-time { + color: var(--primary); + font-weight: 600; + } + + .row-export-link.state-running:hover:not(:disabled), + .row-export-link.state-running:focus-visible { + color: var(--primary); + text-decoration: none; + } + + .row-export-link.state-disabled + .row-export-meta .row-export-meta-label, + .row-export-link.state-disabled + .row-export-meta .row-export-time { + color: var(--text-tertiary); + } + + .row-export-link.state-disabled:hover:not(:disabled), + .row-export-link.state-disabled:focus-visible { + color: var(--text-tertiary); + text-decoration: none; } } @@ -2323,11 +2371,11 @@ top: calc(100% + 8px); right: 0; width: 248px; - max-height: 220px; + max-height: calc((28px * 15) + 16px); overflow-y: auto; border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color)); border-radius: 10px; - background: var(--bg-primary-solid, #fff); + background: var(--bg-primary); box-shadow: 0 14px 26px rgba(0, 0, 0, 0.18); padding: 8px; z-index: 12; @@ -3292,7 +3340,8 @@ font-size: 12px; } - .table-wrap .row-open-chat-link { + .table-wrap .row-open-chat-link, + .table-wrap .row-export-link { font-size: 11px; } @@ -3363,7 +3412,7 @@ .sns-dialog-rank-panel { width: min(78vw, 232px); - max-height: 190px; + max-height: calc((28px * 15) + 16px); } .sns-dialog-tip { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 4026c5a..3891e01 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -444,7 +444,7 @@ const formatPathBrief = (value: string, maxLength = 52): string => { } const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => { - if (!timestamp) return '' + if (!timestamp) return '未导出' const diff = Math.max(0, now - timestamp) const minute = 60 * 1000 const hour = 60 * minute @@ -5067,7 +5067,7 @@ function ExportPage() { const checked = canExport && selectedSessions.has(contact.username) const isRunning = canExport && runningSessionIds.has(contact.username) const isQueued = canExport && queuedSessionIds.has(contact.username) - const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '' + const recentExportTime = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '—' const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username]) const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint) const displayedMessageCount = countedMessages ?? hintedMessages @@ -5214,26 +5214,29 @@ function ExportPage() { > 详情 - +
+ + {canExport && ( +
+ 最近导出 + {recentExportTime} +
+ )} +
- {recent && {recent}} From 05c551d7acd8199d19f57b6109b25a958e093c21 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 20:24:55 +0800 Subject: [PATCH 30/97] fix(export): hide recent-export row when no history --- src/pages/ExportPage.scss | 21 +-------------------- src/pages/ExportPage.tsx | 13 +++++-------- 2 files changed, 6 insertions(+), 28 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 4606e21..3410552 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1877,20 +1877,6 @@ } } - .row-export-meta { - display: inline-flex; - flex-direction: column; - align-items: center; - gap: 1px; - } - - .row-export-meta-label { - font-size: 10px; - line-height: 1.2; - color: var(--text-tertiary); - font-weight: 500; - } - .row-export-time { font-size: 11px; line-height: 1.2; @@ -1900,7 +1886,7 @@ text-align: center; } - .row-export-link.state-running + .row-export-meta .row-export-time { + .row-export-link.state-running + .row-export-time { color: var(--primary); font-weight: 600; } @@ -1911,11 +1897,6 @@ text-decoration: none; } - .row-export-link.state-disabled + .row-export-meta .row-export-meta-label, - .row-export-link.state-disabled + .row-export-meta .row-export-time { - color: var(--text-tertiary); - } - .row-export-link.state-disabled:hover:not(:disabled), .row-export-link.state-disabled:focus-visible { color: var(--text-tertiary); diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 3891e01..f897e62 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -444,7 +444,7 @@ const formatPathBrief = (value: string, maxLength = 52): string => { } const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => { - if (!timestamp) return '未导出' + if (!timestamp) return '' const diff = Math.max(0, now - timestamp) const minute = 60 * 1000 const hour = 60 * minute @@ -5067,7 +5067,9 @@ function ExportPage() { const checked = canExport && selectedSessions.has(contact.username) const isRunning = canExport && runningSessionIds.has(contact.username) const isQueued = canExport && queuedSessionIds.has(contact.username) - const recentExportTime = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '—' + const recentExportTimestamp = lastExportBySession[contact.username] + const hasRecentExport = canExport && Boolean(recentExportTimestamp) + const recentExportTime = hasRecentExport ? formatRecentExportTime(recentExportTimestamp, nowTick) : '' const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username]) const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint) const displayedMessageCount = countedMessages ?? hintedMessages @@ -5229,12 +5231,7 @@ function ExportPage() { > {!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '单会话导出'} - {canExport && ( -
- 最近导出 - {recentExportTime} -
- )} + {hasRecentExport && {recentExportTime}} From 3de4951c9671275c83220580e9e639580d71074f Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 20:25:53 +0800 Subject: [PATCH 31/97] fix(export): show top 15 entries in sns rank strip --- src/pages/ExportPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index f897e62..4f18a72 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -6112,7 +6112,7 @@ function ExportPage() { {sessionSnsRankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'} ) : ( - sessionSnsActiveRankings.slice(0, 10).map((item, index) => ( + sessionSnsActiveRankings.slice(0, 15).map((item, index) => (
{index + 1} {item.name} From ad217d4a3b8138228fe75dc69312671cb71fe33e Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 10:05:46 +0800 Subject: [PATCH 32/97] feat(export): compute sns rankings from full contact timeline --- src/pages/ExportPage.scss | 12 ++ src/pages/ExportPage.tsx | 262 +++++++++++++++++++++++++++++++------- 2 files changed, 225 insertions(+), 49 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 3410552..cfdc918 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2370,6 +2370,18 @@ padding: 6px 0; } + .sns-dialog-rank-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 28px; + padding: 4px 0 8px; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; + } + .sns-dialog-rank-row { display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 4f18a72..aa8e134 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -174,6 +174,8 @@ const SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS = 120 const SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS = 1200 const SNS_USER_POST_COUNT_BATCH_SIZE = 12 const SNS_USER_POST_COUNT_BATCH_INTERVAL_MS = 120 +const SNS_RANK_PAGE_SIZE = 50 +const SNS_RANK_DISPLAY_LIMIT = 15 const contentTypeLabels: Record = { text: '聊天文本', voice: '语音', @@ -684,6 +686,63 @@ interface SessionSnsTimelineTarget { avatarUrl?: string } +interface SessionSnsRankItem { + name: string + count: number + latestTime: number +} + +interface SessionSnsRankCacheEntry { + likes: SessionSnsRankItem[] + comments: SessionSnsRankItem[] + totalPosts: number + computedAt: number +} + +const buildSessionSnsRankings = (posts: SnsPost[]): { likes: SessionSnsRankItem[]; comments: SessionSnsRankItem[] } => { + const likeMap = new Map() + const commentMap = new Map() + + for (const post of posts) { + const createTime = Number(post?.createTime) || 0 + const likes = Array.isArray(post?.likes) ? post.likes : [] + const comments = Array.isArray(post?.comments) ? post.comments : [] + + for (const likeNameRaw of likes) { + const name = String(likeNameRaw || '').trim() || '未知用户' + const current = likeMap.get(name) + if (current) { + current.count += 1 + if (createTime > current.latestTime) current.latestTime = createTime + continue + } + likeMap.set(name, { name, count: 1, latestTime: createTime }) + } + + for (const comment of comments) { + const name = String(comment?.nickname || '').trim() || '未知用户' + const current = commentMap.get(name) + if (current) { + current.count += 1 + if (createTime > current.latestTime) current.latestTime = createTime + continue + } + commentMap.set(name, { name, count: 1, latestTime: createTime }) + } + } + + const sorter = (a: SessionSnsRankItem, b: SessionSnsRankItem): number => { + if (b.count !== a.count) return b.count - a.count + if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime + return a.name.localeCompare(b.name, 'zh-CN') + } + + return { + likes: [...likeMap.values()].sort(sorter), + comments: [...commentMap.values()].sort(sorter) + } +} + interface SessionExportMetric { totalMessages: number voiceMessages: number @@ -1337,6 +1396,12 @@ function ExportPage() { const [sessionSnsTimelineTotalPosts, setSessionSnsTimelineTotalPosts] = useState(null) const [sessionSnsTimelineStatsLoading, setSessionSnsTimelineStatsLoading] = useState(false) const [sessionSnsRankMode, setSessionSnsRankMode] = useState(null) + const [sessionSnsLikeRankings, setSessionSnsLikeRankings] = useState([]) + const [sessionSnsCommentRankings, setSessionSnsCommentRankings] = useState([]) + const [sessionSnsRankLoading, setSessionSnsRankLoading] = useState(false) + const [sessionSnsRankError, setSessionSnsRankError] = useState(null) + const [sessionSnsRankLoadedPosts, setSessionSnsRankLoadedPosts] = useState(0) + const [sessionSnsRankTotalPosts, setSessionSnsRankTotalPosts] = useState(null) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('B') @@ -1429,6 +1494,9 @@ function ExportPage() { const sessionSnsTimelinePostsRef = useRef([]) const sessionSnsTimelineLoadingRef = useRef(false) const sessionSnsTimelineRequestTokenRef = useRef(0) + const sessionSnsRankRequestTokenRef = useRef(0) + const sessionSnsRankLoadingRef = useRef(false) + const sessionSnsRankCacheRef = useRef>({}) const snsUserPostCountsHydrationTokenRef = useRef(0) const snsUserPostCountsBatchTimerRef = useRef(null) const sessionPreciseRefreshAtRef = useRef>({}) @@ -2155,7 +2223,15 @@ function ExportPage() { const closeSessionSnsTimeline = useCallback(() => { sessionSnsTimelineRequestTokenRef.current += 1 sessionSnsTimelineLoadingRef.current = false + sessionSnsRankRequestTokenRef.current += 1 + sessionSnsRankLoadingRef.current = false setSessionSnsRankMode(null) + setSessionSnsLikeRankings([]) + setSessionSnsCommentRankings([]) + setSessionSnsRankLoading(false) + setSessionSnsRankError(null) + setSessionSnsRankLoadedPosts(0) + setSessionSnsRankTotalPosts(null) setSessionSnsTimelineTarget(null) setSessionSnsTimelinePosts([]) setSessionSnsTimelineLoading(false) @@ -2166,7 +2242,14 @@ function ExportPage() { }, []) const openSessionSnsTimelineByTarget = useCallback((target: SessionSnsTimelineTarget) => { + sessionSnsRankRequestTokenRef.current += 1 + sessionSnsRankLoadingRef.current = false setSessionSnsRankMode(null) + setSessionSnsLikeRankings([]) + setSessionSnsCommentRankings([]) + setSessionSnsRankLoading(false) + setSessionSnsRankError(null) + setSessionSnsRankLoadedPosts(0) setSessionSnsTimelineTarget(target) setSessionSnsTimelinePosts([]) setSessionSnsTimelineHasMore(false) @@ -2177,9 +2260,11 @@ function ExportPage() { const count = Number(snsUserPostCounts[target.username] || 0) setSessionSnsTimelineTotalPosts(Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0) setSessionSnsTimelineStatsLoading(false) + setSessionSnsRankTotalPosts(Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0) } else { setSessionSnsTimelineTotalPosts(null) setSessionSnsTimelineStatsLoading(true) + setSessionSnsRankTotalPosts(null) } void loadSessionSnsTimelinePosts(target, { reset: true }) @@ -2225,6 +2310,102 @@ function ExportPage() { sessionSnsTimelineTarget ]) + const loadSessionSnsRankings = useCallback(async (target: SessionSnsTimelineTarget) => { + const normalizedUsername = String(target?.username || '').trim() + if (!normalizedUsername || sessionSnsRankLoadingRef.current) return + + const knownTotal = snsUserPostCountsStatus === 'ready' + ? Number(snsUserPostCounts[normalizedUsername] || 0) + : null + const normalizedKnownTotal = knownTotal !== null && Number.isFinite(knownTotal) + ? Math.max(0, Math.floor(knownTotal)) + : null + const cached = sessionSnsRankCacheRef.current[normalizedUsername] + + if (cached && (normalizedKnownTotal === null || cached.totalPosts === normalizedKnownTotal)) { + setSessionSnsLikeRankings(cached.likes) + setSessionSnsCommentRankings(cached.comments) + setSessionSnsRankLoadedPosts(cached.totalPosts) + setSessionSnsRankTotalPosts(cached.totalPosts) + setSessionSnsRankError(null) + setSessionSnsRankLoading(false) + return + } + + sessionSnsRankLoadingRef.current = true + const requestToken = ++sessionSnsRankRequestTokenRef.current + setSessionSnsRankLoading(true) + setSessionSnsRankError(null) + setSessionSnsRankLoadedPosts(0) + setSessionSnsRankTotalPosts(normalizedKnownTotal) + + try { + const allPosts: SnsPost[] = [] + let endTime: number | undefined + let hasMore = true + + while (hasMore) { + const result = await window.electronAPI.sns.getTimeline( + SNS_RANK_PAGE_SIZE, + 0, + [normalizedUsername], + '', + undefined, + endTime + ) + if (requestToken !== sessionSnsRankRequestTokenRef.current) return + + if (!result.success) { + throw new Error(result.error || '加载朋友圈排行失败') + } + + const pagePosts = Array.isArray(result.timeline) + ? [...(result.timeline as SnsPost[])].sort((a, b) => b.createTime - a.createTime) + : [] + if (pagePosts.length === 0) { + hasMore = false + break + } + + allPosts.push(...pagePosts) + setSessionSnsRankLoadedPosts(allPosts.length) + if (normalizedKnownTotal === null) { + setSessionSnsRankTotalPosts(allPosts.length) + } + + endTime = pagePosts[pagePosts.length - 1].createTime - 1 + hasMore = pagePosts.length >= SNS_RANK_PAGE_SIZE + } + + if (requestToken !== sessionSnsRankRequestTokenRef.current) return + + const rankings = buildSessionSnsRankings(allPosts) + const totalPosts = allPosts.length + sessionSnsRankCacheRef.current[normalizedUsername] = { + likes: rankings.likes, + comments: rankings.comments, + totalPosts, + computedAt: Date.now() + } + setSessionSnsLikeRankings(rankings.likes) + setSessionSnsCommentRankings(rankings.comments) + setSessionSnsRankLoadedPosts(totalPosts) + setSessionSnsRankTotalPosts(totalPosts) + setSessionSnsRankError(null) + } catch (error) { + if (requestToken !== sessionSnsRankRequestTokenRef.current) return + const message = error instanceof Error ? error.message : String(error) + setSessionSnsLikeRankings([]) + setSessionSnsCommentRankings([]) + setSessionSnsRankError(message || '加载朋友圈排行失败') + } finally { + if (requestToken === sessionSnsRankRequestTokenRef.current) { + sessionSnsRankLoadingRef.current = false + setSessionSnsRankLoading(false) + } + } + }, [snsUserPostCounts, snsUserPostCountsStatus]) + const renderSessionSnsTimelineStats = useCallback((): string => { const loadedCount = sessionSnsTimelinePosts.length const loadPart = sessionSnsTimelineStatsLoading @@ -2247,52 +2428,6 @@ function ExportPage() { sessionSnsTimelineTotalPosts ]) - const sessionSnsLikeRankings = useMemo(() => { - const rankMap = new Map() - for (const post of sessionSnsTimelinePosts) { - const createTime = Number(post?.createTime) || 0 - const likes = Array.isArray(post?.likes) ? post.likes : [] - for (const likeNameRaw of likes) { - const name = String(likeNameRaw || '').trim() || '未知用户' - const current = rankMap.get(name) - if (current) { - current.count += 1 - if (createTime > current.latestTime) current.latestTime = createTime - continue - } - rankMap.set(name, { name, count: 1, latestTime: createTime }) - } - } - return [...rankMap.values()].sort((a, b) => { - if (b.count !== a.count) return b.count - a.count - if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime - return a.name.localeCompare(b.name, 'zh-CN') - }) - }, [sessionSnsTimelinePosts]) - - const sessionSnsCommentRankings = useMemo(() => { - const rankMap = new Map() - for (const post of sessionSnsTimelinePosts) { - const createTime = Number(post?.createTime) || 0 - const comments = Array.isArray(post?.comments) ? post.comments : [] - for (const comment of comments) { - const name = String(comment?.nickname || '').trim() || '未知用户' - const current = rankMap.get(name) - if (current) { - current.count += 1 - if (createTime > current.latestTime) current.latestTime = createTime - continue - } - rankMap.set(name, { name, count: 1, latestTime: createTime }) - } - } - return [...rankMap.values()].sort((a, b) => { - if (b.count !== a.count) return b.count - a.count - if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime - return a.name.localeCompare(b.name, 'zh-CN') - }) - }, [sessionSnsTimelinePosts]) - const toggleSessionSnsRankMode = useCallback((mode: SnsRankMode) => { setSessionSnsRankMode((prev) => (prev === mode ? null : mode)) }, []) @@ -4844,11 +4979,14 @@ function ExportPage() { } if (snsUserPostCountsStatus === 'ready') { const total = Number(snsUserPostCounts[sessionSnsTimelineTarget.username] || 0) - setSessionSnsTimelineTotalPosts(Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0) + const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0 + setSessionSnsTimelineTotalPosts(normalizedTotal) + setSessionSnsRankTotalPosts(normalizedTotal) setSessionSnsTimelineStatsLoading(false) return } setSessionSnsTimelineTotalPosts(null) + setSessionSnsRankTotalPosts(null) setSessionSnsTimelineStatsLoading(false) }, [sessionSnsTimelineTarget, snsUserPostCounts, snsUserPostCountsStatus]) @@ -4859,16 +4997,30 @@ function ExportPage() { } }, [sessionSnsTimelinePosts.length, sessionSnsTimelineTotalPosts]) + useEffect(() => { + if (!sessionSnsRankMode || !sessionSnsTimelineTarget) return + void loadSessionSnsRankings(sessionSnsTimelineTarget) + }, [loadSessionSnsRankings, sessionSnsRankMode, sessionSnsTimelineTarget]) + const closeSessionDetailPanel = useCallback(() => { detailRequestSeqRef.current += 1 detailStatsPriorityRef.current = false sessionSnsTimelineRequestTokenRef.current += 1 sessionSnsTimelineLoadingRef.current = false + sessionSnsRankRequestTokenRef.current += 1 + sessionSnsRankLoadingRef.current = false setShowSessionDetailPanel(false) setIsLoadingSessionDetail(false) setIsLoadingSessionDetailExtra(false) setIsRefreshingSessionDetailStats(false) setIsLoadingSessionRelationStats(false) + setSessionSnsRankMode(null) + setSessionSnsLikeRankings([]) + setSessionSnsCommentRankings([]) + setSessionSnsRankLoading(false) + setSessionSnsRankError(null) + setSessionSnsRankLoadedPosts(0) + setSessionSnsRankTotalPosts(null) setSessionSnsTimelineTarget(null) setSessionSnsTimelinePosts([]) setSessionSnsTimelineLoading(false) @@ -6107,12 +6259,24 @@ function ExportPage() { role="region" aria-label={sessionSnsRankMode === 'likes' ? '点赞排行' : '评论排行'} > - {sessionSnsActiveRankings.length === 0 ? ( + {sessionSnsRankLoading && ( +
+ + + {sessionSnsRankTotalPosts !== null && sessionSnsRankTotalPosts > 0 + ? `统计中,已加载 ${sessionSnsRankLoadedPosts} / ${sessionSnsRankTotalPosts} 条` + : `统计中,已加载 ${sessionSnsRankLoadedPosts} 条`} + +
+ )} + {!sessionSnsRankLoading && sessionSnsRankError ? ( +
{sessionSnsRankError}
+ ) : !sessionSnsRankLoading && sessionSnsActiveRankings.length === 0 ? (
{sessionSnsRankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'}
) : ( - sessionSnsActiveRankings.slice(0, 15).map((item, index) => ( + sessionSnsActiveRankings.slice(0, SNS_RANK_DISPLAY_LIMIT).map((item, index) => (
{index + 1} {item.name} From bc2ab60c593f77b8fcd912bb52d29d86d7575939 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 10:22:24 +0800 Subject: [PATCH 33/97] feat(sns): add contact timeline dialog components --- .../Sns/ContactSnsTimelineDialog.scss | 329 ++++++++++ .../Sns/ContactSnsTimelineDialog.tsx | 577 ++++++++++++++++++ src/components/Sns/contactSnsTimeline.ts | 26 + src/pages/ContactsPage.scss | 22 + src/pages/ContactsPage.tsx | 105 +++- 5 files changed, 1049 insertions(+), 10 deletions(-) create mode 100644 src/components/Sns/ContactSnsTimelineDialog.scss create mode 100644 src/components/Sns/ContactSnsTimelineDialog.tsx create mode 100644 src/components/Sns/contactSnsTimeline.ts diff --git a/src/components/Sns/ContactSnsTimelineDialog.scss b/src/components/Sns/ContactSnsTimelineDialog.scss new file mode 100644 index 0000000..b479c0d --- /dev/null +++ b/src/components/Sns/ContactSnsTimelineDialog.scss @@ -0,0 +1,329 @@ +.contact-sns-dialog-overlay { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 24px 16px; + background: rgba(15, 23, 42, 0.38); +} + +.contact-sns-dialog { + width: min(760px, 100%); + max-height: min(86vh, 860px); + border-radius: 14px; + border: 1px solid var(--border-color); + background: var(--bg-secondary-solid, #ffffff); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.24); + display: flex; + flex-direction: column; + overflow: hidden; + + .spin { + animation: contactSnsDialogSpin 1s linear infinite; + } + + .contact-sns-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-color); + } + + .contact-sns-dialog-header-main { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + } + + .contact-sns-dialog-avatar { + width: 42px; + height: 42px; + border-radius: 10px; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + overflow: hidden; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: #fff; + font-size: 14px; + font-weight: 600; + } + } + + .contact-sns-dialog-meta { + min-width: 0; + + h4 { + margin: 0; + font-size: 15px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .contact-sns-dialog-username { + margin-top: 2px; + font-size: 12px; + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .contact-sns-dialog-stats { + margin-top: 4px; + font-size: 12px; + color: var(--text-secondary); + } + + .contact-sns-dialog-header-actions { + display: flex; + align-items: flex-start; + gap: 8px; + flex-shrink: 0; + } + + .contact-sns-dialog-rank-switch { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + } + + .contact-sns-dialog-rank-btn { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-secondary); + height: 28px; + padding: 0 10px; + font-size: 12px; + line-height: 1; + cursor: pointer; + white-space: nowrap; + + &:hover { + color: var(--text-primary); + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + } + + &.active { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 52%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); + } + } + + .contact-sns-dialog-rank-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 248px; + max-height: calc((28px * 15) + 16px); + overflow-y: auto; + border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color)); + border-radius: 10px; + background: var(--bg-primary); + box-shadow: 0 14px 26px rgba(0, 0, 0, 0.18); + padding: 8px; + z-index: 12; + } + + .contact-sns-dialog-rank-empty { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.5; + text-align: center; + padding: 6px 0; + } + + .contact-sns-dialog-rank-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 28px; + padding: 4px 0 8px; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; + } + + .contact-sns-dialog-rank-row { + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-height: 28px; + padding: 0 4px; + border-radius: 7px; + + &:hover { + background: var(--bg-hover); + } + } + + .contact-sns-dialog-rank-index { + font-size: 12px; + color: var(--text-tertiary); + text-align: right; + font-variant-numeric: tabular-nums; + } + + .contact-sns-dialog-rank-name { + font-size: 12px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .contact-sns-dialog-rank-count { + font-size: 12px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .contact-sns-dialog-close-btn { + border: none; + background: transparent; + color: var(--text-secondary); + width: 28px; + height: 28px; + border-radius: 7px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .contact-sns-dialog-tip { + padding: 10px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent); + background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary)); + font-size: 12px; + line-height: 1.6; + color: var(--text-secondary); + word-break: break-word; + } + + .contact-sns-dialog-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 12px 14px 14px; + } + + .contact-sns-dialog-posts-list { + display: flex; + flex-direction: column; + gap: 14px; + } + + .contact-sns-dialog-posts-list .post-header-actions { + display: none; + } + + .contact-sns-dialog-status { + padding: 20px 12px; + text-align: center; + font-size: 13px; + color: var(--text-secondary); + + &.empty { + color: var(--text-tertiary); + } + } + + .contact-sns-dialog-load-more { + display: block; + margin: 12px auto 0; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + border-radius: 10px; + padding: 9px 18px; + font-size: 13px; + cursor: pointer; + + &:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + color: var(--primary); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.72; + } + } +} + +@media (max-width: 768px) { + .contact-sns-dialog-overlay { + padding: 12px 8px; + } + + .contact-sns-dialog { + width: min(100vw - 16px, 760px); + max-height: calc(100vh - 24px); + + .contact-sns-dialog-header { + padding: 12px; + } + + .contact-sns-dialog-header-actions { + gap: 6px; + } + + .contact-sns-dialog-rank-btn { + height: 26px; + padding: 0 8px; + font-size: 11px; + } + + .contact-sns-dialog-rank-panel { + width: min(78vw, 232px); + } + + .contact-sns-dialog-tip { + padding: 10px 12px; + line-height: 1.55; + } + + .contact-sns-dialog-body { + padding: 10px 10px 12px; + } + } +} + +@keyframes contactSnsDialogSpin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} diff --git a/src/components/Sns/ContactSnsTimelineDialog.tsx b/src/components/Sns/ContactSnsTimelineDialog.tsx new file mode 100644 index 0000000..a79d9bd --- /dev/null +++ b/src/components/Sns/ContactSnsTimelineDialog.tsx @@ -0,0 +1,577 @@ +import { createPortal } from 'react-dom' +import { Loader2, X } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { SnsPostItem } from './SnsPostItem' +import type { SnsPost } from '../../types/sns' +import { + type ContactSnsRankItem, + type ContactSnsRankMode, + type ContactSnsTimelineTarget, + getAvatarLetter +} from './contactSnsTimeline' +import './ContactSnsTimelineDialog.scss' + +const TIMELINE_PAGE_SIZE = 20 +const SNS_RANK_PAGE_SIZE = 50 +const SNS_RANK_DISPLAY_LIMIT = 15 + +interface ContactSnsRankCacheEntry { + likes: ContactSnsRankItem[] + comments: ContactSnsRankItem[] + totalPosts: number +} + +interface ContactSnsTimelineDialogProps { + target: ContactSnsTimelineTarget | null + onClose: () => void + initialTotalPosts?: number | null + initialTotalPostsLoading?: boolean +} + +const normalizeTotalPosts = (value?: number | null): number | null => { + if (!Number.isFinite(value)) return null + return Math.max(0, Math.floor(Number(value))) +} + +const formatYmdDateFromSeconds = (timestamp?: number): string => { + if (!timestamp || !Number.isFinite(timestamp)) return '—' + const date = new Date(timestamp * 1000) + const year = date.getFullYear() + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + return `${year}-${month}-${day}` +} + +const buildContactSnsRankings = (posts: SnsPost[]): { likes: ContactSnsRankItem[]; comments: ContactSnsRankItem[] } => { + const likeMap = new Map() + const commentMap = new Map() + + for (const post of posts) { + const createTime = Number(post?.createTime) || 0 + const likes = Array.isArray(post?.likes) ? post.likes : [] + const comments = Array.isArray(post?.comments) ? post.comments : [] + + for (const likeNameRaw of likes) { + const name = String(likeNameRaw || '').trim() || '未知用户' + const current = likeMap.get(name) + if (current) { + current.count += 1 + if (createTime > current.latestTime) current.latestTime = createTime + continue + } + likeMap.set(name, { name, count: 1, latestTime: createTime }) + } + + for (const comment of comments) { + const name = String(comment?.nickname || '').trim() || '未知用户' + const current = commentMap.get(name) + if (current) { + current.count += 1 + if (createTime > current.latestTime) current.latestTime = createTime + continue + } + commentMap.set(name, { name, count: 1, latestTime: createTime }) + } + } + + const sorter = (left: ContactSnsRankItem, right: ContactSnsRankItem): number => { + if (right.count !== left.count) return right.count - left.count + if (right.latestTime !== left.latestTime) return right.latestTime - left.latestTime + return left.name.localeCompare(right.name, 'zh-CN') + } + + return { + likes: [...likeMap.values()].sort(sorter), + comments: [...commentMap.values()].sort(sorter) + } +} + +export function ContactSnsTimelineDialog({ + target, + onClose, + initialTotalPosts = null, + initialTotalPostsLoading = false +}: ContactSnsTimelineDialogProps) { + const [timelinePosts, setTimelinePosts] = useState([]) + const [timelineLoading, setTimelineLoading] = useState(false) + const [timelineLoadingMore, setTimelineLoadingMore] = useState(false) + const [timelineHasMore, setTimelineHasMore] = useState(false) + const [timelineTotalPosts, setTimelineTotalPosts] = useState(null) + const [timelineStatsLoading, setTimelineStatsLoading] = useState(false) + const [rankMode, setRankMode] = useState(null) + const [likeRankings, setLikeRankings] = useState([]) + const [commentRankings, setCommentRankings] = useState([]) + const [rankLoading, setRankLoading] = useState(false) + const [rankError, setRankError] = useState(null) + const [rankLoadedPosts, setRankLoadedPosts] = useState(0) + const [rankTotalPosts, setRankTotalPosts] = useState(null) + + const timelinePostsRef = useRef([]) + const timelineLoadingRef = useRef(false) + const timelineRequestTokenRef = useRef(0) + const totalPostsRequestTokenRef = useRef(0) + const rankRequestTokenRef = useRef(0) + const rankLoadingRef = useRef(false) + const rankCacheRef = useRef>({}) + + const targetUsername = String(target?.username || '').trim() + const targetDisplayName = target?.displayName || targetUsername + const targetAvatarUrl = target?.avatarUrl + + useEffect(() => { + timelinePostsRef.current = timelinePosts + }, [timelinePosts]) + + const loadTimelinePosts = useCallback(async (nextTarget: ContactSnsTimelineTarget, options?: { reset?: boolean }) => { + const reset = Boolean(options?.reset) + if (timelineLoadingRef.current) return + + timelineLoadingRef.current = true + if (reset) { + setTimelineLoading(true) + setTimelineLoadingMore(false) + setTimelineHasMore(false) + } else { + setTimelineLoadingMore(true) + } + + const requestToken = ++timelineRequestTokenRef.current + + try { + let endTime: number | undefined + if (!reset && timelinePostsRef.current.length > 0) { + endTime = timelinePostsRef.current[timelinePostsRef.current.length - 1].createTime - 1 + } + + const result = await window.electronAPI.sns.getTimeline( + TIMELINE_PAGE_SIZE, + 0, + [nextTarget.username], + '', + undefined, + endTime + ) + if (requestToken !== timelineRequestTokenRef.current) return + + if (!result.success || !Array.isArray(result.timeline)) { + if (reset) { + setTimelinePosts([]) + setTimelineHasMore(false) + } + return + } + + const timeline = [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime) + if (reset) { + setTimelinePosts(timeline) + setTimelineHasMore(timeline.length >= TIMELINE_PAGE_SIZE) + return + } + + const existingIds = new Set(timelinePostsRef.current.map((post) => post.id)) + const uniqueOlder = timeline.filter((post) => !existingIds.has(post.id)) + if (uniqueOlder.length > 0) { + const merged = [...timelinePostsRef.current, ...uniqueOlder].sort((left, right) => right.createTime - left.createTime) + setTimelinePosts(merged) + } + if (timeline.length < TIMELINE_PAGE_SIZE) { + setTimelineHasMore(false) + } + } catch (error) { + console.error('加载联系人朋友圈失败:', error) + if (requestToken === timelineRequestTokenRef.current && reset) { + setTimelinePosts([]) + setTimelineHasMore(false) + } + } finally { + if (requestToken === timelineRequestTokenRef.current) { + timelineLoadingRef.current = false + setTimelineLoading(false) + setTimelineLoadingMore(false) + } + } + }, []) + + const loadTimelineTotalPosts = useCallback(async (nextTarget: ContactSnsTimelineTarget) => { + const requestToken = ++totalPostsRequestTokenRef.current + setTimelineStatsLoading(true) + + try { + const result = await window.electronAPI.sns.getUserPostCounts() + if (requestToken !== totalPostsRequestTokenRef.current) return + + if (!result.success || !result.counts) { + setTimelineTotalPosts(null) + setRankTotalPosts(null) + return + } + + const rawCount = Number(result.counts[nextTarget.username] || 0) + const normalized = Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0 + setTimelineTotalPosts(normalized) + setRankTotalPosts(normalized) + } catch (error) { + console.error('加载联系人朋友圈条数失败:', error) + if (requestToken !== totalPostsRequestTokenRef.current) return + setTimelineTotalPosts(null) + setRankTotalPosts(null) + } finally { + if (requestToken === totalPostsRequestTokenRef.current) { + setTimelineStatsLoading(false) + } + } + }, []) + + const loadRankings = useCallback(async (nextTarget: ContactSnsTimelineTarget) => { + const normalizedUsername = String(nextTarget?.username || '').trim() + if (!normalizedUsername || rankLoadingRef.current) return + + const normalizedKnownTotal = normalizeTotalPosts(timelineTotalPosts) + const cached = rankCacheRef.current[normalizedUsername] + + if (cached && (normalizedKnownTotal === null || cached.totalPosts === normalizedKnownTotal)) { + setLikeRankings(cached.likes) + setCommentRankings(cached.comments) + setRankLoadedPosts(cached.totalPosts) + setRankTotalPosts(cached.totalPosts) + setRankError(null) + setRankLoading(false) + return + } + + rankLoadingRef.current = true + const requestToken = ++rankRequestTokenRef.current + setRankLoading(true) + setRankError(null) + setRankLoadedPosts(0) + setRankTotalPosts(normalizedKnownTotal) + + try { + const allPosts: SnsPost[] = [] + let endTime: number | undefined + let hasMore = true + + while (hasMore) { + const result = await window.electronAPI.sns.getTimeline( + SNS_RANK_PAGE_SIZE, + 0, + [normalizedUsername], + '', + undefined, + endTime + ) + if (requestToken !== rankRequestTokenRef.current) return + + if (!result.success) { + throw new Error(result.error || '加载朋友圈排行失败') + } + + const pagePosts = Array.isArray(result.timeline) + ? [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime) + : [] + if (pagePosts.length === 0) { + hasMore = false + break + } + + allPosts.push(...pagePosts) + setRankLoadedPosts(allPosts.length) + if (normalizedKnownTotal === null) { + setRankTotalPosts(allPosts.length) + } + + endTime = pagePosts[pagePosts.length - 1].createTime - 1 + hasMore = pagePosts.length >= SNS_RANK_PAGE_SIZE + } + + if (requestToken !== rankRequestTokenRef.current) return + + const rankings = buildContactSnsRankings(allPosts) + const totalPosts = allPosts.length + rankCacheRef.current[normalizedUsername] = { + likes: rankings.likes, + comments: rankings.comments, + totalPosts + } + setLikeRankings(rankings.likes) + setCommentRankings(rankings.comments) + setRankLoadedPosts(totalPosts) + setRankTotalPosts(totalPosts) + setRankError(null) + } catch (error) { + if (requestToken !== rankRequestTokenRef.current) return + const message = error instanceof Error ? error.message : String(error) + setLikeRankings([]) + setCommentRankings([]) + setRankError(message || '加载朋友圈排行失败') + } finally { + if (requestToken === rankRequestTokenRef.current) { + rankLoadingRef.current = false + setRankLoading(false) + } + } + }, [timelineTotalPosts]) + + useEffect(() => { + if (!targetUsername) return + + totalPostsRequestTokenRef.current += 1 + rankRequestTokenRef.current += 1 + rankLoadingRef.current = false + setRankMode(null) + setLikeRankings([]) + setCommentRankings([]) + setRankLoading(false) + setRankError(null) + setRankLoadedPosts(0) + setRankTotalPosts(null) + setTimelinePosts([]) + setTimelineTotalPosts(null) + setTimelineStatsLoading(false) + setTimelineHasMore(false) + setTimelineLoadingMore(false) + setTimelineLoading(false) + + void loadTimelinePosts({ + username: targetUsername, + displayName: targetDisplayName, + avatarUrl: targetAvatarUrl + }, { reset: true }) + }, [loadTimelinePosts, targetAvatarUrl, targetDisplayName, targetUsername]) + + useEffect(() => { + if (!targetUsername) return + + const normalizedTotal = normalizeTotalPosts(initialTotalPosts) + if (normalizedTotal !== null) { + setTimelineTotalPosts(normalizedTotal) + setRankTotalPosts(normalizedTotal) + setTimelineStatsLoading(false) + return + } + + if (initialTotalPostsLoading) { + setTimelineTotalPosts(null) + setRankTotalPosts(null) + setTimelineStatsLoading(true) + return + } + + void loadTimelineTotalPosts({ + username: targetUsername, + displayName: targetDisplayName, + avatarUrl: targetAvatarUrl + }) + }, [ + initialTotalPosts, + initialTotalPostsLoading, + loadTimelineTotalPosts, + targetAvatarUrl, + targetDisplayName, + targetUsername + ]) + + useEffect(() => { + if (timelineTotalPosts === null) return + if (timelinePosts.length >= timelineTotalPosts) { + setTimelineHasMore(false) + } + }, [timelinePosts.length, timelineTotalPosts]) + + useEffect(() => { + if (!rankMode || !targetUsername) return + void loadRankings({ + username: targetUsername, + displayName: targetDisplayName, + avatarUrl: targetAvatarUrl + }) + }, [loadRankings, rankMode, targetAvatarUrl, targetDisplayName, targetUsername]) + + useEffect(() => { + if (!targetUsername) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [onClose, targetUsername]) + + const timelineStatsText = useMemo(() => { + const loadedCount = timelinePosts.length + const loadPart = timelineStatsLoading + ? `已加载 ${loadedCount} / 总数统计中...` + : timelineTotalPosts === null + ? `已加载 ${loadedCount} 条` + : `已加载 ${loadedCount} / 共 ${timelineTotalPosts} 条` + + if (timelineLoading && loadedCount === 0) return `${loadPart} | 加载中...` + if (loadedCount === 0) return loadPart + + const latest = timelinePosts[0]?.createTime + const earliest = timelinePosts[timelinePosts.length - 1]?.createTime + return `${loadPart} | ${formatYmdDateFromSeconds(earliest)} ~ ${formatYmdDateFromSeconds(latest)}` + }, [timelineLoading, timelinePosts, timelineStatsLoading, timelineTotalPosts]) + + const activeRankings = useMemo(() => { + if (rankMode === 'likes') return likeRankings + if (rankMode === 'comments') return commentRankings + return [] + }, [commentRankings, likeRankings, rankMode]) + + const loadMore = useCallback(() => { + if (!targetUsername || timelineLoading || timelineLoadingMore || !timelineHasMore) return + void loadTimelinePosts({ + username: targetUsername, + displayName: targetDisplayName, + avatarUrl: targetAvatarUrl + }, { reset: false }) + }, [ + loadTimelinePosts, + targetAvatarUrl, + targetDisplayName, + targetUsername, + timelineHasMore, + timelineLoading, + timelineLoadingMore + ]) + + const toggleRankMode = useCallback((mode: ContactSnsRankMode) => { + setRankMode((previous) => (previous === mode ? null : mode)) + }, []) + + if (!target) return null + + return createPortal( +
+
event.stopPropagation()} + > +
+
+
+ {targetAvatarUrl ? ( + + ) : ( + {getAvatarLetter(targetDisplayName)} + )} +
+
+

{targetDisplayName}

+
@{targetUsername}
+
{timelineStatsText}
+
+
+
+
+ + + {rankMode && ( +
+ {rankLoading && ( +
+ + + {rankTotalPosts !== null && rankTotalPosts > 0 + ? `统计中,已加载 ${rankLoadedPosts} / ${rankTotalPosts} 条` + : `统计中,已加载 ${rankLoadedPosts} 条`} + +
+ )} + {!rankLoading && rankError ? ( +
{rankError}
+ ) : !rankLoading && activeRankings.length === 0 ? ( +
+ {rankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'} +
+ ) : ( + activeRankings.slice(0, SNS_RANK_DISPLAY_LIMIT).map((item, index) => ( +
+ {index + 1} + {item.name} + + {item.count.toLocaleString('zh-CN')} + {rankMode === 'likes' ? '次' : '条'} + +
+ )) + )} +
+ )} +
+ +
+
+ +
+ 在微信桌面客户端中打开这个人的朋友圈浏览,可快速把其朋友圈同步到这里。若你在乎这个人,一定要试试~ +
+ +
+ {timelinePosts.length > 0 && ( +
+ {timelinePosts.map((post) => ( + { + if (isVideo) { + void window.electronAPI.window.openVideoPlayerWindow(src) + } else { + void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined) + } + }} + onDebug={() => {}} + hideAuthorMeta + /> + ))} +
+ )} + + {timelineLoading && ( +
正在加载该联系人的朋友圈...
+ )} + + {!timelineLoading && timelinePosts.length === 0 && ( +
该联系人暂无朋友圈
+ )} + + {!timelineLoading && timelineHasMore && ( + + )} +
+
+
, + document.body + ) +} diff --git a/src/components/Sns/contactSnsTimeline.ts b/src/components/Sns/contactSnsTimeline.ts new file mode 100644 index 0000000..0ec6eab --- /dev/null +++ b/src/components/Sns/contactSnsTimeline.ts @@ -0,0 +1,26 @@ +export interface ContactSnsTimelineTarget { + username: string + displayName: string + avatarUrl?: string +} + +export interface ContactSnsRankItem { + name: string + count: number + latestTime: number +} + +export type ContactSnsRankMode = 'likes' | 'comments' + +export const isSingleContactSession = (sessionId: string): boolean => { + const normalized = String(sessionId || '').trim() + if (!normalized) return false + if (normalized.includes('@chatroom')) return false + if (normalized.startsWith('gh_')) return false + return true +} + +export const getAvatarLetter = (name: string): string => { + if (!name) return '?' + return [...name][0] || '?' +} diff --git a/src/pages/ContactsPage.scss b/src/pages/ContactsPage.scss index bd6fc98..ed71ec3 100644 --- a/src/pages/ContactsPage.scss +++ b/src/pages/ContactsPage.scss @@ -535,6 +535,28 @@ word-break: break-all; user-select: text; } + + .detail-entry-btn { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + padding: 6px 10px; + font-size: 12px; + line-height: 1; + cursor: pointer; + transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease; + + &:hover { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary)); + } + } } .goto-chat-btn { diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 2d489f9..cc7e86d 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -1,20 +1,14 @@ import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react' import { useNavigate } from 'react-router-dom' -import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList } from 'lucide-react' +import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList, Aperture } from 'lucide-react' import { useChatStore } from '../stores/chatStore' import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore' import * as configService from '../services/config' +import type { ContactInfo } from '../types/models' +import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' +import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline' import './ContactsPage.scss' -interface ContactInfo { - username: string - displayName: string - remark?: string - nickname?: string - avatarUrl?: string - type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' -} - interface ContactEnrichInfo { displayName?: string avatarUrl?: string @@ -62,6 +56,9 @@ function ContactsPage() { // 导出模式与查看详情 const [exportMode, setExportMode] = useState(false) const [selectedContact, setSelectedContact] = useState(null) + const [snsUserPostCounts, setSnsUserPostCounts] = useState>({}) + const [snsUserPostCountsStatus, setSnsUserPostCountsStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle') + const [snsTimelineTarget, setSnsTimelineTarget] = useState(null) const navigate = useNavigate() const { setCurrentSession } = useChatStore() @@ -509,6 +506,41 @@ function ContactsPage() { return () => window.clearTimeout(timer) }, [searchKeyword]) + const loadSnsUserPostCounts = useCallback(async (options?: { force?: boolean }) => { + if (!options?.force && (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'ready')) { + return + } + + setSnsUserPostCountsStatus('loading') + try { + const result = await window.electronAPI.sns.getUserPostCounts() + if (!result.success || !result.counts) { + setSnsUserPostCountsStatus('error') + return + } + + const normalizedCounts: Record = {} + for (const [rawUsername, rawCount] of Object.entries(result.counts)) { + const username = String(rawUsername || '').trim() + if (!username) continue + const value = Number(rawCount) + normalizedCounts[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 + } + + setSnsUserPostCounts(normalizedCounts) + setSnsUserPostCountsStatus('ready') + } catch (error) { + console.error('加载通讯录联系人朋友圈条数失败:', error) + setSnsUserPostCountsStatus('error') + } + }, [snsUserPostCountsStatus]) + + useEffect(() => { + if (!selectedContact || !isSingleContactSession(selectedContact.username)) return + if (snsUserPostCountsStatus !== 'idle') return + void loadSnsUserPostCounts() + }, [loadSnsUserPostCounts, selectedContact, snsUserPostCountsStatus]) + const filteredContacts = useMemo(() => { let filtered = contacts.filter(contact => { if (contact.type === 'friend' && !contactTypes.friends) return false @@ -579,6 +611,38 @@ function ContactsPage() { }, [filteredContacts, selectedUsernames]) const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length + const selectedContactSupportsSns = useMemo(() => { + return Boolean(selectedContact && isSingleContactSession(selectedContact.username)) + }, [selectedContact]) + + const selectedContactSnsCount = useMemo(() => { + if (!selectedContactSupportsSns || !selectedContact) return null + if (snsUserPostCountsStatus !== 'ready') return null + const rawCount = Number(snsUserPostCounts[selectedContact.username] || 0) + return Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0 + }, [selectedContact, selectedContactSupportsSns, snsUserPostCounts, snsUserPostCountsStatus]) + + const selectedContactSnsEntryLabel = useMemo(() => { + if (!selectedContactSupportsSns) return '' + if (selectedContactSnsCount !== null) { + return `朋友圈:${selectedContactSnsCount.toLocaleString('zh-CN')}条` + } + if (snsUserPostCountsStatus === 'error') return '朋友圈:查看' + return '朋友圈:统计中...' + }, [selectedContactSupportsSns, selectedContactSnsCount, snsUserPostCountsStatus]) + + const openSelectedContactSnsTimeline = useCallback(() => { + if (!selectedContact || !selectedContactSupportsSns) return + if (snsUserPostCountsStatus === 'idle') { + void loadSnsUserPostCounts() + } + setSnsTimelineTarget({ + username: selectedContact.username, + displayName: selectedContact.displayName || selectedContact.remark || selectedContact.nickname || selectedContact.username, + avatarUrl: selectedContact.avatarUrl + }) + }, [loadSnsUserPostCounts, selectedContact, selectedContactSupportsSns, snsUserPostCountsStatus]) + const { startIndex, endIndex } = useMemo(() => { if (filteredContacts.length === 0) { return { startIndex: 0, endIndex: 0 } @@ -1069,6 +1133,19 @@ function ContactsPage() {
昵称{selectedContact.nickname || selectedContact.displayName}
{selectedContact.remark &&
备注{selectedContact.remark}
}
类型{getContactTypeName(selectedContact.type)}
+ {selectedContactSupportsSns && ( +
+ 朋友圈 + +
+ )}
)} + setSnsTimelineTarget(null)} + initialTotalPosts={selectedContact && snsTimelineTarget?.username === selectedContact.username ? selectedContactSnsCount : null} + initialTotalPostsLoading={selectedContact && snsTimelineTarget?.username === selectedContact.username + ? snsUserPostCountsStatus === 'idle' || snsUserPostCountsStatus === 'loading' + : false} + /> ) } From ef05466d6d590a0e0217a93c19959490d315a1c4 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 10:34:16 +0800 Subject: [PATCH 34/97] refactor(sns): reuse shared contact timeline dialog in sns page --- .../Sns/ContactSnsTimelineDialog.tsx | 9 +- src/pages/SnsPage.tsx | 276 +----------------- 2 files changed, 21 insertions(+), 264 deletions(-) diff --git a/src/components/Sns/ContactSnsTimelineDialog.tsx b/src/components/Sns/ContactSnsTimelineDialog.tsx index a79d9bd..e7e6dde 100644 --- a/src/components/Sns/ContactSnsTimelineDialog.tsx +++ b/src/components/Sns/ContactSnsTimelineDialog.tsx @@ -26,6 +26,8 @@ interface ContactSnsTimelineDialogProps { onClose: () => void initialTotalPosts?: number | null initialTotalPostsLoading?: boolean + isProtected?: boolean + onDeletePost?: (postId: string, username: string) => void } const normalizeTotalPosts = (value?: number | null): number | null => { @@ -90,7 +92,9 @@ export function ContactSnsTimelineDialog({ target, onClose, initialTotalPosts = null, - initialTotalPostsLoading = false + initialTotalPostsLoading = false, + isProtected = false, + onDeletePost }: ContactSnsTimelineDialogProps) { const [timelinePosts, setTimelinePosts] = useState([]) const [timelineLoading, setTimelineLoading] = useState(false) @@ -536,7 +540,7 @@ export function ContactSnsTimelineDialog({ {timelinePosts.map((post) => ( { if (isVideo) { void window.electronAPI.window.openVideoPlayerWindow(src) @@ -545,6 +549,7 @@ export function ContactSnsTimelineDialog({ } }} onDebug={() => {}} + onDelete={onDeletePost} hideAuthorMeta /> ))} diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index f088e76..49abd13 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -5,7 +5,8 @@ import './SnsPage.scss' import { SnsPost } from '../types/sns' import { SnsPostItem } from '../components/Sns/SnsPostItem' import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel' -import { Avatar } from '../components/Avatar' +import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' +import type { ContactSnsTimelineTarget } from '../components/Sns/contactSnsTimeline' import * as configService from '../services/config' const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000 @@ -42,12 +43,6 @@ interface SnsOverviewStats { type OverviewStatsStatus = 'loading' | 'ready' | 'error' -interface AuthorTimelineTarget { - username: string - nickname: string - avatarUrl?: string -} - export default function SnsPage() { const [posts, setPosts] = useState([]) const [loading, setLoading] = useState(false) @@ -80,13 +75,7 @@ export default function SnsPage() { // UI states const [showJumpDialog, setShowJumpDialog] = useState(false) const [debugPost, setDebugPost] = useState(null) - const [authorTimelineTarget, setAuthorTimelineTarget] = useState(null) - const [authorTimelinePosts, setAuthorTimelinePosts] = useState([]) - const [authorTimelineLoading, setAuthorTimelineLoading] = useState(false) - const [authorTimelineLoadingMore, setAuthorTimelineLoadingMore] = useState(false) - const [authorTimelineHasMore, setAuthorTimelineHasMore] = useState(false) - const [authorTimelineTotalPosts, setAuthorTimelineTotalPosts] = useState(null) - const [authorTimelineStatsLoading, setAuthorTimelineStatsLoading] = useState(false) + const [authorTimelineTarget, setAuthorTimelineTarget] = useState(null) // 导出相关状态 const [showExportDialog, setShowExportDialog] = useState(false) @@ -125,10 +114,6 @@ export default function SnsPage() { const contactsLoadTokenRef = useRef(0) const contactsCountHydrationTokenRef = useRef(0) const contactsCountBatchTimerRef = useRef(null) - const authorTimelinePostsRef = useRef([]) - const authorTimelineLoadingRef = useRef(false) - const authorTimelineRequestTokenRef = useRef(0) - const authorTimelineStatsTokenRef = useRef(0) // Sync posts ref useEffect(() => { @@ -152,9 +137,6 @@ export default function SnsPage() { useEffect(() => { jumpTargetDateRef.current = jumpTargetDate }, [jumpTargetDate]) - useEffect(() => { - authorTimelinePostsRef.current = authorTimelinePosts - }, [authorTimelinePosts]) // 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动 useLayoutEffect(() => { const snapshot = scrollAdjustmentRef.current; @@ -760,137 +742,16 @@ export default function SnsPage() { }, [ensureSnsUserPostCountsCacheScopeKey, hydrateContactPostCounts, sortContactsForRanking, stopContactsCountHydration]) const closeAuthorTimeline = useCallback(() => { - authorTimelineRequestTokenRef.current += 1 - authorTimelineStatsTokenRef.current += 1 - authorTimelineLoadingRef.current = false setAuthorTimelineTarget(null) - setAuthorTimelinePosts([]) - setAuthorTimelineLoading(false) - setAuthorTimelineLoadingMore(false) - setAuthorTimelineHasMore(false) - setAuthorTimelineTotalPosts(null) - setAuthorTimelineStatsLoading(false) - }, []) - - const loadAuthorTimelineTotalPosts = useCallback(async (target: AuthorTimelineTarget) => { - const requestToken = ++authorTimelineStatsTokenRef.current - setAuthorTimelineStatsLoading(true) - setAuthorTimelineTotalPosts(null) - - try { - const result = await window.electronAPI.sns.getUserPostCounts() - if (requestToken !== authorTimelineStatsTokenRef.current) return - - if (result.success && result.counts) { - const totalPosts = result.counts[target.username] ?? 0 - setAuthorTimelineTotalPosts(Math.max(0, Number(totalPosts || 0))) - } else { - setAuthorTimelineTotalPosts(null) - } - } catch (error) { - console.error('Failed to load author timeline total posts:', error) - if (requestToken === authorTimelineStatsTokenRef.current) { - setAuthorTimelineTotalPosts(null) - } - } finally { - if (requestToken === authorTimelineStatsTokenRef.current) { - setAuthorTimelineStatsLoading(false) - } - } - }, []) - - const loadAuthorTimelinePosts = useCallback(async (target: AuthorTimelineTarget, options: { reset?: boolean } = {}) => { - const { reset = false } = options - if (authorTimelineLoadingRef.current) return - - authorTimelineLoadingRef.current = true - if (reset) { - setAuthorTimelineLoading(true) - setAuthorTimelineLoadingMore(false) - setAuthorTimelineHasMore(false) - } else { - setAuthorTimelineLoadingMore(true) - } - - const requestToken = ++authorTimelineRequestTokenRef.current - - try { - const limit = 20 - let endTs: number | undefined = undefined - - if (!reset && authorTimelinePostsRef.current.length > 0) { - endTs = authorTimelinePostsRef.current[authorTimelinePostsRef.current.length - 1].createTime - 1 - } - - const result = await window.electronAPI.sns.getTimeline( - limit, - 0, - [target.username], - '', - undefined, - endTs - ) - - if (requestToken !== authorTimelineRequestTokenRef.current) return - if (!result.success || !result.timeline) { - if (reset) { - setAuthorTimelinePosts([]) - setAuthorTimelineHasMore(false) - } - return - } - - if (reset) { - const sorted = [...result.timeline].sort((a, b) => b.createTime - a.createTime) - setAuthorTimelinePosts(sorted) - setAuthorTimelineHasMore(result.timeline.length >= limit) - return - } - - const existingIds = new Set(authorTimelinePostsRef.current.map((p) => p.id)) - const uniqueOlder = result.timeline.filter((p) => !existingIds.has(p.id)) - if (uniqueOlder.length > 0) { - const merged = [...authorTimelinePostsRef.current, ...uniqueOlder].sort((a, b) => b.createTime - a.createTime) - setAuthorTimelinePosts(merged) - } - if (result.timeline.length < limit) { - setAuthorTimelineHasMore(false) - } - } catch (error) { - console.error('Failed to load author timeline:', error) - if (requestToken === authorTimelineRequestTokenRef.current && reset) { - setAuthorTimelinePosts([]) - setAuthorTimelineHasMore(false) - } - } finally { - if (requestToken === authorTimelineRequestTokenRef.current) { - authorTimelineLoadingRef.current = false - setAuthorTimelineLoading(false) - setAuthorTimelineLoadingMore(false) - } - } }, []) const openAuthorTimeline = useCallback((post: SnsPost) => { - authorTimelineRequestTokenRef.current += 1 - authorTimelineLoadingRef.current = false - const target = { + setAuthorTimelineTarget({ username: post.username, - nickname: post.nickname, + displayName: decodeHtmlEntities(post.nickname || '') || post.username, avatarUrl: post.avatarUrl - } - setAuthorTimelineTarget(target) - setAuthorTimelinePosts([]) - setAuthorTimelineHasMore(false) - setAuthorTimelineTotalPosts(null) - void loadAuthorTimelinePosts(target, { reset: true }) - void loadAuthorTimelineTotalPosts(target) - }, [loadAuthorTimelinePosts, loadAuthorTimelineTotalPosts]) - - const loadMoreAuthorTimeline = useCallback(() => { - if (!authorTimelineTarget || authorTimelineLoading || authorTimelineLoadingMore || !authorTimelineHasMore) return - void loadAuthorTimelinePosts(authorTimelineTarget, { reset: false }) - }, [authorTimelineHasMore, authorTimelineLoading, authorTimelineLoadingMore, authorTimelineTarget, loadAuthorTimelinePosts]) + }) + }, [decodeHtmlEntities]) const handlePostDelete = useCallback((postId: string, username: string) => { setPosts(prev => { @@ -898,12 +759,8 @@ export default function SnsPage() { void persistSnsPageCache({ posts: next }) return next }) - setAuthorTimelinePosts(prev => prev.filter(p => p.id !== postId)) - if (authorTimelineTarget && authorTimelineTarget.username === username) { - setAuthorTimelineTotalPosts(prev => prev === null ? null : Math.max(0, prev - 1)) - } void loadOverviewStats() - }, [authorTimelineTarget, loadOverviewStats, persistSnsPageCache]) + }, [loadOverviewStats, persistSnsPageCache]) // Initial Load & Listeners useEffect(() => { @@ -947,24 +804,6 @@ export default function SnsPage() { return () => clearTimeout(timer) }, [selectedUsernames, searchKeyword, jumpTargetDate, loadPosts]) - useEffect(() => { - if (!authorTimelineTarget) return - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - closeAuthorTimeline() - } - } - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [authorTimelineTarget, closeAuthorTimeline]) - - useEffect(() => { - if (authorTimelineTotalPosts === null) return - if (authorTimelinePosts.length >= authorTimelineTotalPosts) { - setAuthorTimelineHasMore(false) - } - }, [authorTimelinePosts.length, authorTimelineTotalPosts]) - const handleScroll = (e: React.UIEvent) => { const { scrollTop, clientHeight, scrollHeight } = e.currentTarget if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) { @@ -983,29 +822,6 @@ export default function SnsPage() { } } - const handleAuthorTimelineScroll = (e: React.UIEvent) => { - const { scrollTop, clientHeight, scrollHeight } = e.currentTarget - if (scrollHeight - scrollTop - clientHeight < 260) { - loadMoreAuthorTimeline() - } - } - - const renderAuthorTimelineStats = () => { - const loadedCount = authorTimelinePosts.length - const loadPart = authorTimelineStatsLoading - ? `已加载 ${loadedCount} / 总数统计中...` - : authorTimelineTotalPosts === null - ? `已加载 ${loadedCount} 条` - : `已加载 ${loadedCount} / 共 ${authorTimelineTotalPosts} 条` - - if (authorTimelineLoading && loadedCount === 0) return `${loadPart} | 加载中...` - if (loadedCount === 0) return loadPart - - const latest = authorTimelinePosts[0]?.createTime ?? null - const earliest = authorTimelinePosts[authorTimelinePosts.length - 1]?.createTime ?? null - return `${loadPart} | ${formatDateOnly(earliest)} ~ ${formatDateOnly(latest)}` - } - return (
@@ -1166,76 +982,12 @@ export default function SnsPage() { currentDate={jumpTargetDate || new Date()} /> - {authorTimelineTarget && ( -
-
e.stopPropagation()}> -
-
- -
-

{decodeHtmlEntities(authorTimelineTarget.nickname)}

-
@{authorTimelineTarget.username}
-
{renderAuthorTimelineStats()}
-
-
- -
- -
- {authorTimelinePosts.length > 0 && ( -
- {authorTimelinePosts.map(post => ( - { - if (isVideo) { - void window.electronAPI.window.openVideoPlayerWindow(src) - } else { - void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined) - } - }} - onDebug={(p) => setDebugPost(p)} - onDelete={handlePostDelete} - onOpenAuthorPosts={openAuthorTimeline} - hideAuthorMeta - /> - ))} -
- )} - - {authorTimelineLoading && ( -
- - 正在加载该用户朋友圈... -
- )} - - {!authorTimelineLoading && authorTimelinePosts.length === 0 && ( -
该用户暂无朋友圈
- )} - - {!authorTimelineLoading && authorTimelineHasMore && ( - - )} -
-
-
- )} + {debugPost && (
setDebugPost(null)}> From 8d9a04248942f01639c6a52b8e5e1c12314198a0 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 10:41:06 +0800 Subject: [PATCH 35/97] feat(chat): add sns timeline entry for private sessions --- src/pages/ChatPage.tsx | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 23d5ea5..a663b67 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' @@ -11,6 +11,8 @@ import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' import { LivePhotoIcon } from '../components/LivePhotoIcon' import { AnimatedStreamingText } from '../components/AnimatedStreamingText' import JumpToDatePopover from '../components/JumpToDatePopover' +import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' +import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline' import * as configService from '../services/config' import { emitOpenSingleExport, @@ -520,6 +522,7 @@ function ChatPage(props: ChatPageProps) { const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null) const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState>(new Set()) const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false) + const [chatSnsTimelineTarget, setChatSnsTimelineTarget] = useState(null) const [exportPrepareHint, setExportPrepareHint] = useState('') // 消息右键菜单 @@ -2982,6 +2985,20 @@ function ChatPage(props: ChatPageProps) { ) ) ) + const isCurrentSessionPrivateSnsSupported = Boolean( + currentSession && + isSingleContactSession(currentSession.username) && + !isCurrentSessionGroup + ) + + const openCurrentSessionSnsTimeline = useCallback(() => { + if (!currentSession || !isCurrentSessionPrivateSnsSupported) return + setChatSnsTimelineTarget({ + username: currentSession.username, + displayName: currentSession.displayName || currentSession.username, + avatarUrl: currentSession.avatarUrl + }) + }, [currentSession, isCurrentSessionPrivateSnsSupported]) useEffect(() => { if (!standaloneSessionWindow) return @@ -3904,6 +3921,16 @@ function ChatPage(props: ChatPageProps) { )} )} + {!standaloneSessionWindow && isCurrentSessionPrivateSnsSupported && ( + + )} {!standaloneSessionWindow && (
{renderOverviewStats()}
@@ -985,6 +1140,14 @@ export default function SnsPage() { From a62ba8e167da781b03ae245b356b9dc4a957cf97 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 11:03:11 +0800 Subject: [PATCH 37/97] fix(sns): sync my timeline count and auto load more --- src/components/Sns/ContactSnsTimelineDialog.tsx | 13 ++++++++++++- src/pages/SnsPage.scss | 6 ++++++ src/pages/SnsPage.tsx | 14 ++++++-------- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/components/Sns/ContactSnsTimelineDialog.tsx b/src/components/Sns/ContactSnsTimelineDialog.tsx index e7e6dde..3547954 100644 --- a/src/components/Sns/ContactSnsTimelineDialog.tsx +++ b/src/components/Sns/ContactSnsTimelineDialog.tsx @@ -441,6 +441,14 @@ export function ContactSnsTimelineDialog({ timelineLoadingMore ]) + const handleBodyScroll = useCallback((event: React.UIEvent) => { + const element = event.currentTarget + const remaining = element.scrollHeight - element.scrollTop - element.clientHeight + if (remaining <= 160) { + loadMore() + } + }, [loadMore]) + const toggleRankMode = useCallback((mode: ContactSnsRankMode) => { setRankMode((previous) => (previous === mode ? null : mode)) }, []) @@ -534,7 +542,10 @@ export function ContactSnsTimelineDialog({ 在微信桌面客户端中打开这个人的朋友圈浏览,可快速把其朋友圈同步到这里。若你在乎这个人,一定要试试~
-
+
{timelinePosts.length > 0 && (
{timelinePosts.map((post) => ( diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 465f595..19b63a1 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -101,6 +101,12 @@ .feed-my-timeline-count { color: var(--text-primary); font-weight: 600; + display: inline-flex; + align-items: center; + + .spin { + animation: spin 0.8s linear infinite; + } } &.ready { diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index a2c06da..c3e0a80 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect, useState, useRef, useCallback, useMemo } from 'react' -import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight, Shield, ShieldOff } from 'lucide-react' +import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight, Shield, ShieldOff, Loader2 } from 'lucide-react' import JumpToDateDialog from '../components/JumpToDateDialog' import './SnsPage.scss' import { SnsPost } from '../types/sns' @@ -274,18 +274,16 @@ export default function SnsPage() { }, [authorTimelineTarget, contacts]) const myTimelineCount = useMemo(() => { - if (typeof overviewStats.myPosts === 'number' && Number.isFinite(overviewStats.myPosts) && overviewStats.myPosts >= 0) { - return Math.floor(overviewStats.myPosts) - } if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') { return normalizePostCount(resolvedCurrentUserContact.postCount) } return null - }, [normalizePostCount, overviewStats.myPosts, resolvedCurrentUserContact]) + }, [normalizePostCount, resolvedCurrentUserContact]) const myTimelineCountLoading = Boolean( - overviewStatsStatus === 'loading' - || resolvedCurrentUserContact?.postCountStatus === 'loading' + resolvedCurrentUserContact + ? resolvedCurrentUserContact.postCountStatus !== 'ready' + : overviewStatsStatus === 'loading' || contactsLoading ) const openCurrentUserTimeline = useCallback(() => { @@ -980,7 +978,7 @@ export default function SnsPage() { {myTimelineCount !== null ? `${myTimelineCount.toLocaleString('zh-CN')} 条` : myTimelineCountLoading - ? '...' + ?
)} - {sessionSnsTimelineTarget && ( -
-
event.stopPropagation()} - > -
-
-
- {sessionSnsTimelineTarget.avatarUrl ? ( - - ) : ( - {getAvatarLetter(sessionSnsTimelineTarget.displayName || sessionSnsTimelineTarget.username)} - )} -
-
-

{sessionSnsTimelineTarget.displayName}

-
@{sessionSnsTimelineTarget.username}
-
{renderSessionSnsTimelineStats()}
-
-
-
-
- - - {sessionSnsRankMode && ( -
- {sessionSnsRankLoading && ( -
- - - {sessionSnsRankTotalPosts !== null && sessionSnsRankTotalPosts > 0 - ? `统计中,已加载 ${sessionSnsRankLoadedPosts} / ${sessionSnsRankTotalPosts} 条` - : `统计中,已加载 ${sessionSnsRankLoadedPosts} 条`} - -
- )} - {!sessionSnsRankLoading && sessionSnsRankError ? ( -
{sessionSnsRankError}
- ) : !sessionSnsRankLoading && sessionSnsActiveRankings.length === 0 ? ( -
- {sessionSnsRankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'} -
- ) : ( - sessionSnsActiveRankings.slice(0, SNS_RANK_DISPLAY_LIMIT).map((item, index) => ( -
- {index + 1} - {item.name} - - {item.count.toLocaleString('zh-CN')} - {sessionSnsRankMode === 'likes' ? '次' : '条'} - -
- )) - )} -
- )} -
- -
-
- -
- 在微信桌面客户端中打开这个人的朋友圈浏览,可快速把其朋友圈同步到这里。若你在乎这个人,一定要试试~ -
- -
- {sessionSnsTimelinePosts.length > 0 && ( -
- {sessionSnsTimelinePosts.map((post) => ( - { - if (isVideo) { - void window.electronAPI.window.openVideoPlayerWindow(src) - } else { - void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined) - } - }} - onDebug={() => {}} - hideAuthorMeta - /> - ))} -
- )} - - {sessionSnsTimelineLoading && ( -
正在加载该联系人的朋友圈...
- )} - - {!sessionSnsTimelineLoading && sessionSnsTimelinePosts.length === 0 && ( -
该联系人暂无朋友圈
- )} - - {!sessionSnsTimelineLoading && sessionSnsTimelineHasMore && ( - - )} -
-
-
- )} +
From fe57d80a009bb84e7ad0dc7f9e88ae948829997c Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 11:27:43 +0800 Subject: [PATCH 39/97] fix(export): center single export action text --- src/pages/ExportPage.scss | 9 +++++++++ src/pages/ExportPage.tsx | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index cfdc918..4bd15bf 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1806,6 +1806,10 @@ display: inline-flex; align-items: flex-start; gap: 6px; + + &.single-line { + align-items: center; + } } .row-detail-btn { @@ -1837,6 +1841,11 @@ align-items: center; gap: 2px; min-width: 84px; + + &.single-line { + min-height: 28px; + justify-content: center; + } } .row-export-link { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index c1b504a..6d7f13e 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -5376,14 +5376,14 @@ function ExportPage() {
)}
-
+
-
+
- 联系人(头像/名称/微信号) + {contactsHeaderMainLabel} 总消息数 表情包 From 75b58d0423d57b667be4bd2992e8a4ebc176f380 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 11:37:04 +0800 Subject: [PATCH 41/97] fix(export): tighten sticky action divider --- src/pages/ExportPage.scss | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 28d4adc..f97d7c4 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1355,17 +1355,17 @@ position: sticky; right: 0; z-index: 8; - background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary)); + background: var(--bg-primary); &::before { content: ''; position: absolute; top: 0; bottom: 0; - left: -18px; - width: 18px; + left: -8px; + width: 8px; pointer-events: none; - background: linear-gradient(to right, transparent, color-mix(in srgb, var(--bg-primary) 82%, var(--bg-secondary))); + background: linear-gradient(to right, transparent, var(--bg-primary)); } } @@ -1819,17 +1819,17 @@ position: sticky; right: 0; z-index: 6; - background: inherit; + background: var(--bg-primary); &::before { content: ''; position: absolute; top: -12px; bottom: -12px; - left: -18px; - width: 18px; + left: -8px; + width: 8px; pointer-events: none; - background: linear-gradient(to right, transparent, color-mix(in srgb, var(--bg-primary) 84%, var(--bg-secondary))); + background: linear-gradient(to right, transparent, var(--bg-primary)); } .row-action-main { From 39662038f75ffee268eeba49924ce5f7455bae2f Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 11:45:38 +0800 Subject: [PATCH 42/97] fix(export): tighten action column layout --- src/pages/ExportPage.scss | 4 ++-- src/pages/ExportPage.tsx | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index f97d7c4..517a7a8 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1163,7 +1163,7 @@ --contacts-select-col-width: 34px; --contacts-message-col-width: 120px; --contacts-media-col-width: 72px; - --contacts-action-col-width: 280px; + --contacts-action-col-width: 160px; overflow: hidden; border: 1px solid var(--border-color); border-radius: 10px; @@ -3324,7 +3324,7 @@ .table-wrap { --contacts-message-col-width: 104px; --contacts-media-col-width: 62px; - --contacts-action-col-width: 236px; + --contacts-action-col-width: 160px; } .table-wrap .contacts-list-header { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index e6a5183..b26f4af 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -5382,12 +5382,6 @@ function ExportPage() { )}
-
+
From 92d37abbc51b92a0469484185806e038cc7fff56 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 11:48:10 +0800 Subject: [PATCH 43/97] fix(export): reduce sticky action width --- src/pages/ExportPage.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 517a7a8..5b5ca19 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1163,7 +1163,7 @@ --contacts-select-col-width: 34px; --contacts-message-col-width: 120px; --contacts-media-col-width: 72px; - --contacts-action-col-width: 160px; + --contacts-action-col-width: 140px; overflow: hidden; border: 1px solid var(--border-color); border-radius: 10px; @@ -3324,7 +3324,7 @@ .table-wrap { --contacts-message-col-width: 104px; --contacts-media-col-width: 62px; - --contacts-action-col-width: 160px; + --contacts-action-col-width: 140px; } .table-wrap .contacts-list-header { From 38a0453cbbf00a0a7cdcef66eb7ce623f2246d19 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 12:01:21 +0800 Subject: [PATCH 44/97] fix(export): restore loading states for session metrics --- src/pages/ExportPage.tsx | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index b26f4af..0cc85ec 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -5236,6 +5236,7 @@ function ExportPage() { const renderContactRow = useCallback((_: number, contact: ContactInfo) => { const matchedSession = sessionRowByUsername.get(contact.username) const canExport = Boolean(matchedSession?.hasSession) + const isSessionBindingPending = !matchedSession && (isLoading || isSessionEnriching) const checked = canExport && selectedSessions.has(contact.username) const isRunning = canExport && runningSessionIds.has(contact.username) const isQueued = canExport && queuedSessionIds.has(contact.username) @@ -5246,14 +5247,17 @@ function ExportPage() { const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint) const displayedMessageCount = countedMessages ?? hintedMessages const mediaMetric = sessionContentMetrics[contact.username] - const messageCountLabel = !canExport - ? '--' - : typeof displayedMessageCount === 'number' - ? displayedMessageCount.toLocaleString('zh-CN') - : '获取中' + const messageCountState: { state: 'value'; text: string } | { state: 'loading' } | { state: 'na'; text: '--' } = + !canExport + ? (isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' }) + : typeof displayedMessageCount === 'number' + ? { state: 'value', text: displayedMessageCount.toLocaleString('zh-CN') } + : { state: 'loading' } const metricToDisplay = (value: unknown): { state: 'value'; text: string } | { state: 'loading' } | { state: 'na'; text: '--' } => { const normalized = normalizeMessageCount(value) - if (!canExport) return { state: 'na', text: '--' } + if (!canExport) { + return isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' } + } if (typeof normalized === 'number') { return { state: 'value', text: normalized.toLocaleString('zh-CN') } } @@ -5310,8 +5314,10 @@ function ExportPage() {
- - {messageCountLabel} + + {messageCountState.state === 'loading' + ? + : messageCountState.text}
{canExport && ( @@ -5424,6 +5430,8 @@ function ExportPage() { sessionLoadTraceMap, sessionMessageCounts, sessionRowByUsername, + isLoading, + isSessionEnriching, showSessionDetailPanel, shouldShowSnsColumn, snsUserPostCounts, From cf45ae30acc889d4715d02b8c5010370d5454080 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 12:12:12 +0800 Subject: [PATCH 45/97] fix(export): hide scope card for single dialog --- src/pages/ExportPage.tsx | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 0cc85ec..251006f 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -6259,19 +6259,21 @@ function ExportPage() {
-
-

导出范围

-
- {scopeLabel} - {scopeCountLabel} + {exportDialog.scope !== 'single' && ( +
+

导出范围

+
+ {scopeLabel} + {scopeCountLabel} +
+
+ {exportDialog.sessionNames.slice(0, 20).map(name => ( + {name} + ))} + {exportDialog.sessionNames.length > 20 && ... 还有 {exportDialog.sessionNames.length - 20} 个} +
-
- {exportDialog.sessionNames.slice(0, 20).map(name => ( - {name} - ))} - {exportDialog.sessionNames.length > 20 && ... 还有 {exportDialog.sessionNames.length - 20} 个} -
-
+ )} {shouldShowFormatSection && (
From 6e870ef300e8d8e781b45773ed3d9bf68e680e06 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 12:29:32 +0800 Subject: [PATCH 46/97] feat(settings): unify export date range defaults --- .../Export/ExportDateRangeDialog.scss | 254 ++++++++ .../Export/ExportDateRangeDialog.tsx | 340 +++++++++++ src/pages/ExportPage.tsx | 550 ++---------------- src/pages/SettingsPage.scss | 47 +- src/pages/SettingsPage.tsx | 76 ++- src/services/config.ts | 11 +- src/utils/exportDateRange.ts | 341 +++++++++++ 7 files changed, 1058 insertions(+), 561 deletions(-) create mode 100644 src/components/Export/ExportDateRangeDialog.scss create mode 100644 src/components/Export/ExportDateRangeDialog.tsx create mode 100644 src/utils/exportDateRange.ts diff --git a/src/components/Export/ExportDateRangeDialog.scss b/src/components/Export/ExportDateRangeDialog.scss new file mode 100644 index 0000000..fc8bd95 --- /dev/null +++ b/src/components/Export/ExportDateRangeDialog.scss @@ -0,0 +1,254 @@ +.export-date-range-dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 1015; +} + +.export-date-range-dialog { + width: min(480px, calc(100vw - 32px)); + max-height: calc(100vh - 64px); + overflow-y: auto; + border-radius: 12px; + border: 1px solid var(--border-color); + background: var(--bg-secondary-solid, var(--bg-primary)); + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.export-date-range-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + + h4 { + margin: 0; + font-size: 14px; + color: var(--text-primary); + } +} + +.export-date-range-dialog-close-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 8px; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--text-secondary); +} + +.export-date-range-preset-list { + display: flex; + flex-wrap: nowrap; + gap: 4px; + overflow-x: auto; + padding-bottom: 2px; + + &::-webkit-scrollbar { + height: 4px; + } +} + +.export-date-range-preset-item { + flex: 0 0 auto; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + min-height: 30px; + padding: 0 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + font-size: 11px; + cursor: pointer; + white-space: nowrap; + + &.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + color: var(--primary); + } +} + +.export-date-range-mode-banner { + border-radius: 8px; + padding: 6px 8px; + font-size: 11px; + line-height: 1.4; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + + &.range { + border-color: rgba(var(--primary-rgb), 0.4); + background: rgba(var(--primary-rgb), 0.1); + color: var(--primary); + } +} + +.export-date-range-calendar-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.export-date-range-calendar-panel { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + padding: 7px; +} + +.export-date-range-calendar-panel-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; +} + +.export-date-range-calendar-date-label { + display: flex; + flex-direction: column; + gap: 2px; + + span { + font-size: 11px; + color: var(--text-secondary); + } +} + +.export-date-range-date-input { + width: 100%; + min-width: 0; + border-radius: 6px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + height: 24px; + padding: 0 7px; + font-size: 11px; + + &.invalid { + border-color: #e84d4d; + box-shadow: 0 0 0 1px rgba(232, 77, 77, 0.2); + } +} + +.export-date-range-calendar-nav { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--text-primary); + + button { + width: 20px; + height: 20px; + border-radius: 5px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; + padding: 0; + line-height: 1; + } +} + +.export-date-range-calendar-weekdays { + margin-top: 6px; + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; + + span { + text-align: center; + font-size: 10px; + color: var(--text-tertiary); + } +} + +.export-date-range-calendar-days { + margin-top: 4px; + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} + +.export-date-range-calendar-day { + border: 1px solid transparent; + border-radius: 6px; + min-height: 20px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 10px; + cursor: pointer; + padding: 0; + + &.outside { + color: var(--text-quaternary); + opacity: 0.75; + } + + &.selected { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.14); + color: var(--primary); + font-weight: 600; + } +} + +.export-date-range-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.export-date-range-dialog-btn { + border-radius: 8px; + padding: 7px 12px; + font-size: 12px; + font-weight: 600; + border: 1px solid var(--border-color); + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + + &.primary { + border-color: var(--primary); + background: var(--primary); + color: #fff; + + &:hover { + background: var(--primary-hover); + } + } + + &.secondary { + background: var(--bg-secondary); + color: var(--text-primary); + + &:hover { + border-color: var(--primary); + color: var(--primary); + } + } +} + +@media (max-width: 860px) { + .export-date-range-calendar-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/components/Export/ExportDateRangeDialog.tsx b/src/components/Export/ExportDateRangeDialog.tsx new file mode 100644 index 0000000..e6695f1 --- /dev/null +++ b/src/components/Export/ExportDateRangeDialog.tsx @@ -0,0 +1,340 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' +import { Check, X } from 'lucide-react' +import { + EXPORT_DATE_RANGE_PRESETS, + WEEKDAY_SHORT_LABELS, + addMonths, + buildCalendarCells, + cloneExportDateRangeSelection, + createDateRangeByPreset, + createDefaultDateRange, + formatCalendarMonthTitle, + formatDateInputValue, + isSameDay, + parseDateInputValue, + startOfDay, + endOfDay, + toMonthStart, + type ExportDateRangePreset, + type ExportDateRangeSelection +} from '../../utils/exportDateRange' +import './ExportDateRangeDialog.scss' + +interface ExportDateRangeDialogProps { + open: boolean + value: ExportDateRangeSelection + title?: string + onClose: () => void + onConfirm: (value: ExportDateRangeSelection) => void +} + +interface ExportDateRangeDialogDraft extends ExportDateRangeSelection { + startPanelMonth: Date + endPanelMonth: Date +} + +const buildDialogDraft = (value: ExportDateRangeSelection): ExportDateRangeDialogDraft => ({ + ...cloneExportDateRangeSelection(value), + startPanelMonth: toMonthStart(value.dateRange.start), + endPanelMonth: toMonthStart(value.dateRange.end) +}) + +export function ExportDateRangeDialog({ + open, + value, + title = '时间范围设置', + onClose, + onConfirm +}: ExportDateRangeDialogProps) { + const [draft, setDraft] = useState(() => buildDialogDraft(value)) + const [dateInput, setDateInput] = useState({ + start: formatDateInputValue(value.dateRange.start), + end: formatDateInputValue(value.dateRange.end) + }) + const [dateInputError, setDateInputError] = useState({ start: false, end: false }) + + useEffect(() => { + if (!open) return + const nextDraft = buildDialogDraft(value) + setDraft(nextDraft) + setDateInput({ + start: formatDateInputValue(nextDraft.dateRange.start), + end: formatDateInputValue(nextDraft.dateRange.end) + }) + setDateInputError({ start: false, end: false }) + }, [open, value]) + + useEffect(() => { + if (!open) return + setDateInput({ + start: formatDateInputValue(draft.dateRange.start), + end: formatDateInputValue(draft.dateRange.end) + }) + setDateInputError({ start: false, end: false }) + }, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open]) + + const applyPreset = useCallback((preset: Exclude) => { + 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 start = startOfDay(targetDate) + setDraft(prev => { + const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end + return { + ...prev, + preset: 'custom', + useAllTime: false, + dateRange: { + start, + end: nextEnd + }, + startPanelMonth: toMonthStart(start), + endPanelMonth: toMonthStart(nextEnd) + } + }) + }, []) + + const updateDraftEnd = useCallback((targetDate: Date) => { + const end = endOfDay(targetDate) + setDraft(prev => { + const nextStart = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start + const nextEnd = end < nextStart ? endOfDay(nextStart) : end + return { + ...prev, + preset: 'custom', + useAllTime: false, + dateRange: { + start: nextStart, + end: nextEnd + }, + startPanelMonth: toMonthStart(nextStart), + endPanelMonth: toMonthStart(nextEnd) + } + }) + }, []) + + const commitStartFromInput = useCallback(() => { + const parsed = parseDateInputValue(dateInput.start) + if (!parsed) { + setDateInputError(prev => ({ ...prev, start: true })) + return + } + setDateInputError(prev => ({ ...prev, start: false })) + updateDraftStart(parsed) + }, [dateInput.start, updateDraftStart]) + + const commitEndFromInput = useCallback(() => { + const parsed = parseDateInputValue(dateInput.end) + if (!parsed) { + setDateInputError(prev => ({ ...prev, end: true })) + return + } + setDateInputError(prev => ({ ...prev, end: false })) + updateDraftEnd(parsed) + }, [dateInput.end, updateDraftEnd]) + + 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 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]) + + if (!open) return null + + return createPortal( +
+
event.stopPropagation()}> +
+

{title}

+ +
+ +
+ {EXPORT_DATE_RANGE_PRESETS.map((preset) => { + const active = isPresetActive(preset.value) + return ( + + ) + })} +
+ +
+ {modeText} +
+ +
+
+
+
+ 起始日期 + { + 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} + /> +
+
+ + {formatCalendarMonthTitle(draft.startPanelMonth)} + +
+
+
+ {WEEKDAY_SHORT_LABELS.map(label => ( + {label} + ))} +
+
+ {startPanelCells.map((cell) => { + const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.start) + return ( + + ) + })} +
+
+ +
+
+
+ 截止日期 + { + 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} + /> +
+
+ + {formatCalendarMonthTitle(draft.endPanelMonth)} + +
+
+
+ {WEEKDAY_SHORT_LABELS.map(label => ( + {label} + ))} +
+
+ {endPanelCells.map((cell) => { + const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.end) + return ( + + ) + })} +
+
+
+ +
+ + +
+
+
, + document.body + ) +} diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 251006f..d92ab1e 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -40,7 +40,16 @@ import { import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' import { SnsPostItem } from '../components/Sns/SnsPostItem' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' +import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' import type { SnsPost } from '../types/sns' +import { + cloneExportDateRange, + createDefaultDateRange, + createDefaultExportDateRangeSelection, + getExportDateRangeLabel, + resolveExportDateRangeConfig, + type ExportDateRangeSelection +} from '../utils/exportDateRange' import './ExportPage.scss' type ConversationTab = 'private' | 'group' | 'official' | 'former_friend' @@ -53,17 +62,6 @@ type SnsRankMode = 'likes' | 'comments' type SessionLayout = 'shared' | 'per-session' type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname' -type DateRangePreset = - | 'all' - | 'today' - | 'yesterday' - | 'last3days' - | 'last7days' - | 'last30days' - | 'last1year' - | 'last2years' - | 'custom' -type CalendarCell = { date: Date; inCurrentMonth: boolean } type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson' @@ -158,14 +156,6 @@ interface ExportDialogState { title: string } -interface TimeRangeDialogDraft { - preset: DateRangePreset - useAllTime: boolean - dateRange: { start: Date; end: Date } - startPanelMonth: Date - endPanelMonth: Date -} - const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000 const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10 @@ -463,126 +453,6 @@ const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => return formatAbsoluteDate(timestamp) } -const startOfDay = (date: Date): Date => { - const next = new Date(date) - next.setHours(0, 0, 0, 0) - return next -} - -const endOfDay = (date: Date): Date => { - const next = new Date(date) - next.setHours(23, 59, 59, 999) - return next -} - -const createDefaultDateRange = (): { start: Date; end: Date } => { - const now = new Date() - return { - start: startOfDay(now), - end: now - } -} - -const createDateRangeByPreset = ( - preset: Exclude, - now = new Date() -): { start: Date; end: Date } => { - const end = new Date(now) - const baseStart = startOfDay(now) - - if (preset === 'today') { - return { start: baseStart, end } - } - - if (preset === 'yesterday') { - const yesterday = new Date(baseStart) - yesterday.setDate(yesterday.getDate() - 1) - return { - start: yesterday, - end: endOfDay(yesterday) - } - } - - if (preset === 'last1year' || preset === 'last2years') { - const yearsBack = preset === 'last1year' ? 1 : 2 - const start = new Date(baseStart) - const expectedMonth = start.getMonth() - start.setFullYear(start.getFullYear() - yearsBack) - // Handle leap-year fallback (e.g. Feb 29 -> Feb 28). - if (start.getMonth() !== expectedMonth) { - start.setDate(0) - } - return { start, end } - } - - const daysBack = preset === 'last3days' ? 2 : preset === 'last7days' ? 6 : 29 - const start = new Date(baseStart) - start.setDate(start.getDate() - daysBack) - return { start, end } -} - -const formatDateInputValue = (date: Date): string => { - const y = date.getFullYear() - const m = `${date.getMonth() + 1}`.padStart(2, '0') - const d = `${date.getDate()}`.padStart(2, '0') - return `${y}-${m}-${d}` -} - -const parseDateInputValue = (raw: string): Date | null => { - const text = String(raw || '').trim() - const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text) - if (!matched) return null - const year = Number(matched[1]) - const month = Number(matched[2]) - const day = Number(matched[3]) - if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null - if (month < 1 || month > 12 || day < 1 || day > 31) return null - const parsed = new Date(year, month - 1, day) - if ( - parsed.getFullYear() !== year || - parsed.getMonth() !== month - 1 || - parsed.getDate() !== day - ) { - return null - } - return parsed -} - -const toMonthStart = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), 1) - -const addMonths = (date: Date, delta: number): Date => { - const next = new Date(date) - next.setMonth(next.getMonth() + delta) - return toMonthStart(next) -} - -const isSameDay = (left: Date, right: Date): boolean => ( - left.getFullYear() === right.getFullYear() && - left.getMonth() === right.getMonth() && - left.getDate() === right.getDate() -) - -const buildCalendarCells = (monthStart: Date): CalendarCell[] => { - const firstDay = new Date(monthStart.getFullYear(), monthStart.getMonth(), 1) - const startOffset = firstDay.getDay() - const gridStart = new Date(firstDay) - gridStart.setDate(gridStart.getDate() - startOffset) - const cells: CalendarCell[] = [] - for (let index = 0; index < 42; index += 1) { - const current = new Date(gridStart) - current.setDate(gridStart.getDate() + index) - cells.push({ - date: current, - inCurrentMonth: current.getMonth() === monthStart.getMonth() - }) - } - return cells -} - -const formatCalendarMonthTitle = (date: Date): string => `${date.getFullYear()}年${date.getMonth() + 1}月` - -const WEEKDAY_SHORT_LABELS = ['日', '一', '二', '三', '四', '五', '六'] - const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => { if (session.username.endsWith('@chatroom')) return 'group' if (session.username.startsWith('gh_')) return 'official' @@ -1412,10 +1282,8 @@ function ExportPage() { const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false) const [snsExportVideos, setSnsExportVideos] = useState(false) const [isTimeRangeDialogOpen, setIsTimeRangeDialogOpen] = useState(false) - const [timeRangePreset, setTimeRangePreset] = useState('all') - const [timeRangeDialogDraft, setTimeRangeDialogDraft] = useState(null) - const [timeRangeDateInput, setTimeRangeDateInput] = useState<{ start: string; end: string }>({ start: '', end: '' }) - const [timeRangeDateInputError, setTimeRangeDateInputError] = useState<{ start: boolean; end: boolean }>({ start: false, end: false }) + const [timeRangeSelection, setTimeRangeSelection] = useState(() => createDefaultExportDateRangeSelection()) + const [exportDefaultDateRangeSelection, setExportDefaultDateRangeSelection] = useState(() => createDefaultExportDateRangeSelection()) const [options, setOptions] = useState({ format: 'json', @@ -1917,7 +1785,7 @@ function ExportPage() { setIsBaseConfigLoading(true) let isReady = true try { - const [savedPath, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, exportCacheScope] = await Promise.all([ + const [savedPath, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([ configService.getExportPath(), configService.getExportDefaultMedia(), configService.getExportDefaultVoiceAsText(), @@ -1930,6 +1798,7 @@ function ExportPage() { configService.getExportLastSnsPostCount(), configService.getExportWriteLayout(), configService.getExportSessionNamePrefixEnabled(), + configService.getExportDefaultDateRange(), ensureExportCacheScope() ]) @@ -1948,6 +1817,9 @@ function ExportPage() { setLastExportByContent(savedContentMap) setExportRecordsBySession(savedSessionRecordMap) setLastSnsExportPostCount(savedSnsPostCount) + const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange) + setExportDefaultDateRangeSelection(resolvedDefaultDateRange) + setTimeRangeSelection(resolvedDefaultDateRange) await configService.setExportDefaultFormat('json') if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) { @@ -3313,14 +3185,14 @@ function ExportPage() { const openExportDialog = useCallback((payload: Omit) => { setExportDialog({ open: true, ...payload }) setIsTimeRangeDialogOpen(false) - setTimeRangePreset('all') + setTimeRangeSelection(exportDefaultDateRangeSelection) setOptions(prev => { - const nextDateRange = prev.dateRange ?? createDefaultDateRange() + const nextDateRange = cloneExportDateRange(exportDefaultDateRangeSelection.dateRange) const next: ExportOptions = { ...prev, - useAllTime: true, + useAllTime: exportDefaultDateRangeSelection.useAllTime, dateRange: nextDateRange } @@ -3348,219 +3220,22 @@ function ExportPage() { return next }) - }, []) + }, [exportDefaultDateRangeSelection]) const closeExportDialog = useCallback(() => { setExportDialog(prev => ({ ...prev, open: false })) setIsTimeRangeDialogOpen(false) - setTimeRangeDialogDraft(null) - setTimeRangeDateInput({ start: '', end: '' }) - setTimeRangeDateInputError({ start: false, end: false }) }, []) - const buildTimeRangeDialogDraft = useCallback((): TimeRangeDialogDraft => { - const dateRange = options.dateRange ?? createDefaultDateRange() - return { - preset: timeRangePreset, - useAllTime: options.useAllTime, - dateRange: { - start: new Date(dateRange.start), - end: new Date(dateRange.end) - }, - startPanelMonth: toMonthStart(dateRange.start), - endPanelMonth: toMonthStart(dateRange.end) - } - }, [options.dateRange, options.useAllTime, timeRangePreset]) - const openTimeRangeDialog = useCallback(() => { - const draft = buildTimeRangeDialogDraft() - setTimeRangeDialogDraft(draft) setIsTimeRangeDialogOpen(true) - }, [buildTimeRangeDialogDraft]) + }, []) const closeTimeRangeDialog = useCallback(() => { setIsTimeRangeDialogOpen(false) - setTimeRangeDialogDraft(null) - setTimeRangeDateInput({ start: '', end: '' }) - setTimeRangeDateInputError({ start: false, end: false }) }, []) - const applyTimeRangePresetToDraft = useCallback((preset: Exclude) => { - setTimeRangeDialogDraft(prev => { - const base = prev ?? buildTimeRangeDialogDraft() - if (preset === 'all') { - const previewRange = createDefaultDateRange() - return { - ...base, - preset, - useAllTime: true, - dateRange: { - start: previewRange.start, - end: previewRange.end - }, - startPanelMonth: toMonthStart(previewRange.start), - endPanelMonth: toMonthStart(previewRange.end) - } - } - const range = createDateRangeByPreset(preset) - return { - ...base, - preset, - useAllTime: false, - dateRange: { - start: range.start, - end: range.end - }, - startPanelMonth: toMonthStart(range.start), - endPanelMonth: toMonthStart(range.end) - } - }) - }, [buildTimeRangeDialogDraft]) - - const handleTimeRangePresetClick = useCallback((preset: Exclude) => { - applyTimeRangePresetToDraft(preset) - }, [applyTimeRangePresetToDraft]) - - const updateTimeRangeDraftStart = useCallback((targetDate: Date) => { - const start = startOfDay(targetDate) - setTimeRangeDialogDraft(prev => { - const base = prev ?? buildTimeRangeDialogDraft() - const nextEnd = base.dateRange.end < start ? endOfDay(start) : base.dateRange.end - return { - ...base, - preset: 'custom', - useAllTime: false, - dateRange: { - start, - end: nextEnd - }, - startPanelMonth: toMonthStart(start), - endPanelMonth: toMonthStart(nextEnd) - } - }) - }, [buildTimeRangeDialogDraft]) - - const updateTimeRangeDraftEnd = useCallback((targetDate: Date) => { - const end = endOfDay(targetDate) - setTimeRangeDialogDraft(prev => { - const base = prev ?? buildTimeRangeDialogDraft() - const isAllTimeMode = base.useAllTime - const nextStart = isAllTimeMode - ? startOfDay(targetDate) - : base.dateRange.start - const nextEnd = end < nextStart ? endOfDay(nextStart) : end - return { - ...base, - preset: 'custom', - useAllTime: false, - dateRange: { - start: nextStart, - end: nextEnd - }, - startPanelMonth: toMonthStart(nextStart), - endPanelMonth: toMonthStart(nextEnd) - } - }) - }, [buildTimeRangeDialogDraft]) - - const commitTimeRangeStartFromInput = useCallback(() => { - const parsed = parseDateInputValue(timeRangeDateInput.start) - if (!parsed) { - setTimeRangeDateInputError(prev => ({ ...prev, start: true })) - return - } - setTimeRangeDateInputError(prev => ({ ...prev, start: false })) - updateTimeRangeDraftStart(parsed) - }, [timeRangeDateInput.start, updateTimeRangeDraftStart]) - - const commitTimeRangeEndFromInput = useCallback(() => { - const parsed = parseDateInputValue(timeRangeDateInput.end) - if (!parsed) { - setTimeRangeDateInputError(prev => ({ ...prev, end: true })) - return - } - setTimeRangeDateInputError(prev => ({ ...prev, end: false })) - updateTimeRangeDraftEnd(parsed) - }, [timeRangeDateInput.end, updateTimeRangeDraftEnd]) - - const shiftTimeRangePanelMonth = useCallback((panel: 'start' | 'end', delta: number) => { - setTimeRangeDialogDraft(prev => { - const base = prev ?? buildTimeRangeDialogDraft() - if (panel === 'start') { - return { - ...base, - startPanelMonth: addMonths(base.startPanelMonth, delta) - } - } - return { - ...base, - endPanelMonth: addMonths(base.endPanelMonth, delta) - } - }) - }, [buildTimeRangeDialogDraft]) - - const commitTimeRangeDialogDraft = useCallback(() => { - const draft = timeRangeDialogDraft ?? buildTimeRangeDialogDraft() - setTimeRangePreset(draft.preset) - setOptions(prev => ({ - ...prev, - useAllTime: draft.useAllTime, - dateRange: { - start: new Date(draft.dateRange.start), - end: new Date(draft.dateRange.end) - } - })) - closeTimeRangeDialog() - }, [buildTimeRangeDialogDraft, closeTimeRangeDialog, timeRangeDialogDraft]) - - const timeRangeSummaryLabel = useMemo(() => { - if (options.useAllTime) return '默认导出全部时间' - if (timeRangePreset === 'today') return '今天' - if (timeRangePreset === 'yesterday') return '昨天' - if (timeRangePreset === 'last3days') return '最近3天' - if (timeRangePreset === 'last7days') return '最近一周' - if (timeRangePreset === 'last30days') return '最近30 天' - if (timeRangePreset === 'last1year') return '最近一年' - if (timeRangePreset === 'last2years') return '最近两年' - if (options.dateRange) { - return `${formatDateInputValue(options.dateRange.start)} 至 ${formatDateInputValue(options.dateRange.end)}` - } - return '自定义时间范围' - }, [options.useAllTime, options.dateRange, timeRangePreset]) - - const activeTimeRangeDialogDraft = timeRangeDialogDraft ?? buildTimeRangeDialogDraft() - const isRangeModeActive = !activeTimeRangeDialogDraft.useAllTime - const timeRangeModeText = isRangeModeActive - ? '当前导出模式:按时间范围导出' - : '当前导出模式:全部时间导出(选择下方日期将切换为按时间范围导出)' - - useEffect(() => { - if (!isTimeRangeDialogOpen) return - setTimeRangeDateInput({ - start: formatDateInputValue(activeTimeRangeDialogDraft.dateRange.start), - end: formatDateInputValue(activeTimeRangeDialogDraft.dateRange.end) - }) - setTimeRangeDateInputError({ start: false, end: false }) - }, [ - isTimeRangeDialogOpen, - activeTimeRangeDialogDraft.dateRange.start.getTime(), - activeTimeRangeDialogDraft.dateRange.end.getTime() - ]) - - const isTimeRangePresetActive = useCallback((preset: DateRangePreset): boolean => { - if (preset === 'all') return activeTimeRangeDialogDraft.useAllTime - return !activeTimeRangeDialogDraft.useAllTime && activeTimeRangeDialogDraft.preset === preset - }, [activeTimeRangeDialogDraft]) - - const startPanelCells = useMemo( - () => buildCalendarCells(activeTimeRangeDialogDraft.startPanelMonth), - [activeTimeRangeDialogDraft.startPanelMonth] - ) - - const endPanelCells = useMemo( - () => buildCalendarCells(activeTimeRangeDialogDraft.endPanelMonth), - [activeTimeRangeDialogDraft.endPanelMonth] - ) + const timeRangeSummaryLabel = useMemo(() => getExportDateRangeLabel(timeRangeSelection), [timeRangeSelection]) useEffect(() => { const unsubscribe = onOpenSingleExport((payload) => { @@ -6377,173 +6052,20 @@ function ExportPage() {
- {isTimeRangeDialogOpen && ( -
-
event.stopPropagation()}> -
-

时间范围设置

- -
- -
- {([ - { value: 'all', label: '全部时间' }, - { value: 'today', label: '今天' }, - { value: 'yesterday', label: '昨天' }, - { value: 'last3days', label: '最近3天' }, - { value: 'last7days', label: '最近一周' }, - { value: 'last30days', label: '最近30 天' }, - { value: 'last1year', label: '最近一年' } - ] as Array<{ value: Exclude; label: string }>).map((preset) => { - const isActive = isTimeRangePresetActive(preset.value) - return ( - - ) - })} -
- -
- {timeRangeModeText} -
- -
-
-
-
- 起始日期 - { - const nextValue = event.target.value - setTimeRangeDateInput(prev => ({ ...prev, start: nextValue })) - if (timeRangeDateInputError.start) { - setTimeRangeDateInputError(prev => ({ ...prev, start: false })) - } - }} - onKeyDown={(event) => { - if (event.key !== 'Enter') return - event.preventDefault() - commitTimeRangeStartFromInput() - }} - onBlur={() => { - commitTimeRangeStartFromInput() - }} - /> -
-
- - {formatCalendarMonthTitle(activeTimeRangeDialogDraft.startPanelMonth)} - -
-
-
- {WEEKDAY_SHORT_LABELS.map(label => ( - {label} - ))} -
-
- {startPanelCells.map((cell) => { - const isSelected = !activeTimeRangeDialogDraft.useAllTime && - isSameDay(cell.date, activeTimeRangeDialogDraft.dateRange.start) - return ( - - ) - })} -
-
- -
-
-
- 截止日期 - { - const nextValue = event.target.value - setTimeRangeDateInput(prev => ({ ...prev, end: nextValue })) - if (timeRangeDateInputError.end) { - setTimeRangeDateInputError(prev => ({ ...prev, end: false })) - } - }} - onKeyDown={(event) => { - if (event.key !== 'Enter') return - event.preventDefault() - commitTimeRangeEndFromInput() - }} - onBlur={() => { - commitTimeRangeEndFromInput() - }} - /> -
-
- - {formatCalendarMonthTitle(activeTimeRangeDialogDraft.endPanelMonth)} - -
-
-
- {WEEKDAY_SHORT_LABELS.map(label => ( - {label} - ))} -
-
- {endPanelCells.map((cell) => { - const isSelected = !activeTimeRangeDialogDraft.useAllTime && - isSameDay(cell.date, activeTimeRangeDialogDraft.dateRange.end) - return ( - - ) - })} -
-
-
- -
- - -
-
-
- )} + { + setTimeRangeSelection(nextSelection) + setOptions(prev => ({ + ...prev, + useAllTime: nextSelection.useAllTime, + dateRange: cloneExportDateRange(nextSelection.dateRange) + })) + closeTimeRangeDialog() + }} + />
, document.body diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index a27d74d..8dc2525 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -348,6 +348,51 @@ margin-bottom: 10px; } + .settings-time-range-field { + margin-bottom: 10px; + } + + .settings-time-range-trigger { + width: 100%; + padding: 10px 16px; + border: 1px solid var(--border-color); + border-radius: 9999px; + font-size: 14px; + background: var(--bg-primary); + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: rgba(var(--primary-rgb), 0.45); + color: var(--primary); + } + + &.open { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); + } + } + + .settings-time-range-value { + flex: 1; + min-width: 0; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .settings-time-range-arrow { + color: var(--text-tertiary); + font-weight: 700; + line-height: 1; + } + .select-trigger { width: 100%; padding: 10px 16px; @@ -2239,4 +2284,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 6161d23..59ddc09 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -12,6 +12,14 @@ import { ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2 } from 'lucide-react' import { Avatar } from '../components/Avatar' +import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' +import { + createDefaultExportDateRangeSelection, + getExportDateRangeLabel, + resolveExportDateRangeConfig, + serializeExportDateRangeConfig, + type ExportDateRangeSelection +} from '../utils/exportDateRange' import './SettingsPage.scss' type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'export' | 'cache' | 'api' | 'security' | 'about' | 'analytics' @@ -74,11 +82,9 @@ function SettingsPage() { const [wxidOptions, setWxidOptions] = useState([]) const [showWxidSelect, setShowWxidSelect] = useState(false) const [showExportFormatSelect, setShowExportFormatSelect] = useState(false) - const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false) const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) const [showExportConcurrencySelect, setShowExportConcurrencySelect] = useState(false) const exportFormatDropdownRef = useRef(null) - const exportDateRangeDropdownRef = useRef(null) const exportExcelColumnsDropdownRef = useRef(null) const exportConcurrencyDropdownRef = useRef(null) const [cachePath, setCachePath] = useState('') @@ -104,7 +110,8 @@ function SettingsPage() { const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false) const [transcribeLanguages, setTranscribeLanguages] = useState(['zh']) const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') - const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today') + const [exportDefaultDateRange, setExportDefaultDateRange] = useState(() => createDefaultExportDateRangeSelection()) + const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false) const [exportDefaultMedia, setExportDefaultMedia] = useState(false) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) @@ -209,9 +216,6 @@ function SettingsPage() { if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) { setShowExportFormatSelect(false) } - if (showExportDateRangeSelect && exportDateRangeDropdownRef.current && !exportDateRangeDropdownRef.current.contains(target)) { - setShowExportDateRangeSelect(false) - } if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) { setShowExportExcelColumnsSelect(false) } @@ -221,7 +225,7 @@ function SettingsPage() { } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect]) + }, [showExportFormatSelect, showExportExcelColumnsSelect, showExportConcurrencySelect]) useEffect(() => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => { @@ -331,7 +335,7 @@ function SettingsPage() { setAutoTranscribeVoice(savedAutoTranscribe) setTranscribeLanguages(savedTranscribeLanguages) setExportDefaultFormat(savedExportDefaultFormat || 'excel') - setExportDefaultDateRange(savedExportDefaultDateRange || 'today') + setExportDefaultDateRange(resolveExportDateRangeConfig(savedExportDefaultDateRange)) setExportDefaultMedia(savedExportDefaultMedia ?? false) setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? false) setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true) @@ -1557,13 +1561,6 @@ function SettingsPage() { { value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' }, { value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' } ] - const exportDateRangeOptions = [ - { value: 'today', label: '今天' }, - { value: '7d', label: '最近7天' }, - { value: '30d', label: '最近30天' }, - { value: '90d', label: '最近90天' }, - { value: 'all', label: '全部时间' } - ] const exportExcelColumnOptions = [ { value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' }, { value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' } @@ -1585,7 +1582,7 @@ function SettingsPage() { const renderExportTab = () => { const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full' const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat) - const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange) + const exportDateRangeLabel = getExportDateRangeLabel(exportDefaultDateRange) const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue) const exportConcurrencyLabel = String(exportDefaultConcurrency) @@ -1600,7 +1597,7 @@ function SettingsPage() { className={`select-trigger ${showExportFormatSelect ? 'open' : ''}`} onClick={() => { setShowExportFormatSelect(!showExportFormatSelect) - setShowExportDateRangeSelect(false) + setIsExportDateRangeDialogOpen(false) setShowExportExcelColumnsSelect(false) setShowExportConcurrencySelect(false) }} @@ -1634,42 +1631,35 @@ function SettingsPage() {
控制导出页面的默认时间选择 -
+
- {showExportDateRangeSelect && ( -
- {exportDateRangeOptions.map((option) => ( - - ))} -
- )}
+ setIsExportDateRangeDialogOpen(false)} + onConfirm={async (nextSelection) => { + setExportDefaultDateRange(nextSelection) + await configService.setExportDefaultDateRange(serializeExportDateRangeConfig(nextSelection)) + showMessage('已更新默认导出时间范围', true) + setIsExportDateRangeDialogOpen(false) + }} + /> +
控制图片/语音/表情的默认导出开关 @@ -1726,7 +1716,7 @@ function SettingsPage() { onClick={() => { setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect) setShowExportFormatSelect(false) - setShowExportDateRangeSelect(false) + setIsExportDateRangeDialogOpen(false) setShowExportConcurrencySelect(false) }} > @@ -1767,7 +1757,7 @@ function SettingsPage() { onClick={() => { setShowExportConcurrencySelect(!showExportConcurrencySelect) setShowExportFormatSelect(false) - setShowExportDateRangeSelect(false) + setIsExportDateRangeDialogOpen(false) setShowExportExcelColumnsSelect(false) }} > diff --git a/src/services/config.ts b/src/services/config.ts index 0f7a58c..f3ab875 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -1,5 +1,6 @@ // 配置服务 - 封装 Electron Store import { config } from './ipc' +import type { ExportDefaultDateRangeConfig } from '../utils/exportDateRange' // 配置键名 export const CONFIG_KEYS = { @@ -335,13 +336,17 @@ export async function setExportDefaultFormat(format: string): Promise { } // 获取导出默认时间范围 -export async function getExportDefaultDateRange(): Promise { +export async function getExportDefaultDateRange(): Promise { const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE) - return (value as string) || null + if (typeof value === 'string') return value + if (value && typeof value === 'object') { + return value as ExportDefaultDateRangeConfig + } + return null } // 设置导出默认时间范围 -export async function setExportDefaultDateRange(range: string): Promise { +export async function setExportDefaultDateRange(range: ExportDefaultDateRangeConfig | string): Promise { await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range) } diff --git a/src/utils/exportDateRange.ts b/src/utils/exportDateRange.ts new file mode 100644 index 0000000..e1f2def --- /dev/null +++ b/src/utils/exportDateRange.ts @@ -0,0 +1,341 @@ +export type ExportDateRangePreset = + | 'all' + | 'today' + | 'yesterday' + | 'last3days' + | 'last7days' + | 'last30days' + | 'last1year' + | 'last2years' + | 'custom' + +export type CalendarCell = { date: Date; inCurrentMonth: boolean } + +export interface ExportDateRange { + start: Date + end: Date +} + +export interface ExportDateRangeSelection { + preset: ExportDateRangePreset + useAllTime: boolean + dateRange: ExportDateRange +} + +export interface ExportDefaultDateRangeConfig { + version?: 1 + preset?: ExportDateRangePreset | string + useAllTime?: boolean + start?: string | number | Date | null + end?: string | number | Date | null +} + +export const EXPORT_DATE_RANGE_PRESETS: Array<{ + value: Exclude + label: string +}> = [ + { value: 'all', label: '全部时间' }, + { value: 'today', label: '今天' }, + { value: 'yesterday', label: '昨天' }, + { value: 'last3days', label: '最近3天' }, + { value: 'last7days', label: '最近一周' }, + { value: 'last30days', label: '最近30天' }, + { value: 'last1year', label: '最近一年' } +] + +const PRESET_LABELS: Record, string> = { + all: '全部时间', + today: '今天', + yesterday: '昨天', + last3days: '最近3天', + last7days: '最近一周', + last30days: '最近30天', + last1year: '最近一年', + last2years: '最近两年' +} + +const LEGACY_PRESET_MAP: Record | 'legacy90days'> = { + all: 'all', + today: 'today', + yesterday: 'yesterday', + last3days: 'last3days', + last7days: 'last7days', + last30days: 'last30days', + last1year: 'last1year', + last2years: 'last2years', + '7d': 'last7days', + '30d': 'last30days', + '90d': 'legacy90days' +} + +export const WEEKDAY_SHORT_LABELS = ['日', '一', '二', '三', '四', '五', '六'] + +export const startOfDay = (date: Date): Date => { + const next = new Date(date) + next.setHours(0, 0, 0, 0) + return next +} + +export const endOfDay = (date: Date): Date => { + const next = new Date(date) + next.setHours(23, 59, 59, 999) + return next +} + +export const createDefaultDateRange = (): ExportDateRange => { + const now = new Date() + return { + start: startOfDay(now), + end: now + } +} + +export const createDateRangeByPreset = ( + preset: Exclude, + now = new Date() +): ExportDateRange => { + const end = new Date(now) + const baseStart = startOfDay(now) + + if (preset === 'today') { + return { start: baseStart, end } + } + + if (preset === 'yesterday') { + const yesterday = new Date(baseStart) + yesterday.setDate(yesterday.getDate() - 1) + return { + start: yesterday, + end: endOfDay(yesterday) + } + } + + if (preset === 'last1year' || preset === 'last2years') { + const yearsBack = preset === 'last1year' ? 1 : 2 + const start = new Date(baseStart) + const expectedMonth = start.getMonth() + start.setFullYear(start.getFullYear() - yearsBack) + if (start.getMonth() !== expectedMonth) { + start.setDate(0) + } + return { start, end } + } + + const daysBack = preset === 'last3days' ? 2 : preset === 'last7days' ? 6 : 29 + const start = new Date(baseStart) + start.setDate(start.getDate() - daysBack) + return { start, end } +} + +export const createDateRangeByLastNDays = (days: number, now = new Date()): ExportDateRange => { + const end = new Date(now) + const start = startOfDay(now) + start.setDate(start.getDate() - Math.max(0, days - 1)) + return { start, end } +} + +export const formatDateInputValue = (date: Date): string => { + const y = date.getFullYear() + const m = `${date.getMonth() + 1}`.padStart(2, '0') + const d = `${date.getDate()}`.padStart(2, '0') + return `${y}-${m}-${d}` +} + +export const parseDateInputValue = (raw: string): Date | null => { + const text = String(raw || '').trim() + const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text) + if (!matched) return null + const year = Number(matched[1]) + const month = Number(matched[2]) + const day = Number(matched[3]) + if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null + if (month < 1 || month > 12 || day < 1 || day > 31) return null + const parsed = new Date(year, month - 1, day) + if ( + parsed.getFullYear() !== year || + parsed.getMonth() !== month - 1 || + parsed.getDate() !== day + ) { + return null + } + return parsed +} + +export const toMonthStart = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), 1) + +export const addMonths = (date: Date, delta: number): Date => { + const next = new Date(date) + next.setMonth(next.getMonth() + delta) + return toMonthStart(next) +} + +export const isSameDay = (left: Date, right: Date): boolean => ( + left.getFullYear() === right.getFullYear() && + left.getMonth() === right.getMonth() && + left.getDate() === right.getDate() +) + +export const buildCalendarCells = (monthStart: Date): CalendarCell[] => { + const firstDay = new Date(monthStart.getFullYear(), monthStart.getMonth(), 1) + const startOffset = firstDay.getDay() + const gridStart = new Date(firstDay) + gridStart.setDate(gridStart.getDate() - startOffset) + const cells: CalendarCell[] = [] + for (let index = 0; index < 42; index += 1) { + const current = new Date(gridStart) + current.setDate(gridStart.getDate() + index) + cells.push({ + date: current, + inCurrentMonth: current.getMonth() === monthStart.getMonth() + }) + } + return cells +} + +export const formatCalendarMonthTitle = (date: Date): string => `${date.getFullYear()}年${date.getMonth() + 1}月` + +export const cloneExportDateRange = (range: ExportDateRange): ExportDateRange => ({ + start: new Date(range.start), + end: new Date(range.end) +}) + +export const cloneExportDateRangeSelection = (selection: ExportDateRangeSelection): ExportDateRangeSelection => ({ + preset: selection.preset, + useAllTime: selection.useAllTime, + dateRange: cloneExportDateRange(selection.dateRange) +}) + +export const createExportDateRangeSelectionFromPreset = ( + preset: Exclude, + now = new Date() +): ExportDateRangeSelection => { + if (preset === 'all') { + return { + preset, + useAllTime: true, + dateRange: createDefaultDateRange() + } + } + + return { + preset, + useAllTime: false, + dateRange: createDateRangeByPreset(preset, now) + } +} + +export const createDefaultExportDateRangeSelection = (): ExportDateRangeSelection => ( + createExportDateRangeSelectionFromPreset('today') +) + +const parseStoredDate = (value: unknown): Date | null => { + if (value instanceof Date && !Number.isNaN(value.getTime())) { + return new Date(value) + } + if (typeof value === 'number' && Number.isFinite(value)) { + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? null : parsed + } + if (typeof value === 'string') { + const normalized = parseDateInputValue(value) + if (normalized) return normalized + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? null : parsed + } + return null +} + +const normalizePreset = (raw: unknown): Exclude | 'legacy90days' | null => { + if (typeof raw !== 'string') return null + const normalized = LEGACY_PRESET_MAP[raw] + return normalized ?? null +} + +export const resolveExportDateRangeConfig = ( + raw: ExportDefaultDateRangeConfig | string | null | undefined, + now = new Date() +): ExportDateRangeSelection => { + if (!raw) { + return createDefaultExportDateRangeSelection() + } + + if (typeof raw === 'string') { + const preset = normalizePreset(raw) + if (!preset) return createDefaultExportDateRangeSelection() + if (preset === 'legacy90days') { + return { + preset: 'custom', + useAllTime: false, + dateRange: createDateRangeByLastNDays(90, now) + } + } + return createExportDateRangeSelectionFromPreset(preset, now) + } + + const preset = normalizePreset(raw.preset) + if (raw.useAllTime || preset === 'all') { + return createExportDateRangeSelectionFromPreset('all', now) + } + if (preset && preset !== 'legacy90days') { + return createExportDateRangeSelectionFromPreset(preset, now) + } + + if (preset === 'legacy90days') { + return { + preset: 'custom', + useAllTime: false, + dateRange: createDateRangeByLastNDays(90, now) + } + } + + const parsedStart = parseStoredDate(raw.start) + const parsedEnd = parseStoredDate(raw.end) + if (parsedStart && parsedEnd) { + const start = startOfDay(parsedStart) + const end = endOfDay(parsedEnd) + return { + preset: 'custom', + useAllTime: false, + dateRange: { + start, + end: end < start ? endOfDay(start) : end + } + } + } + + return createDefaultExportDateRangeSelection() +} + +export const serializeExportDateRangeConfig = ( + selection: ExportDateRangeSelection +): ExportDefaultDateRangeConfig => { + if (selection.useAllTime) { + return { + version: 1, + preset: 'all', + useAllTime: true + } + } + + if (selection.preset === 'custom') { + return { + version: 1, + preset: 'custom', + useAllTime: false, + start: formatDateInputValue(selection.dateRange.start), + end: formatDateInputValue(selection.dateRange.end) + } + } + + return { + version: 1, + preset: selection.preset, + useAllTime: false + } +} + +export const getExportDateRangeLabel = (selection: ExportDateRangeSelection): string => { + if (selection.useAllTime) return PRESET_LABELS.all + if (selection.preset !== 'custom') return PRESET_LABELS[selection.preset] + return `${formatDateInputValue(selection.dateRange.start)} 至 ${formatDateInputValue(selection.dateRange.end)}` +} From b9c16dbee4b247da9a9e4dd02663378c76825081 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 12:29:46 +0800 Subject: [PATCH 47/97] fix(export): align header actions layout --- src/pages/ExportPage.scss | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 5b5ca19..425b1fa 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1307,7 +1307,9 @@ .contacts-list-header-select { width: var(--contacts-select-col-width); min-width: var(--contacts-select-col-width); - text-align: center; + display: flex; + align-items: center; + justify-content: center; flex-shrink: 0; } @@ -1346,16 +1348,19 @@ } .contacts-list-header-actions { - width: var(--contacts-action-col-width); + width: max(var(--contacts-action-col-width), 184px); + min-width: max(var(--contacts-action-col-width), 184px); display: flex; align-items: center; justify-content: flex-end; gap: 8px; + flex-wrap: nowrap; flex-shrink: 0; position: sticky; right: 0; z-index: 8; background: var(--bg-primary); + white-space: nowrap; &::before { content: ''; @@ -1399,6 +1404,7 @@ font-size: 12px; padding: 6px 10px; cursor: pointer; + white-space: nowrap; &:hover:not(:disabled) { border-color: var(--text-tertiary); @@ -1422,6 +1428,8 @@ display: inline-flex; align-items: center; gap: 6px; + white-space: nowrap; + flex-shrink: 0; &:hover:not(:disabled) { background: var(--primary-hover); From 80a193a394d9d2eda60bcca02923bf58e9422878 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 12:35:02 +0800 Subject: [PATCH 48/97] fix(export): align selection column baseline --- src/pages/ExportPage.scss | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 425b1fa..3b56219 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1161,6 +1161,7 @@ --contacts-default-visible-rows: 10; --contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows)); --contacts-select-col-width: 34px; + --contacts-inline-padding: 12px; --contacts-message-col-width: 120px; --contacts-media-col-width: 72px; --contacts-action-col-width: 140px; @@ -1294,7 +1295,7 @@ display: flex; align-items: center; gap: 12px; - padding: 10px 12px 8px; + padding: 10px var(--contacts-inline-padding) 8px; border-bottom: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent); background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary)); font-size: 12px; @@ -1379,7 +1380,7 @@ min-height: var(--contacts-default-list-height); height: var(--contacts-default-list-height); overflow: hidden; - padding: 0 12px 12px; + padding: 0 0 12px; } .contacts-virtuoso { @@ -1459,13 +1460,17 @@ &.selected .contact-item { background: rgba(var(--primary-rgb), 0.08); } + + &.selected .row-action-cell { + background: rgba(var(--primary-rgb), 0.08); + } } .contact-item { display: flex; align-items: center; gap: 12px; - padding: 12px; + padding: 12px var(--contacts-inline-padding); height: 72px; box-sizing: border-box; border-radius: 10px; @@ -3330,6 +3335,7 @@ } .table-wrap { + --contacts-inline-padding: 10px; --contacts-message-col-width: 104px; --contacts-media-col-width: 62px; --contacts-action-col-width: 140px; @@ -3337,7 +3343,7 @@ .table-wrap .contacts-list-header { gap: 8px; - padding: 8px 10px 6px; + padding: 8px var(--contacts-inline-padding) 6px; } .table-wrap .contacts-list-header-main { @@ -3349,7 +3355,7 @@ } .table-wrap .contacts-list { - padding: 0 10px 10px; + padding: 0 0 10px; min-height: 300px; height: min(56vh, 560px); } From c108070696bc7deb45426d36eb7151e3a5050128 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 12:55:08 +0800 Subject: [PATCH 49/97] feat(sidebar): surface unlock entry --- src/components/Sidebar.tsx | 29 +++++++++++++++++------------ src/pages/SettingsPage.tsx | 8 ++++++++ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b72ddcd..76547fb 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' -import { NavLink, useLocation } from 'react-router-dom' -import { Home, MessageSquare, BarChart3, Users, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, ChevronUp, Trash2 } from 'lucide-react' +import { NavLink, useLocation, useNavigate } from 'react-router-dom' +import { Home, MessageSquare, BarChart3, Users, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, Trash2 } from 'lucide-react' import { useAppStore } from '../stores/appStore' import * as configService from '../services/config' import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge' @@ -64,6 +64,7 @@ const normalizeAccountId = (value?: string | null): string => { function Sidebar() { const location = useLocation() + const navigate = useNavigate() const [collapsed, setCollapsed] = useState(false) const [authEnabled, setAuthEnabled] = useState(false) const [activeExportTaskCount, setActiveExportTaskCount] = useState(0) @@ -465,16 +466,20 @@ function Sidebar() {
- {authEnabled && ( - - )} + { + const initialTab = (location.state as { initialTab?: SettingsTab } | null)?.initialTab + if (!initialTab) return + setActiveTab(initialTab) + }, [location.state]) + // 点击外部关闭下拉框 useEffect(() => { const handleClickOutside = (e: MouseEvent) => { From 86b372de6882622ed5e04e7c9c7350863e4e5a5b Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 12:58:22 +0800 Subject: [PATCH 50/97] feat(export): compact task center entry --- src/pages/ExportPage.scss | 148 ++++++++++++++++++-------------------- src/pages/ExportPage.tsx | 93 ++++++++++++------------ 2 files changed, 115 insertions(+), 126 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 3b56219..8f34a07 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -20,6 +20,12 @@ flex-shrink: 0; } +.export-top-bar { + display: flex; + align-items: flex-start; + gap: 12px; +} + .export-section-title-row { display: flex; align-items: center; @@ -255,6 +261,7 @@ } .global-export-controls { + flex: 1; background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 12px; @@ -499,81 +506,6 @@ } } - .task-center-control { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; - } - - .task-center-inline { - min-height: 34px; - border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--bg-secondary); - padding: 0 8px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 6px; - flex-wrap: nowrap; - } - - .task-summary { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 11px; - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; - } - - .task-open-btn { - border: 1px solid var(--border-color); - background: var(--bg-primary); - border-radius: 7px; - padding: 3px 7px; - font-size: 11px; - color: var(--text-secondary); - display: inline-flex; - align-items: center; - gap: 6px; - cursor: pointer; - flex-shrink: 0; - transition: border-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; - - &:hover { - border-color: var(--primary); - color: var(--primary); - } - - &.active-running { - border-color: rgba(255, 77, 79, 0.45); - color: #ff4d4f; - box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.16); - } - } - - .task-running-badge { - min-width: 16px; - height: 16px; - border-radius: 999px; - background: #ff4d4f; - color: #fff; - font-size: 10px; - font-weight: 700; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0 4px; - line-height: 1; - box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.2); - animation: exportTaskBadgePulse 1.2s ease-in-out infinite; - } - .secondary-btn { border-radius: 7px; padding: 6px 9px; @@ -583,6 +515,58 @@ } } +.task-center-card { + min-width: 92px; + min-height: 42px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--card-bg); + color: var(--text-primary); + padding: 10px 12px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + flex-shrink: 0; + transition: border-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease; + + &:hover { + border-color: var(--primary); + color: var(--primary); + transform: translateY(-1px); + } + + &.has-alert { + border-color: rgba(255, 77, 79, 0.28); + box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.08); + } +} + +.task-center-card-label { + line-height: 1; + white-space: nowrap; +} + +.task-center-card-badge { + min-width: 18px; + height: 18px; + border-radius: 999px; + background: #ff4d4f; + color: #fff; + font-size: 10px; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 5px; + line-height: 1; + box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.16); + animation: exportTaskBadgePulse 1.2s ease-in-out infinite; +} + .content-card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); @@ -3288,6 +3272,10 @@ } @media (max-width: 1360px) { + .export-top-bar { + gap: 10px; + } + .global-export-controls { padding: 10px; gap: 8px; @@ -3307,12 +3295,18 @@ } @media (max-width: 760px) { + .export-top-bar { + flex-direction: column; + align-items: stretch; + } + .global-export-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .task-center-control { - grid-column: 1 / -1; + .task-center-card { + width: 100%; + justify-content: space-between; } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index d92ab1e..8c659b5 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -4884,6 +4884,7 @@ function ExportPage() { const isSnsCardStatsLoading = !hasSeededSnsStats const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length + const taskCenterAlertCount = taskRunningCount + taskQueuedCount const hasFilteredContacts = filteredContacts.length > 0 const sessionLoadDetailUpdatedAt = useMemo(() => { let latest = 0 @@ -5149,60 +5150,54 @@ function ExportPage() { return (
-
-
- 导出位置 -
-
- - + +
+
-
+ + { + setWriteLayout(value) + await configService.setExportWriteLayout(value) + }} + sessionNameWithTypePrefix={sessionNameWithTypePrefix} + onSessionNameWithTypePrefixChange={async (enabled) => { + setSessionNameWithTypePrefix(enabled) + await configService.setExportSessionNamePrefixEnabled(enabled) + }} + />
- { - setWriteLayout(value) - await configService.setExportWriteLayout(value) - }} - sessionNameWithTypePrefix={sessionNameWithTypePrefix} - onSessionNameWithTypePrefixChange={async (enabled) => { - setSessionNameWithTypePrefix(enabled) - await configService.setExportSessionNamePrefixEnabled(enabled) - }} - /> - -
- 任务中心 -
-
- 进行中 {taskRunningCount} - 排队 {taskQueuedCount} - 总计 {tasks.length} -
- -
-
+
From 64b96f00f7cf1ddb6e92d9a298bf224123aa5e60 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 13:02:27 +0800 Subject: [PATCH 51/97] fix(export): align task center card height --- src/pages/ExportPage.scss | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 8f34a07..70df2da 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -22,7 +22,7 @@ .export-top-bar { display: flex; - align-items: flex-start; + align-items: stretch; gap: 12px; } @@ -531,6 +531,7 @@ font-weight: 600; cursor: pointer; flex-shrink: 0; + align-self: stretch; transition: border-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease; &:hover { @@ -3305,6 +3306,7 @@ } .task-center-card { + align-self: auto; width: 100%; justify-content: space-between; } From c543fabdf4ca3327358088fed7eccbd858444cc2 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 13:06:32 +0800 Subject: [PATCH 52/97] fix(export): tighten top control bar --- src/pages/ExportPage.scss | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 70df2da..594fc44 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -22,7 +22,7 @@ .export-top-bar { display: flex; - align-items: stretch; + align-items: flex-start; gap: 12px; } @@ -267,22 +267,25 @@ border-radius: 12px; padding: 12px; display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: minmax(0, 1.7fr) minmax(260px, 1fr); gap: 10px; - align-items: stretch; + align-items: center; .control-label { font-size: 11px; color: var(--text-secondary); font-weight: 600; letter-spacing: 0.2px; + width: 78px; + flex: 0 0 78px; + line-height: 1.2; } .path-control { min-width: 0; display: flex; - flex-direction: column; - gap: 4px; + align-items: center; + gap: 8px; } .path-inline-row { @@ -347,8 +350,8 @@ .write-layout-control { position: relative; display: flex; - flex-direction: column; - gap: 4px; + align-items: center; + gap: 8px; min-width: 0; width: 100%; max-width: 100%; @@ -531,7 +534,6 @@ font-weight: 600; cursor: pointer; flex-shrink: 0; - align-self: stretch; transition: border-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease; &:hover { @@ -3280,6 +3282,7 @@ .global-export-controls { padding: 10px; gap: 8px; + grid-template-columns: minmax(0, 1.5fr) minmax(240px, 1fr); } .format-grid { @@ -3302,11 +3305,23 @@ } .global-export-controls { - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: 1fr; + align-items: stretch; + } + + .global-export-controls .path-control, + .global-export-controls .write-layout-control { + align-items: stretch; + flex-direction: column; + gap: 4px; + } + + .global-export-controls .control-label { + width: auto; + flex-basis: auto; } .task-center-card { - align-self: auto; width: 100%; justify-content: space-between; } From 60a64cd7775514bd96891fea6329e40e6d9d895b Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 13:09:26 +0800 Subject: [PATCH 53/97] fix(export): balance top card spacing --- src/pages/ExportPage.scss | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 594fc44..90bbe08 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -22,7 +22,7 @@ .export-top-bar { display: flex; - align-items: flex-start; + align-items: stretch; gap: 12px; } @@ -261,7 +261,8 @@ } .global-export-controls { - flex: 1; + flex: 0 1 820px; + width: min(820px, 100%); background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 12px; @@ -269,7 +270,7 @@ display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(260px, 1fr); gap: 10px; - align-items: center; + align-items: stretch; .control-label { font-size: 11px; @@ -534,6 +535,7 @@ font-weight: 600; cursor: pointer; flex-shrink: 0; + align-self: stretch; transition: border-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease; &:hover { @@ -3282,6 +3284,8 @@ .global-export-controls { padding: 10px; gap: 8px; + flex-basis: 760px; + width: min(760px, 100%); grid-template-columns: minmax(0, 1.5fr) minmax(240px, 1fr); } @@ -3305,6 +3309,8 @@ } .global-export-controls { + flex: 1 1 auto; + width: 100%; grid-template-columns: 1fr; align-items: stretch; } @@ -3322,6 +3328,7 @@ } .task-center-card { + align-self: auto; width: 100%; justify-content: space-between; } From 3fa0b364261a7bd5566b61bb1f36e8f97ed1411c Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 13:11:29 +0800 Subject: [PATCH 54/97] fix(export): pin task center card right --- src/pages/ExportPage.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 90bbe08..4f4234d 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -522,6 +522,7 @@ .task-center-card { min-width: 92px; min-height: 42px; + margin-left: auto; border: 1px solid var(--border-color); border-radius: 12px; background: var(--card-bg); From 90b33ef444c3caed902541b7b5317a3dd6e5f317 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 13:36:25 +0800 Subject: [PATCH 55/97] feat(export): add more defaults modal --- .../Export/ExportDefaultsSettingsForm.scss | 230 ++++++++++++ .../Export/ExportDefaultsSettingsForm.tsx | 332 ++++++++++++++++++ src/pages/ExportPage.scss | 98 +++++- src/pages/ExportPage.tsx | 107 +++++- src/pages/SettingsPage.tsx | 206 +---------- 5 files changed, 758 insertions(+), 215 deletions(-) create mode 100644 src/components/Export/ExportDefaultsSettingsForm.scss create mode 100644 src/components/Export/ExportDefaultsSettingsForm.tsx diff --git a/src/components/Export/ExportDefaultsSettingsForm.scss b/src/components/Export/ExportDefaultsSettingsForm.scss new file mode 100644 index 0000000..24f4621 --- /dev/null +++ b/src/components/Export/ExportDefaultsSettingsForm.scss @@ -0,0 +1,230 @@ +.export-defaults-settings-form { + .form-group { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + } + + label { + display: block; + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 2px; + } + + .form-hint { + display: block; + font-size: 12px; + color: var(--text-tertiary); + margin-bottom: 8px; + } + + .select-field { + position: relative; + margin-bottom: 10px; + } + + .select-trigger { + width: 100%; + padding: 10px 16px; + border: 1px solid var(--border-color); + border-radius: 9999px; + font-size: 14px; + background: var(--bg-primary); + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--text-tertiary); + } + + &.open { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); + } + } + + .select-value { + flex: 1; + min-width: 0; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .select-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary)); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 6px; + box-shadow: var(--shadow-md); + z-index: 120; + max-height: 320px; + overflow-y: auto; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + } + + .select-option { + width: 100%; + text-align: left; + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border: none; + border-radius: 10px; + background: transparent; + cursor: pointer; + transition: all 0.15s; + color: var(--text-primary); + font-size: 14px; + + &:hover { + background: var(--bg-tertiary); + } + + &.active { + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + } + } + + .option-label { + font-weight: 500; + } + + .option-desc { + font-size: 12px; + color: var(--text-tertiary); + } + + .select-option.active .option-desc { + color: var(--primary); + } + + .settings-time-range-field { + margin-bottom: 10px; + } + + .settings-time-range-trigger { + width: 100%; + padding: 10px 16px; + border: 1px solid var(--border-color); + border-radius: 9999px; + font-size: 14px; + background: var(--bg-primary); + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: rgba(var(--primary-rgb), 0.45); + color: var(--primary); + } + + &.open { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); + } + } + + .settings-time-range-value { + flex: 1; + min-width: 0; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .settings-time-range-arrow { + color: var(--text-tertiary); + font-weight: 700; + line-height: 1; + } + + .log-toggle-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; + padding: 10px 14px; + border: 1px solid var(--border-color); + border-radius: 14px; + background: var(--bg-primary); + } + + .log-status { + font-size: 13px; + color: var(--text-secondary); + } + + .switch { + position: relative; + display: inline-flex; + width: 48px; + height: 28px; + cursor: pointer; + flex-shrink: 0; + } + + .switch-input { + opacity: 0; + width: 0; + height: 0; + position: absolute; + + &:checked + .switch-slider { + background: var(--primary); + } + + &:checked + .switch-slider::before { + transform: translateX(20px); + } + + &:focus + .switch-slider { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); + } + } + + .switch-slider { + position: absolute; + inset: 0; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 999px; + transition: all 0.2s ease; + + &::before { + content: ''; + position: absolute; + width: 20px; + height: 20px; + left: 3px; + top: 3px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); + transition: transform 0.2s ease; + } + } +} diff --git a/src/components/Export/ExportDefaultsSettingsForm.tsx b/src/components/Export/ExportDefaultsSettingsForm.tsx new file mode 100644 index 0000000..b164ad5 --- /dev/null +++ b/src/components/Export/ExportDefaultsSettingsForm.tsx @@ -0,0 +1,332 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { ChevronDown } from 'lucide-react' +import * as configService from '../../services/config' +import { ExportDateRangeDialog } from './ExportDateRangeDialog' +import { + createDefaultExportDateRangeSelection, + getExportDateRangeLabel, + resolveExportDateRangeConfig, + serializeExportDateRangeConfig, + type ExportDateRangeSelection +} from '../../utils/exportDateRange' +import './ExportDefaultsSettingsForm.scss' + +export interface ExportDefaultsSettingsPatch { + format?: string + dateRange?: ExportDateRangeSelection + media?: boolean + voiceAsText?: boolean + excelCompactColumns?: boolean + concurrency?: number +} + +interface ExportDefaultsSettingsFormProps { + onNotify?: (text: string, success: boolean) => void + onDefaultsChanged?: (patch: ExportDefaultsSettingsPatch) => void +} + +const exportFormatOptions = [ + { value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' }, + { value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' }, + { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' }, + { value: 'txt', label: 'TXT', desc: '纯文本,通用格式' }, + { value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' }, + { value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' }, + { value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' }, + { value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' }, + { value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' } +] as const + +const exportExcelColumnOptions = [ + { value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' }, + { value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' } +] as const + +const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const + +const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => { + return options.find((option) => option.value === value)?.label ?? value +} + +export function ExportDefaultsSettingsForm({ + onNotify, + onDefaultsChanged +}: ExportDefaultsSettingsFormProps) { + const [showExportFormatSelect, setShowExportFormatSelect] = useState(false) + const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) + const [showExportConcurrencySelect, setShowExportConcurrencySelect] = useState(false) + const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false) + const exportFormatDropdownRef = useRef(null) + const exportExcelColumnsDropdownRef = useRef(null) + const exportConcurrencyDropdownRef = useRef(null) + + const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') + const [exportDefaultDateRange, setExportDefaultDateRange] = useState(() => createDefaultExportDateRangeSelection()) + const [exportDefaultMedia, setExportDefaultMedia] = useState(false) + const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) + const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) + const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2) + + useEffect(() => { + let cancelled = false + void (async () => { + const [savedFormat, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([ + configService.getExportDefaultFormat(), + configService.getExportDefaultDateRange(), + configService.getExportDefaultMedia(), + configService.getExportDefaultVoiceAsText(), + configService.getExportDefaultExcelCompactColumns(), + configService.getExportDefaultConcurrency() + ]) + + if (cancelled) return + + setExportDefaultFormat(savedFormat || 'excel') + setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange)) + setExportDefaultMedia(savedMedia ?? false) + setExportDefaultVoiceAsText(savedVoiceAsText ?? false) + setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) + setExportDefaultConcurrency(savedConcurrency ?? 2) + })() + + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node + if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) { + setShowExportFormatSelect(false) + } + if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) { + setShowExportExcelColumnsSelect(false) + } + if (showExportConcurrencySelect && exportConcurrencyDropdownRef.current && !exportConcurrencyDropdownRef.current.contains(target)) { + setShowExportConcurrencySelect(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [showExportFormatSelect, showExportExcelColumnsSelect, showExportConcurrencySelect]) + + const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full' + const exportFormatLabel = useMemo(() => getOptionLabel(exportFormatOptions, exportDefaultFormat), [exportDefaultFormat]) + const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange]) + const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue]) + const exportConcurrencyLabel = String(exportDefaultConcurrency) + + const notify = (text: string, success = true) => { + onNotify?.(text, success) + } + + return ( +
+
+ + 导出页面默认选中的格式 +
+ + {showExportFormatSelect && ( +
+ {exportFormatOptions.map((option) => ( + + ))} +
+ )} +
+
+ +
+ + 控制导出页面的默认时间选择 +
+ +
+
+ + setIsExportDateRangeDialogOpen(false)} + onConfirm={async (nextSelection) => { + setExportDefaultDateRange(nextSelection) + await configService.setExportDefaultDateRange(serializeExportDateRangeConfig(nextSelection)) + onDefaultsChanged?.({ dateRange: nextSelection }) + notify('已更新默认导出时间范围', true) + setIsExportDateRangeDialogOpen(false) + }} + /> + +
+ + 控制图片/语音/表情的默认导出开关 +
+ {exportDefaultMedia ? '已开启' : '已关闭'} + +
+
+ +
+ + 导出时默认将语音转写为文字 +
+ {exportDefaultVoiceAsText ? '已开启' : '已关闭'} + +
+
+ +
+ + 控制 Excel 导出的列字段 +
+ + {showExportExcelColumnsSelect && ( +
+ {exportExcelColumnOptions.map((option) => ( + + ))} +
+ )} +
+
+ +
+ + 导出多个会话时的最大并发(1~6) +
+ + {showExportConcurrencySelect && ( +
+ {exportConcurrencyOptions.map((option) => ( + + ))} +
+ )} +
+
+
+ ) +} diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 4f4234d..81fd177 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -261,14 +261,14 @@ } .global-export-controls { - flex: 0 1 820px; - width: min(820px, 100%); + flex: 0 1 980px; + width: min(980px, 100%); background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 12px; padding: 12px; display: grid; - grid-template-columns: minmax(0, 1.7fr) minmax(260px, 1fr); + grid-template-columns: minmax(0, 1.55fr) minmax(240px, 1fr) auto; gap: 10px; align-items: stretch; @@ -359,6 +359,32 @@ z-index: 40; } + .more-export-settings-control { + display: flex; + align-items: center; + justify-content: flex-end; + } + + .more-export-settings-btn { + min-height: 38px; + border-radius: 10px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + padding: 0 14px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease; + + &:hover { + border-color: var(--primary); + color: var(--primary); + background: color-mix(in srgb, var(--primary) 6%, var(--bg-secondary)); + } + } + .layout-trigger { width: 100%; padding: 8px 10px; @@ -551,6 +577,62 @@ } } +.export-defaults-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.42); + display: flex; + align-items: center; + justify-content: center; + z-index: 2300; + padding: 20px; +} + +.export-defaults-modal { + width: min(720px, 100%); + max-height: min(80vh, 860px); + overflow: hidden; + border-radius: 14px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28); + display: flex; + flex-direction: column; +} + +.export-defaults-modal-header { + padding: 14px 16px 10px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + + h3 { + margin: 0; + font-size: 15px; + color: var(--text-primary); + } + + p { + margin: 4px 0 0; + font-size: 12px; + color: var(--text-tertiary); + } +} + +.export-defaults-modal-body { + padding: 16px; + overflow: auto; +} + +.export-defaults-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 0 16px 16px; +} + .task-center-card-label { line-height: 1; white-space: nowrap; @@ -3285,9 +3367,9 @@ .global-export-controls { padding: 10px; gap: 8px; - flex-basis: 760px; - width: min(760px, 100%); - grid-template-columns: minmax(0, 1.5fr) minmax(240px, 1fr); + flex-basis: 920px; + width: min(920px, 100%); + grid-template-columns: minmax(0, 1.35fr) minmax(220px, 1fr) auto; } .format-grid { @@ -3333,6 +3415,10 @@ width: 100%; justify-content: space-between; } + + .export-defaults-modal { + width: min(92vw, 720px); + } } @media (max-width: 720px) { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 8c659b5..65c03df 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -41,6 +41,7 @@ import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' import { SnsPostItem } from '../components/Sns/SnsPostItem' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' +import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '../components/Export/ExportDefaultsSettingsForm' import type { SnsPost } from '../types/sns' import { cloneExportDateRange, @@ -1282,8 +1283,14 @@ function ExportPage() { const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false) const [snsExportVideos, setSnsExportVideos] = useState(false) const [isTimeRangeDialogOpen, setIsTimeRangeDialogOpen] = useState(false) + const [isExportDefaultsModalOpen, setIsExportDefaultsModalOpen] = useState(false) const [timeRangeSelection, setTimeRangeSelection] = useState(() => createDefaultExportDateRangeSelection()) + const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') const [exportDefaultDateRangeSelection, setExportDefaultDateRangeSelection] = useState(() => createDefaultExportDateRangeSelection()) + const [exportDefaultMedia, setExportDefaultMedia] = useState(false) + const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) + const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) + const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2) const [options, setOptions] = useState({ format: 'json', @@ -1785,8 +1792,9 @@ function ExportPage() { setIsBaseConfigLoading(true) let isReady = true try { - const [savedPath, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([ + const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([ configService.getExportPath(), + configService.getExportDefaultFormat(), configService.getExportDefaultMedia(), configService.getExportDefaultVoiceAsText(), configService.getExportDefaultExcelCompactColumns(), @@ -1817,10 +1825,14 @@ function ExportPage() { setLastExportByContent(savedContentMap) setExportRecordsBySession(savedSessionRecordMap) setLastSnsExportPostCount(savedSnsPostCount) + setExportDefaultFormat((savedFormat as TextExportFormat) || 'excel') + setExportDefaultMedia(savedMedia ?? false) + setExportDefaultVoiceAsText(savedVoiceAsText ?? false) + setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) + setExportDefaultConcurrency(savedConcurrency ?? 2) const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange) setExportDefaultDateRangeSelection(resolvedDefaultDateRange) setTimeRangeSelection(resolvedDefaultDateRange) - await configService.setExportDefaultFormat('json') if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) { setSnsStats({ @@ -1835,7 +1847,7 @@ function ExportPage() { const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns setOptions(prev => ({ ...prev, - format: 'json', + format: ((savedFormat as TextExportFormat) || 'excel'), exportMedia: savedMedia ?? prev.exportMedia, exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText, excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns, @@ -3192,8 +3204,13 @@ function ExportPage() { const next: ExportOptions = { ...prev, + format: exportDefaultFormat, useAllTime: exportDefaultDateRangeSelection.useAllTime, - dateRange: nextDateRange + dateRange: nextDateRange, + exportMedia: exportDefaultMedia, + exportVoiceAsText: exportDefaultVoiceAsText, + excelCompactColumns: exportDefaultExcelCompactColumns, + exportConcurrency: exportDefaultConcurrency } if (payload.scope === 'sns') { @@ -3220,7 +3237,14 @@ function ExportPage() { return next }) - }, [exportDefaultDateRangeSelection]) + }, [ + exportDefaultDateRangeSelection, + exportDefaultExcelCompactColumns, + exportDefaultFormat, + exportDefaultMedia, + exportDefaultVoiceAsText, + exportDefaultConcurrency + ]) const closeExportDialog = useCallback(() => { setExportDialog(prev => ({ ...prev, open: false })) @@ -5147,6 +5171,27 @@ function ExportPage() { } }, []) + const handleExportDefaultsChanged = useCallback((patch: ExportDefaultsSettingsPatch) => { + if (patch.format) { + setExportDefaultFormat(patch.format as TextExportFormat) + } + if (patch.dateRange) { + setExportDefaultDateRangeSelection(patch.dateRange) + } + if (typeof patch.media === 'boolean') { + setExportDefaultMedia(patch.media) + } + if (typeof patch.voiceAsText === 'boolean') { + setExportDefaultVoiceAsText(patch.voiceAsText) + } + if (typeof patch.excelCompactColumns === 'boolean') { + setExportDefaultExcelCompactColumns(patch.excelCompactColumns) + } + if (typeof patch.concurrency === 'number') { + setExportDefaultConcurrency(patch.concurrency) + } + }, []) + return (
@@ -5186,6 +5231,16 @@ function ExportPage() { await configService.setExportSessionNamePrefixEnabled(enabled) }} /> + +
+ +
+
+
+ +
+
+ +
+
+
+ )} +

按类型批量导出

{ - const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full' - const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat) - const exportDateRangeLabel = getExportDateRangeLabel(exportDefaultDateRange) - const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue) - const exportConcurrencyLabel = String(exportDefaultConcurrency) - return (
-
- - 导出页面默认选中的格式 -
- - {showExportFormatSelect && ( -
- {exportFormatOptions.map((option) => ( - - ))} -
- )} -
-
- -
- - 控制导出页面的默认时间选择 -
- -
-
- - setIsExportDateRangeDialogOpen(false)} - onConfirm={async (nextSelection) => { - setExportDefaultDateRange(nextSelection) - await configService.setExportDefaultDateRange(serializeExportDateRangeConfig(nextSelection)) - showMessage('已更新默认导出时间范围', true) - setIsExportDateRangeDialogOpen(false) - }} - /> - -
- - 控制图片/语音/表情的默认导出开关 -
- {exportDefaultMedia ? '已开启' : '已关闭'} - -
-
- -
- - 导出时默认将语音转写为文字 -
- {exportDefaultVoiceAsText ? '已开启' : '已关闭'} - -
-
- -
- - 控制 Excel 导出的列字段 -
- - {showExportExcelColumnsSelect && ( -
- {exportExcelColumnOptions.map((option) => ( - - ))} -
- )} -
-
- -
- - 导出多个会话时的最大并发(1~6) -
- - {showExportConcurrencySelect && ( -
- {exportConcurrencyOptions.map((option) => ( - - ))} -
- )} -
-
- +
) } From 8779bbc532d50479ed1a0f065f4bdd2299d4f52a Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 13:39:52 +0800 Subject: [PATCH 56/97] refactor(settings): remove export tab --- src/pages/SettingsPage.tsx | 94 +------------------------------------- 1 file changed, 2 insertions(+), 92 deletions(-) diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 78e70d5..8afa61b 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -9,29 +9,19 @@ import * as configService from '../services/config' import { Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor, - Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic, + Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic, ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2 } from 'lucide-react' import { Avatar } from '../components/Avatar' -import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' -import { ExportDefaultsSettingsForm } from '../components/Export/ExportDefaultsSettingsForm' -import { - createDefaultExportDateRangeSelection, - getExportDateRangeLabel, - resolveExportDateRangeConfig, - serializeExportDateRangeConfig, - type ExportDateRangeSelection -} from '../utils/exportDateRange' import './SettingsPage.scss' -type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'export' | 'cache' | 'api' | 'security' | 'about' | 'analytics' +type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'security' | 'about' | 'analytics' const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'appearance', label: '外观', icon: Palette }, { id: 'notification', label: '通知', icon: Bell }, { id: 'database', label: '数据库连接', icon: Database }, { id: 'models', label: '模型管理', icon: Mic }, - { id: 'export', label: '导出', icon: Download }, { id: 'cache', label: '缓存', icon: HardDrive }, { id: 'api', label: 'API 服务', icon: Globe }, @@ -84,12 +74,6 @@ function SettingsPage() { const [wxid, setWxid] = useState('') const [wxidOptions, setWxidOptions] = useState([]) const [showWxidSelect, setShowWxidSelect] = useState(false) - const [showExportFormatSelect, setShowExportFormatSelect] = useState(false) - const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) - const [showExportConcurrencySelect, setShowExportConcurrencySelect] = useState(false) - const exportFormatDropdownRef = useRef(null) - const exportExcelColumnsDropdownRef = useRef(null) - const exportConcurrencyDropdownRef = useRef(null) const [cachePath, setCachePath] = useState('') const [imageKeyProgress, setImageKeyProgress] = useState(0) const [imageKeyPercent, setImageKeyPercent] = useState(null) @@ -112,13 +96,6 @@ function SettingsPage() { const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false) const [transcribeLanguages, setTranscribeLanguages] = useState(['zh']) - const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') - const [exportDefaultDateRange, setExportDefaultDateRange] = useState(() => createDefaultExportDateRangeSelection()) - const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false) - const [exportDefaultMedia, setExportDefaultMedia] = useState(false) - const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) - const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) - const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2) const [notificationEnabled, setNotificationEnabled] = useState(true) const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right') @@ -218,24 +195,6 @@ function SettingsPage() { setActiveTab(initialTab) }, [location.state]) - // 点击外部关闭下拉框 - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - const target = e.target as Node - if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) { - setShowExportFormatSelect(false) - } - if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) { - setShowExportExcelColumnsSelect(false) - } - if (showExportConcurrencySelect && exportConcurrencyDropdownRef.current && !exportConcurrencyDropdownRef.current.contains(target)) { - setShowExportConcurrencySelect(false) - } - } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showExportFormatSelect, showExportExcelColumnsSelect, showExportConcurrencySelect]) - useEffect(() => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => { setDbKeyStatus(payload.message) @@ -302,13 +261,6 @@ function SettingsPage() { const savedWhisperModelDir = await configService.getWhisperModelDir() const savedAutoTranscribe = await configService.getAutoTranscribeVoice() const savedTranscribeLanguages = await configService.getTranscribeLanguages() - const savedExportDefaultFormat = await configService.getExportDefaultFormat() - const savedExportDefaultDateRange = await configService.getExportDefaultDateRange() - const savedExportDefaultMedia = await configService.getExportDefaultMedia() - const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText() - const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns() - const savedExportDefaultConcurrency = await configService.getExportDefaultConcurrency() - const savedNotificationEnabled = await configService.getNotificationEnabled() const savedNotificationPosition = await configService.getNotificationPosition() const savedNotificationFilterMode = await configService.getNotificationFilterMode() @@ -343,12 +295,6 @@ function SettingsPage() { setLogEnabled(savedLogEnabled) setAutoTranscribeVoice(savedAutoTranscribe) setTranscribeLanguages(savedTranscribeLanguages) - setExportDefaultFormat(savedExportDefaultFormat || 'excel') - setExportDefaultDateRange(resolveExportDateRangeConfig(savedExportDefaultDateRange)) - setExportDefaultMedia(savedExportDefaultMedia ?? false) - setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? false) - setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true) - setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2) setNotificationEnabled(savedNotificationEnabled) setNotificationPosition(savedNotificationPosition) @@ -1560,41 +1506,6 @@ function SettingsPage() {
) - const exportFormatOptions = [ - { value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' }, - { value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' }, - { value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' }, - { value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' }, - { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' }, - { value: 'txt', label: 'TXT', desc: '纯文本,通用格式' }, - { value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' }, - { value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' } - ] - const exportExcelColumnOptions = [ - { value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' }, - { value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' } - ] - - const exportConcurrencyOptions = [ - { value: 1, label: '1' }, - { value: 2, label: '2' }, - { value: 3, label: '3' }, - { value: 4, label: '4' }, - { value: 5, label: '5' }, - { value: 6, label: '6' } - ] - - const getOptionLabel = (options: { value: string; label: string }[], value: string) => { - return options.find((option) => option.value === value)?.label ?? value - } - - const renderExportTab = () => { - return ( -
- -
- ) - } const renderCacheTab = () => (

管理应用缓存数据

@@ -2191,7 +2102,6 @@ function SettingsPage() { {activeTab === 'notification' && renderNotificationTab()} {activeTab === 'database' && renderDatabaseTab()} {activeTab === 'models' && renderModelsTab()} - {activeTab === 'export' && renderExportTab()} {activeTab === 'cache' && renderCacheTab()} {activeTab === 'api' && renderApiTab()} {activeTab === 'analytics' && renderAnalyticsTab()} From 51a3ee4a9b15bdba6de4bd6dc18ef873db4f9c96 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 13:42:56 +0800 Subject: [PATCH 57/97] feat(export): split defaults modal layout --- .../Export/ExportDefaultsSettingsForm.scss | 68 ++++ .../Export/ExportDefaultsSettingsForm.tsx | 376 ++++++++++-------- src/pages/ExportPage.tsx | 2 +- 3 files changed, 270 insertions(+), 176 deletions(-) diff --git a/src/components/Export/ExportDefaultsSettingsForm.scss b/src/components/Export/ExportDefaultsSettingsForm.scss index 24f4621..ad9e77e 100644 --- a/src/components/Export/ExportDefaultsSettingsForm.scss +++ b/src/components/Export/ExportDefaultsSettingsForm.scss @@ -227,4 +227,72 @@ transition: transform 0.2s ease; } } + + &.layout-split { + .form-group { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); + gap: 18px; + align-items: center; + padding: 14px 0; + margin-bottom: 0; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent); + } + + .form-group:last-child { + border-bottom: none; + padding-bottom: 0; + } + + .form-group:first-child { + padding-top: 0; + } + + .form-copy { + min-width: 0; + } + + .form-control { + min-width: 0; + display: flex; + justify-content: flex-end; + } + + .form-hint { + margin-bottom: 0; + line-height: 1.5; + } + + .select-field, + .settings-time-range-field { + width: 100%; + max-width: 360px; + margin-bottom: 0; + } + + .log-toggle-line { + width: 100%; + max-width: 360px; + margin-bottom: 0; + } + } +} + +@media (max-width: 760px) { + .export-defaults-settings-form.layout-split { + .form-group { + grid-template-columns: 1fr; + gap: 10px; + } + + .form-control { + justify-content: flex-start; + } + + .select-field, + .settings-time-range-field, + .log-toggle-line { + max-width: none; + } + } } diff --git a/src/components/Export/ExportDefaultsSettingsForm.tsx b/src/components/Export/ExportDefaultsSettingsForm.tsx index b164ad5..9962be3 100644 --- a/src/components/Export/ExportDefaultsSettingsForm.tsx +++ b/src/components/Export/ExportDefaultsSettingsForm.tsx @@ -23,6 +23,7 @@ export interface ExportDefaultsSettingsPatch { interface ExportDefaultsSettingsFormProps { onNotify?: (text: string, success: boolean) => void onDefaultsChanged?: (patch: ExportDefaultsSettingsPatch) => void + layout?: 'stacked' | 'split' } const exportFormatOptions = [ @@ -50,7 +51,8 @@ const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }> export function ExportDefaultsSettingsForm({ onNotify, - onDefaultsChanged + onDefaultsChanged, + layout = 'stacked' }: ExportDefaultsSettingsFormProps) { const [showExportFormatSelect, setShowExportFormatSelect] = useState(false) const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) @@ -123,65 +125,73 @@ export function ExportDefaultsSettingsForm({ } return ( -
+
- - 导出页面默认选中的格式 -
- - {showExportFormatSelect && ( -
- {exportFormatOptions.map((option) => ( - - ))} -
- )} +
+ + 导出页面默认选中的格式 +
+
+
+ + {showExportFormatSelect && ( +
+ {exportFormatOptions.map((option) => ( + + ))} +
+ )} +
- - 控制导出页面的默认时间选择 -
- +
+ + 控制导出页面的默认时间选择 +
+
+
+ +
@@ -199,132 +209,148 @@ export function ExportDefaultsSettingsForm({ />
- - 控制图片/语音/表情的默认导出开关 -
- {exportDefaultMedia ? '已开启' : '已关闭'} -
+ +
+
+ + 导出时默认将语音转写为文字 +
+
+
+ {exportDefaultVoiceAsText ? '已开启' : '已关闭'} + +
+
+
+ +
+
+ + 控制 Excel 导出的列字段 +
+
+
+ + {showExportExcelColumnsSelect && ( +
+ {exportExcelColumnOptions.map((option) => ( + + ))} +
+ )} +
- - 导出时默认将语音转写为文字 -
- {exportDefaultVoiceAsText ? '已开启' : '已关闭'} -
diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 65c03df..742c90a 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -5294,7 +5294,7 @@ function ExportPage() {
- +
+ {showExportConcurrencySelect && ( +
+ {exportConcurrencyOptions.map((option) => ( + + ))} +
+ )} +
+
+
+
@@ -310,49 +354,6 @@ export function ExportDefaultsSettingsForm({
-
-
- - 导出多个会话时的最大并发(1~6) -
-
-
- - {showExportConcurrencySelect && ( -
- {exportConcurrencyOptions.map((option) => ( - - ))} -
- )} -
-
-
) } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 742c90a..32baae5 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -5282,7 +5282,6 @@ function ExportPage() {

更多导出设置

-

这里的配置与设置页中的导出设置保持一致,并会立即生效。

-
-
- - 导出时默认将语音转写为文字 -
-
-
- {exportDefaultVoiceAsText ? '已开启' : '已关闭'} - -
-
-
-
@@ -354,6 +326,34 @@ export function ExportDefaultsSettingsForm({
+
+
+ + 导出时默认将语音转写为文字 +
+
+
+ {exportDefaultVoiceAsText ? '已开启' : '已关闭'} + +
+
+
+
) } From 56d7ad6999168d5ddbd6e4c1fff5827c86bf95b7 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 13:57:22 +0800 Subject: [PATCH 60/97] feat(export): expand defaults format options --- .../Export/ExportDefaultsSettingsForm.scss | 55 +++++++++++++++- .../Export/ExportDefaultsSettingsForm.tsx | 65 ++++++------------- 2 files changed, 73 insertions(+), 47 deletions(-) diff --git a/src/components/Export/ExportDefaultsSettingsForm.scss b/src/components/Export/ExportDefaultsSettingsForm.scss index ad9e77e..4dd6575 100644 --- a/src/components/Export/ExportDefaultsSettingsForm.scss +++ b/src/components/Export/ExportDefaultsSettingsForm.scss @@ -112,6 +112,53 @@ color: var(--text-tertiary); } + .format-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(156px, 1fr)); + gap: 6px; + width: 100%; + margin-bottom: 10px; + } + + .format-card { + width: 100%; + min-height: 0; + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 8px 10px; + text-align: left; + background: var(--bg-primary); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + transition: border-color 0.2s ease, background 0.2s ease; + + &:hover { + border-color: var(--text-tertiary); + } + + &.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + } + } + + .format-label { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.35; + } + + .format-desc { + margin-top: 1px; + font-size: 11px; + color: var(--text-tertiary); + line-height: 1.35; + } + .select-option.active .option-desc { color: var(--primary); } @@ -275,6 +322,11 @@ max-width: 360px; margin-bottom: 0; } + + .format-grid { + max-width: 360px; + margin-bottom: 0; + } } } @@ -291,7 +343,8 @@ .select-field, .settings-time-range-field, - .log-toggle-line { + .log-toggle-line, + .format-grid { max-width: none; } } diff --git a/src/components/Export/ExportDefaultsSettingsForm.tsx b/src/components/Export/ExportDefaultsSettingsForm.tsx index e8ecfcd..2a13304 100644 --- a/src/components/Export/ExportDefaultsSettingsForm.tsx +++ b/src/components/Export/ExportDefaultsSettingsForm.tsx @@ -54,11 +54,9 @@ export function ExportDefaultsSettingsForm({ onDefaultsChanged, layout = 'stacked' }: ExportDefaultsSettingsFormProps) { - const [showExportFormatSelect, setShowExportFormatSelect] = useState(false) const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) const [showExportConcurrencySelect, setShowExportConcurrencySelect] = useState(false) const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false) - const exportFormatDropdownRef = useRef(null) const exportExcelColumnsDropdownRef = useRef(null) const exportConcurrencyDropdownRef = useRef(null) @@ -99,9 +97,6 @@ export function ExportDefaultsSettingsForm({ useEffect(() => { const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node - if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) { - setShowExportFormatSelect(false) - } if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) { setShowExportExcelColumnsSelect(false) } @@ -112,10 +107,9 @@ export function ExportDefaultsSettingsForm({ document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showExportFormatSelect, showExportExcelColumnsSelect, showExportConcurrencySelect]) + }, [showExportExcelColumnsSelect, showExportConcurrencySelect]) const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full' - const exportFormatLabel = useMemo(() => getOptionLabel(exportFormatOptions, exportDefaultFormat), [exportDefaultFormat]) const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange]) const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue]) const exportConcurrencyLabel = String(exportDefaultConcurrency) @@ -138,7 +132,6 @@ export function ExportDefaultsSettingsForm({ className={`select-trigger ${showExportConcurrencySelect ? 'open' : ''}`} onClick={() => { setShowExportConcurrencySelect(!showExportConcurrencySelect) - setShowExportFormatSelect(false) setIsExportDateRangeDialogOpen(false) setShowExportExcelColumnsSelect(false) }} @@ -172,45 +165,27 @@ export function ExportDefaultsSettingsForm({
- + 导出页面默认选中的格式
-
- - {showExportFormatSelect && ( -
- {exportFormatOptions.map((option) => ( - - ))} -
- )} +
+ {exportFormatOptions.map((option) => ( + + ))}
@@ -226,7 +201,6 @@ export function ExportDefaultsSettingsForm({ type="button" className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`} onClick={() => { - setShowExportFormatSelect(false) setShowExportExcelColumnsSelect(false) setShowExportConcurrencySelect(false) setIsExportDateRangeDialogOpen(true) @@ -292,7 +266,6 @@ export function ExportDefaultsSettingsForm({ className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`} onClick={() => { setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect) - setShowExportFormatSelect(false) setIsExportDateRangeDialogOpen(false) setShowExportConcurrencySelect(false) }} From d2ec9c680d27b1f729c432e2146277df163daa37 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 13:59:17 +0800 Subject: [PATCH 61/97] feat(export): simplify concurrency selector --- .../Export/ExportDefaultsSettingsForm.scss | 37 ++++++++++++ .../Export/ExportDefaultsSettingsForm.tsx | 60 ++++++------------- 2 files changed, 55 insertions(+), 42 deletions(-) diff --git a/src/components/Export/ExportDefaultsSettingsForm.scss b/src/components/Export/ExportDefaultsSettingsForm.scss index 4dd6575..ccfa2bd 100644 --- a/src/components/Export/ExportDefaultsSettingsForm.scss +++ b/src/components/Export/ExportDefaultsSettingsForm.scss @@ -225,6 +225,37 @@ color: var(--text-secondary); } + .concurrency-inline-options { + width: 100%; + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 6px; + margin-bottom: 10px; + } + + .concurrency-option { + border: 1px solid var(--border-color); + border-radius: 10px; + min-height: 38px; + padding: 0; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease; + + &:hover { + border-color: var(--text-tertiary); + } + + &.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + color: var(--primary); + } + } + .switch { position: relative; display: inline-flex; @@ -323,6 +354,11 @@ margin-bottom: 0; } + .concurrency-inline-options { + max-width: 360px; + margin-bottom: 0; + } + .format-grid { max-width: 360px; margin-bottom: 0; @@ -344,6 +380,7 @@ .select-field, .settings-time-range-field, .log-toggle-line, + .concurrency-inline-options, .format-grid { max-width: none; } diff --git a/src/components/Export/ExportDefaultsSettingsForm.tsx b/src/components/Export/ExportDefaultsSettingsForm.tsx index 2a13304..437a4fe 100644 --- a/src/components/Export/ExportDefaultsSettingsForm.tsx +++ b/src/components/Export/ExportDefaultsSettingsForm.tsx @@ -55,10 +55,8 @@ export function ExportDefaultsSettingsForm({ layout = 'stacked' }: ExportDefaultsSettingsFormProps) { const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) - const [showExportConcurrencySelect, setShowExportConcurrencySelect] = useState(false) const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false) const exportExcelColumnsDropdownRef = useRef(null) - const exportConcurrencyDropdownRef = useRef(null) const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') const [exportDefaultDateRange, setExportDefaultDateRange] = useState(() => createDefaultExportDateRangeSelection()) @@ -100,19 +98,15 @@ export function ExportDefaultsSettingsForm({ if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) { setShowExportExcelColumnsSelect(false) } - if (showExportConcurrencySelect && exportConcurrencyDropdownRef.current && !exportConcurrencyDropdownRef.current.contains(target)) { - setShowExportConcurrencySelect(false) - } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showExportExcelColumnsSelect, showExportConcurrencySelect]) + }, [showExportExcelColumnsSelect]) const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full' const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange]) const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue]) - const exportConcurrencyLabel = String(exportDefaultConcurrency) const notify = (text: string, success = true) => { onNotify?.(text, success) @@ -126,39 +120,23 @@ export function ExportDefaultsSettingsForm({ 导出多个会话时的最大并发(1~6)
-
- - {showExportConcurrencySelect && ( -
- {exportConcurrencyOptions.map((option) => ( - - ))} -
- )} +
+ {exportConcurrencyOptions.map((option) => ( + + ))}
@@ -202,7 +180,6 @@ export function ExportDefaultsSettingsForm({ className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`} onClick={() => { setShowExportExcelColumnsSelect(false) - setShowExportConcurrencySelect(false) setIsExportDateRangeDialogOpen(true) }} > @@ -267,7 +244,6 @@ export function ExportDefaultsSettingsForm({ onClick={() => { setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect) setIsExportDateRangeDialogOpen(false) - setShowExportConcurrencySelect(false) }} > {exportExcelColumnsLabel} From 450e5f7e6121f77de3292ab4e1f78ff70fb7b5f5 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 14:11:02 +0800 Subject: [PATCH 62/97] feat(export): centralize avatar export default --- .../Export/ExportDefaultsSettingsForm.tsx | 34 ++++++++++++++++++- src/pages/ExportPage.tsx | 28 +++++++++++---- src/services/config.ts | 13 +++++++ 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/components/Export/ExportDefaultsSettingsForm.tsx b/src/components/Export/ExportDefaultsSettingsForm.tsx index 437a4fe..681a3ee 100644 --- a/src/components/Export/ExportDefaultsSettingsForm.tsx +++ b/src/components/Export/ExportDefaultsSettingsForm.tsx @@ -13,6 +13,7 @@ import './ExportDefaultsSettingsForm.scss' export interface ExportDefaultsSettingsPatch { format?: string + avatars?: boolean dateRange?: ExportDateRangeSelection media?: boolean voiceAsText?: boolean @@ -59,6 +60,7 @@ export function ExportDefaultsSettingsForm({ const exportExcelColumnsDropdownRef = useRef(null) const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') + const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true) const [exportDefaultDateRange, setExportDefaultDateRange] = useState(() => createDefaultExportDateRangeSelection()) const [exportDefaultMedia, setExportDefaultMedia] = useState(false) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) @@ -68,8 +70,9 @@ export function ExportDefaultsSettingsForm({ useEffect(() => { let cancelled = false void (async () => { - const [savedFormat, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([ + const [savedFormat, savedAvatars, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([ configService.getExportDefaultFormat(), + configService.getExportDefaultAvatars(), configService.getExportDefaultDateRange(), configService.getExportDefaultMedia(), configService.getExportDefaultVoiceAsText(), @@ -80,6 +83,7 @@ export function ExportDefaultsSettingsForm({ if (cancelled) return setExportDefaultFormat(savedFormat || 'excel') + setExportDefaultAvatars(savedAvatars ?? true) setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange)) setExportDefaultMedia(savedMedia ?? false) setExportDefaultVoiceAsText(savedVoiceAsText ?? false) @@ -168,6 +172,34 @@ export function ExportDefaultsSettingsForm({
+
+
+ + 开启后导出的聊天消息对应的文件中会带头像信息。 +
+
+
+ {exportDefaultAvatars ? '已开启' : '已关闭'} + +
+
+
+
diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 32baae5..b329a0e 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1286,6 +1286,7 @@ function ExportPage() { const [isExportDefaultsModalOpen, setIsExportDefaultsModalOpen] = useState(false) const [timeRangeSelection, setTimeRangeSelection] = useState(() => createDefaultExportDateRangeSelection()) const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') + const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true) const [exportDefaultDateRangeSelection, setExportDefaultDateRangeSelection] = useState(() => createDefaultExportDateRangeSelection()) const [exportDefaultMedia, setExportDefaultMedia] = useState(false) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) @@ -1792,9 +1793,10 @@ function ExportPage() { setIsBaseConfigLoading(true) let isReady = true try { - const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([ + const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([ configService.getExportPath(), configService.getExportDefaultFormat(), + configService.getExportDefaultAvatars(), configService.getExportDefaultMedia(), configService.getExportDefaultVoiceAsText(), configService.getExportDefaultExcelCompactColumns(), @@ -1826,6 +1828,7 @@ function ExportPage() { setExportRecordsBySession(savedSessionRecordMap) setLastSnsExportPostCount(savedSnsPostCount) setExportDefaultFormat((savedFormat as TextExportFormat) || 'excel') + setExportDefaultAvatars(savedAvatars ?? true) setExportDefaultMedia(savedMedia ?? false) setExportDefaultVoiceAsText(savedVoiceAsText ?? false) setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) @@ -1848,6 +1851,7 @@ function ExportPage() { setOptions(prev => ({ ...prev, format: ((savedFormat as TextExportFormat) || 'excel'), + exportAvatars: savedAvatars ?? true, exportMedia: savedMedia ?? prev.exportMedia, exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText, excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns, @@ -3205,6 +3209,7 @@ function ExportPage() { const next: ExportOptions = { ...prev, format: exportDefaultFormat, + exportAvatars: exportDefaultAvatars, useAllTime: exportDefaultDateRangeSelection.useAllTime, dateRange: nextDateRange, exportMedia: exportDefaultMedia, @@ -3224,7 +3229,6 @@ function ExportPage() { next.exportVoices = false next.exportVideos = false next.exportEmojis = false - next.exportAvatars = true } else { next.exportMedia = true next.exportImages = payload.contentType === 'image' @@ -3241,6 +3245,7 @@ function ExportPage() { exportDefaultDateRangeSelection, exportDefaultExcelCompactColumns, exportDefaultFormat, + exportDefaultAvatars, exportDefaultMedia, exportDefaultVoiceAsText, exportDefaultConcurrency @@ -3351,7 +3356,7 @@ function ExportPage() { format: fastTextFormat, contentType, exportConcurrency: textExportConcurrency, - exportAvatars: true, + exportAvatars: base.exportAvatars, exportMedia: false, exportImages: false, exportVoices: false, @@ -3764,6 +3769,7 @@ function ExportPage() { closeExportDialog() await configService.setExportDefaultFormat(options.format) + await configService.setExportDefaultAvatars(options.exportAvatars) await configService.setExportDefaultMedia(Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)) await configService.setExportDefaultVoiceAsText(options.exportVoiceAsText) await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns) @@ -4892,6 +4898,10 @@ function ExportPage() { const isContentTextDialog = isContentScopeDialog && exportDialog.contentType === 'text' const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog const shouldShowMediaSection = !isContentScopeDialog + const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像' + const textContentFormatNote = options.exportAvatars + ? '此模式包含用户头像,不导出图片语音视频表情包等多媒体内容' + : '此模式不包含用户头像,不导出图片语音视频表情包等多媒体内容' const shouldShowDisplayNameSection = !( exportDialog.scope === 'sns' || ( @@ -5175,6 +5185,10 @@ function ExportPage() { if (patch.format) { setExportDefaultFormat(patch.format as TextExportFormat) } + if (typeof patch.avatars === 'boolean') { + setExportDefaultAvatars(patch.avatars) + setOptions(prev => ({ ...prev, exportAvatars: patch.avatars! })) + } if (patch.dateRange) { setExportDefaultDateRangeSelection(patch.dateRange) } @@ -6044,8 +6058,11 @@ function ExportPage() { {shouldShowFormatSection && (

{exportDialog.scope === 'sns' ? '朋友圈导出格式选择' : '对话文本导出格式选择'}

+ {!isContentScopeDialog && exportDialog.scope !== 'sns' && ( +
{avatarExportStatusLabel}
+ )} {isContentTextDialog && ( -
说明:此模式默认导出头像,不导出图片、语音、视频、表情包等媒体内容。
+
{textContentFormatNote}
)}
{formatCandidateOptions.map(option => ( @@ -6086,7 +6103,7 @@ function ExportPage() { {shouldShowMediaSection && (
-

{exportDialog.scope === 'sns' ? '媒体文件(可多选)' : '媒体与头像'}

+

{exportDialog.scope === 'sns' ? '媒体文件(可多选)' : '媒体内容'}

{exportDialog.scope === 'sns' ? ( <> @@ -6101,7 +6118,6 @@ function ExportPage() { - )}
diff --git a/src/services/config.ts b/src/services/config.ts index f3ab875..0ac5008 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -27,6 +27,7 @@ export const CONFIG_KEYS = { AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice', TRANSCRIBE_LANGUAGES: 'transcribeLanguages', EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat', + EXPORT_DEFAULT_AVATARS: 'exportDefaultAvatars', EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange', EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia', EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText', @@ -335,6 +336,18 @@ export async function setExportDefaultFormat(format: string): Promise { await config.set(CONFIG_KEYS.EXPORT_DEFAULT_FORMAT, format) } +// 获取导出默认头像设置 +export async function getExportDefaultAvatars(): Promise { + const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_AVATARS) + if (typeof value === 'boolean') return value + return null +} + +// 设置导出默认头像设置 +export async function setExportDefaultAvatars(enabled: boolean): Promise { + await config.set(CONFIG_KEYS.EXPORT_DEFAULT_AVATARS, enabled) +} + // 获取导出默认时间范围 export async function getExportDefaultDateRange(): Promise { const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE) From 6253def76c3f3bccceb7bb8c2406ea92b484af4d Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 14:15:18 +0800 Subject: [PATCH 63/97] feat(export): refine format selector layouts --- .../Export/ExportDefaultsSettingsForm.scss | 17 ++- .../Export/ExportDefaultsSettingsForm.tsx | 2 +- src/pages/ExportPage.scss | 60 ++++++++++ src/pages/ExportPage.tsx | 103 ++++++++++++++---- 4 files changed, 159 insertions(+), 23 deletions(-) diff --git a/src/components/Export/ExportDefaultsSettingsForm.scss b/src/components/Export/ExportDefaultsSettingsForm.scss index ccfa2bd..f570820 100644 --- a/src/components/Export/ExportDefaultsSettingsForm.scss +++ b/src/components/Export/ExportDefaultsSettingsForm.scss @@ -359,8 +359,19 @@ margin-bottom: 0; } + .format-setting-group { + grid-template-columns: 1fr; + gap: 10px; + align-items: stretch; + } + + .format-setting-group .form-control { + justify-content: flex-start; + } + .format-grid { - max-width: 360px; + max-width: none; + grid-template-columns: repeat(3, minmax(0, 1fr)); margin-bottom: 0; } } @@ -384,5 +395,9 @@ .format-grid { max-width: none; } + + .format-grid { + grid-template-columns: repeat(auto-fit, minmax(156px, 1fr)); + } } } diff --git a/src/components/Export/ExportDefaultsSettingsForm.tsx b/src/components/Export/ExportDefaultsSettingsForm.tsx index 681a3ee..974aa19 100644 --- a/src/components/Export/ExportDefaultsSettingsForm.tsx +++ b/src/components/Export/ExportDefaultsSettingsForm.tsx @@ -145,7 +145,7 @@ export function ExportDefaultsSettingsForm({
-
+
导出页面默认选中的格式 diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 81fd177..a7d9683 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2858,6 +2858,66 @@ } } +.dialog-format-select { + position: relative; + min-width: 220px; +} + +.dialog-format-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + width: min(360px, calc(100vw - 64px)); + background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary)); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 6px; + box-shadow: var(--shadow-md); + z-index: 120; + max-height: 320px; + overflow-y: auto; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); +} + +.dialog-format-option { + width: 100%; + text-align: left; + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border: none; + border-radius: 10px; + background: transparent; + cursor: pointer; + transition: all 0.15s; + color: var(--text-primary); + font-size: 14px; + + &:hover { + background: var(--bg-tertiary); + } + + &.active { + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + } + + .option-label { + font-weight: 500; + } + + .option-desc { + font-size: 12px; + color: var(--text-tertiary); + } + + &.active .option-desc { + color: var(--primary); + } +} + .scope-tag-row { display: flex; align-items: center; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index b329a0e..5d243a8 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1320,6 +1320,7 @@ function ExportPage() { sessionNames: [], title: '' }) + const [showSessionFormatSelect, setShowSessionFormatSelect] = useState(false) const [tasks, setTasks] = useState([]) const [lastExportBySession, setLastExportBySession] = useState>({}) @@ -1354,6 +1355,7 @@ function ExportPage() { const contactsAvatarCacheRef = useRef>({}) const contactsVirtuosoRef = useRef(null) const sessionTableSectionRef = useRef(null) + const sessionFormatDropdownRef = useRef(null) const detailRequestSeqRef = useRef(0) const sessionsRef = useRef([]) const sessionContentMetricsRef = useRef>({}) @@ -4801,6 +4803,24 @@ function ExportPage() { return () => window.removeEventListener('keydown', handleKeyDown) }, [closeSessionSnsTimeline, sessionSnsTimelineTarget]) + useEffect(() => { + if (!showSessionFormatSelect) return + const handlePointerDown = (event: MouseEvent) => { + const target = event.target as Node + if (sessionFormatDropdownRef.current && !sessionFormatDropdownRef.current.contains(target)) { + setShowSessionFormatSelect(false) + } + } + document.addEventListener('mousedown', handlePointerDown) + return () => document.removeEventListener('mousedown', handlePointerDown) + }, [showSessionFormatSelect]) + + useEffect(() => { + if (!exportDialog.open) { + setShowSessionFormatSelect(false) + } + }, [exportDialog.open]) + const handleCopyDetailField = useCallback(async (text: string, field: string) => { try { await navigator.clipboard.writeText(text) @@ -4894,14 +4914,19 @@ function ExportPage() { const formatCandidateOptions = exportDialog.scope === 'sns' ? snsFormatOptions : formatOptions + const isSessionScopeDialog = exportDialog.scope === 'single' || exportDialog.scope === 'multi' const isContentScopeDialog = exportDialog.scope === 'content' const isContentTextDialog = isContentScopeDialog && exportDialog.contentType === 'text' + const useCollapsedSessionFormatSelector = isSessionScopeDialog const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog const shouldShowMediaSection = !isContentScopeDialog const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像' const textContentFormatNote = options.exportAvatars ? '此模式包含用户头像,不导出图片语音视频表情包等多媒体内容' : '此模式不包含用户头像,不导出图片语音视频表情包等多媒体内容' + const activeDialogFormatLabel = exportDialog.scope === 'sns' + ? (snsFormatOptions.find(option => option.value === snsExportFormat)?.label ?? snsExportFormat) + : (formatOptions.find(option => option.value === options.format)?.label ?? options.format) const shouldShowDisplayNameSection = !( exportDialog.scope === 'sns' || ( @@ -6057,33 +6082,69 @@ function ExportPage() { {shouldShowFormatSection && (
-

{exportDialog.scope === 'sns' ? '朋友圈导出格式选择' : '对话文本导出格式选择'}

+ {useCollapsedSessionFormatSelector ? ( +
+

对话文本导出格式选择

+
+ + {showSessionFormatSelect && ( +
+ {formatOptions.map(option => ( + + ))} +
+ )} +
+
+ ) : ( +

{exportDialog.scope === 'sns' ? '朋友圈导出格式选择' : '对话文本导出格式选择'}

+ )} {!isContentScopeDialog && exportDialog.scope !== 'sns' && (
{avatarExportStatusLabel}
)} {isContentTextDialog && (
{textContentFormatNote}
)} -
- {formatCandidateOptions.map(option => ( - - ))} -
+ {!useCollapsedSessionFormatSelector && ( +
+ {formatCandidateOptions.map(option => ( + + ))} +
+ )}
)} From 98a3b06e5663f3e5314a3f458ac32f1dd28b3af5 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 14:19:27 +0800 Subject: [PATCH 64/97] fix(export): align collapsed format selector --- src/pages/ExportPage.scss | 1 + src/pages/ExportPage.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index a7d9683..9c6c756 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2861,6 +2861,7 @@ .dialog-format-select { position: relative; min-width: 220px; + margin-left: auto; } .dialog-format-dropdown { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 5d243a8..6cb128b 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -4917,7 +4917,7 @@ function ExportPage() { const isSessionScopeDialog = exportDialog.scope === 'single' || exportDialog.scope === 'multi' const isContentScopeDialog = exportDialog.scope === 'content' const isContentTextDialog = isContentScopeDialog && exportDialog.contentType === 'text' - const useCollapsedSessionFormatSelector = isSessionScopeDialog + const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog const shouldShowMediaSection = !isContentScopeDialog const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像' From 5b6be864fde112dc9db0bd152ad0f5f792f8d0a5 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 14:23:54 +0800 Subject: [PATCH 65/97] fix(export): clarify text batch dialog copy --- src/pages/ExportPage.scss | 13 +++++++++++++ src/pages/ExportPage.tsx | 13 ++++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 9c6c756..77179df 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2794,6 +2794,19 @@ } } +.dialog-header-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.dialog-header-note { + font-size: 12px; + line-height: 1.45; + color: var(--text-secondary); +} + .close-icon-btn { border: 1px solid var(--border-color); background: var(--bg-secondary); diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 6cb128b..9a7d6bf 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -4921,9 +4921,7 @@ function ExportPage() { const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog const shouldShowMediaSection = !isContentScopeDialog const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像' - const textContentFormatNote = options.exportAvatars - ? '此模式包含用户头像,不导出图片语音视频表情包等多媒体内容' - : '此模式不包含用户头像,不导出图片语音视频表情包等多媒体内容' + const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。' const activeDialogFormatLabel = exportDialog.scope === 'sns' ? (snsFormatOptions.find(option => option.value === snsExportFormat)?.label ?? snsExportFormat) : (formatOptions.find(option => option.value === options.format)?.label ?? options.format) @@ -6059,7 +6057,12 @@ function ExportPage() {
event.stopPropagation()}>
-

{exportDialog.title}

+
+

{exportDialog.title}

+ {isContentTextDialog && ( +
{contentTextDialogSummary}
+ )} +
@@ -6121,7 +6124,7 @@ function ExportPage() {
{avatarExportStatusLabel}
)} {isContentTextDialog && ( -
{textContentFormatNote}
+
{avatarExportStatusLabel}
)} {!useCollapsedSessionFormatSelector && (
From 919357a374aa88d0d245a79bce4e65c77d2a97a4 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 14:26:43 +0800 Subject: [PATCH 66/97] fix(export): stretch format trigger --- src/pages/ExportPage.scss | 11 +++++++++++ src/pages/ExportPage.tsx | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 77179df..0a9aa68 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2877,6 +2877,17 @@ margin-left: auto; } +.dialog-format-trigger { + width: 100%; + justify-content: space-between; +} + +.dialog-format-trigger-label { + flex: 1; + min-width: 0; + text-align: left; +} + .dialog-format-dropdown { position: absolute; top: calc(100% + 6px); diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 9a7d6bf..62f3547 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -6091,10 +6091,10 @@ function ExportPage() {
{showSessionFormatSelect && ( From 3e917e206279bcb608a224e0d435569552057e4c Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 14:29:04 +0800 Subject: [PATCH 67/97] fix(export): right align format trigger label --- src/pages/ExportPage.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 0a9aa68..74fd2cc 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2885,7 +2885,7 @@ .dialog-format-trigger-label { flex: 1; min-width: 0; - text-align: left; + text-align: right; } .dialog-format-dropdown { From f4037a1ccff0c46e8344f2294708fe323e2c6f6d Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 14:31:00 +0800 Subject: [PATCH 68/97] fix(export): size format trigger to content --- src/pages/ExportPage.scss | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 74fd2cc..20cdce0 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2873,18 +2873,14 @@ .dialog-format-select { position: relative; - min-width: 220px; margin-left: auto; } .dialog-format-trigger { - width: 100%; - justify-content: space-between; + justify-content: flex-end; } .dialog-format-trigger-label { - flex: 1; - min-width: 0; text-align: right; } From d735ed19cb8f8d8141d0dd766c9075c0aaf357b9 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 14:34:25 +0800 Subject: [PATCH 69/97] style(export): soften non-text batch buttons --- src/pages/ExportPage.scss | 29 ++++++++++++++++++++++++----- src/pages/ExportPage.tsx | 3 ++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 20cdce0..fa5fe6f 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -721,11 +721,9 @@ .card-export-btn { margin-top: auto; - border: none; + border: 1px solid transparent; border-radius: 7px; padding: 7px 9px; - background: var(--primary); - color: #fff; cursor: pointer; font-size: 12px; font-weight: 600; @@ -734,19 +732,40 @@ justify-content: center; gap: 6px; - &:hover { + &.primary { + background: var(--primary); + color: #fff; + border-color: transparent; + } + + &.primary:hover { background: var(--primary-hover); } + &.secondary { + background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary)); + color: var(--text-secondary); + border-color: color-mix(in srgb, var(--border-color) 85%, transparent); + } + + &.secondary:hover { + border-color: color-mix(in srgb, var(--primary) 28%, transparent); + color: var(--text-primary); + background: color-mix(in srgb, var(--bg-primary) 94%, var(--primary) 6%); + } + &:disabled { cursor: not-allowed; opacity: 0.86; } &.running { - background: var(--primary-hover); opacity: 0.65; } + + &.primary.running { + background: var(--primary-hover); + } } &.skeleton-card { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 62f3547..89449ea 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -5364,6 +5364,7 @@ function ExportPage() { ? isSnsCardStatsLoading : false const isCardRunning = runningCardTypes.has(card.type) + const isPrimaryCard = card.type === 'text' return (
@@ -5393,7 +5394,7 @@ function ExportPage() { ))}
+ {showExportExcelColumnsSelect && ( +
+ {exportExcelColumnOptions.map((option) => ( + + ))} +
+ )} +
+
+
+
@@ -312,50 +356,6 @@ export function ExportDefaultsSettingsForm({
-
-
- - 控制 Excel 导出的列字段 -
-
-
- - {showExportExcelColumnsSelect && ( -
- {exportExcelColumnOptions.map((option) => ( - - ))} -
- )} -
-
-
-
From cacb9e449cc11ea9b21843596a2b555ebd3c3358 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 14:53:51 +0800 Subject: [PATCH 76/97] style(export): make media defaults responsive --- .../Export/ExportDefaultsSettingsForm.scss | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/components/Export/ExportDefaultsSettingsForm.scss b/src/components/Export/ExportDefaultsSettingsForm.scss index 25f57dc..c24b44f 100644 --- a/src/components/Export/ExportDefaultsSettingsForm.scss +++ b/src/components/Export/ExportDefaultsSettingsForm.scss @@ -397,20 +397,10 @@ align-items: stretch; } - .media-setting-group { - grid-template-columns: 1fr; - gap: 10px; - align-items: stretch; - } - .format-setting-group .form-control { justify-content: flex-start; } - .media-setting-group .form-control { - justify-content: flex-start; - } - .format-grid { max-width: none; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -419,6 +409,25 @@ } } +@media (max-width: 1024px) { + .export-defaults-settings-form.layout-split { + .media-setting-group { + grid-template-columns: 1fr; + gap: 10px; + align-items: stretch; + } + + .media-setting-group .form-control { + justify-content: flex-start; + } + + .media-default-grid { + max-width: none; + flex-wrap: wrap; + } + } +} + @media (max-width: 760px) { .export-defaults-settings-form.layout-split { .form-group { From 344dd3343b15b3f9b34cbeb29362b0f6150eabaf Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 14:58:28 +0800 Subject: [PATCH 77/97] feat(export): separate voice transcription toggle --- src/pages/ExportPage.scss | 69 ++++++++++++++++++++++++++++++++++++--- src/pages/ExportPage.tsx | 21 +++++++++++- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 84bdac4..d945f02 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -3077,16 +3077,18 @@ .media-check-grid { margin-top: 10px; - display: grid; - grid-template-columns: repeat(3, minmax(100px, 1fr)); - gap: 8px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 16px; label { - display: flex; + display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-primary); + white-space: nowrap; } input[type='checkbox'] { @@ -3094,6 +3096,59 @@ } } +.dialog-switch-row { + margin-top: 2px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.dialog-switch-copy { + min-width: 0; + + h4 { + margin: 0; + } + + .format-note { + margin-top: 4px; + margin-bottom: 0; + } +} + +.dialog-switch { + position: relative; + flex-shrink: 0; + width: 46px; + height: 26px; + border: none; + border-radius: 999px; + background: color-mix(in srgb, var(--text-tertiary) 45%, transparent); + cursor: pointer; + transition: background 0.2s ease; + + &.on { + background: var(--primary); + } +} + +.dialog-switch-thumb { + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); + transition: transform 0.2s ease; +} + +.dialog-switch.on .dialog-switch-thumb { + transform: translateX(20px); +} + .display-name-options { display: grid; grid-template-columns: 1fr 1fr 1fr; @@ -3481,7 +3536,7 @@ } .media-check-grid { - grid-template-columns: repeat(2, minmax(120px, 1fr)); + gap: 8px 12px; } } @@ -3516,6 +3571,10 @@ justify-content: space-between; } + .dialog-switch-row { + align-items: flex-start; + } + .export-defaults-modal { width: min(92vw, 720px); } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index bcdb977..69ec349 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -6224,7 +6224,6 @@ function ExportPage() { - )}
@@ -6234,6 +6233,26 @@ function ExportPage() {
)} + {isSessionScopeDialog && ( +
+
+
+

语音转文字

+
默认状态跟随更多导出设置中的语音转文字开关。
+
+ +
+
+ )} + {shouldShowDisplayNameSection && (

发送者名称显示

From 6c1e7f6f12cd91bd12c007f22db6f6c3c0266826 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 15:24:53 +0800 Subject: [PATCH 78/97] fix(sns): expand feed viewport height --- src/pages/SnsPage.scss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 19b63a1..012a3a3 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -11,7 +11,8 @@ .sns-page-layout { display: flex; - height: 100%; + height: calc(100% + 48px); + margin: -24px; overflow: hidden; background: var(--sns-bg-color); position: relative; @@ -32,7 +33,7 @@ .sns-feed-container { width: 100%; max-width: var(--sns-max-width); - padding: 20px 24px 60px 24px; + padding: 20px 24px 16px 24px; display: flex; flex-direction: column; gap: 0; From 21cb09fbded2918240e90d6a1dc00f42a490ea05 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 15:26:03 +0800 Subject: [PATCH 79/97] style(sns): tighten feed header spacing --- src/pages/SnsPage.scss | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 012a3a3..5f0f160 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -33,7 +33,7 @@ .sns-feed-container { width: 100%; max-width: var(--sns-max-width); - padding: 20px 24px 16px 24px; + padding: 10px 24px 12px 24px; display: flex; flex-direction: column; gap: 0; @@ -45,13 +45,13 @@ display: flex; align-items: center; justify-content: space-between; - margin-bottom: 8px; + margin-bottom: 4px; padding: 0 4px; z-index: 2; background: var(--sns-bg-color); border-bottom: 1px solid var(--border-color); - padding-top: 10px; - padding-bottom: 10px; + padding-top: 4px; + padding-bottom: 6px; .feed-header-main { display: flex; From 03f65317a969b916b5696e00ab00eb2488e977c1 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 15:30:10 +0800 Subject: [PATCH 80/97] refactor(sns): use shared jump date popover --- src/components/Sns/SnsFilterPanel.tsx | 69 +++++++++++++++++---------- src/pages/SnsPage.scss | 12 +++++ src/pages/SnsPage.tsx | 13 ----- 3 files changed, 56 insertions(+), 38 deletions(-) diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index 788ac3d..5dbfad0 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -1,7 +1,7 @@ -import React from 'react' +import React, { useEffect, useRef, useState } from 'react' import { Search, Calendar, User, X, Loader2 } from 'lucide-react' import { Avatar } from '../Avatar' -// import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved +import JumpToDatePopover from '../JumpToDatePopover' interface Contact { username: string @@ -22,7 +22,6 @@ interface SnsFilterPanelProps { setSearchKeyword: (val: string) => void jumpTargetDate?: Date setJumpTargetDate: (date?: Date) => void - onOpenJumpDialog: () => void selectedUsernames: string[] setSelectedUsernames: (val: string[]) => void contacts: Contact[] @@ -37,7 +36,6 @@ export const SnsFilterPanel: React.FC = ({ setSearchKeyword, jumpTargetDate, setJumpTargetDate, - onOpenJumpDialog, selectedUsernames, setSelectedUsernames, contacts, @@ -46,6 +44,19 @@ export const SnsFilterPanel: React.FC = ({ loading, contactsCountProgress }) => { + const [showJumpPopover, setShowJumpPopover] = useState(false) + const jumpCalendarWrapRef = useRef(null) + + useEffect(() => { + if (!showJumpPopover) return + const handleClickOutside = (event: MouseEvent) => { + if (!jumpCalendarWrapRef.current) return + if (jumpCalendarWrapRef.current.contains(event.target as Node)) return + setShowJumpPopover(false) + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [showJumpPopover]) const filteredContacts = contacts.filter(c => (c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) || @@ -116,27 +127,35 @@ export const SnsFilterPanel: React.FC = ({ 时间跳转
- +
+ + setShowJumpPopover(false)} + onSelect={(date) => setJumpTargetDate(date)} + /> +
{/* Contact Widget */} diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 5f0f160..a3ee46a 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -1063,6 +1063,18 @@ } /* Date Widget */ + .jump-calendar-anchor { + position: relative; + display: flex; + align-items: center; + isolation: isolate; + z-index: 20; + + .jump-date-popover { + z-index: 2600; + } + } + .date-picker-trigger { width: 100%; display: flex; diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index c3e0a80..5b082cb 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -1,6 +1,5 @@ import { useEffect, useLayoutEffect, useState, useRef, useCallback, useMemo } from 'react' import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight, Shield, ShieldOff, Loader2 } from 'lucide-react' -import JumpToDateDialog from '../components/JumpToDateDialog' import './SnsPage.scss' import { SnsPost } from '../types/sns' import { SnsPostItem } from '../components/Sns/SnsPostItem' @@ -118,7 +117,6 @@ export default function SnsPage() { }) // UI states - const [showJumpDialog, setShowJumpDialog] = useState(false) const [debugPost, setDebugPost] = useState(null) const [authorTimelineTarget, setAuthorTimelineTarget] = useState(null) @@ -1114,7 +1112,6 @@ export default function SnsPage() { setSearchKeyword={setSearchKeyword} jumpTargetDate={jumpTargetDate} setJumpTargetDate={setJumpTargetDate} - onOpenJumpDialog={() => setShowJumpDialog(true)} selectedUsernames={selectedUsernames} setSelectedUsernames={setSelectedUsernames} contacts={contacts} @@ -1125,16 +1122,6 @@ export default function SnsPage() { /> {/* Dialogs and Overlays */} - setShowJumpDialog(false)} - onSelect={(date) => { - setJumpTargetDate(date) - setShowJumpDialog(false) - }} - currentDate={jumpTargetDate || new Date()} - /> - Date: Fri, 6 Mar 2026 15:32:13 +0800 Subject: [PATCH 81/97] style(sns): compact jump date filter row --- src/components/Sns/SnsFilterPanel.tsx | 66 ++++++++++++++------------- src/pages/SnsPage.scss | 15 ++++++ 2 files changed, 49 insertions(+), 32 deletions(-) diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index 5dbfad0..4b646b2 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -123,38 +123,40 @@ export const SnsFilterPanel: React.FC = ({ {/* Date Widget */}
-
- - 时间跳转 -
-
- - setShowJumpPopover(false)} - onSelect={(date) => setJumpTargetDate(date)} - /> +
+
+ + 时间跳转 +
+
+ + setShowJumpPopover(false)} + onSelect={(date) => setJumpTargetDate(date)} + /> +
diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index a3ee46a..7624101 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -1063,10 +1063,25 @@ } /* Date Widget */ + .date-widget-row { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + } + + .date-widget .widget-header { + margin-bottom: 0; + flex-shrink: 0; + min-width: 72px; + } + .jump-calendar-anchor { position: relative; display: flex; align-items: center; + flex: 1; + min-width: 0; isolation: isolate; z-index: 20; From 02aefcf15531bc5c81e065815fa27f348562a20c Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 15:34:58 +0800 Subject: [PATCH 82/97] fix(sns): stabilize jump date popover month --- src/components/Sns/SnsFilterPanel.tsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index 4b646b2..f6f8e93 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -45,6 +45,7 @@ export const SnsFilterPanel: React.FC = ({ contactsCountProgress }) => { const [showJumpPopover, setShowJumpPopover] = useState(false) + const [jumpPopoverDate, setJumpPopoverDate] = useState(jumpTargetDate || new Date()) const jumpCalendarWrapRef = useRef(null) useEffect(() => { @@ -58,6 +59,11 @@ export const SnsFilterPanel: React.FC = ({ return () => document.removeEventListener('mousedown', handleClickOutside) }, [showJumpPopover]) + useEffect(() => { + if (showJumpPopover) return + setJumpPopoverDate(jumpTargetDate || new Date()) + }, [jumpTargetDate, showJumpPopover]) + const filteredContacts = contacts.filter(c => (c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) || c.username.toLowerCase().includes(contactSearch.toLowerCase()) @@ -131,7 +137,12 @@ export const SnsFilterPanel: React.FC = ({
setShowJumpPopover(false)} - onSelect={(date) => setJumpTargetDate(date)} + onSelect={(date) => { + setJumpPopoverDate(date) + setJumpTargetDate(date) + }} />
From 5fd846bfc8267c42eac90bef8fa23a493389d4bc Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 15:39:13 +0800 Subject: [PATCH 83/97] feat(sns): show daily counts in jump calendar --- src/components/JumpToDatePopover.tsx | 10 ++- src/components/Sns/SnsFilterPanel.tsx | 92 ++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/src/components/JumpToDatePopover.tsx b/src/components/JumpToDatePopover.tsx index 0a21929..ef3c807 100644 --- a/src/components/JumpToDatePopover.tsx +++ b/src/components/JumpToDatePopover.tsx @@ -6,6 +6,7 @@ interface JumpToDatePopoverProps { isOpen: boolean onClose: () => void onSelect: (date: Date) => void + onMonthChange?: (date: Date) => void className?: string style?: React.CSSProperties currentDate?: Date @@ -20,6 +21,7 @@ const JumpToDatePopover: React.FC = ({ isOpen, onClose, onSelect, + onMonthChange, className, style, currentDate = new Date(), @@ -112,13 +114,17 @@ const JumpToDatePopover: React.FC = ({ const weekdays = ['日', '一', '二', '三', '四', '五', '六'] const days = generateCalendar() const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim() + const updateCalendarDate = (nextDate: Date) => { + setCalendarDate(nextDate) + onMonthChange?.(nextDate) + } return (
From 1c89ee27978f257cfa24c6b02ffb228467f7cafd Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 15:42:10 +0800 Subject: [PATCH 84/97] style(sns): move friends count to contact header --- src/components/Sns/SnsFilterPanel.tsx | 5 +++++ src/pages/SnsPage.scss | 12 ++++++++++++ src/pages/SnsPage.tsx | 9 ++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index 6814e82..d769360 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -22,6 +22,7 @@ interface SnsFilterPanelProps { setSearchKeyword: (val: string) => void jumpTargetDate?: Date setJumpTargetDate: (date?: Date) => void + totalFriendsLabel?: string selectedUsernames: string[] setSelectedUsernames: (val: string[]) => void contacts: Contact[] @@ -36,6 +37,7 @@ export const SnsFilterPanel: React.FC = ({ setSearchKeyword, jumpTargetDate, setJumpTargetDate, + totalFriendsLabel, selectedUsernames, setSelectedUsernames, contacts, @@ -270,6 +272,9 @@ export const SnsFilterPanel: React.FC = ({ {selectedUsernames.length > 0 && ( {selectedUsernames.length} )} + {totalFriendsLabel && ( + {totalFriendsLabel} + )}
diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 7624101..ef2fc6b 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -1021,9 +1021,21 @@ padding: 2px 6px; border-radius: 10px; } + + .widget-header-summary { + margin-left: auto; + font-size: 12px; + font-weight: 500; + color: var(--text-tertiary); + white-space: nowrap; + } } } + .contact-widget .widget-header .badge + .widget-header-summary { + margin-left: 8px; + } + /* Search Widget */ .input-group { position: relative; diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 5b082cb..3c00fe8 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -451,7 +451,7 @@ export default function SnsPage() { if (overviewStatsStatus === 'loading') { return '统计中...' } - return `共 ${overviewStats.totalPosts} 条 | ${formatDateOnly(overviewStats.earliestTime)} ~ ${formatDateOnly(overviewStats.latestTime)} | ${overviewStats.totalFriends} 位好友` + return `共 ${overviewStats.totalPosts} 条 | ${formatDateOnly(overviewStats.earliestTime)} ~ ${formatDateOnly(overviewStats.latestTime)}` } const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => { @@ -1112,6 +1112,13 @@ export default function SnsPage() { setSearchKeyword={setSearchKeyword} jumpTargetDate={jumpTargetDate} setJumpTargetDate={setJumpTargetDate} + totalFriendsLabel={ + overviewStatsStatus === 'loading' + ? '统计中' + : overviewStatsStatus === 'ready' + ? `${overviewStats.totalFriends} 位好友` + : undefined + } selectedUsernames={selectedUsernames} setSelectedUsernames={setSelectedUsernames} contacts={contacts} From 0599de372a66f54e89f5b78b3f58faaa9d4fa123 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 15:44:24 +0800 Subject: [PATCH 85/97] refactor(sns): move jump calendar to header --- src/components/Sns/SnsFilterPanel.tsx | 169 +------------------------- src/pages/SnsPage.scss | 53 -------- src/pages/SnsPage.tsx | 135 +++++++++++++++++++- 3 files changed, 136 insertions(+), 221 deletions(-) diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index d769360..b07e596 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -1,7 +1,6 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react' -import { Search, Calendar, User, X, Loader2 } from 'lucide-react' +import React from 'react' +import { Search, User, X, Loader2 } from 'lucide-react' import { Avatar } from '../Avatar' -import JumpToDatePopover from '../JumpToDatePopover' interface Contact { username: string @@ -20,8 +19,6 @@ interface ContactsCountProgress { interface SnsFilterPanelProps { searchKeyword: string setSearchKeyword: (val: string) => void - jumpTargetDate?: Date - setJumpTargetDate: (date?: Date) => void totalFriendsLabel?: string selectedUsernames: string[] setSelectedUsernames: (val: string[]) => void @@ -35,8 +32,6 @@ interface SnsFilterPanelProps { export const SnsFilterPanel: React.FC = ({ searchKeyword, setSearchKeyword, - jumpTargetDate, - setJumpTargetDate, totalFriendsLabel, selectedUsernames, setSelectedUsernames, @@ -46,104 +41,6 @@ export const SnsFilterPanel: React.FC = ({ loading, contactsCountProgress }) => { - const [showJumpPopover, setShowJumpPopover] = useState(false) - const [jumpPopoverDate, setJumpPopoverDate] = useState(jumpTargetDate || new Date()) - const [jumpDateCounts, setJumpDateCounts] = useState>({}) - const [jumpDateMessageDates, setJumpDateMessageDates] = useState>(new Set()) - const [hasLoadedJumpDateCounts, setHasLoadedJumpDateCounts] = useState(false) - const [loadingJumpDateCounts, setLoadingJumpDateCounts] = useState(false) - const jumpCalendarWrapRef = useRef(null) - const jumpDateCountsCacheRef = useRef>>(new Map()) - const jumpDateRequestSeqRef = useRef(0) - - useEffect(() => { - if (!showJumpPopover) return - const handleClickOutside = (event: MouseEvent) => { - if (!jumpCalendarWrapRef.current) return - if (jumpCalendarWrapRef.current.contains(event.target as Node)) return - setShowJumpPopover(false) - } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showJumpPopover]) - - useEffect(() => { - if (showJumpPopover) return - setJumpPopoverDate(jumpTargetDate || new Date()) - }, [jumpTargetDate, showJumpPopover]) - - const toMonthKey = useCallback((date: Date) => { - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` - }, []) - - const toDateKey = useCallback((timestampSeconds: number) => { - const date = new Date(timestampSeconds * 1000) - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` - }, []) - - const applyJumpDateCounts = useCallback((counts: Record) => { - setJumpDateCounts(counts) - setJumpDateMessageDates(new Set(Object.keys(counts))) - setHasLoadedJumpDateCounts(true) - }, []) - - const loadJumpDateCounts = useCallback(async (monthDate: Date) => { - const monthKey = toMonthKey(monthDate) - const cached = jumpDateCountsCacheRef.current.get(monthKey) - if (cached) { - applyJumpDateCounts(cached) - setLoadingJumpDateCounts(false) - return - } - - const requestSeq = ++jumpDateRequestSeqRef.current - setLoadingJumpDateCounts(true) - setHasLoadedJumpDateCounts(false) - - const year = monthDate.getFullYear() - const month = monthDate.getMonth() - const monthStart = new Date(year, month, 1, 0, 0, 0, 0) - const monthEnd = new Date(year, month + 1, 0, 23, 59, 59, 999) - const startTime = Math.floor(monthStart.getTime() / 1000) - const endTime = Math.floor(monthEnd.getTime() / 1000) - const pageSize = 200 - let offset = 0 - const counts: Record = {} - - try { - while (true) { - const result = await window.electronAPI.sns.getTimeline(pageSize, offset, [], '', startTime, endTime) - if (!result?.success || !Array.isArray(result.timeline) || result.timeline.length === 0) { - break - } - result.timeline.forEach((post) => { - const key = toDateKey(Number(post.createTime || 0)) - if (!key) return - counts[key] = (counts[key] || 0) + 1 - }) - if (result.timeline.length < pageSize) break - offset += pageSize - } - - if (requestSeq !== jumpDateRequestSeqRef.current) return - jumpDateCountsCacheRef.current.set(monthKey, counts) - applyJumpDateCounts(counts) - } catch (error) { - console.error('加载朋友圈按日条数失败:', error) - if (requestSeq !== jumpDateRequestSeqRef.current) return - setJumpDateCounts({}) - setJumpDateMessageDates(new Set()) - setHasLoadedJumpDateCounts(true) - } finally { - if (requestSeq === jumpDateRequestSeqRef.current) { - setLoadingJumpDateCounts(false) - } - } - }, [applyJumpDateCounts, toDateKey, toMonthKey]) - const filteredContacts = contacts.filter(c => (c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) || c.username.toLowerCase().includes(contactSearch.toLowerCase()) @@ -153,7 +50,6 @@ export const SnsFilterPanel: React.FC = ({ if (selectedUsernames.includes(username)) { setSelectedUsernames(selectedUsernames.filter(u => u !== username)) } else { - setJumpTargetDate(undefined) // Reset date jump when selecting user setSelectedUsernames([...selectedUsernames, username]) } } @@ -161,7 +57,6 @@ export const SnsFilterPanel: React.FC = ({ const clearFilters = () => { setSearchKeyword('') setSelectedUsernames([]) - setJumpTargetDate(undefined) } const getEmptyStateText = () => { @@ -178,7 +73,7 @@ export const SnsFilterPanel: React.FC = ({
- - {/* Date Widget */} -
-
-
- - 时间跳转 -
-
- - setShowJumpPopover(false)} - onMonthChange={(date) => { - setJumpPopoverDate(date) - void loadJumpDateCounts(date) - }} - onSelect={(date) => { - setJumpPopoverDate(date) - setJumpTargetDate(date) - }} - messageDates={jumpDateMessageDates} - hasLoadedMessageDates={hasLoadedJumpDateCounts} - messageDateCounts={jumpDateCounts} - loadingDateCounts={loadingJumpDateCounts} - /> -
-
-
- {/* Contact Widget */}
diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index ef2fc6b..59ce664 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -1074,26 +1074,10 @@ } } - /* Date Widget */ - .date-widget-row { - display: flex; - align-items: center; - gap: 12px; - min-width: 0; - } - - .date-widget .widget-header { - margin-bottom: 0; - flex-shrink: 0; - min-width: 72px; - } - .jump-calendar-anchor { position: relative; display: flex; align-items: center; - flex: 1; - min-width: 0; isolation: isolate; z-index: 20; @@ -1102,43 +1086,6 @@ } } - .date-picker-trigger { - width: 100%; - display: flex; - justify-content: space-between; - align-items: center; - background: var(--bg-tertiary); - border: 1px solid transparent; - border-radius: var(--sns-border-radius-sm); - padding: 12px; - cursor: pointer; - transition: all 0.2s; - font-size: 13px; - color: var(--text-secondary); - - &:hover { - background: var(--bg-primary); - border-color: var(--primary); - } - - &.active { - background: rgba(var(--primary-rgb), 0.08); - border-color: var(--primary); - color: var(--primary); - font-weight: 500; - } - - .clear-date-btn { - padding: 4px; - display: flex; - color: var(--primary); - - &:hover { - transform: scale(1.1); - } - } - } - /* Contact Widget - Refactored */ .contact-widget { display: flex; diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 3c00fe8..cec1c9c 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -6,6 +6,7 @@ import { SnsPostItem } from '../components/Sns/SnsPostItem' import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import type { ContactSnsTimelineTarget } from '../components/Sns/contactSnsTimeline' +import JumpToDatePopover from '../components/JumpToDatePopover' import * as configService from '../services/config' const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000 @@ -119,6 +120,12 @@ export default function SnsPage() { // UI states const [debugPost, setDebugPost] = useState(null) const [authorTimelineTarget, setAuthorTimelineTarget] = useState(null) + const [showJumpPopover, setShowJumpPopover] = useState(false) + const [jumpPopoverDate, setJumpPopoverDate] = useState(jumpTargetDate || new Date()) + const [jumpDateCounts, setJumpDateCounts] = useState>({}) + const [jumpDateMessageDates, setJumpDateMessageDates] = useState>(new Set()) + const [hasLoadedJumpDateCounts, setHasLoadedJumpDateCounts] = useState(false) + const [loadingJumpDateCounts, setLoadingJumpDateCounts] = useState(false) // 导出相关状态 const [showExportDialog, setShowExportDialog] = useState(false) @@ -142,6 +149,7 @@ export default function SnsPage() { const [triggerMessage, setTriggerMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) const postsContainerRef = useRef(null) + const jumpCalendarWrapRef = useRef(null) const [hasNewer, setHasNewer] = useState(false) const [loadingNewer, setLoadingNewer] = useState(false) const postsRef = useRef([]) @@ -157,6 +165,8 @@ export default function SnsPage() { const contactsLoadTokenRef = useRef(0) const contactsCountHydrationTokenRef = useRef(0) const contactsCountBatchTimerRef = useRef(null) + const jumpDateCountsCacheRef = useRef>>(new Map()) + const jumpDateRequestSeqRef = useRef(0) // Sync posts ref useEffect(() => { @@ -180,6 +190,21 @@ export default function SnsPage() { useEffect(() => { jumpTargetDateRef.current = jumpTargetDate }, [jumpTargetDate]) + useEffect(() => { + if (!showJumpPopover) { + setJumpPopoverDate(jumpTargetDate || new Date()) + } + }, [jumpTargetDate, showJumpPopover]) + useEffect(() => { + if (!showJumpPopover) return + const handleClickOutside = (event: MouseEvent) => { + if (!jumpCalendarWrapRef.current) return + if (jumpCalendarWrapRef.current.contains(event.target as Node)) return + setShowJumpPopover(false) + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [showJumpPopover]) // 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动 useLayoutEffect(() => { const snapshot = scrollAdjustmentRef.current; @@ -221,6 +246,78 @@ export default function SnsPage() { return Math.max(0, Math.floor(numeric)) }, []) + const toMonthKey = useCallback((date: Date) => { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + }, []) + + const toDateKey = useCallback((timestampSeconds: number) => { + const date = new Date(timestampSeconds * 1000) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` + }, []) + + const applyJumpDateCounts = useCallback((counts: Record) => { + setJumpDateCounts(counts) + setJumpDateMessageDates(new Set(Object.keys(counts))) + setHasLoadedJumpDateCounts(true) + }, []) + + const loadJumpDateCounts = useCallback(async (monthDate: Date) => { + const monthKey = toMonthKey(monthDate) + const cached = jumpDateCountsCacheRef.current.get(monthKey) + if (cached) { + applyJumpDateCounts(cached) + setLoadingJumpDateCounts(false) + return + } + + const requestSeq = ++jumpDateRequestSeqRef.current + setLoadingJumpDateCounts(true) + setHasLoadedJumpDateCounts(false) + + const year = monthDate.getFullYear() + const month = monthDate.getMonth() + const monthStart = new Date(year, month, 1, 0, 0, 0, 0) + const monthEnd = new Date(year, month + 1, 0, 23, 59, 59, 999) + const startTime = Math.floor(monthStart.getTime() / 1000) + const endTime = Math.floor(monthEnd.getTime() / 1000) + const pageSize = 200 + let offset = 0 + const counts: Record = {} + + try { + while (true) { + const result = await window.electronAPI.sns.getTimeline(pageSize, offset, [], '', startTime, endTime) + if (!result?.success || !Array.isArray(result.timeline) || result.timeline.length === 0) { + break + } + result.timeline.forEach((post) => { + const key = toDateKey(Number(post.createTime || 0)) + if (!key) return + counts[key] = (counts[key] || 0) + 1 + }) + if (result.timeline.length < pageSize) break + offset += pageSize + } + + if (requestSeq !== jumpDateRequestSeqRef.current) return + jumpDateCountsCacheRef.current.set(monthKey, counts) + applyJumpDateCounts(counts) + } catch (error) { + console.error('加载朋友圈按日条数失败:', error) + if (requestSeq !== jumpDateRequestSeqRef.current) return + setJumpDateCounts({}) + setJumpDateMessageDates(new Set()) + setHasLoadedJumpDateCounts(true) + } finally { + if (requestSeq === jumpDateRequestSeqRef.current) { + setLoadingJumpDateCounts(false) + } + } + }, [applyJumpDateCounts, toDateKey, toMonthKey]) + const compareContactsForRanking = useCallback((a: Contact, b: Contact): number => { const aReady = a.postCountStatus === 'ready' const bReady = b.postCountStatus === 'ready' @@ -985,6 +1082,42 @@ export default function SnsPage() {
+
+ + setShowJumpPopover(false)} + onMonthChange={(date) => { + setJumpPopoverDate(date) + void loadJumpDateCounts(date) + }} + onSelect={(date) => { + setJumpPopoverDate(date) + setJumpTargetDate(date) + }} + messageDates={jumpDateMessageDates} + hasLoadedMessageDates={hasLoadedJumpDateCounts} + messageDateCounts={jumpDateCounts} + loadingDateCounts={loadingJumpDateCounts} + /> +
- {renderOverviewStats()} + + {overviewStatsStatus === 'loading' + ? '共 统计中...' + : `共 ${overviewStats.totalPosts.toLocaleString('zh-CN')} 条`} + + + +
+
+ {renderOverviewRangeText()}
From af2fe91f81696cec4527fc7152db6980a2bc76d9 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 15:47:48 +0800 Subject: [PATCH 87/97] fix(sns): restore jump calendar positioning --- src/pages/SnsPage.scss | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 1c77af0..5f18693 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -173,6 +173,18 @@ gap: 10px; } + .jump-calendar-anchor { + position: relative; + display: flex; + align-items: center; + isolation: isolate; + z-index: 20; + + .jump-date-popover { + z-index: 2600; + } + } + .icon-btn { background: var(--bg-tertiary); border: 1px solid var(--border-color); @@ -1092,18 +1104,6 @@ } } - .jump-calendar-anchor { - position: relative; - display: flex; - align-items: center; - isolation: isolate; - z-index: 20; - - .jump-date-popover { - z-index: 2600; - } - } - /* Contact Widget - Refactored */ .contact-widget { display: flex; From 9575ba2a9fc2cd04a62157a561493c8bd2698394 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 15:51:03 +0800 Subject: [PATCH 88/97] feat(sns): show selected jump date in header --- src/pages/SnsPage.scss | 44 ++++++++++++++++++++++++++++++++++++++++++ src/pages/SnsPage.tsx | 32 ++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 5f18693..c642a50 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -210,6 +210,50 @@ animation: spin 0.8s linear infinite; } } + + .jump-date-chip { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid var(--border-color); + border-radius: var(--sns-border-radius-sm); + background: var(--bg-tertiary); + color: var(--text-secondary); + padding: 8px 10px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--hover-bg); + color: var(--primary); + } + + &.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + color: var(--primary); + } + } + + .jump-date-chip-label { + font-size: 13px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + } + + .jump-date-chip-clear { + display: inline-flex; + align-items: center; + justify-content: center; + color: inherit; + border-radius: 999px; + padding: 1px; + + &:hover { + background: color-mix(in srgb, var(--primary) 12%, transparent); + } + } } .sns-posts-scroll { diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 25f146e..2c536f7 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -1093,7 +1093,7 @@ export default function SnsPage() {
Date: Fri, 6 Mar 2026 16:07:48 +0800 Subject: [PATCH 89/97] refactor(sns): open contact timeline from sidebar --- src/components/Sns/SnsFilterPanel.tsx | 27 ++++--------- src/pages/SnsPage.scss | 45 +--------------------- src/pages/SnsPage.tsx | 55 +++++++++++---------------- 3 files changed, 30 insertions(+), 97 deletions(-) diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index b07e596..b7e6a49 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -20,43 +20,33 @@ interface SnsFilterPanelProps { searchKeyword: string setSearchKeyword: (val: string) => void totalFriendsLabel?: string - selectedUsernames: string[] - setSelectedUsernames: (val: string[]) => void contacts: Contact[] contactSearch: string setContactSearch: (val: string) => void loading?: boolean contactsCountProgress?: ContactsCountProgress + onOpenContactTimeline: (contact: Contact) => void } export const SnsFilterPanel: React.FC = ({ searchKeyword, setSearchKeyword, totalFriendsLabel, - selectedUsernames, - setSelectedUsernames, contacts, contactSearch, setContactSearch, loading, - contactsCountProgress + contactsCountProgress, + onOpenContactTimeline }) => { const filteredContacts = contacts.filter(c => (c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) || c.username.toLowerCase().includes(contactSearch.toLowerCase()) ) - const toggleUserSelection = (username: string) => { - if (selectedUsernames.includes(username)) { - setSelectedUsernames(selectedUsernames.filter(u => u !== username)) - } else { - setSelectedUsernames([...selectedUsernames, username]) - } - } - const clearFilters = () => { setSearchKeyword('') - setSelectedUsernames([]) + setContactSearch('') } const getEmptyStateText = () => { @@ -73,7 +63,7 @@ export const SnsFilterPanel: React.FC = ({