diff --git a/electron/main.ts b/electron/main.ts index b91f2ad..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' @@ -87,10 +87,12 @@ 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 let shouldShowMain = true +let isAppQuitting = false // 更新下载状态管理(Issue #294 修复) let isDownloadInProgress = false @@ -123,6 +125,47 @@ interface AnnualReportYearsTaskState { updatedAt: number } +interface OpenSessionChatWindowOptions { + source?: 'chat' | 'export' + initialDisplayName?: string + initialAvatarUrl?: string + initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other' +} + +const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => { + return String(source || '').trim().toLowerCase() === 'export' ? 'export' : 'chat' +} + +const normalizeSessionChatWindowOptionString = (value: unknown): string => { + return String(value || '').trim() +} + +const loadSessionChatWindowContent = ( + win: BrowserWindow, + sessionId: string, + source: 'chat' | 'export', + options?: OpenSessionChatWindowOptions +) => { + const queryParams = new URLSearchParams({ + sessionId, + source + }) + const initialDisplayName = normalizeSessionChatWindowOptionString(options?.initialDisplayName) + const initialAvatarUrl = normalizeSessionChatWindowOptionString(options?.initialAvatarUrl) + const initialContactType = normalizeSessionChatWindowOptionString(options?.initialContactType) + if (initialDisplayName) queryParams.set('initialDisplayName', initialDisplayName) + if (initialAvatarUrl) queryParams.set('initialAvatarUrl', initialAvatarUrl) + if (initialContactType) queryParams.set('initialContactType', initialContactType) + const query = queryParams.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() @@ -290,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 } @@ -688,12 +746,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, options) + sessionChatWindowSources.set(normalizedSessionId, normalizedSource) + } if (existing.isMinimized()) { existing.restore() } @@ -730,10 +794,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, options) + 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 +807,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 +818,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 +1132,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 +1471,7 @@ function registerIpcHandlers() { forceRefresh?: boolean allowStaleCache?: boolean preferAccurateSpecialTypes?: boolean + cacheOnly?: boolean }) => { return chatService.getExportSessionStats(sessionIds, options) }) @@ -1463,6 +1525,10 @@ function registerIpcHandlers() { return snsService.getSnsUsernames() }) + ipcMain.handle('sns:getUserPostCounts', async () => { + return snsService.getUserPostCounts() + }) + ipcMain.handle('sns:getExportStats', async () => { return snsService.getExportStats() }) @@ -1471,6 +1537,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) }) @@ -2373,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/preload.ts b/electron/preload.ts index c173d10..41039e2 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -99,8 +99,16 @@ 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' + initialDisplayName?: string + initialAvatarUrl?: string + initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other' + } + ) => + ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options) }, // 数据库路径 @@ -174,7 +182,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), @@ -339,8 +353,10 @@ 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), 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/chatService.ts b/electron/services/chatService.ts index e50817e..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 { @@ -5209,39 +5210,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 + } } } @@ -5357,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( @@ -5380,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/electron/services/snsService.ts b/electron/services/snsService.ts index 1e0be35..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 @@ -864,6 +866,84 @@ 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 countsResult = await this.getUserPostCounts({ preferCache: true }) + if (countsResult.success) { + const totalPosts = countsResult.counts?.[normalizedUsername] ?? 0 + return { + success: true, + data: { + username: normalizedUsername, + totalPosts: Math.max(0, Number(totalPosts || 0)) + } + } + } + + return { success: false, error: countsResult.error || '统计单个好友朋友圈失败' } + } + // 安装朋友圈删除拦截 async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { return wcdbService.installSnsBlockDeleteTrigger() @@ -881,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/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 diff --git a/src/App.tsx b/src/App.tsx index 137ba7b..416d3e1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -405,8 +405,22 @@ 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') + const standaloneInitialDisplayName = params.get('initialDisplayName') + const standaloneInitialAvatarUrl = params.get('initialAvatarUrl') + const standaloneInitialContactType = params.get('initialContactType') + return ( + + ) } // 独立通知窗口 diff --git a/src/components/Export/ExportDateRangeDialog.scss b/src/components/Export/ExportDateRangeDialog.scss new file mode 100644 index 0000000..9bb27c4 --- /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: 2400; +} + +.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/components/Export/ExportDefaultsSettingsForm.scss b/src/components/Export/ExportDefaultsSettingsForm.scss new file mode 100644 index 0000000..c24b44f --- /dev/null +++ b/src/components/Export/ExportDefaultsSettingsForm.scss @@ -0,0 +1,459 @@ +.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); + } + + .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); + } + + .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); + } + + .media-default-grid { + width: 100%; + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 12px; + margin-bottom: 10px; + + label { + display: inline-flex; + align-items: center; + gap: 5px; + margin-bottom: 0; + font-size: 13px; + line-height: 1; + font-weight: 500; + color: var(--text-primary); + cursor: pointer; + white-space: nowrap; + } + + input[type='checkbox'] { + margin: 0; + accent-color: var(--primary); + } + } + + .log-status { + font-size: 13px; + 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; + 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; + } + } + + &.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-default-grid { + max-width: 360px; + margin-bottom: 0; + } + + .concurrency-inline-options { + max-width: 360px; + 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: none; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 0; + } + } +} + +@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 { + grid-template-columns: 1fr; + gap: 10px; + } + + .form-control { + justify-content: flex-start; + } + + .select-field, + .settings-time-range-field, + .log-toggle-line, + .media-default-grid, + .concurrency-inline-options, + .format-grid { + max-width: none; + } + + .media-default-grid { + flex-wrap: wrap; + } + + .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 new file mode 100644 index 0000000..17090e2 --- /dev/null +++ b/src/components/Export/ExportDefaultsSettingsForm.tsx @@ -0,0 +1,389 @@ +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 + avatars?: boolean + dateRange?: ExportDateRangeSelection + media?: configService.ExportDefaultMediaConfig + voiceAsText?: boolean + excelCompactColumns?: boolean + concurrency?: number +} + +interface ExportDefaultsSettingsFormProps { + onNotify?: (text: string, success: boolean) => void + onDefaultsChanged?: (patch: ExportDefaultsSettingsPatch) => void + layout?: 'stacked' | 'split' +} + +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, + layout = 'stacked' +}: ExportDefaultsSettingsFormProps) { + const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) + const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false) + const exportExcelColumnsDropdownRef = useRef(null) + + const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') + const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true) + const [exportDefaultDateRange, setExportDefaultDateRange] = useState(() => createDefaultExportDateRangeSelection()) + const [exportDefaultMedia, setExportDefaultMedia] = useState({ + images: true, + videos: true, + voices: true, + emojis: true + }) + const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) + const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) + const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2) + + useEffect(() => { + let cancelled = false + void (async () => { + const [savedFormat, savedAvatars, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([ + configService.getExportDefaultFormat(), + configService.getExportDefaultAvatars(), + configService.getExportDefaultDateRange(), + configService.getExportDefaultMedia(), + configService.getExportDefaultVoiceAsText(), + configService.getExportDefaultExcelCompactColumns(), + configService.getExportDefaultConcurrency() + ]) + + if (cancelled) return + + setExportDefaultFormat(savedFormat || 'excel') + setExportDefaultAvatars(savedAvatars ?? true) + setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange)) + setExportDefaultMedia(savedMedia ?? { + images: true, + videos: true, + voices: true, + emojis: true + }) + setExportDefaultVoiceAsText(savedVoiceAsText ?? false) + setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) + setExportDefaultConcurrency(savedConcurrency ?? 2) + })() + + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node + if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) { + setShowExportExcelColumnsSelect(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [showExportExcelColumnsSelect]) + + const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full' + const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange]) + const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue]) + + const notify = (text: string, success = true) => { + onNotify?.(text, success) + } + + return ( +
+
+
+ + 导出多个会话时的最大并发(1~6) +
+
+
+ {exportConcurrencyOptions.map((option) => ( + + ))} +
+
+
+ +
+
+ + 导出页面默认选中的格式 +
+
+
+ {exportFormatOptions.map((option) => ( + + ))} +
+
+
+ +
+
+ + 开启后导出的聊天消息对应的文件中会带头像信息。 +
+
+
+ {exportDefaultAvatars ? '已开启' : '已关闭'} + +
+
+
+ +
+
+ + 控制导出页面的默认时间选择 +
+
+
+ +
+
+
+ + setIsExportDateRangeDialogOpen(false)} + onConfirm={async (nextSelection) => { + setExportDefaultDateRange(nextSelection) + await configService.setExportDefaultDateRange(serializeExportDateRangeConfig(nextSelection)) + onDefaultsChanged?.({ dateRange: nextSelection }) + notify('已更新默认导出时间范围', true) + setIsExportDateRangeDialogOpen(false) + }} + /> + +
+
+ + 控制 Excel 导出的列字段 +
+
+
+ + {showExportExcelColumnsSelect && ( +
+ {exportExcelColumnOptions.map((option) => ( + + ))} +
+ )} +
+
+
+ +
+
+ + 控制图片、视频、语音、表情包的默认导出开关 +
+
+
+ + + + +
+
+
+ +
+
+ + 导出时默认将语音转写为文字 +
+
+
+ {exportDefaultVoiceAsText ? '已开启' : '已关闭'} + +
+
+
+ +
+ ) +} 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 (
- {authEnabled && ( - - )} + void + initialTotalPosts?: number | null + initialTotalPostsLoading?: boolean + isProtected?: boolean + onDeletePost?: (postId: string, username: string) => void +} + +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, + isProtected = false, + onDeletePost +}: 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 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)) + }, []) + + 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={() => {}} + onDelete={onDeletePost} + hideAuthorMeta + /> + ))} +
+ )} + + {timelineLoading && ( +
正在加载该联系人的朋友圈...
+ )} + + {!timelineLoading && timelinePosts.length === 0 && ( +
该联系人暂无朋友圈
+ )} + + {!timelineLoading && timelineHasMore && ( + + )} +
+
+
, + document.body + ) +} diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index 35e23fe..b7e6a49 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -1,60 +1,52 @@ -import React, { useState } from 'react' -import { Search, Calendar, User, X, Filter, Check } from 'lucide-react' +import React from 'react' +import { Search, User, X, Loader2 } from 'lucide-react' import { Avatar } from '../Avatar' -// import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved interface Contact { username: string displayName: string avatarUrl?: string + postCount?: number + postCountStatus?: 'idle' | 'loading' | 'ready' +} + +interface ContactsCountProgress { + resolved: number + total: number + running: boolean } interface SnsFilterPanelProps { searchKeyword: string setSearchKeyword: (val: string) => void - jumpTargetDate?: Date - setJumpTargetDate: (date?: Date) => void - onOpenJumpDialog: () => void - selectedUsernames: string[] - setSelectedUsernames: (val: string[]) => void + totalFriendsLabel?: string contacts: Contact[] contactSearch: string setContactSearch: (val: string) => void loading?: boolean + contactsCountProgress?: ContactsCountProgress + onOpenContactTimeline: (contact: Contact) => void } export const SnsFilterPanel: React.FC = ({ searchKeyword, setSearchKeyword, - jumpTargetDate, - setJumpTargetDate, - onOpenJumpDialog, - selectedUsernames, - setSelectedUsernames, + totalFriendsLabel, contacts, contactSearch, setContactSearch, - loading + loading, + contactsCountProgress, + onOpenContactTimeline }) => { - const filteredContacts = contacts.filter(c => - c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) || + (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 { - setJumpTargetDate(undefined) // Reset date jump when selecting user - setSelectedUsernames([...selectedUsernames, username]) - } - } - const clearFilters = () => { setSearchKeyword('') - setSelectedUsernames([]) - setJumpTargetDate(undefined) + setContactSearch('') } const getEmptyStateText = () => { @@ -71,7 +63,7 @@ export const SnsFilterPanel: React.FC = ({ )} + + @@ -4550,51 +6860,97 @@ function ExportPage() {
event.stopPropagation()}>
-

{exportDialog.title}

+
+

{exportDialog.title}

+ {isContentTextDialog && ( +
{contentTextDialogSummary}
+ )} +
-
-

导出范围

-
- {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 && (
-

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

- {isContentTextDialog && ( -
说明:此模式默认导出头像,不导出图片、语音、视频、表情包等媒体内容。
+ {useCollapsedSessionFormatSelector ? ( +
+

对话文本导出格式选择

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

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

+ )} + {!isContentScopeDialog && exportDialog.scope !== 'sns' && ( +
{avatarExportStatusLabel}
+ )} + {isContentTextDialog && ( +
{avatarExportStatusLabel}
+ )} + {!useCollapsedSessionFormatSelector && ( +
+ {formatCandidateOptions.map(option => ( + + ))} +
)} -
- {formatCandidateOptions.map(option => ( - - ))} -
)} @@ -4614,7 +6970,7 @@ function ExportPage() { {shouldShowMediaSection && (
-

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

+

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

{exportDialog.scope === 'sns' ? ( <> @@ -4628,8 +6984,6 @@ function ExportPage() { - - )}
@@ -4639,6 +6993,26 @@ function ExportPage() {
)} + {isSessionScopeDialog && ( +
+
+
+

语音转文字

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

发送者名称显示

@@ -4671,173 +7045,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..8afa61b 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react' +import { useLocation } from 'react-router-dom' import { useAppStore } from '../stores/appStore' import { useChatStore } from '../stores/chatStore' import { useThemeStore, themes } from '../stores/themeStore' @@ -8,20 +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 './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 }, @@ -37,6 +37,7 @@ interface WxidOption { } function SettingsPage() { + const location = useLocation() const { isDbConnected, setDbConnected, @@ -73,14 +74,6 @@ function SettingsPage() { const [wxid, setWxid] = useState('') 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('') const [imageKeyProgress, setImageKeyProgress] = useState(0) const [imageKeyPercent, setImageKeyPercent] = useState(null) @@ -103,12 +96,6 @@ function SettingsPage() { const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false) const [transcribeLanguages, setTranscribeLanguages] = useState(['zh']) - const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') - const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today') - 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') @@ -202,26 +189,11 @@ function SettingsPage() { } }, []) - // 点击外部关闭下拉框 useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - const target = e.target as Node - 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) - } - if (showExportConcurrencySelect && exportConcurrencyDropdownRef.current && !exportConcurrencyDropdownRef.current.contains(target)) { - setShowExportConcurrencySelect(false) - } - } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect]) + const initialTab = (location.state as { initialTab?: SettingsTab } | null)?.initialTab + if (!initialTab) return + setActiveTab(initialTab) + }, [location.state]) useEffect(() => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => { @@ -289,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() @@ -330,12 +295,6 @@ function SettingsPage() { setLogEnabled(savedLogEnabled) setAutoTranscribeVoice(savedAutoTranscribe) setTranscribeLanguages(savedTranscribeLanguages) - setExportDefaultFormat(savedExportDefaultFormat || 'excel') - setExportDefaultDateRange(savedExportDefaultDateRange || 'today') - setExportDefaultMedia(savedExportDefaultMedia ?? false) - setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? false) - setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true) - setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2) setNotificationEnabled(savedNotificationEnabled) setNotificationPosition(savedNotificationPosition) @@ -1547,258 +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 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/备注' } - ] - - 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 = () => { - const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full' - const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat) - const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange) - const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue) - const exportConcurrencyLabel = String(exportDefaultConcurrency) - - return ( -
-
- - 导出页面默认选中的格式 -
- - {showExportFormatSelect && ( -
- {exportFormatOptions.map((option) => ( - - ))} -
- )} -
-
- -
- - 控制导出页面的默认时间选择 -
- - {showExportDateRangeSelect && ( -
- {exportDateRangeOptions.map((option) => ( - - ))} -
- )} -
-
- -
- - 控制图片/语音/表情的默认导出开关 -
- {exportDefaultMedia ? '已开启' : '已关闭'} - -
-
- -
- - 导出时默认将语音转写为文字 -
- {exportDefaultVoiceAsText ? '已开启' : '已关闭'} - -
-
- -
- - 控制 Excel 导出的列字段 -
- - {showExportExcelColumnsSelect && ( -
- {exportExcelColumnOptions.map((option) => ( - - ))} -
- )} -
-
- -
- - 导出多个会话时的最大并发(1~6) -
- - {showExportConcurrencySelect && ( -
- {exportConcurrencyOptions.map((option) => ( - - ))} -
- )} -
-
- -
- ) - } const renderCacheTab = () => (

管理应用缓存数据

@@ -2395,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()} diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index bc52b0c..d6d24ad 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: 10px 24px 12px 24px; display: flex; flex-direction: column; gap: 0; @@ -44,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; @@ -67,6 +68,10 @@ } .feed-stats-line { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; font-size: 13px; color: var(--text-secondary); line-height: 1.4; @@ -80,6 +85,76 @@ } } + .feed-stats-range { + gap: 0; + } + + .feed-overview-total { + font-size: inherit; + color: inherit; + white-space: nowrap; + } + + .feed-stats-divider { + color: color-mix(in srgb, var(--text-secondary) 78%, transparent); + } + + .feed-my-timeline-entry { + display: inline-flex; + align-items: center; + gap: 8px; + width: fit-content; + padding: 0; + border: none; + background: transparent; + font-size: 13px; + line-height: 1.4; + color: var(--text-secondary); + cursor: default; + transition: color 0.2s ease, opacity 0.2s ease; + + .feed-my-timeline-label { + font-weight: 500; + } + + .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 { + cursor: pointer; + + &:hover { + color: var(--primary); + } + + &:hover .feed-my-timeline-count { + color: var(--primary); + } + + &:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 3px; + border-radius: 6px; + } + } + + &.loading { + opacity: 0.72; + } + + &:disabled { + opacity: 0.68; + } + } + .feed-stats-retry { border: none; background: transparent; @@ -98,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); @@ -123,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 { @@ -179,6 +310,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 +361,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); @@ -219,6 +398,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; @@ -909,9 +1095,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; @@ -950,44 +1148,6 @@ } } - /* Date Widget */ - .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; @@ -1043,6 +1203,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; @@ -1060,9 +1228,8 @@ border-radius: var(--sns-border-radius-md); cursor: pointer; transition: background 0.2s ease, transform 0.2s ease; - border: 2px solid transparent; + border: 1px solid transparent; margin-bottom: 4px; - /* Separation for unselected items */ &:hover { background: var(--hover-bg); @@ -1070,41 +1237,6 @@ z-index: 10; } - &.selected { - background: rgba(var(--primary-rgb), 0.1); - border-color: var(--primary); - box-shadow: none; - z-index: 5; - margin-bottom: 0; - /* Remove margin to merge */ - - .contact-meta { - .contact-name { - color: var(--primary); - font-weight: 600; - } - } - - /* If the NEXT item is also selected */ - &:has(+ .contact-row.selected) { - border-bottom: none; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - padding-bottom: 12px; - /* Compensate for missing border (+2px) */ - } - } - - /* If the PREVIOUS item is selected */ - &.selected+.contact-row.selected { - border-top: none; - border-top-left-radius: 0; - border-top-right-radius: 0; - margin-top: 0; - padding-top: 12px; - /* Compensate for missing border */ - } - .contact-meta { flex: 1; min-width: 0; @@ -1120,6 +1252,33 @@ 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; + } + } } } @@ -1317,6 +1476,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; @@ -1436,6 +1705,44 @@ gap: 8px; } +.export-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.time-range-trigger.sns-export-time-range-trigger { + border: 1px solid var(--border-color); + background: var(--bg-primary); + border-radius: 999px; + color: var(--text-primary); + font-size: 12px; + min-height: 32px; + padding: 0 10px; + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease; + + &:hover:not(:disabled) { + border-color: rgba(var(--primary-rgb), 0.45); + color: var(--primary); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .time-range-arrow { + color: var(--text-tertiary); + font-weight: 700; + line-height: 1; + } +} + .export-format-options { display: grid; diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index e7caad5..0482240 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -1,21 +1,51 @@ -import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react' -import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight, Shield, ShieldOff } from 'lucide-react' -import JumpToDateDialog from '../components/JumpToDateDialog' +import { useEffect, useLayoutEffect, useState, useRef, useCallback, useMemo } from 'react' +import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, Shield, ShieldOff, Loader2 } from 'lucide-react' import './SnsPage.scss' import { SnsPost } from '../types/sns' 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 { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' import * as configService from '../services/config' +import { + createExportDateRangeSelectionFromPreset, + getExportDateRangeLabel, + type ExportDateRangeSelection +} from '../utils/exportDateRange' 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 + remark?: string + nickname?: string type?: 'friend' | 'former_friend' | 'sns_only' + lastSessionTimestamp?: number + postCount?: number + postCountStatus?: ContactPostCountStatus +} + +interface SidebarUserProfile { + wxid: string + displayName: string + alias?: string + avatarUrl?: string +} + +interface ContactsCountProgress { + resolved: number + total: number + running: boolean } interface SnsOverviewStats { @@ -28,6 +58,38 @@ interface SnsOverviewStats { type OverviewStatsStatus = 'loading' | 'ready' | 'error' +const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1' + +const readSidebarUserProfileCache = (): SidebarUserProfile | null => { + try { + const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as SidebarUserProfile + if (!parsed || typeof parsed !== 'object') return null + return { + wxid: String(parsed.wxid || '').trim(), + displayName: String(parsed.displayName || '').trim(), + alias: parsed.alias ? String(parsed.alias).trim() : undefined, + avatarUrl: parsed.avatarUrl ? String(parsed.avatarUrl).trim() : undefined + } + } catch { + return null + } +} + +const normalizeAccountId = (value?: string | null): string => { + const trimmed = String(value || '').trim() + if (!trimmed) return '' + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + return (match?.[1] || trimmed).toLowerCase() + } + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + return (suffixMatch ? suffixMatch[1] : trimmed).toLowerCase() +} + +const normalizeNameForCompare = (value?: string | null): string => String(value || '').trim().toLowerCase() + export default function SnsPage() { const [posts, setPosts] = useState([]) const [loading, setLoading] = useState(false) @@ -44,17 +106,31 @@ export default function SnsPage() { // Filter states const [searchKeyword, setSearchKeyword] = useState('') - const [selectedUsernames, setSelectedUsernames] = useState([]) const [jumpTargetDate, setJumpTargetDate] = useState(undefined) // Contacts state const [contacts, setContacts] = useState([]) const [contactSearch, setContactSearch] = useState('') const [contactsLoading, setContactsLoading] = useState(false) + const [contactsCountProgress, setContactsCountProgress] = useState({ + resolved: 0, + total: 0, + running: false + }) + const [currentUserProfile, setCurrentUserProfile] = useState(() => readSidebarUserProfileCache() || { + wxid: '', + displayName: '' + }) // UI states - const [showJumpDialog, setShowJumpDialog] = useState(false) 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) @@ -63,13 +139,14 @@ export default function SnsPage() { const [exportImages, setExportImages] = useState(false) const [exportLivePhotos, setExportLivePhotos] = useState(false) const [exportVideos, setExportVideos] = useState(false) - const [exportDateRange, setExportDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' }) + const [exportDateRangeSelection, setExportDateRangeSelection] = useState( + () => createExportDateRangeSelectionFromPreset('all') + ) const [isExporting, setIsExporting] = useState(false) const [exportProgress, setExportProgress] = useState<{ current: number; total: number; status: string } | null>(null) const [exportResult, setExportResult] = useState<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string } | null>(null) const [refreshSpin, setRefreshSpin] = useState(false) - const [calendarPicker, setCalendarPicker] = useState<{ field: 'start' | 'end'; month: Date } | null>(null) - const [showYearMonthPicker, setShowYearMonthPicker] = useState(false) + const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false) // 触发器相关状态 const [showTriggerDialog, setShowTriggerDialog] = useState(false) @@ -78,37 +155,58 @@ 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([]) + 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) + const contactsCountBatchTimerRef = useRef(null) + const jumpDateCountsCacheRef = useRef>>(new Map()) + const jumpDateRequestSeqRef = useRef(0) // Sync posts ref useEffect(() => { postsRef.current = posts }, [posts]) + useEffect(() => { + contactsRef.current = contacts + }, [contacts]) useEffect(() => { overviewStatsRef.current = overviewStats }, [overviewStats]) useEffect(() => { overviewStatsStatusRef.current = overviewStatsStatus }, [overviewStatsStatus]) - useEffect(() => { - selectedUsernamesRef.current = selectedUsernames - }, [selectedUsernames]) useEffect(() => { searchKeywordRef.current = searchKeyword }, [searchKeyword]) 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; @@ -132,8 +230,170 @@ 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 normalizePostCount = useCallback((value: unknown): number => { + const numeric = Number(value) + if (!Number.isFinite(numeric)) return 0 + 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' + 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 resolvedCurrentUserContact = useMemo(() => { + const normalizedWxid = normalizeAccountId(currentUserProfile.wxid) + const normalizedAlias = normalizeAccountId(currentUserProfile.alias) + const normalizedDisplayName = normalizeNameForCompare(currentUserProfile.displayName) + + if (normalizedWxid) { + const exactByUsername = contacts.find((contact) => normalizeAccountId(contact.username) === normalizedWxid) + if (exactByUsername) return exactByUsername + } + + if (normalizedAlias) { + const exactByAliasLikeName = contacts.find((contact) => { + const candidates = [contact.displayName, contact.remark, contact.nickname].map(normalizeNameForCompare) + return candidates.includes(normalizedAlias) + }) + if (exactByAliasLikeName) return exactByAliasLikeName + } + + if (!normalizedDisplayName) return null + return contacts.find((contact) => { + const candidates = [contact.displayName, contact.remark, contact.nickname].map(normalizeNameForCompare) + return candidates.includes(normalizedDisplayName) + }) || null + }, [contacts, currentUserProfile.alias, currentUserProfile.displayName, currentUserProfile.wxid]) + + const currentTimelineTargetContact = useMemo(() => { + const normalizedTargetUsername = String(authorTimelineTarget?.username || '').trim() + if (!normalizedTargetUsername) return null + return contacts.find((contact) => contact.username === normalizedTargetUsername) || null + }, [authorTimelineTarget, contacts]) + + const myTimelineCount = useMemo(() => { + if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') { + return normalizePostCount(resolvedCurrentUserContact.postCount) + } + return null + }, [normalizePostCount, resolvedCurrentUserContact]) + + const myTimelineCountLoading = Boolean( + resolvedCurrentUserContact + ? resolvedCurrentUserContact.postCountStatus !== 'ready' + : overviewStatsStatus === 'loading' || contactsLoading + ) + + const openCurrentUserTimeline = useCallback(() => { + if (!resolvedCurrentUserContact) return + setAuthorTimelineTarget({ + username: resolvedCurrentUserContact.username, + displayName: resolvedCurrentUserContact.displayName || currentUserProfile.displayName || resolvedCurrentUserContact.username, + avatarUrl: resolvedCurrentUserContact.avatarUrl || currentUserProfile.avatarUrl + }) + }, [currentUserProfile.avatarUrl, currentUserProfile.displayName, resolvedCurrentUserContact]) + const isDefaultViewNow = useCallback(() => { - return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current + return !searchKeywordRef.current.trim() && !jumpTargetDateRef.current }, []) const ensureSnsCacheScopeKey = useCallback(async () => { @@ -144,6 +404,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 { @@ -264,7 +539,7 @@ export default function SnsPage() { } }, [persistSnsPageCache]) - const renderOverviewStats = () => { + const renderOverviewRangeText = () => { if (overviewStatsStatus === 'error') { return ( +
+
+ {renderOverviewRangeText()}
+
+ + setShowJumpPopover(false)} + onMonthChange={(date) => { + setJumpPopoverDate(date) + void loadJumpDateCounts(date) + }} + onSelect={(date) => { + setJumpPopoverDate(date) + setJumpTargetDate(date) + }} + messageDates={jumpDateMessageDates} + hasLoadedMessageDates={hasLoadedJumpDateCounts} + messageDateCounts={jumpDateCounts} + loadingDateCounts={loadingJumpDateCounts} + /> +
@@ -607,21 +1264,16 @@ export default function SnsPage() { )} {!hasMore && posts.length > 0 && ( -
{ - selectedUsernames.length === 1 && - contacts.find(c => c.username === selectedUsernames[0])?.type === 'former_friend' - ? '在时间的长河里刻舟求剑' - : '或许过往已无可溯洄,但好在还有可以与你相遇的明天' - }
+
或许过往已无可溯洄,但好在还有可以与你相遇的明天
)} {!loading && posts.length === 0 && (

未找到相关动态

- {(selectedUsernames.length > 0 || searchKeyword || jumpTargetDate) && ( + {(searchKeyword || jumpTargetDate) && ( @@ -635,26 +1287,35 @@ export default function SnsPage() { setShowJumpDialog(true)} - selectedUsernames={selectedUsernames} - setSelectedUsernames={setSelectedUsernames} + totalFriendsLabel={ + overviewStatsStatus === 'loading' + ? '统计中' + : overviewStatsStatus === 'ready' + ? `${overviewStats.totalFriends} 位好友` + : undefined + } contacts={contacts} contactSearch={contactSearch} setContactSearch={setContactSearch} loading={contactsLoading} + contactsCountProgress={contactsCountProgress} + onOpenContactTimeline={openContactTimeline} /> {/* Dialogs and Overlays */} - setShowJumpDialog(false)} - onSelect={(date) => { - setJumpTargetDate(date) - setShowJumpDialog(false) - }} - currentDate={jumpTargetDate || new Date()} + {debugPost && ( @@ -783,17 +1444,10 @@ export default function SnsPage() {
{/* 筛选条件提示 */} - {(selectedUsernames.length > 0 || searchKeyword) && ( + {searchKeyword && (
筛选导出 {searchKeyword && 关键词: "{searchKeyword}"} - {selectedUsernames.length > 0 && ( - - - {selectedUsernames.length} 个联系人 - (同步自侧栏筛选) - - )}
)} @@ -861,31 +1515,19 @@ export default function SnsPage() { {/* 时间范围 */}
- -
-
{ - if (!isExporting) setCalendarPicker(prev => prev?.field === 'start' ? null : { field: 'start', month: exportDateRange.start ? new Date(exportDateRange.start) : new Date() }) - }}> - - - {exportDateRange.start || '开始日期'} - - {exportDateRange.start && ( - { e.stopPropagation(); setExportDateRange(prev => ({ ...prev, start: '' })) }} /> - )} -
- -
{ - if (!isExporting) setCalendarPicker(prev => prev?.field === 'end' ? null : { field: 'end', month: exportDateRange.end ? new Date(exportDateRange.end) : new Date() }) - }}> - - - {exportDateRange.end || '结束日期'} - - {exportDateRange.end && ( - { e.stopPropagation(); setExportDateRange(prev => ({ ...prev, end: '' })) }} /> - )} -
+
+ +
@@ -930,7 +1572,7 @@ export default function SnsPage() { {/* 同步提示 */}
- 将同步主页面的联系人范围筛选及关键词搜索 + 将同步主页面的关键词搜索
{/* 进度条 */} @@ -972,13 +1614,16 @@ export default function SnsPage() { const result = await window.electronAPI.sns.exportTimeline({ outputDir: exportFolder, format: exportFormat, - usernames: selectedUsernames.length > 0 ? selectedUsernames : undefined, keyword: searchKeyword || undefined, exportImages, exportLivePhotos, exportVideos, - startTime: exportDateRange.start ? Math.floor(new Date(exportDateRange.start).getTime() / 1000) : undefined, - endTime: exportDateRange.end ? Math.floor(new Date(exportDateRange.end + 'T23:59:59').getTime() / 1000) : undefined + startTime: exportDateRangeSelection.useAllTime + ? undefined + : Math.floor(exportDateRangeSelection.dateRange.start.getTime() / 1000), + endTime: exportDateRangeSelection.useAllTime + ? undefined + : Math.floor(exportDateRangeSelection.dateRange.end.getTime() / 1000) }) setExportResult(result) } catch (e: any) { @@ -1045,119 +1690,15 @@ export default function SnsPage() {
)} - {/* 日期选择弹窗 */} - {calendarPicker && ( -
{ setCalendarPicker(null); setShowYearMonthPicker(false) }}> -
e.stopPropagation()}> -
-
- -

选择{calendarPicker.field === 'start' ? '开始' : '结束'}日期

-
- -
-
-
- - setShowYearMonthPicker(!showYearMonthPicker)}> - {calendarPicker.month.getFullYear()}年{calendarPicker.month.getMonth() + 1}月 - - -
- {showYearMonthPicker ? ( -
-
- - {calendarPicker.month.getFullYear()}年 - -
-
- {['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => ( - - ))} -
-
- ) : ( - <> -
- {['日', '一', '二', '三', '四', '五', '六'].map(d =>
{d}
)} -
-
- {(() => { - const y = calendarPicker.month.getFullYear() - const m = calendarPicker.month.getMonth() - const firstDay = new Date(y, m, 1).getDay() - const daysInMonth = new Date(y, m + 1, 0).getDate() - const cells: (number | null)[] = [] - for (let i = 0; i < firstDay; i++) cells.push(null) - for (let i = 1; i <= daysInMonth; i++) cells.push(i) - const today = new Date() - return cells.map((day, i) => { - if (day === null) return
- const dateStr = `${y}-${String(m + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}` - const isToday = day === today.getDate() && m === today.getMonth() && y === today.getFullYear() - const currentVal = calendarPicker.field === 'start' ? exportDateRange.start : exportDateRange.end - const isSelected = dateStr === currentVal - return ( -
{ - setExportDateRange(prev => ({ ...prev, [calendarPicker.field]: dateStr })) - setCalendarPicker(null) - }} - >{day}
- ) - }) - })()} -
- - )} -
-
- - -
-
- -
-
-
- )} + setIsExportDateRangeDialogOpen(false)} + onConfirm={(nextSelection) => { + setExportDateRangeSelection(nextSelection) + setIsExportDateRangeDialogOpen(false) + }} + />
) } diff --git a/src/services/config.ts b/src/services/config.ts index 60ed613..2cd8787 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 = { @@ -26,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', @@ -41,6 +43,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', @@ -75,6 +78,20 @@ export interface WxidConfig { updatedAt?: number } +export interface ExportDefaultMediaConfig { + images: boolean + videos: boolean + voices: boolean + emojis: boolean +} + +const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = { + images: true, + videos: true, + voices: true, + emojis: true +} + // 获取解密密钥 export async function getDecryptKey(): Promise { const value = await config.get(CONFIG_KEYS.DECRYPT_KEY) @@ -333,27 +350,64 @@ export async function setExportDefaultFormat(format: string): Promise { await config.set(CONFIG_KEYS.EXPORT_DEFAULT_FORMAT, format) } -// 获取导出默认时间范围 -export async function getExportDefaultDateRange(): Promise { - const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE) - return (value as string) || null -} - -// 设置导出默认时间范围 -export async function setExportDefaultDateRange(range: string): Promise { - await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range) -} - -// 获取导出默认媒体设置 -export async function getExportDefaultMedia(): Promise { - const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA) +// 获取导出默认头像设置 +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) + if (typeof value === 'string') return value + if (value && typeof value === 'object') { + return value as ExportDefaultDateRangeConfig + } + return null +} + +// 设置导出默认时间范围 +export async function setExportDefaultDateRange(range: ExportDefaultDateRangeConfig | string): Promise { + await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range) +} + +// 获取导出默认媒体设置 +export async function getExportDefaultMedia(): Promise { + const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA) + if (typeof value === 'boolean') { + return { + images: value, + videos: value, + voices: value, + emojis: value + } + } + if (value && typeof value === 'object') { + const raw = value as Partial> + return { + images: typeof raw.images === 'boolean' ? raw.images : DEFAULT_EXPORT_MEDIA_CONFIG.images, + videos: typeof raw.videos === 'boolean' ? raw.videos : DEFAULT_EXPORT_MEDIA_CONFIG.videos, + voices: typeof raw.voices === 'boolean' ? raw.voices : DEFAULT_EXPORT_MEDIA_CONFIG.voices, + emojis: typeof raw.emojis === 'boolean' ? raw.emojis : DEFAULT_EXPORT_MEDIA_CONFIG.emojis + } + } + return null +} + // 设置导出默认媒体设置 -export async function setExportDefaultMedia(enabled: boolean): Promise { - await config.set(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA, enabled) +export async function setExportDefaultMedia(media: ExportDefaultMediaConfig): Promise { + await config.set(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA, { + images: media.images, + videos: media.videos, + voices: media.voices, + emojis: media.emojis + }) } // 获取导出默认语音转文字 @@ -534,6 +588,11 @@ export interface ExportSnsStatsCacheItem { totalFriends: number } +export interface ExportSnsUserPostCountsCacheItem { + updatedAt: number + counts: Record +} + export interface SnsPageOverviewCache { totalPosts: number totalFriends: number @@ -741,6 +800,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) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index a3abc38..72aaa57 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -1,5 +1,12 @@ import type { ChatSession, Message, Contact, ContactInfo } from './models' +export interface SessionChatWindowOpenOptions { + source?: 'chat' | 'export' + initialDisplayName?: string + initialAvatarUrl?: string + initialContactType?: ContactInfo['type'] +} + export interface ElectronAPI { window: { minimize: () => void @@ -13,7 +20,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 +257,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 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 }> installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }> checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }> 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)}` +}