diff --git a/electron/main.ts b/electron/main.ts index 6b692b4..ce8f0ac 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -4,7 +4,7 @@ import { Worker } from 'worker_threads' import { randomUUID } from 'crypto' import { join, dirname } from 'path' import { autoUpdater } from 'electron-updater' -import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises' +import { readFile, writeFile, mkdir, rm, readdir, copyFile } from 'fs/promises' import { existsSync } from 'fs' import { ConfigService } from './services/config' import { dbPathService } from './services/dbPathService' @@ -27,7 +27,7 @@ import { windowsHelloService } from './services/windowsHelloService' import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService' import { cloudControlService } from './services/cloudControlService' -import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow' +import { destroyNotificationWindow, registerNotificationHandlers, showNotification, setNotificationNavigateHandler } from './windows/notificationWindow' import { httpService } from './services/httpService' import { messagePushService } from './services/messagePushService' import { bizService } from './services/bizService' @@ -740,6 +740,14 @@ function createWindow(options: { autoShow?: boolean } = {}) { win.webContents.send('navigate-to-session', sessionId) }) + // 设置用于D-Bus通知的Linux通知导航处理程序 + setNotificationNavigateHandler((sessionId: string) => { + if (win.isMinimized()) win.restore() + win.show() + win.focus() + win.webContents.send('navigate-to-session', sessionId) + }) + // 拦截请求,修改 Referer 和 User-Agent 以通过微信 CDN 鉴权 session.defaultSession.webRequest.onBeforeSendHeaders( { @@ -1371,6 +1379,225 @@ const removeMatchedEntriesInDir = async ( } } +const normalizeFsPathForCompare = (value: string): string => { + const normalized = String(value || '').replace(/\\/g, '/').replace(/\/+$/, '') + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +type SnsCacheMigrationCandidate = { + label: string + sourceDir: string + targetDir: string + fileCount: number +} + +type SnsCacheMigrationPlan = { + legacyBaseDir: string + currentBaseDir: string + candidates: SnsCacheMigrationCandidate[] + totalFiles: number +} + +type SnsCacheMigrationProgressPayload = { + status: 'running' | 'done' | 'error' + phase: 'copying' | 'cleanup' | 'done' | 'error' + current: number + total: number + copied: number + skipped: number + remaining: number + message?: string + currentItemLabel?: string +} + +let snsCacheMigrationInProgress = false + +const countFilesInDir = async (dirPath: string): Promise => { + if (!dirPath || !existsSync(dirPath)) return 0 + try { + const entries = await readdir(dirPath, { withFileTypes: true }) + let count = 0 + for (const entry of entries) { + const fullPath = join(dirPath, entry.name) + if (entry.isDirectory()) { + count += await countFilesInDir(fullPath) + continue + } + if (entry.isFile()) count += 1 + } + return count + } catch { + return 0 + } +} + +const migrateDirectoryPreserveNewFiles = async ( + sourceDir: string, + targetDir: string, + onFileProcessed?: (payload: { copied: boolean }) => void +): Promise<{ copied: number; skipped: number; processed: number }> => { + let copied = 0 + let skipped = 0 + let processed = 0 + + if (!existsSync(sourceDir)) return { copied, skipped, processed } + await mkdir(targetDir, { recursive: true }) + + const entries = await readdir(sourceDir, { withFileTypes: true }) + for (const entry of entries) { + const sourcePath = join(sourceDir, entry.name) + const targetPath = join(targetDir, entry.name) + + if (entry.isDirectory()) { + const nested = await migrateDirectoryPreserveNewFiles(sourcePath, targetPath, onFileProcessed) + copied += nested.copied + skipped += nested.skipped + processed += nested.processed + continue + } + + if (!entry.isFile()) continue + + if (existsSync(targetPath)) { + skipped += 1 + processed += 1 + onFileProcessed?.({ copied: false }) + continue + } + + await mkdir(dirname(targetPath), { recursive: true }) + await copyFile(sourcePath, targetPath) + copied += 1 + processed += 1 + onFileProcessed?.({ copied: true }) + } + + return { copied, skipped, processed } +} + +const collectLegacySnsCacheMigrationPlan = async (): Promise => { + if (!configService) return null + + const legacyBaseDir = configService.getCacheBasePath() + const configuredCachePath = String(configService.get('cachePath') || '').trim() + const currentBaseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow') + + if (!legacyBaseDir || !currentBaseDir) return null + + const candidates = [ + { + label: '朋友圈媒体缓存', + sourceDir: join(legacyBaseDir, 'sns_cache'), + targetDir: join(currentBaseDir, 'sns_cache') + }, + { + label: '朋友圈表情缓存(合并到 Emojis)', + sourceDir: join(legacyBaseDir, 'sns_emoji_cache'), + targetDir: join(currentBaseDir, 'Emojis') + }, + { + label: '朋友圈表情缓存(当前目录残留)', + sourceDir: join(currentBaseDir, 'sns_emoji_cache'), + targetDir: join(currentBaseDir, 'Emojis') + } + ] + + const pendingKeys = new Set() + const pending: SnsCacheMigrationCandidate[] = [] + for (const item of candidates) { + const sourceKey = normalizeFsPathForCompare(item.sourceDir) + const targetKey = normalizeFsPathForCompare(item.targetDir) + if (!sourceKey || sourceKey === targetKey) continue + const dedupeKey = `${sourceKey}=>${targetKey}` + if (pendingKeys.has(dedupeKey)) continue + const fileCount = await countFilesInDir(item.sourceDir) + if (fileCount <= 0) continue + pendingKeys.add(dedupeKey) + pending.push({ ...item, fileCount }) + } + if (pending.length === 0) return null + + const totalFiles = pending.reduce((sum, item) => sum + item.fileCount, 0) + return { + legacyBaseDir, + currentBaseDir, + candidates: pending, + totalFiles + } +} + +const runLegacySnsCacheMigration = async ( + plan: SnsCacheMigrationPlan, + onProgress: (payload: SnsCacheMigrationProgressPayload) => void +): Promise<{ copied: number; skipped: number; totalFiles: number }> => { + let processed = 0 + let copied = 0 + let skipped = 0 + const total = plan.totalFiles + + const emitProgress = (patch?: Partial) => { + onProgress({ + status: 'running', + phase: 'copying', + current: processed, + total, + copied, + skipped, + remaining: Math.max(0, total - processed), + ...patch + }) + } + + emitProgress({ message: '准备迁移缓存...' }) + + for (const item of plan.candidates) { + emitProgress({ currentItemLabel: item.label, message: `正在迁移:${item.label}` }) + const result = await migrateDirectoryPreserveNewFiles(item.sourceDir, item.targetDir, ({ copied: copiedThisFile }) => { + processed += 1 + if (copiedThisFile) copied += 1 + else skipped += 1 + emitProgress({ currentItemLabel: item.label }) + }) + // 兜底对齐计数,防止回调未触发造成偏差 + const expectedProcessed = copied + skipped + if (processed !== expectedProcessed) { + processed = expectedProcessed + copied = Math.max(copied, result.copied) + skipped = Math.max(skipped, result.skipped) + emitProgress({ currentItemLabel: item.label }) + } + } + + emitProgress({ phase: 'cleanup', message: '正在清理旧目录...' }) + for (const item of plan.candidates) { + await rm(item.sourceDir, { recursive: true, force: true }) + } + + if (existsSync(plan.legacyBaseDir)) { + try { + const remaining = await readdir(plan.legacyBaseDir) + if (remaining.length === 0) { + await rm(plan.legacyBaseDir, { recursive: true, force: true }) + } + } catch { + // 忽略旧目录清理失败,不影响迁移结果 + } + } + + onProgress({ + status: 'done', + phase: 'done', + current: processed, + total, + copied, + skipped, + remaining: Math.max(0, total - processed), + message: '迁移完成' + }) + + return { copied, skipped, totalFiles: total } +} + // 注册 IPC 处理器 function registerIpcHandlers() { registerNotificationHandlers() @@ -1764,9 +1991,9 @@ function registerIpcHandlers() { }) // 视频相关 - ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string) => { + ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => { try { - const result = await videoService.getVideoInfo(videoMd5) + const result = await videoService.getVideoInfo(videoMd5, options) return { success: true, ...result } } catch (e) { return { success: false, error: String(e), exists: false } @@ -2088,6 +2315,28 @@ function registerIpcHandlers() { ipcMain.handle('chat:getMessageDateCounts', async (_, sessionId: string) => { return chatService.getMessageDateCounts(sessionId) }) + + ipcMain.handle('chat:getResourceMessages', async (_, options?: { + sessionId?: string + types?: Array<'image' | 'video' | 'voice' | 'file'> + beginTimestamp?: number + endTimestamp?: number + limit?: number + offset?: number + }) => { + return chatService.getResourceMessages(options) + }) + + ipcMain.handle('chat:getMediaStream', async (_, options?: { + sessionId?: string + mediaType?: 'image' | 'video' | 'all' + beginTimestamp?: number + endTimestamp?: number + limit?: number + offset?: number + }) => { + return wcdbService.getMediaStream(options) + }) ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => { return chatService.resolveVoiceCache(sessionId, msgId) }) @@ -2222,17 +2471,121 @@ function registerIpcHandlers() { return snsService.downloadSnsEmoji(params.url, params.encryptUrl, params.aesKey) }) + ipcMain.handle('sns:getCacheMigrationStatus', async () => { + try { + const plan = await collectLegacySnsCacheMigrationPlan() + if (!plan) { + return { + success: true, + needed: false, + inProgress: snsCacheMigrationInProgress, + totalFiles: 0, + items: [] + } + } + return { + success: true, + needed: true, + inProgress: snsCacheMigrationInProgress, + totalFiles: plan.totalFiles, + legacyBaseDir: plan.legacyBaseDir, + currentBaseDir: plan.currentBaseDir, + items: plan.candidates + } + } catch (error) { + return { success: false, needed: false, error: String((error as Error)?.message || error || '') } + } + }) + + ipcMain.handle('sns:startCacheMigration', async (event) => { + if (snsCacheMigrationInProgress) { + return { success: false, error: '迁移任务正在进行中' } + } + + const sender = event.sender + let lastProgress: SnsCacheMigrationProgressPayload = { + status: 'running', + phase: 'copying', + current: 0, + total: 0, + copied: 0, + skipped: 0, + remaining: 0 + } + const emitProgress = (payload: SnsCacheMigrationProgressPayload) => { + lastProgress = payload + if (!sender.isDestroyed()) { + sender.send('sns:cacheMigrationProgress', payload) + } + } + + try { + const plan = await collectLegacySnsCacheMigrationPlan() + if (!plan) { + emitProgress({ + status: 'done', + phase: 'done', + current: 0, + total: 0, + copied: 0, + skipped: 0, + remaining: 0, + message: '无需迁移' + }) + return { success: true, copied: 0, skipped: 0, totalFiles: 0, message: '无需迁移' } + } + + snsCacheMigrationInProgress = true + const result = await runLegacySnsCacheMigration(plan, emitProgress) + return { success: true, ...result } + } catch (error) { + const message = String((error as Error)?.message || error || '') + emitProgress({ + ...lastProgress, + status: 'error', + phase: 'error', + message + }) + return { success: false, error: message } + } finally { + snsCacheMigrationInProgress = false + } + }) + // 私聊克隆 ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => { return imageDecryptService.decryptImage(payload) }) - ipcMain.handle('image:resolveCache', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => { + ipcMain.handle('image:resolveCache', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => { return imageDecryptService.resolveCachedImage(payload) }) - ipcMain.handle('image:preload', async (_, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => { - imagePreloadService.enqueue(payloads || []) + ipcMain.handle( + 'image:resolveCacheBatch', + async ( + _, + payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, + options?: { disableUpdateCheck?: boolean } + ) => { + const list = Array.isArray(payloads) ? payloads : [] + const rows = await Promise.all(list.map(async (payload) => { + return imageDecryptService.resolveCachedImage({ + ...payload, + disableUpdateCheck: options?.disableUpdateCheck === true + }) + })) + return { success: true, rows } + } + ) + ipcMain.handle( + 'image:preload', + async ( + _, + payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, + options?: { allowDecrypt?: boolean } + ) => { + imagePreloadService.enqueue(payloads || [], options) return true }) diff --git a/electron/preload.ts b/electron/preload.ts index 88385ce..89575b6 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -226,6 +226,22 @@ contextBridge.exposeInMainWorld('electronAPI', { getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId), getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId), getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId), + getResourceMessages: (options?: { + sessionId?: string + types?: Array<'image' | 'video' | 'voice' | 'file'> + beginTimestamp?: number + endTimestamp?: number + limit?: number + offset?: number + }) => ipcRenderer.invoke('chat:getResourceMessages', options), + getMediaStream: (options?: { + sessionId?: string + mediaType?: 'image' | 'video' | 'all' + beginTimestamp?: number + endTimestamp?: number + limit?: number + offset?: number + }) => ipcRenderer.invoke('chat:getMediaStream', options), resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId), getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime), onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => { @@ -250,10 +266,16 @@ contextBridge.exposeInMainWorld('electronAPI', { image: { decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => ipcRenderer.invoke('image:decrypt', payload), - resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => + resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => ipcRenderer.invoke('image:resolveCache', payload), - preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => - ipcRenderer.invoke('image:preload', payloads), + resolveCacheBatch: ( + payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, + options?: { disableUpdateCheck?: boolean } + ) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options), + preload: ( + payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, + options?: { allowDecrypt?: boolean } + ) => ipcRenderer.invoke('image:preload', payloads, options), onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => { const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload) ipcRenderer.on('image:updateAvailable', listener) @@ -263,12 +285,33 @@ contextBridge.exposeInMainWorld('electronAPI', { const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload) ipcRenderer.on('image:cacheResolved', listener) return () => ipcRenderer.removeListener('image:cacheResolved', listener) + }, + onDecryptProgress: (callback: (payload: { + cacheKey: string + imageMd5?: string + imageDatName?: string + stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed' + progress: number + status: 'running' | 'done' | 'error' + message?: string + }) => void) => { + const listener = (_: unknown, payload: { + cacheKey: string + imageMd5?: string + imageDatName?: string + stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed' + progress: number + status: 'running' | 'done' | 'error' + message?: string + }) => callback(payload) + ipcRenderer.on('image:decryptProgress', listener) + return () => ipcRenderer.removeListener('image:decryptProgress', listener) } }, // 视频 video: { - getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5), + getVideoInfo: (videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, options), parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content) }, @@ -418,7 +461,14 @@ contextBridge.exposeInMainWorld('electronAPI', { uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'), checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'), deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId), - downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params) + downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params), + getCacheMigrationStatus: () => ipcRenderer.invoke('sns:getCacheMigrationStatus'), + startCacheMigration: () => ipcRenderer.invoke('sns:startCacheMigration'), + onCacheMigrationProgress: (callback: (payload: any) => void) => { + const listener = (_event: unknown, payload: any) => callback(payload) + ipcRenderer.on('sns:cacheMigrationProgress', listener) + return () => ipcRenderer.removeListener('sns:cacheMigrationProgress', listener) + } }, biz: { diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 270b4dc..9cf81b6 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -142,6 +142,14 @@ export interface Message { _db_path?: string // 内部字段:记录消息所属数据库路径 } +type ResourceMessageType = 'image' | 'video' | 'voice' | 'file' + +interface ResourceMessageItem extends Message { + sessionId: string + sessionDisplayName?: string + resourceType: ResourceMessageType +} + export interface Contact { username: string alias: string @@ -7544,6 +7552,152 @@ class ChatService { } } + private resolveResourceType(message: Message): ResourceMessageType | null { + if (message.localType === 3) return 'image' + if (message.localType === 43) return 'video' + if (message.localType === 34) return 'voice' + if ( + message.localType === 49 || + message.localType === 34359738417 || + message.localType === 103079215153 || + message.localType === 25769803825 + ) { + if (message.appMsgKind === 'file' || message.xmlType === '6') return 'file' + if (message.localType !== 49) return 'file' + } + return null + } + + async getResourceMessages(options?: { + sessionId?: string + types?: ResourceMessageType[] + beginTimestamp?: number + endTimestamp?: number + limit?: number + offset?: number + }): Promise<{ + success: boolean + items?: ResourceMessageItem[] + total?: number + hasMore?: boolean + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + const requestedTypes = Array.isArray(options?.types) + ? options.types.filter((type): type is ResourceMessageType => ['image', 'video', 'voice', 'file'].includes(type)) + : [] + const typeSet = new Set(requestedTypes.length > 0 ? requestedTypes : ['image', 'video', 'voice', 'file']) + + const beginTimestamp = Number(options?.beginTimestamp || 0) + const endTimestamp = Number(options?.endTimestamp || 0) + const offset = Math.max(0, Number(options?.offset || 0)) + const limitRaw = Number(options?.limit || 0) + const limit = Number.isFinite(limitRaw) ? Math.min(2000, Math.max(1, Math.floor(limitRaw || 300))) : 300 + + const sessionsResult = await this.getSessions() + if (!sessionsResult.success || !Array.isArray(sessionsResult.sessions)) { + return { success: false, error: sessionsResult.error || '获取会话失败' } + } + + const sessionNameMap = new Map() + sessionsResult.sessions.forEach((session) => { + sessionNameMap.set(session.username, session.displayName || session.username) + }) + + const requestedSessionId = String(options?.sessionId || '').trim() + const sortedSessions = [...sessionsResult.sessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0)) + const targetSessionIds = requestedSessionId + ? [requestedSessionId] + : sortedSessions.map((session) => session.username) + + const localTypes: number[] = [] + if (typeSet.has('image')) localTypes.push(3) + if (typeSet.has('video')) localTypes.push(43) + if (typeSet.has('voice')) localTypes.push(34) + if (typeSet.has('file')) { + localTypes.push(49, 34359738417, 103079215153, 25769803825) + } + const uniqueLocalTypes = Array.from(new Set(localTypes)) + + const allItems: ResourceMessageItem[] = [] + const dedup = new Set() + const targetCount = offset + limit + const candidateBuffer = Math.max(180, limit) + const perTypeFetch = requestedSessionId + ? Math.min(2000, Math.max(200, targetCount * 2)) + : (beginTimestamp > 0 || endTimestamp > 0 ? 140 : 90) + const maxSessionScan = requestedSessionId + ? 1 + : (beginTimestamp > 0 || endTimestamp > 0 ? 240 : 80) + const scanSessionIds = targetSessionIds.slice(0, maxSessionScan) + + let maybeHasMore = targetSessionIds.length > scanSessionIds.length + let stopEarly = false + + for (const sessionId of scanSessionIds) { + const batchRows = await Promise.all( + uniqueLocalTypes.map((localType) => + wcdbService.getMessagesByType(sessionId, localType, false, perTypeFetch, 0) + ) + ) + for (const result of batchRows) { + if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) continue + if (result.rows.length >= perTypeFetch) maybeHasMore = true + + const mapped = this.mapRowsToMessages(result.rows as Record[]) + for (const message of mapped) { + const resourceType = this.resolveResourceType(message) + if (!resourceType || !typeSet.has(resourceType)) continue + if (beginTimestamp > 0 && message.createTime < beginTimestamp) continue + if (endTimestamp > 0 && message.createTime > endTimestamp) continue + + const dedupKey = `${sessionId}:${message.localId}:${message.serverId}:${message.createTime}:${message.localType}` + if (dedup.has(dedupKey)) continue + dedup.add(dedupKey) + + allItems.push({ + ...message, + sessionId, + sessionDisplayName: sessionNameMap.get(sessionId) || sessionId, + resourceType + }) + } + } + + if (allItems.length >= targetCount + candidateBuffer) { + stopEarly = true + maybeHasMore = true + break + } + } + + allItems.sort((a, b) => { + const timeDiff = (b.createTime || 0) - (a.createTime || 0) + if (timeDiff !== 0) return timeDiff + return (b.localId || 0) - (a.localId || 0) + }) + + const total = allItems.length + const start = Math.min(offset, total) + const end = Math.min(start + limit, total) + + return { + success: true, + items: allItems.slice(start, end), + total, + hasMore: end < total || maybeHasMore || stopEarly + } + } catch (e) { + console.error('[ChatService] 获取资源消息失败:', e) + return { success: false, error: String(e) } + } + } + async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> { try { const connectResult = await this.ensureConnected() diff --git a/electron/services/config.ts b/electron/services/config.ts index 6fa0af8..85da9fc 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -137,7 +137,7 @@ export class ConfigService { httpApiToken: '', httpApiEnabled: false, httpApiPort: 5031, - httpApiHost: '0.0.0.0', + httpApiHost: '127.0.0.1', messagePushEnabled: false, windowCloseBehavior: 'ask', quoteLayout: 'quote-top', diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 29d8952..e7a7f83 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -6,6 +6,7 @@ import * as http from 'http' import * as fs from 'fs' import * as path from 'path' import { URL } from 'url' +import { timingSafeEqual } from 'crypto' import { chatService, Message } from './chatService' import { wcdbService } from './wcdbService' import { ConfigService } from './config' @@ -268,9 +269,19 @@ class HttpService { */ private async parseBody(req: http.IncomingMessage): Promise> { if (req.method !== 'POST') return {} + const MAX_BODY_SIZE = 10 * 1024 * 1024 // 10MB return new Promise((resolve) => { let body = '' - req.on('data', chunk => { body += chunk.toString() }) + let bodySize = 0 + req.on('data', chunk => { + bodySize += chunk.length + if (bodySize > MAX_BODY_SIZE) { + req.destroy() + resolve({}) + return + } + body += chunk.toString() + }) req.on('end', () => { try { resolve(JSON.parse(body)) @@ -285,30 +296,44 @@ class HttpService { /** * 鉴权拦截器 */ + private safeEqual(a: string, b: string): boolean { + const bufA = Buffer.from(a) + const bufB = Buffer.from(b) + if (bufA.length !== bufB.length) return false + return timingSafeEqual(bufA, bufB) + } + private verifyToken(req: http.IncomingMessage, url: URL, body: Record): boolean { const expectedToken = String(this.configService.get('httpApiToken') || '').trim() - if (!expectedToken) return true + if (!expectedToken) { + // token 未配置时拒绝所有请求,防止未授权访问 + console.warn('[HttpService] Access denied: httpApiToken not configured') + return false + } const authHeader = req.headers.authorization if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) { const token = authHeader.substring(7).trim() - if (token === expectedToken) return true + if (this.safeEqual(token, expectedToken)) return true } const queryToken = url.searchParams.get('access_token') - if (queryToken && queryToken.trim() === expectedToken) return true + if (queryToken && this.safeEqual(queryToken.trim(), expectedToken)) return true const bodyToken = body['access_token'] - return !!(bodyToken && String(bodyToken).trim() === expectedToken); - - + return !!(bodyToken && this.safeEqual(String(bodyToken).trim(), expectedToken)) } /** * 处理 HTTP 请求 (重构后) */ private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { - res.setHeader('Access-Control-Allow-Origin', '*') + // 仅允许本地来源的跨域请求 + const origin = req.headers.origin || '' + if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin) + res.setHeader('Vary', 'Origin') + } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') @@ -431,9 +456,15 @@ class HttpService { } private handleMediaRequest(pathname: string, res: http.ServerResponse): void { - const mediaBasePath = this.getApiMediaExportPath() + const mediaBasePath = path.resolve(this.getApiMediaExportPath()) const relativePath = pathname.replace('/api/v1/media/', '') - const fullPath = path.join(mediaBasePath, relativePath) + const fullPath = path.resolve(mediaBasePath, relativePath) + + // 防止路径穿越攻击 + if (!fullPath.startsWith(mediaBasePath + path.sep) && fullPath !== mediaBasePath) { + this.sendError(res, 403, 'Forbidden') + return + } if (!fs.existsSync(fullPath)) { this.sendError(res, 404, 'Media not found') diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 5dfe612..37b41f2 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -55,11 +55,14 @@ type DecryptResult = { isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图) } +type DecryptProgressStage = 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed' + type CachedImagePayload = { sessionId?: string imageMd5?: string imageDatName?: string preferFilePath?: boolean + disableUpdateCheck?: boolean } type DecryptImagePayload = CachedImagePayload & { @@ -126,7 +129,9 @@ export class ImageDecryptService { const isThumb = this.isThumbnailPath(cached) const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false if (isThumb) { - this.triggerUpdateCheck(payload, key, cached) + if (!payload.disableUpdateCheck) { + this.triggerUpdateCheck(payload, key, cached) + } } else { this.updateFlags.delete(key) } @@ -146,7 +151,9 @@ export class ImageDecryptService { const isThumb = this.isThumbnailPath(existing) const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false if (isThumb) { - this.triggerUpdateCheck(payload, key, existing) + if (!payload.disableUpdateCheck) { + this.triggerUpdateCheck(payload, key, existing) + } } else { this.updateFlags.delete(key) } @@ -167,6 +174,7 @@ export class ImageDecryptService { if (!cacheKey) { return { success: false, error: '缺少图片标识' } } + this.emitDecryptProgress(payload, cacheKey, 'queued', 4, 'running') if (payload.force) { for (const key of cacheKeys) { @@ -176,6 +184,7 @@ export class ImageDecryptService { this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath)) + this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') return { success: true, localPath } } if (cached && !this.isImageFile(cached)) { @@ -191,6 +200,7 @@ export class ImageDecryptService { this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath) this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath)) + this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') return { success: true, localPath } } } @@ -201,6 +211,7 @@ export class ImageDecryptService { if (cached && existsSync(cached) && this.isImageFile(cached)) { const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath)) + this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') return { success: true, localPath } } if (cached && !this.isImageFile(cached)) { @@ -209,7 +220,10 @@ export class ImageDecryptService { } const pending = this.pending.get(cacheKey) - if (pending) return pending + if (pending) { + this.emitDecryptProgress(payload, cacheKey, 'queued', 8, 'running') + return pending + } const task = this.decryptImageInternal(payload, cacheKey) this.pending.set(cacheKey, task) @@ -261,49 +275,93 @@ export class ImageDecryptService { cacheKey: string ): Promise { this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true }) + this.emitDecryptProgress(payload, cacheKey, 'locating', 14, 'running') try { const wxid = this.configService.get('myWxid') const dbPath = this.configService.get('dbPath') if (!wxid || !dbPath) { this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath }) + this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失') return { success: false, error: '未配置账号或数据库路径' } } const accountDir = this.resolveAccountDir(dbPath, wxid) if (!accountDir) { this.logError('未找到账号目录', undefined, { dbPath, wxid }) + this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '账号目录缺失') return { success: false, error: '未找到账号目录' } } - const datPath = await this.resolveDatPath( - accountDir, - payload.imageMd5, - payload.imageDatName, - payload.sessionId, - { - allowThumbnail: !payload.force, - skipResolvedCache: Boolean(payload.force), - hardlinkOnly: payload.hardlinkOnly === true - } - ) + let datPath: string | null = null + let usedHdAttempt = false + let fallbackToThumbnail = false - // 如果要求高清图但没找到,直接返回提示 - if (!datPath && payload.force) { - this.logError('未找到高清图', undefined, { md5: payload.imageMd5, datName: payload.imageDatName }) - return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' } + // force=true 时先尝试高清;若高清缺失则回退到缩略图,避免直接失败。 + if (payload.force) { + usedHdAttempt = true + datPath = await this.resolveDatPath( + accountDir, + payload.imageMd5, + payload.imageDatName, + payload.sessionId, + { + allowThumbnail: false, + skipResolvedCache: true, + hardlinkOnly: payload.hardlinkOnly === true + } + ) + if (!datPath) { + datPath = await this.resolveDatPath( + accountDir, + payload.imageMd5, + payload.imageDatName, + payload.sessionId, + { + allowThumbnail: true, + skipResolvedCache: true, + hardlinkOnly: payload.hardlinkOnly === true + } + ) + fallbackToThumbnail = Boolean(datPath) + if (fallbackToThumbnail) { + this.logInfo('高清缺失,回退解密缩略图', { + md5: payload.imageMd5, + datName: payload.imageDatName + }) + } + } + } else { + datPath = await this.resolveDatPath( + accountDir, + payload.imageMd5, + payload.imageDatName, + payload.sessionId, + { + allowThumbnail: true, + skipResolvedCache: false, + hardlinkOnly: payload.hardlinkOnly === true + } + ) } + if (!datPath) { this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName }) + this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '未找到DAT文件') + if (usedHdAttempt) { + return { success: false, error: '未找到图片文件,请在微信中点开该图片后重试' } + } return { success: false, error: '未找到图片文件' } } this.logInfo('找到DAT文件', { datPath }) + this.emitDecryptProgress(payload, cacheKey, 'locating', 34, 'running') if (!extname(datPath).toLowerCase().includes('dat')) { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath) const localPath = this.resolveLocalPathForPayload(datPath, payload.preferFilePath) const isThumb = this.isThumbnailPath(datPath) this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(datPath, payload.preferFilePath)) + this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') return { success: true, localPath, isThumb } } @@ -319,6 +377,7 @@ export class ImageDecryptService { const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath) const isThumb = this.isThumbnailPath(existing) this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath)) + this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') return { success: true, localPath, isThumb } } } @@ -340,6 +399,7 @@ export class ImageDecryptService { } } if (Number.isNaN(xorKey) || (!xorKey && xorKey !== 0)) { + this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '缺少解密密钥') return { success: false, error: '未配置图片解密密钥' } } @@ -347,7 +407,9 @@ export class ImageDecryptService { const aesKey = this.resolveAesKey(aesKeyRaw) this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey }) + this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running') let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey) + this.emitDecryptProgress(payload, cacheKey, 'decrypting', 78, 'running') // 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据 const wxgfResult = await this.unwrapWxgf(decrypted) @@ -363,10 +425,12 @@ export class ImageDecryptService { const finalExt = ext || '.jpg' const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId) + this.emitDecryptProgress(payload, cacheKey, 'writing', 90, 'running') await writeFile(outputPath, decrypted) this.logInfo('解密成功', { outputPath, size: decrypted.length }) if (finalExt === '.hevc') { + this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'wxgf转换失败') return { success: false, error: '此图片为微信新格式(wxgf),ffmpeg 转换失败,请检查日志', @@ -378,15 +442,19 @@ export class ImageDecryptService { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) if (!isThumb) { this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) + } else { + this.triggerUpdateCheck(payload, cacheKey, outputPath) } const localPath = payload.preferFilePath ? outputPath : (this.bufferToDataUrl(decrypted, finalExt) || this.filePathToUrl(outputPath)) const emitPath = this.resolveEmitPath(outputPath, payload.preferFilePath) this.emitCacheResolved(payload, cacheKey, emitPath) + this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') return { success: true, localPath, isThumb } } catch (e) { this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName }) + this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', String(e)) return { success: false, error: String(e) } } } @@ -1288,6 +1356,31 @@ export class ImageDecryptService { } } + private emitDecryptProgress( + payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, + cacheKey: string, + stage: DecryptProgressStage, + progress: number, + status: 'running' | 'done' | 'error', + message?: string + ): void { + const safeProgress = Math.max(0, Math.min(100, Math.floor(progress))) + const event = { + cacheKey, + imageMd5: payload.imageMd5, + imageDatName: payload.imageDatName, + stage, + progress: safeProgress, + status, + message: message || '' + } + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) { + win.webContents.send('image:decryptProgress', event) + } + } + } + private async ensureCacheIndexed(): Promise { if (this.cacheIndexed) return if (this.cacheIndexing) return this.cacheIndexing diff --git a/electron/services/imagePreloadService.ts b/electron/services/imagePreloadService.ts index ccedd8c..2916bfe 100644 --- a/electron/services/imagePreloadService.ts +++ b/electron/services/imagePreloadService.ts @@ -6,36 +6,57 @@ type PreloadImagePayload = { imageDatName?: string } +type PreloadOptions = { + allowDecrypt?: boolean +} + type PreloadTask = PreloadImagePayload & { key: string + allowDecrypt: boolean } export class ImagePreloadService { private queue: PreloadTask[] = [] private pending = new Set() - private active = 0 - private readonly maxConcurrent = 2 + private activeCache = 0 + private activeDecrypt = 0 + private readonly maxCacheConcurrent = 8 + private readonly maxDecryptConcurrent = 2 + private readonly maxQueueSize = 320 - enqueue(payloads: PreloadImagePayload[]): void { + enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void { if (!Array.isArray(payloads) || payloads.length === 0) return + const allowDecrypt = options?.allowDecrypt !== false for (const payload of payloads) { + if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break const cacheKey = payload.imageMd5 || payload.imageDatName if (!cacheKey) continue const key = `${payload.sessionId || 'unknown'}|${cacheKey}` if (this.pending.has(key)) continue this.pending.add(key) - this.queue.push({ ...payload, key }) + this.queue.push({ ...payload, key, allowDecrypt }) } this.processQueue() } private processQueue(): void { - while (this.active < this.maxConcurrent && this.queue.length > 0) { - const task = this.queue.shift() + while (this.queue.length > 0) { + const taskIndex = this.queue.findIndex((task) => ( + task.allowDecrypt + ? this.activeDecrypt < this.maxDecryptConcurrent + : this.activeCache < this.maxCacheConcurrent + )) + if (taskIndex < 0) return + + const task = this.queue.splice(taskIndex, 1)[0] if (!task) return - this.active += 1 + + if (task.allowDecrypt) this.activeDecrypt += 1 + else this.activeCache += 1 + void this.handleTask(task).finally(() => { - this.active -= 1 + if (task.allowDecrypt) this.activeDecrypt = Math.max(0, this.activeDecrypt - 1) + else this.activeCache = Math.max(0, this.activeCache - 1) this.pending.delete(task.key) this.processQueue() }) @@ -49,9 +70,11 @@ export class ImagePreloadService { const cached = await imageDecryptService.resolveCachedImage({ sessionId: task.sessionId, imageMd5: task.imageMd5, - imageDatName: task.imageDatName + imageDatName: task.imageDatName, + disableUpdateCheck: !task.allowDecrypt }) if (cached.success) return + if (!task.allowDecrypt) return await imageDecryptService.decryptImage({ sessionId: task.sessionId, imageMd5: task.imageMd5, diff --git a/electron/services/keyServiceLinux.ts b/electron/services/keyServiceLinux.ts index 7364f83..2c8aef9 100644 --- a/electron/services/keyServiceLinux.ts +++ b/electron/services/keyServiceLinux.ts @@ -17,9 +17,9 @@ export class KeyServiceLinux { constructor() { try { - this.sudo = require('sudo-prompt'); + this.sudo = require('@vscode/sudo-prompt'); } catch (e) { - console.error('Failed to load sudo-prompt', e); + console.error('Failed to load @vscode/sudo-prompt', e); } } @@ -361,4 +361,4 @@ export class KeyServiceLinux { return { ciphertext, xorKey } } -} \ No newline at end of file +} diff --git a/electron/services/linuxNotificationService.ts b/electron/services/linuxNotificationService.ts new file mode 100644 index 0000000..1e4bd22 --- /dev/null +++ b/electron/services/linuxNotificationService.ts @@ -0,0 +1,344 @@ +import dbus from "dbus-native"; +import https from "https"; +import http, { IncomingMessage } from "http"; +import { promises as fs } from "fs"; +import { join } from "path"; +import { app } from "electron"; + +const BUS_NAME = "org.freedesktop.Notifications"; +const OBJECT_PATH = "/org/freedesktop/Notifications"; + +export interface LinuxNotificationData { + sessionId?: string; + title: string; + content: string; + avatarUrl?: string; + expireTimeout?: number; +} + +type NotificationCallback = (sessionId: string) => void; + +let sessionBus: dbus.DBusConnection | null = null; +let notificationCallbacks: NotificationCallback[] = []; +let pendingNotifications: Map = new Map(); + +// 头像缓存:url->localFilePath +const avatarCache: Map = new Map(); +// 缓存目录 +let avatarCacheDir: string | null = null; + +async function getSessionBus(): Promise { + if (!sessionBus) { + sessionBus = dbus.sessionBus(); + + // 挂载底层socket的error事件,防止掉线即可 + sessionBus.connection.on("error", (err: Error) => { + console.error("[LinuxNotification] D-Bus connection error:", err); + sessionBus = null; // 报错清理死对象 + }); + } + return sessionBus; +} + +// 确保缓存目录存在 +async function ensureCacheDir(): Promise { + if (!avatarCacheDir) { + avatarCacheDir = join(app.getPath("temp"), "weflow-avatars"); + try { + await fs.mkdir(avatarCacheDir, { recursive: true }); + } catch (error) { + console.error( + "[LinuxNotification] Failed to create avatar cache dir:", + error, + ); + } + } + return avatarCacheDir; +} + +// 下载头像到本地临时文件 +async function downloadAvatarToLocal(url: string): Promise { + // 检查缓存 + if (avatarCache.has(url)) { + return avatarCache.get(url) || null; + } + + try { + const cacheDir = await ensureCacheDir(); + // 生成唯一文件名 + const fileName = `avatar_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.png`; + const localPath = join(cacheDir, fileName); + + await new Promise((resolve, reject) => { + // 微信 CDN 需要特殊的请求头才能下载图片 + const options = { + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351", + Referer: "https://servicewechat.com/", + Accept: + "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "zh-CN,zh;q=0.9", + Connection: "keep-alive", + }, + }; + + const callback = (res: IncomingMessage) => { + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}`)); + return; + } + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", async () => { + try { + const buffer = Buffer.concat(chunks); + await fs.writeFile(localPath, buffer); + avatarCache.set(url, localPath); + resolve(); + } catch (err) { + reject(err); + } + }); + res.on("error", reject); + }; + + const req = url.startsWith("https") + ? https.get(url, options, callback) + : http.get(url, options, callback); + + req.on("error", reject); + req.setTimeout(10000, () => { + req.destroy(); + reject(new Error("Download timeout")); + }); + }); + + console.log( + `[LinuxNotification] Avatar downloaded: ${url} -> ${localPath}`, + ); + return localPath; + } catch (error) { + console.error("[LinuxNotification] Failed to download avatar:", error); + return null; + } +} + +export async function showLinuxNotification( + data: LinuxNotificationData, +): Promise { + try { + const bus = await getSessionBus(); + + const appName = "WeFlow"; + const replaceId = 0; + const expireTimeout = data.expireTimeout ?? 5000; + + // 处理头像:下载到本地或使用URL + let appIcon = ""; + let hints: any[] = []; + if (data.avatarUrl) { + // 优先尝试下载到本地 + const localPath = await downloadAvatarToLocal(data.avatarUrl); + if (localPath) { + hints = [["image-path", ["s", localPath]]]; + } + } + + return new Promise((resolve, reject) => { + bus.invoke( + { + destination: BUS_NAME, + path: OBJECT_PATH, + interface: "org.freedesktop.Notifications", + member: "Notify", + signature: "susssasa{sv}i", + body: [ + appName, + replaceId, + appIcon, + data.title, + data.content, + ["default", "打开"], // 提供default action,否则系统不会抛出点击事件 + hints, + // [], // 传空数组以避开a{sv}变体的序列化崩溃,有pendingNotifications映射维护保证不出错 + expireTimeout, + ], + }, + (err: Error | null, result: any) => { + if (err) { + console.error("[LinuxNotification] Notify error:", err); + reject(err); + return; + } + const notificationId = + typeof result === "number" ? result : result[0]; + if (data.sessionId) { + // 依赖Map实现点击追踪,没有使用D-Bus hints + pendingNotifications.set(notificationId, data); + } + console.log( + `[LinuxNotification] Shown notification ${notificationId}: ${data.title}, icon: ${appIcon || "none"}`, + ); + resolve(notificationId); + }, + ); + }); + } catch (error) { + console.error("[LinuxNotification] Failed to show notification:", error); + return null; + } +} + +export async function closeLinuxNotification( + notificationId: number, +): Promise { + try { + const bus = await getSessionBus(); + return new Promise((resolve, reject) => { + bus.invoke( + { + destination: BUS_NAME, + path: OBJECT_PATH, + interface: "org.freedesktop.Notifications", + member: "CloseNotification", + signature: "u", + body: [notificationId], + }, + (err: Error | null) => { + if (err) { + console.error("[LinuxNotification] CloseNotification error:", err); + reject(err); + return; + } + pendingNotifications.delete(notificationId); + resolve(); + }, + ); + }); + } catch (error) { + console.error("[LinuxNotification] Failed to close notification:", error); + } +} + +export async function getCapabilities(): Promise { + try { + const bus = await getSessionBus(); + return new Promise((resolve, reject) => { + bus.invoke( + { + destination: BUS_NAME, + path: OBJECT_PATH, + interface: "org.freedesktop.Notifications", + member: "GetCapabilities", + }, + (err: Error | null, result: any) => { + if (err) { + console.error("[LinuxNotification] GetCapabilities error:", err); + reject(err); + return; + } + resolve(result as string[]); + }, + ); + }); + } catch (error) { + console.error("[LinuxNotification] Failed to get capabilities:", error); + return []; + } +} + +export function onNotificationAction(callback: NotificationCallback): void { + notificationCallbacks.push(callback); +} + +export function removeNotificationCallback( + callback: NotificationCallback, +): void { + const index = notificationCallbacks.indexOf(callback); + if (index > -1) { + notificationCallbacks.splice(index, 1); + } +} + +function triggerNotificationCallback(sessionId: string): void { + for (const callback of notificationCallbacks) { + try { + callback(sessionId); + } catch (error) { + console.error("[LinuxNotification] Callback error:", error); + } + } +} + +export async function initLinuxNotificationService(): Promise { + if (process.platform !== "linux") { + console.log("[LinuxNotification] Not on Linux, skipping init"); + return; + } + + try { + const bus = await getSessionBus(); + + // 监听底层connection的message事件 + bus.connection.on("message", (msg: any) => { + // type 4表示SIGNAL + if ( + msg.type === 4 && + msg.path === OBJECT_PATH && + msg.interface === "org.freedesktop.Notifications" + ) { + if (msg.member === "ActionInvoked") { + const [notificationId, actionId] = msg.body; + console.log( + `[LinuxNotification] Action invoked: ${notificationId}, ${actionId}`, + ); + + // 如果用户点击了通知本体,actionId会是'default' + if (actionId === "default") { + const data = pendingNotifications.get(notificationId); + if (data?.sessionId) { + triggerNotificationCallback(data.sessionId); + } + } + } + + if (msg.member === "NotificationClosed") { + const [notificationId] = msg.body; + pendingNotifications.delete(notificationId); + } + } + }); + + // AddMatch用来接收信号 + await new Promise((resolve, reject) => { + bus.invoke( + { + destination: "org.freedesktop.DBus", + path: "/org/freedesktop/DBus", + interface: "org.freedesktop.DBus", + member: "AddMatch", + signature: "s", + body: ["type='signal',interface='org.freedesktop.Notifications'"], + }, + (err: Error | null) => { + if (err) { + console.error("[LinuxNotification] AddMatch error:", err); + reject(err); + return; + } + resolve(); + }, + ); + }); + + console.log("[LinuxNotification] Service initialized"); + + // 打印相关日志 + const caps = await getCapabilities(); + console.log("[LinuxNotification] Server capabilities:", caps); + } catch (error) { + console.error("[LinuxNotification] Failed to initialize:", error); + } +} diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 3a23acb..8d3fed7 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -1,6 +1,7 @@ import { wcdbService } from './wcdbService' import { ConfigService } from './config' import { ContactCacheService } from './contactCacheService' +import { app } from 'electron' import { existsSync, mkdirSync } from 'fs' import { readFile, writeFile, mkdir } from 'fs/promises' import { basename, join } from 'path' @@ -801,14 +802,25 @@ class SnsService { } private getSnsCacheDir(): string { - const cachePath = this.configService.getCacheBasePath() - const snsCacheDir = join(cachePath, 'sns_cache') + const configuredCachePath = String(this.configService.get('cachePath') || '').trim() + const baseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow') + const snsCacheDir = join(baseDir, 'sns_cache') if (!existsSync(snsCacheDir)) { mkdirSync(snsCacheDir, { recursive: true }) } return snsCacheDir } + private getEmojiCacheDir(): string { + const configuredCachePath = String(this.configService.get('cachePath') || '').trim() + const baseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow') + const emojiDir = join(baseDir, 'Emojis') + if (!existsSync(emojiDir)) { + mkdirSync(emojiDir, { recursive: true }) + } + return emojiDir + } + private getCacheFilePath(url: string): string { const hash = crypto.createHash('md5').update(url).digest('hex') const ext = isVideoUrl(url) ? '.mp4' : '.jpg' @@ -1832,7 +1844,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class const isVideo = isVideoUrl(url) const cachePath = this.getCacheFilePath(url) - // 1. 尝试从磁盘缓存读取 + // 1. 优先尝试从当前缓存目录读取 if (existsSync(cachePath)) { try { // 对于视频,不读取整个文件到内存,只确认存在即可 @@ -2293,9 +2305,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class const fs = require('fs') const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex') - const cachePath = this.configService.getCacheBasePath() - const emojiDir = join(cachePath, 'sns_emoji_cache') - if (!existsSync(emojiDir)) mkdirSync(emojiDir, { recursive: true }) + const emojiDir = this.getEmojiCacheDir() // 检查本地缓存 for (const ext of ['.gif', '.png', '.webp', '.jpg', '.jpeg']) { diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index 761e017..b108dec 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -1,5 +1,8 @@ import { join } from 'path' -import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs' +import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync, unlinkSync } from 'fs' +import { spawn } from 'child_process' +import { pathToFileURL } from 'url' +import crypto from 'crypto' import { app } from 'electron' import { ConfigService } from './config' import { wcdbService } from './wcdbService' @@ -22,15 +25,50 @@ interface VideoIndexEntry { thumbPath?: string } +type PosterFormat = 'dataUrl' | 'fileUrl' + +function getStaticFfmpegPath(): string | null { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const ffmpegStatic = require('ffmpeg-static') + if (typeof ffmpegStatic === 'string') { + let fixedPath = ffmpegStatic + if (fixedPath.includes('app.asar') && !fixedPath.includes('app.asar.unpacked')) { + fixedPath = fixedPath.replace('app.asar', 'app.asar.unpacked') + } + if (existsSync(fixedPath)) return fixedPath + } + } catch { + // ignore + } + + const ffmpegName = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' + const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', ffmpegName) + if (existsSync(devPath)) return devPath + + if (app.isPackaged) { + const packedPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', ffmpegName) + if (existsSync(packedPath)) return packedPath + } + + return null +} + class VideoService { private configService: ConfigService private hardlinkResolveCache = new Map>() private videoInfoCache = new Map>() private videoDirIndexCache = new Map>>() private pendingVideoInfo = new Map>() + private pendingPosterExtract = new Map>() + private extractedPosterCache = new Map>() + private posterExtractRunning = 0 + private posterExtractQueue: Array<() => void> = [] private readonly hardlinkCacheTtlMs = 10 * 60 * 1000 private readonly videoInfoCacheTtlMs = 2 * 60 * 1000 private readonly videoIndexCacheTtlMs = 90 * 1000 + private readonly extractedPosterCacheTtlMs = 15 * 60 * 1000 + private readonly maxPosterExtractConcurrency = 1 private readonly maxCacheEntries = 2000 private readonly maxIndexEntries = 6 @@ -256,12 +294,10 @@ class VideoService { await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid) } - /** - * 将文件转换为 data URL - */ - private fileToDataUrl(filePath: string | undefined, mimeType: string): string | undefined { + private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined { try { if (!filePath || !existsSync(filePath)) return undefined + if (posterFormat === 'fileUrl') return pathToFileURL(filePath).toString() const buffer = readFileSync(filePath) return `data:${mimeType};base64,${buffer.toString('base64')}` } catch { @@ -355,7 +391,12 @@ class VideoService { return index } - private getVideoInfoFromIndex(index: Map, md5: string, includePoster = true): VideoInfo | null { + private getVideoInfoFromIndex( + index: Map, + md5: string, + includePoster = true, + posterFormat: PosterFormat = 'dataUrl' + ): VideoInfo | null { const normalizedMd5 = String(md5 || '').trim().toLowerCase() if (!normalizedMd5) return null @@ -379,8 +420,8 @@ class VideoService { } return { videoUrl: entry.videoPath, - coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'), - thumbUrl: this.fileToDataUrl(entry.thumbPath, 'image/jpeg'), + coverUrl: this.fileToPosterUrl(entry.coverPath, 'image/jpeg', posterFormat), + thumbUrl: this.fileToPosterUrl(entry.thumbPath, 'image/jpeg', posterFormat), exists: true } } @@ -388,7 +429,12 @@ class VideoService { return null } - private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string, includePoster = true): VideoInfo | null { + private fallbackScanVideo( + videoBaseDir: string, + realVideoMd5: string, + includePoster = true, + posterFormat: PosterFormat = 'dataUrl' + ): VideoInfo | null { try { const yearMonthDirs = readdirSync(videoBaseDir) .filter((dir) => { @@ -416,8 +462,8 @@ class VideoService { const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`) return { videoUrl: videoPath, - coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), - thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), + coverUrl: this.fileToPosterUrl(coverPath, 'image/jpeg', posterFormat), + thumbUrl: this.fileToPosterUrl(thumbPath, 'image/jpeg', posterFormat), exists: true } } @@ -427,14 +473,165 @@ class VideoService { return null } + private getFfmpegPath(): string { + const staticPath = getStaticFfmpegPath() + if (staticPath) return staticPath + return 'ffmpeg' + } + + private async withPosterExtractSlot(run: () => Promise): Promise { + if (this.posterExtractRunning >= this.maxPosterExtractConcurrency) { + await new Promise((resolve) => { + this.posterExtractQueue.push(resolve) + }) + } + this.posterExtractRunning += 1 + try { + return await run() + } finally { + this.posterExtractRunning = Math.max(0, this.posterExtractRunning - 1) + const next = this.posterExtractQueue.shift() + if (next) next() + } + } + + private async extractFirstFramePoster(videoPath: string, posterFormat: PosterFormat): Promise { + const normalizedPath = String(videoPath || '').trim() + if (!normalizedPath || !existsSync(normalizedPath)) return null + + const cacheKey = `${normalizedPath}|format=${posterFormat}` + const cached = this.readTimedCache(this.extractedPosterCache, cacheKey) + if (cached !== undefined) return cached + + const pending = this.pendingPosterExtract.get(cacheKey) + if (pending) return pending + + const task = this.withPosterExtractSlot(() => new Promise((resolve) => { + const tmpDir = join(app.getPath('temp'), 'weflow_video_frames') + try { + if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true }) + } catch { + resolve(null) + return + } + + const stableHash = crypto.createHash('sha1').update(normalizedPath).digest('hex').slice(0, 24) + const outputPath = join(tmpDir, `frame_${stableHash}.jpg`) + if (posterFormat === 'fileUrl' && existsSync(outputPath)) { + resolve(pathToFileURL(outputPath).toString()) + return + } + + const ffmpegPath = this.getFfmpegPath() + const args = [ + '-hide_banner', '-loglevel', 'error', '-y', + '-ss', '0', + '-i', normalizedPath, + '-frames:v', '1', + '-q:v', '3', + outputPath + ] + + const errChunks: Buffer[] = [] + let done = false + const finish = (value: string | null) => { + if (done) return + done = true + if (posterFormat === 'dataUrl') { + try { + if (existsSync(outputPath)) unlinkSync(outputPath) + } catch { + // ignore + } + } + resolve(value) + } + + const proc = spawn(ffmpegPath, args, { + stdio: ['ignore', 'ignore', 'pipe'], + windowsHide: true + }) + + const timer = setTimeout(() => { + try { proc.kill('SIGKILL') } catch { /* ignore */ } + finish(null) + }, 12000) + + proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk)) + + proc.on('error', () => { + clearTimeout(timer) + finish(null) + }) + + proc.on('close', (code: number) => { + clearTimeout(timer) + if (code !== 0 || !existsSync(outputPath)) { + if (errChunks.length > 0) { + this.log('extractFirstFrameDataUrl failed', { + videoPath: normalizedPath, + error: Buffer.concat(errChunks).toString().slice(0, 240) + }) + } + finish(null) + return + } + try { + const jpgBuf = readFileSync(outputPath) + if (!jpgBuf.length) { + finish(null) + return + } + if (posterFormat === 'fileUrl') { + finish(pathToFileURL(outputPath).toString()) + return + } + finish(`data:image/jpeg;base64,${jpgBuf.toString('base64')}`) + } catch { + finish(null) + } + }) + })) + + this.pendingPosterExtract.set(cacheKey, task) + try { + const result = await task + this.writeTimedCache( + this.extractedPosterCache, + cacheKey, + result, + this.extractedPosterCacheTtlMs, + this.maxCacheEntries + ) + return result + } finally { + this.pendingPosterExtract.delete(cacheKey) + } + } + + private async ensurePoster(info: VideoInfo, includePoster: boolean, posterFormat: PosterFormat): Promise { + if (!includePoster) return info + if (!info.exists || !info.videoUrl) return info + if (info.coverUrl || info.thumbUrl) return info + + const extracted = await this.extractFirstFramePoster(info.videoUrl, posterFormat) + if (!extracted) return info + return { + ...info, + coverUrl: extracted, + thumbUrl: extracted + } + } + /** * 根据视频MD5获取视频文件信息 * 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/ * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg */ - async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean }): Promise { + async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean; posterFormat?: PosterFormat }): Promise { const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase() const includePoster = options?.includePoster !== false + const posterFormat: PosterFormat = options?.posterFormat === 'fileUrl' ? 'fileUrl' : 'dataUrl' const dbPath = this.getDbPath() const wxid = this.getMyWxid() @@ -446,7 +643,7 @@ class VideoService { } const scopeKey = this.getScopeKey(dbPath, wxid) - const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}` + const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}|format=${posterFormat}` const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey) if (cachedInfo) return cachedInfo @@ -465,16 +662,18 @@ class VideoService { } const index = this.getOrBuildVideoIndex(videoBaseDir) - const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster) + const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster, posterFormat) if (indexed) { - this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries) - return indexed + const withPoster = await this.ensurePoster(indexed, includePoster, posterFormat) + this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries) + return withPoster } - const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster) + const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster, posterFormat) if (fallback) { - this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries) - return fallback + const withPoster = await this.ensurePoster(fallback, includePoster, posterFormat) + this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries) + return withPoster } const miss = { exists: false } diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 8c389c2..dcf6dee 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -1,6 +1,7 @@ import { join, dirname, basename } from 'path' import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' import { tmpdir } from 'os' +import * as fzstd from 'fzstd' //数据服务初始化错误信息,用于帮助用户诊断问题 let lastDllInitError: string | null = null @@ -80,6 +81,7 @@ export class WcdbCore { private wcdbGetSessionMessageDateCounts: any = null private wcdbGetSessionMessageDateCountsBatch: any = null private wcdbGetMessagesByType: any = null + private wcdbScanMediaStream: any = null private wcdbGetHeadImageBuffers: any = null private wcdbSearchMessages: any = null private wcdbGetSnsTimeline: any = null @@ -1013,6 +1015,11 @@ export class WcdbCore { } catch { this.wcdbGetMessagesByType = null } + try { + this.wcdbScanMediaStream = this.lib.func('int32 wcdb_scan_media_stream(int64 handle, const char* sessionIdsJson, int32 mediaType, int32 beginTimestamp, int32 endTimestamp, int32 limit, int32 offset, _Out_ void** outJson, _Out_ int32* outHasMore)') + } catch { + this.wcdbScanMediaStream = null + } try { this.wcdbGetHeadImageBuffers = this.lib.func('int32 wcdb_get_head_image_buffers(int64 handle, const char* usernamesJson, _Out_ void** outJson)') } catch { @@ -1921,6 +1928,397 @@ export class WcdbCore { } } + async getMediaStream(options?: { + sessionId?: string + mediaType?: 'image' | 'video' | 'all' + beginTimestamp?: number + endTimestamp?: number + limit?: number + offset?: number + }): Promise<{ + success: boolean + items?: Array<{ + sessionId: string + sessionDisplayName?: string + mediaType: 'image' | 'video' + localId: number + serverId?: string + createTime: number + localType: number + senderUsername?: string + isSend?: number | null + imageMd5?: string + imageDatName?: string + videoMd5?: string + content?: string + }> + hasMore?: boolean + nextOffset?: number + error?: string + }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbScanMediaStream) return { success: false, error: '当前数据服务版本不支持媒体流扫描,请先更新 wcdb 数据服务' } + try { + const toInt = (value: unknown): number => { + const n = Number(value || 0) + if (!Number.isFinite(n)) return 0 + return Math.floor(n) + } + const pickString = (row: Record, keys: string[]): string => { + for (const key of keys) { + const value = row[key] + if (value === null || value === undefined) continue + const text = String(value).trim() + if (text) return text + } + return '' + } + const extractXmlValue = (xml: string, tag: string): string => { + if (!xml) return '' + const regex = new RegExp(`<${tag}>([\\s\\S]*?)`, 'i') + const match = regex.exec(xml) + if (!match) return '' + return String(match[1] || '').replace(//g, '').trim() + } + const looksLikeHex = (text: string): boolean => { + if (!text || text.length < 2 || text.length % 2 !== 0) return false + return /^[0-9a-fA-F]+$/.test(text) + } + const looksLikeBase64 = (text: string): boolean => { + if (!text || text.length < 16 || text.length % 4 !== 0) return false + return /^[A-Za-z0-9+/]+={0,2}$/.test(text) + } + const decodeBinaryContent = (data: Buffer, fallbackValue?: string): string => { + if (!data || data.length === 0) return '' + try { + if (data.length >= 4) { + const magicLE = data.readUInt32LE(0) + const magicBE = data.readUInt32BE(0) + if (magicLE === 0xFD2FB528 || magicBE === 0xFD2FB528) { + try { + const decompressed = fzstd.decompress(data) + return Buffer.from(decompressed).toString('utf-8') + } catch { + // ignore + } + } + } + const decoded = data.toString('utf-8') + const replacementCount = (decoded.match(/\uFFFD/g) || []).length + if (replacementCount < decoded.length * 0.2) { + return decoded.replace(/\uFFFD/g, '') + } + if (fallbackValue && replacementCount > 0) return fallbackValue + return data.toString('latin1') + } catch { + return fallbackValue || '' + } + } + const decodeMaybeCompressed = (raw: unknown): string => { + if (raw === null || raw === undefined) return '' + if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) { + return decodeBinaryContent(Buffer.from(raw as any), String(raw)) + } + const text = String(raw).trim() + if (!text) return '' + + if (text.length > 16 && looksLikeHex(text)) { + try { + const bytes = Buffer.from(text, 'hex') + if (bytes.length > 0) return decodeBinaryContent(bytes, text) + } catch { + // ignore + } + } + if (text.length > 16 && looksLikeBase64(text)) { + try { + const bytes = Buffer.from(text, 'base64') + if (bytes.length > 0) return decodeBinaryContent(bytes, text) + } catch { + // ignore + } + } + return text + } + const decodeMessageContent = (messageContent: unknown, compressContent: unknown): string => { + const compressedDecoded = decodeMaybeCompressed(compressContent) + if (compressedDecoded) return compressedDecoded + return decodeMaybeCompressed(messageContent) + } + const extractImageMd5 = (xml: string): string => { + const byTag = extractXmlValue(xml, 'md5') || extractXmlValue(xml, 'imgmd5') + if (byTag) return byTag + const byAttr = /(?:md5|imgmd5)\s*=\s*['"]?([a-fA-F0-9]{16,64})['"]?/i.exec(xml) + return byAttr?.[1] || '' + } + const normalizeDatBase = (value: string): string => { + const input = String(value || '').trim() + if (!input) return '' + const fileBase = input.replace(/^.*[\\/]/, '').replace(/\.(?:t\.)?dat$/i, '') + const md5Like = /([0-9a-fA-F]{16,64})/.exec(fileBase) + return String(md5Like?.[1] || fileBase || '').trim().toLowerCase() + } + const decodePackedToPrintable = (raw: string): string => { + const text = String(raw || '').trim() + if (!text) return '' + let buf: Buffer | null = null + if (/^[a-fA-F0-9]+$/.test(text) && text.length % 2 === 0) { + try { + buf = Buffer.from(text, 'hex') + } catch { + buf = null + } + } + if (!buf) { + try { + const base64 = Buffer.from(text, 'base64') + if (base64.length > 0) buf = base64 + } catch { + buf = null + } + } + if (!buf || buf.length === 0) return '' + const printable: number[] = [] + for (const byte of buf) { + if (byte >= 0x20 && byte <= 0x7e) printable.push(byte) + else printable.push(0x20) + } + return Buffer.from(printable).toString('utf-8') + } + const extractHexMd5 = (text: string): string => { + const input = String(text || '') + if (!input) return '' + const match = /([a-fA-F0-9]{32})/.exec(input) + return String(match?.[1] || '').toLowerCase() + } + const extractImageDatName = (row: Record, content: string): string => { + const direct = pickString(row, [ + 'image_path', + 'imagePath', + 'image_dat_name', + 'imageDatName', + 'img_path', + 'imgPath', + 'img_name', + 'imgName' + ]) + const normalizedDirect = normalizeDatBase(direct) + if (normalizedDirect) return normalizedDirect + + const xmlCandidate = extractXmlValue(content, 'imgname') || extractXmlValue(content, 'cdnmidimgurl') + const normalizedXml = normalizeDatBase(xmlCandidate) + if (normalizedXml) return normalizedXml + + const packedRaw = pickString(row, [ + 'packed_info_data', + 'packedInfoData', + 'packed_info_blob', + 'packedInfoBlob', + 'packed_info', + 'packedInfo', + 'BytesExtra', + 'bytes_extra', + 'WCDB_CT_packed_info', + 'reserved0', + 'Reserved0', + 'WCDB_CT_Reserved0' + ]) + const packedText = decodePackedToPrintable(packedRaw) + if (packedText) { + const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(packedText) + if (datLike?.[1]) return String(datLike[1]).toLowerCase() + const md5Like = /([0-9a-fA-F]{16,64})/.exec(packedText) + if (md5Like?.[1]) return String(md5Like[1]).toLowerCase() + } + + return '' + } + const extractPackedPayload = (row: Record): string => { + const packedRaw = pickString(row, [ + 'packed_info_data', + 'packedInfoData', + 'packed_info_blob', + 'packedInfoBlob', + 'packed_info', + 'packedInfo', + 'BytesExtra', + 'bytes_extra', + 'WCDB_CT_packed_info', + 'reserved0', + 'Reserved0', + 'WCDB_CT_Reserved0' + ]) + return decodePackedToPrintable(packedRaw) + } + const extractVideoMd5 = (xml: string): string => { + const byTag = + extractXmlValue(xml, 'rawmd5') || + extractXmlValue(xml, 'videomd5') || + extractXmlValue(xml, 'newmd5') || + extractXmlValue(xml, 'md5') + if (byTag) return byTag + const byAttr = /(?:rawmd5|videomd5|newmd5|md5)\s*=\s*['"]?([a-fA-F0-9]{16,64})['"]?/i.exec(xml) + return byAttr?.[1] || '' + } + + const requestedSessionId = String(options?.sessionId || '').trim() + const mediaType = String(options?.mediaType || 'all').trim() as 'image' | 'video' | 'all' + const beginTimestamp = Math.max(0, toInt(options?.beginTimestamp)) + const endTimestamp = Math.max(0, toInt(options?.endTimestamp)) + const offset = Math.max(0, toInt(options?.offset)) + const limit = Math.min(1200, Math.max(40, toInt(options?.limit) || 240)) + + const sessionsRes = await this.getSessions() + if (!sessionsRes.success || !Array.isArray(sessionsRes.sessions)) { + return { success: false, error: sessionsRes.error || '读取会话失败' } + } + + const sessions = (sessionsRes.sessions || []) + .map((row: any) => ({ + sessionId: String( + row.username || + row.user_name || + row.userName || + row.usrName || + row.UsrName || + row.talker || + '' + ).trim(), + displayName: String(row.displayName || row.display_name || row.remark || '').trim(), + sortTimestamp: toInt( + row.sort_timestamp || + row.sortTimestamp || + row.last_timestamp || + row.lastTimestamp || + 0 + ) + })) + .filter((row) => Boolean(row.sessionId)) + .sort((a, b) => b.sortTimestamp - a.sortTimestamp) + + const sessionRows = requestedSessionId + ? sessions.filter((row) => row.sessionId === requestedSessionId) + : sessions + if (sessionRows.length === 0) { + return { success: true, items: [], hasMore: false, nextOffset: offset } + } + const sessionNameMap = new Map(sessionRows.map((row) => [row.sessionId, row.displayName || row.sessionId])) + + const outPtr = [null as any] + const outHasMore = [0] + const mediaTypeCode = mediaType === 'image' ? 1 : mediaType === 'video' ? 2 : 0 + const result = this.wcdbScanMediaStream( + this.handle, + JSON.stringify(sessionRows.map((row) => row.sessionId)), + mediaTypeCode, + beginTimestamp, + endTimestamp, + limit, + offset, + outPtr, + outHasMore + ) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `扫描媒体流失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析媒体流失败' } + const rows = JSON.parse(jsonStr) + const list = Array.isArray(rows) ? rows as Array> : [] + + let items = list.map((row) => { + const sessionId = pickString(row, ['session_id', 'sessionId']) || requestedSessionId + const localType = toInt(row.local_type ?? row.localType) + const rawMessageContent = pickString(row, [ + 'message_content', + 'messageContent', + 'message_content_text', + 'messageText', + 'StrContent', + 'str_content', + 'msg_content', + 'msgContent', + 'strContent', + 'content', + 'rawContent', + 'WCDB_CT_message_content' + ]) + const rawCompressContent = pickString(row, [ + 'compress_content', + 'compressContent', + 'msg_compress_content', + 'msgCompressContent', + 'WCDB_CT_compress_content' + ]) + const useRawMessageContent = Boolean( + rawMessageContent && + (rawMessageContent.includes('<') || rawMessageContent.includes('md5') || rawMessageContent.includes('videomsg')) + ) + const content = useRawMessageContent + ? rawMessageContent + : decodeMessageContent(rawMessageContent, rawCompressContent) + const packedPayload = extractPackedPayload(row) + const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5']) + const imageMd5 = localType === 3 + ? (imageMd5ByColumn || extractImageMd5(content) || extractHexMd5(packedPayload) || undefined) + : undefined + const imageDatName = localType === 3 ? (extractImageDatName(row, content) || undefined) : undefined + const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5']) + const videoMd5 = localType === 43 + ? (videoMd5ByColumn || extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined) + : undefined + return { + sessionId, + sessionDisplayName: sessionNameMap.get(sessionId) || sessionId, + mediaType: localType === 43 ? 'video' as const : 'image' as const, + localId: toInt(row.local_id ?? row.localId), + serverId: pickString(row, ['server_id', 'serverId']) || undefined, + createTime: toInt(row.create_time ?? row.createTime), + localType, + senderUsername: pickString(row, ['sender_username', 'senderUsername']) || undefined, + isSend: row.is_send === null || row.is_send === undefined ? null : toInt(row.is_send), + imageMd5, + imageDatName, + videoMd5, + content: content || undefined + } + }) + + const unresolvedSessionIds = Array.from( + new Set( + items + .map((item) => item.sessionId) + .filter((sessionId) => { + const name = String(sessionNameMap.get(sessionId) || '').trim() + return !name || name === sessionId + }) + ) + ) + if (unresolvedSessionIds.length > 0) { + const displayNameRes = await this.getDisplayNames(unresolvedSessionIds) + if (displayNameRes.success && displayNameRes.map) { + unresolvedSessionIds.forEach((sessionId) => { + const display = String(displayNameRes.map?.[sessionId] || '').trim() + if (display) sessionNameMap.set(sessionId, display) + }) + items = items.map((item) => ({ + ...item, + sessionDisplayName: sessionNameMap.get(item.sessionId) || item.sessionId + })) + } + } + + return { + success: true, + items, + hasMore: Number(outHasMore[0]) > 0, + nextOffset: offset + items.length + } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 9516922..5e7478c 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -268,6 +268,37 @@ export class WcdbService { return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset }) } + async getMediaStream(options?: { + sessionId?: string + mediaType?: 'image' | 'video' | 'all' + beginTimestamp?: number + endTimestamp?: number + limit?: number + offset?: number + }): Promise<{ + success: boolean + items?: Array<{ + sessionId: string + sessionDisplayName?: string + mediaType: 'image' | 'video' + localId: number + serverId?: string + createTime: number + localType: number + senderUsername?: string + isSend?: number | null + imageMd5?: string + imageDatName?: string + videoMd5?: string + content?: string + }> + hasMore?: boolean + nextOffset?: number + error?: string + }> { + return this.callWorker('getMediaStream', { options }) + } + /** * 获取联系人昵称 */ diff --git a/electron/types/dbus.d.ts b/electron/types/dbus.d.ts new file mode 100644 index 0000000..9585a42 --- /dev/null +++ b/electron/types/dbus.d.ts @@ -0,0 +1,18 @@ +declare module 'dbus-native' { + namespace dbus { + interface DBusConnection { + invoke(options: any, callback: (err: Error | null, result?: any) => void): void; + on(event: string, listener: Function): void; + // 底层connection,用于监听signal + connection: { + on(event: string, listener: Function): void; + }; + } + + // 声明sessionBus方法 + function sessionBus(): DBusConnection; + function systemBus(): DBusConnection; + } + + export = dbus; +} diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 57b1045..a666732 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -80,6 +80,9 @@ if (parentPort) { case 'getMessagesByType': result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset) break + case 'getMediaStream': + result = await core.getMediaStream(payload.options) + break case 'getDisplayNames': result = await core.getDisplayNames(payload.usernames) break diff --git a/electron/windows/notificationWindow.ts b/electron/windows/notificationWindow.ts index fc31ccc..587f43e 100644 --- a/electron/windows/notificationWindow.ts +++ b/electron/windows/notificationWindow.ts @@ -1,224 +1,333 @@ -import { BrowserWindow, ipcMain, screen } from 'electron' -import { join } from 'path' -import { ConfigService } from '../services/config' +import { BrowserWindow, ipcMain, screen } from "electron"; +import { join } from "path"; +import { ConfigService } from "../services/config"; -let notificationWindow: BrowserWindow | null = null -let closeTimer: NodeJS.Timeout | null = null +// Linux D-Bus通知服务 +const isLinux = process.platform === "linux"; +let linuxNotificationService: + | typeof import("../services/linuxNotificationService") + | null = null; + +// 用于处理通知点击的回调函数(在Linux上用于导航到会话) +let onNotificationNavigate: ((sessionId: string) => void) | null = null; + +export function setNotificationNavigateHandler( + callback: (sessionId: string) => void, +) { + onNotificationNavigate = callback; +} + +let notificationWindow: BrowserWindow | null = null; +let closeTimer: NodeJS.Timeout | null = null; export function destroyNotificationWindow() { - if (closeTimer) { - clearTimeout(closeTimer) - closeTimer = null - } - lastNotificationData = null + if (closeTimer) { + clearTimeout(closeTimer); + closeTimer = null; + } + lastNotificationData = null; - if (!notificationWindow || notificationWindow.isDestroyed()) { - notificationWindow = null - return - } + if (!notificationWindow || notificationWindow.isDestroyed()) { + notificationWindow = null; + return; + } - const win = notificationWindow - notificationWindow = null + const win = notificationWindow; + notificationWindow = null; - try { - win.destroy() - } catch (error) { - console.warn('[NotificationWindow] Failed to destroy window:', error) - } + try { + win.destroy(); + } catch (error) { + console.warn("[NotificationWindow] Failed to destroy window:", error); + } } export function createNotificationWindow() { - if (notificationWindow && !notificationWindow.isDestroyed()) { - return notificationWindow - } + if (notificationWindow && !notificationWindow.isDestroyed()) { + return notificationWindow; + } - const isDev = !!process.env.VITE_DEV_SERVER_URL - const iconPath = isDev - ? join(__dirname, '../../public/icon.ico') - : join(process.resourcesPath, 'icon.ico') + const isDev = !!process.env.VITE_DEV_SERVER_URL; + const iconPath = isDev + ? join(__dirname, "../../public/icon.ico") + : join(process.resourcesPath, "icon.ico"); - console.log('[NotificationWindow] Creating window...') - const width = 344 - const height = 114 + console.log("[NotificationWindow] Creating window..."); + const width = 344; + const height = 114; - // Update default creation size - notificationWindow = new BrowserWindow({ - width: width, - height: height, - type: 'toolbar', // 有助于在某些操作系统上保持置顶 - frame: false, - transparent: true, - resizable: false, - show: false, - alwaysOnTop: true, - skipTaskbar: true, - focusable: false, // 不抢占焦点 - icon: iconPath, - webPreferences: { - preload: join(__dirname, 'preload.js'), // FIX: Use correct relative path (same dir in dist) - contextIsolation: true, - nodeIntegration: false, - // devTools: true // Enable DevTools - } - }) + // Update default creation size + notificationWindow = new BrowserWindow({ + width: width, + height: height, + type: "toolbar", // 有助于在某些操作系统上保持置顶 + frame: false, + transparent: true, + resizable: false, + show: false, + alwaysOnTop: true, + skipTaskbar: true, + focusable: false, // 不抢占焦点 + icon: iconPath, + webPreferences: { + preload: join(__dirname, "preload.js"), // FIX: Use correct relative path (same dir in dist) + contextIsolation: true, + nodeIntegration: false, + // devTools: true // Enable DevTools + }, + }); - // notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools - notificationWindow.setIgnoreMouseEvents(true, { forward: true }) // 初始点击穿透 + // notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools + notificationWindow.setIgnoreMouseEvents(true, { forward: true }); // 初始点击穿透 - // 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?) - // 实际上,我们希望窗口可点击。 - // 我们将在显示时将忽略鼠标事件设为 false。 + // 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?) + // 实际上,我们希望窗口可点击。 + // 我们将在显示时将忽略鼠标事件设为 false。 - const loadUrl = isDev - ? `${process.env.VITE_DEV_SERVER_URL}#/notification-window` - : `file://${join(__dirname, '../dist/index.html')}#/notification-window` + const loadUrl = isDev + ? `${process.env.VITE_DEV_SERVER_URL}#/notification-window` + : `file://${join(__dirname, "../dist/index.html")}#/notification-window`; - console.log('[NotificationWindow] Loading URL:', loadUrl) - notificationWindow.loadURL(loadUrl) + console.log("[NotificationWindow] Loading URL:", loadUrl); + notificationWindow.loadURL(loadUrl); - notificationWindow.on('closed', () => { - notificationWindow = null - }) + notificationWindow.on("closed", () => { + notificationWindow = null; + }); - return notificationWindow + return notificationWindow; } export async function showNotification(data: any) { - // 先检查配置 - const config = ConfigService.getInstance() - const enabled = await config.get('notificationEnabled') - if (enabled === false) return // 默认为 true + // 先检查配置 + const config = ConfigService.getInstance(); + const enabled = await config.get("notificationEnabled"); + if (enabled === false) return; // 默认为 true - // 检查会话过滤 - const filterMode = config.get('notificationFilterMode') || 'all' - const filterList = config.get('notificationFilterList') || [] - const sessionId = data.sessionId + // 检查会话过滤 + const filterMode = config.get("notificationFilterMode") || "all"; + const filterList = config.get("notificationFilterList") || []; + const sessionId = data.sessionId; - if (sessionId && filterMode !== 'all' && filterList.length > 0) { - const isInList = filterList.includes(sessionId) - if (filterMode === 'whitelist' && !isInList) { - // 白名单模式:不在列表中则不显示 - return - } - if (filterMode === 'blacklist' && isInList) { - // 黑名单模式:在列表中则不显示 - return - } + if (sessionId && filterMode !== "all" && filterList.length > 0) { + const isInList = filterList.includes(sessionId); + if (filterMode === "whitelist" && !isInList) { + // 白名单模式:不在列表中则不显示 + return; } - - let win = notificationWindow - if (!win || win.isDestroyed()) { - win = createNotificationWindow() + if (filterMode === "blacklist" && isInList) { + // 黑名单模式:在列表中则不显示 + return; } + } - if (!win) return + // Linux 使用 D-Bus 通知 + if (isLinux) { + await showLinuxNotification(data); + return; + } - // 确保加载完成 - if (win.webContents.isLoading()) { - win.once('ready-to-show', () => { - showAndSend(win!, data) - }) - } else { - showAndSend(win, data) - } + let win = notificationWindow; + if (!win || win.isDestroyed()) { + win = createNotificationWindow(); + } + + if (!win) return; + + // 确保加载完成 + if (win.webContents.isLoading()) { + win.once("ready-to-show", () => { + showAndSend(win!, data); + }); + } else { + showAndSend(win, data); + } } -let lastNotificationData: any = null +// 显示Linux通知 +async function showLinuxNotification(data: any) { + if (!linuxNotificationService) { + try { + linuxNotificationService = + await import("../services/linuxNotificationService"); + } catch (error) { + console.error( + "[NotificationWindow] Failed to load Linux notification service:", + error, + ); + return; + } + } + + const { showLinuxNotification: showNotification } = linuxNotificationService; + + const notificationData = { + title: data.title, + content: data.content, + avatarUrl: data.avatarUrl, + sessionId: data.sessionId, + expireTimeout: 5000, + }; + + showNotification(notificationData); +} + +let lastNotificationData: any = null; async function showAndSend(win: BrowserWindow, data: any) { - lastNotificationData = data - const config = ConfigService.getInstance() - const position = (await config.get('notificationPosition')) || 'top-right' + lastNotificationData = data; + const config = ConfigService.getInstance(); + const position = (await config.get("notificationPosition")) || "top-right"; - // 更新位置 - const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize - const winWidth = position === 'top-center' ? 280 : 344 - const winHeight = 114 - const padding = 20 + // 更新位置 + const { width: screenWidth, height: screenHeight } = + screen.getPrimaryDisplay().workAreaSize; + const winWidth = position === "top-center" ? 280 : 344; + const winHeight = 114; + const padding = 20; - let x = 0 - let y = 0 + let x = 0; + let y = 0; - switch (position) { - case 'top-center': - x = (screenWidth - winWidth) / 2 - y = padding - break - case 'top-right': - x = screenWidth - winWidth - padding - y = padding - break - case 'bottom-right': - x = screenWidth - winWidth - padding - y = screenHeight - winHeight - padding - break - case 'top-left': - x = padding - y = padding - break - case 'bottom-left': - x = padding - y = screenHeight - winHeight - padding - break + switch (position) { + case "top-center": + x = (screenWidth - winWidth) / 2; + y = padding; + break; + case "top-right": + x = screenWidth - winWidth - padding; + y = padding; + break; + case "bottom-right": + x = screenWidth - winWidth - padding; + y = screenHeight - winHeight - padding; + break; + case "top-left": + x = padding; + y = padding; + break; + case "bottom-left": + x = padding; + y = screenHeight - winHeight - padding; + break; + } + + win.setPosition(Math.floor(x), Math.floor(y)); + win.setSize(winWidth, winHeight); // 确保尺寸 + + // 设为可交互 + win.setIgnoreMouseEvents(false); + win.showInactive(); // 显示但不聚焦 + win.setAlwaysOnTop(true, "screen-saver"); // 最高层级 + + win.webContents.send("notification:show", { ...data, position }); + + // 自动关闭计时器通常由渲染进程管理 + // 渲染进程发送 'notification:close' 来隐藏窗口 +} + +// 注册通知处理 +export async function registerNotificationHandlers() { + // Linux: 初始化D-Bus服务 + if (isLinux) { + try { + const linuxNotificationModule = + await import("../services/linuxNotificationService"); + linuxNotificationService = linuxNotificationModule; + + // 初始化服务 + await linuxNotificationModule.initLinuxNotificationService(); + + // 在Linux上注册通知点击回调 + linuxNotificationModule.onNotificationAction((sessionId: string) => { + console.log( + "[NotificationWindow] Linux notification clicked, sessionId:", + sessionId, + ); + // 如果设置了导航处理程序,则使用该处理程序;否则,回退到ipcMain方法。 + if (onNotificationNavigate) { + onNotificationNavigate(sessionId); + } else { + // 如果尚未设置处理程序,则通过ipcMain发出事件 + // 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。 + console.warn( + "[NotificationWindow] onNotificationNavigate not set yet", + ); + } + }); + + console.log( + "[NotificationWindow] Linux notification service initialized", + ); + } catch (error) { + console.error( + "[NotificationWindow] Failed to initialize Linux notification service:", + error, + ); } + } - win.setPosition(Math.floor(x), Math.floor(y)) - win.setSize(winWidth, winHeight) // 确保尺寸 + ipcMain.handle("notification:show", (_, data) => { + showNotification(data); + }); - // 设为可交互 - win.setIgnoreMouseEvents(false) - win.showInactive() // 显示但不聚焦 - win.setAlwaysOnTop(true, 'screen-saver') // 最高层级 + ipcMain.handle("notification:close", () => { + if (isLinux && linuxNotificationService) { + // 注册通知点击回调函数。Linux通知通过D-Bus自动关闭,但我们可以根据需要进行跟踪 + return; + } + if (notificationWindow && !notificationWindow.isDestroyed()) { + notificationWindow.hide(); + notificationWindow.setIgnoreMouseEvents(true, { forward: true }); + } + }); - win.webContents.send('notification:show', { ...data, position }) + // Handle renderer ready event (fix race condition) + ipcMain.on("notification:ready", (event) => { + if (isLinux) { + // Linux不需要通知窗口,拦截通知窗口渲染 + return; + } + console.log("[NotificationWindow] Renderer ready, checking cached data"); + if ( + lastNotificationData && + notificationWindow && + !notificationWindow.isDestroyed() + ) { + console.log("[NotificationWindow] Re-sending cached data"); + notificationWindow.webContents.send( + "notification:show", + lastNotificationData, + ); + } + }); - // 自动关闭计时器通常由渲染进程管理 - // 渲染进程发送 'notification:close' 来隐藏窗口 -} - -export function registerNotificationHandlers() { - ipcMain.handle('notification:show', (_, data) => { - showNotification(data) - }) - - ipcMain.handle('notification:close', () => { - if (notificationWindow && !notificationWindow.isDestroyed()) { - notificationWindow.hide() - notificationWindow.setIgnoreMouseEvents(true, { forward: true }) - } - }) - - // Handle renderer ready event (fix race condition) - ipcMain.on('notification:ready', (event) => { - console.log('[NotificationWindow] Renderer ready, checking cached data') - if (lastNotificationData && notificationWindow && !notificationWindow.isDestroyed()) { - console.log('[NotificationWindow] Re-sending cached data') - notificationWindow.webContents.send('notification:show', lastNotificationData) - } - }) - - // Handle resize request from renderer - ipcMain.on('notification:resize', (event, { width, height }) => { - if (notificationWindow && !notificationWindow.isDestroyed()) { - // Enforce max-height if needed, or trust renderer - // Ensure it doesn't go off screen bottom? - // Logic in showAndSend handles position, but we need to keep anchor point (top-right usually). - // If we resize, we should re-calculate position to keep it anchored? - // Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right. - // If bottom-right, growing down pushes it off screen. - - // Simple version: just setSize. For V1 we assume Top-Right. - // But wait, the config supports bottom-right. - // We can re-call setPosition or just let it be. - // If bottom-right, y needs to prevent overflow. - - // Ideally we get current config position - const bounds = notificationWindow.getBounds() - // Check if we need to adjust Y? - // For now, let's just set the size as requested. - notificationWindow.setSize(Math.round(width), Math.round(height)) - } - }) - - // 'notification-clicked' 在 main.ts 中处理 (导航) + // Handle resize request from renderer + ipcMain.on("notification:resize", (event, { width, height }) => { + if (isLinux) { + // Linux 通知通过D-Bus自动调整大小 + return; + } + if (notificationWindow && !notificationWindow.isDestroyed()) { + // Enforce max-height if needed, or trust renderer + // Ensure it doesn't go off screen bottom? + // Logic in showAndSend handles position, but we need to keep anchor point (top-right usually). + // If we resize, we should re-calculate position to keep it anchored? + // Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right. + // If bottom-right, growing down pushes it off screen. + + // Simple version: just setSize. For V1 we assume Top-Right. + // But wait, the config supports bottom-right. + // We can re-call setPosition or just let it be. + // If bottom-right, y needs to prevent overflow. + + // Ideally we get current config position + const bounds = notificationWindow.getBounds(); + // Check if we need to adjust Y? + // For now, let's just set the size as requested. + notificationWindow.setSize(Math.round(width), Math.round(height)); + } + }); + + // 'notification-clicked' 在 main.ts 中处理 (导航) } diff --git a/package-lock.json b/package-lock.json index 8fa56e9..024ebe2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "4.3.0", "hasInstallScript": true, "dependencies": { + "@vscode/sudo-prompt": "^9.3.2", "echarts": "^6.0.0", "echarts-for-react": "^3.0.2", "electron-store": "^11.0.2", @@ -29,7 +30,6 @@ "remark-gfm": "^4.0.1", "sherpa-onnx-node": "^1.10.38", "silk-wasm": "^3.7.1", - "sudo-prompt": "^9.2.1", "wechat-emojis": "^1.0.2", "zustand": "^5.0.2" }, @@ -3050,6 +3050,12 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vscode/sudo-prompt": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==", + "license": "MIT" + }, "node_modules/@xmldom/xmldom": { "version": "0.8.12", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", @@ -9456,13 +9462,6 @@ "inline-style-parser": "0.2.7" } }, - "node_modules/sudo-prompt": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.2.1.tgz", - "integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT" - }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", diff --git a/package.json b/package.json index 5361a0e..3e7e9e2 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "electron:build": "npm run build" }, "dependencies": { + "@vscode/sudo-prompt": "^9.3.2", + "dbus-native": "^0.4.0", "echarts": "^6.0.0", "echarts-for-react": "^3.0.2", "electron-store": "^11.0.2", @@ -43,7 +45,6 @@ "remark-gfm": "^4.0.1", "sherpa-onnx-node": "^1.10.38", "silk-wasm": "^3.7.1", - "sudo-prompt": "^9.2.1", "wechat-emojis": "^1.0.2", "zustand": "^5.0.2" }, diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 2ba487b..4dc46d7 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/App.tsx b/src/App.tsx index 92d0a93..f54442d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ import ImageWindow from './pages/ImageWindow' import SnsPage from './pages/SnsPage' import BizPage from './pages/BizPage' import ContactsPage from './pages/ContactsPage' +import ResourcesPage from './pages/ResourcesPage' import ChatHistoryPage from './pages/ChatHistoryPage' import NotificationWindow from './pages/NotificationWindow' @@ -669,32 +670,32 @@ function App() { )} - {showWaylandWarning && ( -
-
-
- -

环境兼容性提示 (Wayland)

-
-
-
-

检测到您当前正在使用 Wayland 显示服务器。

-

在 Wayland 环境下,出于系统级的安全与设计机制,应用程序无法直接控制新弹出窗口的位置

-

这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。

-
-

如果您觉得窗口位置异常严重影响了使用体验,建议尝试:

-

1. 在系统登录界面,将会话切换回 X11 (Xorg) 模式。

-

2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。

-
-
-
-
- -
-
-
-
- )} + {/*{showWaylandWarning && (*/} + {/*
*/} + {/*
*/} + {/*
*/} + {/* */} + {/*

环境兼容性提示 (Wayland)

*/} + {/*
*/} + {/*
*/} + {/*
*/} + {/*

检测到您当前正在使用 Wayland 显示服务器。

*/} + {/*

在 Wayland 环境下,出于系统级的安全与设计机制,应用程序无法直接控制新弹出窗口的位置

*/} + {/*

这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。

*/} + {/*
*/} + {/*

如果您觉得窗口位置异常严重影响了使用体验,建议尝试:

*/} + {/*

1. 在系统登录界面,将会话切换回 X11 (Xorg) 模式。

*/} + {/*

2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。

*/} + {/*
*/} + {/*
*/} + {/*
*/} + {/*
*/} + {/* */} + {/*
*/} + {/*
*/} + {/*
*/} + {/*
*/} + {/*)}*/} {/* 更新提示对话框 */} } /> } /> } /> + } /> } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 6b14cb4..5fa3e9b 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { NavLink, useLocation, useNavigate } from 'react-router-dom' -import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw } from 'lucide-react' +import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw, FolderClosed } from 'lucide-react' import { useAppStore } from '../stores/appStore' import { useChatStore } from '../stores/chatStore' import { useAnalyticsStore } from '../stores/analyticsStore' @@ -429,6 +429,16 @@ function Sidebar({ collapsed }: SidebarProps) { 通讯录 + {/* 资源浏览 */} + + + 资源浏览 + + {/* 聊天分析 */} void onToggleContactSelected: (contact: Contact) => void + onToggleFilteredContacts: (usernames: string[], shouldSelect: boolean) => void onClearSelectedContacts: () => void onExportSelectedContacts: () => void } @@ -46,6 +47,7 @@ export const SnsFilterPanel: React.FC = ({ activeContactUsername, onOpenContactTimeline, onToggleContactSelected, + onToggleFilteredContacts, onClearSelectedContacts, onExportSelectedContacts }) => { @@ -57,6 +59,16 @@ export const SnsFilterPanel: React.FC = ({ () => new Set(selectedContactUsernames), [selectedContactUsernames] ) + const filteredContactUsernames = React.useMemo( + () => filteredContacts.map((contact) => contact.username), + [filteredContacts] + ) + const selectedFilteredCount = React.useMemo( + () => filteredContactUsernames.filter((username) => selectedContactLookup.has(username)).length, + [filteredContactUsernames, selectedContactLookup] + ) + const hasFilteredContacts = filteredContactUsernames.length > 0 + const allFilteredSelected = hasFilteredContacts && selectedFilteredCount === filteredContactUsernames.length const clearFilters = () => { setSearchKeyword('') @@ -128,6 +140,20 @@ export const SnsFilterPanel: React.FC = ({ )} +
+ + 当前 {filteredContactUsernames.length} 人,已选 {selectedFilteredCount} 人 + + +
+ {contactsCountProgress && contactsCountProgress.total > 0 && (
{contactsCountProgress.running diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index a65d513..f513db0 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -6601,19 +6601,15 @@ function ExportPage() { const taskQueuedCount = tasks.filter(task => task.status === 'queued').length const taskCenterAlertCount = taskRunningCount + taskQueuedCount const hasFilteredContacts = filteredContacts.length > 0 - const CONTACTS_ACTION_STICKY_WIDTH = 184 - const contactsTableMinWidth = useMemo(() => { - const baseWidth = 24 + 34 + 44 + 280 + 120 + (4 * 72) + CONTACTS_ACTION_STICKY_WIDTH + (8 * 12) - const snsWidth = shouldShowSnsColumn ? 72 + 12 : 0 - const mutualFriendsWidth = shouldShowMutualFriendsColumn ? 72 + 12 : 0 - return baseWidth + snsWidth + mutualFriendsWidth - }, [shouldShowMutualFriendsColumn, shouldShowSnsColumn]) + const optionalMetricColumnCount = (shouldShowSnsColumn ? 1 : 0) + (shouldShowMutualFriendsColumn ? 1 : 0) + const contactsMetricColumnCount = 4 + optionalMetricColumnCount + const contactsColumnGapCount = 6 + optionalMetricColumnCount const contactsTableStyle = useMemo(() => ( { - ['--contacts-table-min-width' as const]: `${contactsTableMinWidth}px` + ['--contacts-table-min-width' as const]: `calc((2 * var(--contacts-inline-padding)) + var(--contacts-left-sticky-width) + var(--contacts-message-col-width) + (${contactsMetricColumnCount} * var(--contacts-media-col-width)) + var(--contacts-actions-sticky-width) + (${contactsColumnGapCount} * var(--contacts-column-gap)))` } as CSSProperties - ), [contactsTableMinWidth]) - const hasContactsHorizontalOverflow = contactsHorizontalScrollMetrics.contentWidth - contactsHorizontalScrollMetrics.viewportWidth > 1 + ), [contactsColumnGapCount, contactsMetricColumnCount]) + const hasContactsHorizontalOverflow = contactsHorizontalScrollMetrics.contentWidth - contactsHorizontalScrollMetrics.viewportWidth > 4 const contactsBottomScrollbarInnerStyle = useMemo(() => ({ width: `${Math.max(contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth)}px` }), [contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth]) diff --git a/src/pages/ResourcesPage.scss b/src/pages/ResourcesPage.scss new file mode 100644 index 0000000..cdbd17c --- /dev/null +++ b/src/pages/ResourcesPage.scss @@ -0,0 +1,620 @@ +.resources-page.stream-rebuild { + --stream-columns: 4; + --stream-grid-gap: 12px; + --stream-card-width: 272px; + --stream-card-height: 356px; + --stream-visual-height: 236px; + --stream-slot-width: calc(var(--stream-card-width) + var(--stream-grid-gap)); + --stream-slot-height: calc(var(--stream-card-height) + var(--stream-grid-gap)); + --stream-grid-width: calc(var(--stream-slot-width) * var(--stream-columns)); + height: calc(100% + 48px); + margin: -24px; + padding: 16px 18px; + position: relative; + background: var(--bg-primary); + display: flex; + flex-direction: column; + gap: 12px; + overflow: hidden; + + .stream-toolbar { + border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent); + background: var(--card-bg, #f8f9fb); + border-radius: 16px; + padding: 12px; + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + } + + .toolbar-left { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + flex: 1; + } + + .media-tabs { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 4px; + border-radius: 12px; + background: color-mix(in srgb, var(--bg-secondary) 85%, transparent); + border: 1px solid var(--border-color); + + button { + border: none; + background: transparent; + color: var(--text-secondary, #5f6674); + border-radius: 9px; + padding: 7px 14px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; + + &.active { + background: color-mix(in srgb, var(--primary) 18%, var(--card-bg)); + color: var(--text-primary, #1c2230); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 45%, transparent); + } + } + } + + .filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + + .filter-field { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid color-mix(in srgb, var(--border-color, #d2d7df) 95%, transparent); + background: var(--bg-secondary, #f3f5f8); + color: var(--text-secondary, #566074); + border-radius: 10px; + padding: 0 10px; + min-height: 36px; + box-sizing: border-box; + + svg { + color: var(--text-tertiary, #8a92a3); + flex: 0 0 auto; + } + } + + .filter-select { + min-width: 300px; + } + + .filter-date { + min-width: 160px; + } + + input, + select { + border: none; + outline: none; + background: transparent; + color: var(--text-primary, #1c2230); + font-size: 13px; + min-width: 0; + height: 34px; + line-height: 34px; + font-family: "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif; + appearance: none; + } + + .contact-select { + width: 100%; + min-width: 220px; + padding-right: 8px; + } + + .date-input { + width: 128px; + min-width: 128px; + } + + .sep { + color: var(--text-tertiary); + font-size: 12px; + } + + .ghost { + border: 1px solid var(--border-color); + background: transparent; + color: var(--text-secondary, #5f6674); + border-radius: 10px; + height: 36px; + padding: 0 12px; + cursor: pointer; + } + + .reset-btn { + border-color: color-mix(in srgb, var(--border-color, #d2d7df) 95%, transparent); + background: var(--bg-secondary, #f3f5f8); + } + } + + .toolbar-right { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; + + button { + border: 1px solid var(--border-color); + background: var(--bg-secondary, #f3f5f8); + color: var(--text-secondary, #5f6674); + border-radius: 10px; + height: 34px; + padding: 0 12px; + font-size: 13px; + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + + &:disabled { + opacity: 0.58; + cursor: not-allowed; + } + + &.danger { + border-color: color-mix(in srgb, var(--danger) 45%, var(--border-color)); + color: var(--danger); + background: color-mix(in srgb, var(--danger) 10%, var(--bg-secondary)); + } + } + } + + .stream-summary { + display: flex; + gap: 14px; + font-size: 12px; + color: var(--text-tertiary); + padding: 0 4px; + flex-wrap: wrap; + } + + .stream-state { + height: 120px; + border: 1px dashed var(--border-color); + border-radius: 12px; + background: var(--card-bg); + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + + &.error { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 45%, var(--border-color)); + } + } + + .stream-grid-wrap { + flex: 1; + min-height: 0; + border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); + border-radius: 16px; + background: color-mix(in srgb, var(--card-bg) 94%, transparent); + overflow: hidden; + display: flex; + flex-direction: column; + } + + .stream-grid { + flex: 1; + min-height: 0; + height: 100%; + overflow-anchor: none; + } + + .stream-grid-list, + .virtuoso-grid-list { + box-sizing: border-box; + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + align-items: flex-start; + align-content: flex-start; + padding: 10px 0 2px; + width: var(--stream-grid-width); + min-width: var(--stream-grid-width); + max-width: var(--stream-grid-width); + margin: 0 auto; + } + + .stream-grid-item, + .virtuoso-grid-item { + box-sizing: border-box; + width: var(--stream-slot-width); + min-width: var(--stream-slot-width); + max-width: var(--stream-slot-width); + flex: 0 0 var(--stream-slot-width); + height: var(--stream-slot-height); + padding-right: var(--stream-grid-gap); + padding-bottom: var(--stream-grid-gap); + display: flex; + } + + .stream-grid-item > *, + .virtuoso-grid-item > * { + width: 100%; + height: 100%; + } + + .media-card { + width: 100%; + height: 100%; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 14px; + overflow: hidden; + transition: border-color 0.16s ease; + position: relative; + display: flex; + flex-direction: column; + + &:hover { + border-color: color-mix(in srgb, var(--primary) 34%, var(--border-color)); + } + + &.selected { + border-color: color-mix(in srgb, var(--primary) 56%, var(--border-color)); + outline: 1px solid color-mix(in srgb, var(--primary) 30%, transparent); + outline-offset: -1px; + } + + &.decrypting { + .card-visual { + opacity: 0.68; + } + } + } + + .floating-delete { + position: absolute; + top: 10px; + right: 10px; + z-index: 4; + width: 28px; + height: 28px; + border-radius: 9px; + border: 1px solid color-mix(in srgb, var(--danger) 48%, var(--border-color)); + color: var(--danger); + background: color-mix(in srgb, var(--bg-secondary) 90%, transparent); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transform: translateY(-2px) scale(0.96); + pointer-events: none; + transition: opacity 0.16s ease, transform 0.16s ease; + } + + .media-card:hover .floating-delete, + .media-card:focus-within .floating-delete { + opacity: 1; + transform: translateY(0) scale(1); + pointer-events: auto; + } + + .floating-update { + position: absolute; + top: 10px; + left: 10px; + z-index: 4; + border: 1px solid color-mix(in srgb, var(--primary) 45%, var(--border-color)); + background: color-mix(in srgb, var(--bg-secondary) 90%, transparent); + color: var(--text-primary); + border-radius: 9px; + height: 28px; + padding: 0 8px; + font-size: 11px; + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } + } + + .card-visual { + width: 100%; + height: var(--stream-visual-height); + min-height: var(--stream-visual-height); + max-height: var(--stream-visual-height); + border: none; + cursor: pointer; + background: color-mix(in srgb, var(--bg-tertiary) 70%, transparent); + padding: 0; + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; + + &:disabled { + cursor: not-allowed; + } + + &.image img, + &.video img { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; + display: block; + } + + &.image img.long-image { + object-fit: cover; + object-position: top center; + } + + .placeholder { + width: 100%; + min-height: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--text-tertiary); + padding: 12px; + text-align: center; + + span { + font-size: 12px; + color: var(--text-secondary); + max-width: 90%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .decrypting-overlay { + position: absolute; + inset: 0; + z-index: 3; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + background: linear-gradient(140deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.04)); + overflow: hidden; + + &::before { + content: ''; + position: absolute; + inset: -40%; + background: linear-gradient(105deg, transparent 35%, rgba(255, 255, 255, 0.35) 50%, transparent 65%); + animation: decrypt-sheen 1.6s linear infinite; + pointer-events: none; + } + } + + .decrypting-spinner { + position: relative; + z-index: 1; + width: 30px; + height: 30px; + border-radius: 999px; + border: 2px solid rgba(15, 23, 42, 0.2); + border-top-color: color-mix(in srgb, var(--primary) 78%, #ffffff); + animation: decrypt-spin 0.85s linear infinite, decrypt-pulse 1.2s ease-in-out infinite; + box-shadow: + 0 0 0 8px rgba(255, 255, 255, 0.26), + 0 10px 24px rgba(15, 23, 42, 0.12); + } + } + + .card-meta { + padding: 9px 10px 8px; + min-height: 66px; + margin-top: auto; + cursor: pointer; + border-top: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent); + transition: background-color 0.15s ease; + + &:hover { + background: color-mix(in srgb, var(--bg-secondary) 68%, transparent); + } + } + + .title-row, + .sub-row { + display: flex; + justify-content: space-between; + gap: 8px; + } + + .title-row { + .session { + color: var(--text-primary); + font-size: 13px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 62%; + } + .time { + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; + } + } + + .sub-row { + margin-top: 4px; + font-size: 11px; + color: var(--text-tertiary); + } + + .grid-loading-more, + .grid-end { + height: 34px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: 12px; + color: var(--text-tertiary); + } + + .spin { + animation: resources-spin 1s linear infinite; + } + + .action-message { + color: color-mix(in srgb, var(--primary) 75%, var(--text-secondary)); + font-weight: 600; + } + + .resource-dialog-mask { + position: absolute; + inset: 0; + background: rgba(8, 11, 18, 0.24); + display: flex; + align-items: center; + justify-content: center; + z-index: 30; + } + + .resource-dialog { + width: min(420px, calc(100% - 32px)); + background: var(--card-bg, #ffffff); + border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent); + border-radius: 14px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.22); + overflow: hidden; + } + + .dialog-header { + padding: 12px 14px; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); + background: color-mix(in srgb, var(--bg-secondary) 85%, transparent); + } + + .dialog-body { + padding: 16px 14px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.55; + white-space: pre-wrap; + } + + .dialog-actions { + padding: 0 14px 14px; + display: flex; + justify-content: flex-end; + gap: 8px; + } + + .dialog-btn { + min-width: 72px; + height: 32px; + border-radius: 8px; + border: 1px solid var(--border-color); + font-size: 13px; + cursor: pointer; + + &.ghost { + background: var(--bg-secondary); + color: var(--text-secondary); + } + + &.solid { + background: color-mix(in srgb, var(--primary) 16%, var(--bg-secondary)); + border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color)); + color: var(--text-primary); + } + } +} + +@media (max-width: 900px) { + .resources-page.stream-rebuild { + .stream-toolbar { + flex-direction: column; + } + + .toolbar-right { + justify-content: flex-start; + } + + .filters { + .filter-select { + min-width: 220px; + } + } + } +} + +@media (max-width: 680px) { + .resources-page.stream-rebuild { + --stream-grid-width: calc(var(--stream-slot-width) * var(--stream-columns)); + + .stream-grid-list, + .virtuoso-grid-list { + margin: 0; + padding-left: 0; + padding-right: 0; + } + } +} + +@keyframes resources-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes decrypt-sheen { + from { + transform: translateX(-45%); + } + to { + transform: translateX(45%); + } +} + +@keyframes decrypt-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes decrypt-pulse { + 0%, + 100% { + opacity: 0.92; + } + 50% { + opacity: 0.68; + } +} diff --git a/src/pages/ResourcesPage.tsx b/src/pages/ResourcesPage.tsx new file mode 100644 index 0000000..f58c729 --- /dev/null +++ b/src/pages/ResourcesPage.tsx @@ -0,0 +1,1265 @@ +import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react' +import { Calendar, Image as ImageIcon, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react' +import { VirtuosoGrid } from 'react-virtuoso' +import './ResourcesPage.scss' + +type MediaTab = 'image' | 'video' + +interface MediaStreamItem { + sessionId: string + sessionDisplayName?: string + mediaType: 'image' | 'video' + localId: number + serverId?: string + createTime: number + localType: number + senderUsername?: string + isSend?: number | null + imageMd5?: string + imageDatName?: string + videoMd5?: string + content?: string +} + +interface ContactOption { + id: string + name: string +} + +type DialogState = { + mode: 'alert' | 'confirm' + title: string + message: string + confirmText?: string + cancelText?: string + onConfirm?: (() => void) | null +} + +const PAGE_SIZE = 120 +const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 18 +const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 36 +const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 4 + +const GridList = forwardRef>(function GridList(props, ref) { + const { className = '', ...rest } = props + const mergedClassName = ['stream-grid-list', className].filter(Boolean).join(' ') + return
+}) + +function GridItem(props: HTMLAttributes) { + const { className = '', ...rest } = props + const mergedClassName = ['stream-grid-item', className].filter(Boolean).join(' ') + return
+} + +function getRangeTimestampStart(date: string): number | undefined { + if (!date) return undefined + const parsed = new Date(`${date}T00:00:00`) + const n = Math.floor(parsed.getTime() / 1000) + return Number.isFinite(n) ? n : undefined +} + +function getRangeTimestampEnd(date: string): number | undefined { + if (!date) return undefined + const parsed = new Date(`${date}T23:59:59`) + const n = Math.floor(parsed.getTime() / 1000) + return Number.isFinite(n) ? n : undefined +} + +function getItemKey(item: MediaStreamItem): string { + const sessionId = String(item.sessionId || '').trim().toLowerCase() + const localId = Number(item.localId || 0) + if (sessionId && Number.isFinite(localId) && localId > 0) { + return `${sessionId}|${localId}` + } + + const serverId = String(item.serverId || '').trim().toLowerCase() + const createTime = Number(item.createTime || 0) + const localType = Number(item.localType || 0) + const mediaId = String( + item.mediaType === 'video' + ? (item.videoMd5 || '') + : (item.imageMd5 || item.imageDatName || '') + ).trim().toLowerCase() + return `${sessionId}|${createTime}|${localType}|${serverId}|${mediaId}` +} + +function formatTimeLabel(timestampSec: number): string { + if (!timestampSec) return '--:--' + return new Date(timestampSec * 1000).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) +} + +function extractVideoTitle(content?: string): string { + const xml = String(content || '') + if (!xml) return '视频' + const match = /([\s\S]*?)<\/title>/i.exec(xml) + const text = String(match?.[1] || '').replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim() + return text || '视频' +} + +function toRenderableMediaSrc(rawPath?: string): string { + const src = String(rawPath || '').trim() + if (!src) return '' + if (/^(data:image\/|blob:|https?:\/\/)/i.test(src)) { + return src + } + if (/^file:\/\//i.test(src)) { + return src.replace(/#/g, '%23') + } + if (src.startsWith('/')) { + return encodeURI(`file://${src}`).replace(/#/g, '%23') + } + if (/^[a-zA-Z]:[\\/]/.test(src)) { + return encodeURI(`file:///${src.replace(/\\/g, '/')}`).replace(/#/g, '%23') + } + return encodeURI(`file://${src.startsWith('/') ? '' : '/'}${src.replace(/\\/g, '/')}`).replace(/#/g, '%23') +} + +const MediaCard = memo(function MediaCard({ + item, + sessionName, + previewPath, + videoPosterPath, + imageIsLong, + hasPreviewUpdate, + selected, + decrypting, + onToggleSelect, + onDelete, + onImagePreviewAction, + onUpdateImageQuality, + onOpenVideo, + onImageLoaded +}: { + item: MediaStreamItem + sessionName: string + previewPath: string + videoPosterPath: string + imageIsLong: boolean + hasPreviewUpdate: boolean + selected: boolean + decrypting: boolean + onToggleSelect: (item: MediaStreamItem) => void + onDelete: (item: MediaStreamItem) => void + onImagePreviewAction: (item: MediaStreamItem) => void + onUpdateImageQuality: (item: MediaStreamItem) => void + onOpenVideo: (item: MediaStreamItem) => void + onImageLoaded: (item: MediaStreamItem, width: number, height: number) => void +}) { + const isImage = item.mediaType === 'image' + const isDecryptingVisual = decrypting + const showDecryptOverlay = isImage && isDecryptingVisual + + return ( + <article className={`media-card ${selected ? 'selected' : ''} ${isDecryptingVisual ? 'decrypting' : ''}`}> + <button type="button" className="floating-delete" onClick={() => onDelete(item)} aria-label="删除资源"> + <Trash2 size={14} /> + </button> + + {isImage && hasPreviewUpdate && ( + <button + type="button" + className="floating-update" + disabled={decrypting} + onClick={() => onUpdateImageQuality(item)} + title="已扫描到高清图,点击更新画质" + aria-label="更新画质" + > + <RefreshCw size={13} /> + 更新 + </button> + )} + + <button + type="button" + className={`card-visual ${isImage ? 'image' : 'video'}`} + disabled={isImage && isDecryptingVisual} + onClick={() => { + if (isImage) { + onImagePreviewAction(item) + return + } + onOpenVideo(item) + }} + > + {isImage ? ( + previewPath + ? <img + src={toRenderableMediaSrc(previewPath)} + alt="图片资源" + className={imageIsLong ? 'long-image' : ''} + loading="lazy" + decoding="async" + onLoad={(event) => { + const width = event.currentTarget.naturalWidth || 0 + const height = event.currentTarget.naturalHeight || 0 + onImageLoaded(item, width, height) + }} + /> + : <div className="placeholder"><ImageIcon size={30} /></div> + ) : ( + videoPosterPath + ? <img src={toRenderableMediaSrc(videoPosterPath)} alt="视频封面" loading="lazy" decoding="async" /> + : <div className="placeholder"> + <PlayCircle size={34} /> + <span>{extractVideoTitle(item.content)}</span> + </div> + )} + {showDecryptOverlay && ( + <div className="decrypting-overlay" aria-hidden="true"> + <div className="decrypting-spinner" /> + </div> + )} + </button> + + <div className="card-meta" onClick={() => onToggleSelect(item)}> + <div className="title-row"> + <span className="session" title={sessionName}>{sessionName}</span> + <span className="time">{formatTimeLabel(item.createTime)}</span> + </div> + <div className="sub-row"> + <span>{item.mediaType === 'image' ? '图片' : '视频'}</span> + {item.senderUsername && <span>{item.senderUsername}</span>} + </div> + </div> + </article> + ) +}) + +function ResourcesPage() { + const [tab, setTab] = useState<MediaTab>('image') + const [contacts, setContacts] = useState<ContactOption[]>([{ id: 'all', name: '全部联系人' }]) + const [selectedContact, setSelectedContact] = useState('all') + const [dateStart, setDateStart] = useState('') + const [dateEnd, setDateEnd] = useState('') + + const [items, setItems] = useState<MediaStreamItem[]>([]) + const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set()) + const [nextOffset, setNextOffset] = useState(0) + const [hasMore, setHasMore] = useState(false) + const [loading, setLoading] = useState(false) + const [loadingMore, setLoadingMore] = useState(false) + const [error, setError] = useState('') + const [batchBusy, setBatchBusy] = useState(false) + const [decryptingKeys, setDecryptingKeys] = useState<Set<string>>(new Set()) + const [actionMessage, setActionMessage] = useState('') + const [sessionNameMap, setSessionNameMap] = useState<Record<string, string>>({}) + const [previewPathMap, setPreviewPathMap] = useState<Record<string, string>>({}) + const [previewUpdateMap, setPreviewUpdateMap] = useState<Record<string, boolean>>({}) + const [videoPosterMap, setVideoPosterMap] = useState<Record<string, string>>({}) + const [imageAspectMap, setImageAspectMap] = useState<Record<string, number>>({}) + const [dialog, setDialog] = useState<DialogState | null>(null) + + const previewPathMapRef = useRef<Record<string, string>>({}) + const previewUpdateMapRef = useRef<Record<string, boolean>>({}) + const videoPosterMapRef = useRef<Record<string, string>>({}) + const imageAspectMapRef = useRef<Record<string, number>>({}) + const resolvingImageCacheBatchRef = useRef(false) + const pendingImageResolveRangeRef = useRef<{ start: number; end: number } | null>(null) + const imagePreloadUntilRef = useRef<Record<string, number>>({}) + const imageCacheMissUntilRef = useRef<Record<string, number>>({}) + const resolvingVideoPosterKeysRef = useRef<Set<string>>(new Set()) + const attemptedVideoPosterKeysRef = useRef<Set<string>>(new Set()) + const resolvedVideoMd5Ref = useRef<Record<string, string>>({}) + const previewPatchRef = useRef<Record<string, string>>({}) + const updatePatchRef = useRef<Record<string, boolean>>({}) + const previewPatchTimerRef = useRef<number | null>(null) + const posterPatchRef = useRef<Record<string, string>>({}) + const posterPatchTimerRef = useRef<number | null>(null) + const aspectPatchRef = useRef<Record<string, number>>({}) + const aspectPatchTimerRef = useRef<number | null>(null) + const pendingRangeRef = useRef<{ start: number; end: number } | null>(null) + const rangeTimerRef = useRef<number | null>(null) + + useEffect(() => { + previewPathMapRef.current = previewPathMap + }, [previewPathMap]) + + useEffect(() => { + previewUpdateMapRef.current = previewUpdateMap + }, [previewUpdateMap]) + + useEffect(() => { + videoPosterMapRef.current = videoPosterMap + }, [videoPosterMap]) + + useEffect(() => { + imageAspectMapRef.current = imageAspectMap + }, [imageAspectMap]) + + useEffect(() => () => { + if (previewPatchTimerRef.current !== null) { + window.clearTimeout(previewPatchTimerRef.current) + previewPatchTimerRef.current = null + } + if (posterPatchTimerRef.current !== null) { + window.clearTimeout(posterPatchTimerRef.current) + posterPatchTimerRef.current = null + } + if (aspectPatchTimerRef.current !== null) { + window.clearTimeout(aspectPatchTimerRef.current) + aspectPatchTimerRef.current = null + } + if (rangeTimerRef.current !== null) { + window.clearTimeout(rangeTimerRef.current) + rangeTimerRef.current = null + } + }, []) + + const showAlert = useCallback((message: string, title: string = '提示') => { + setDialog({ + mode: 'alert', + title, + message, + confirmText: '确定', + onConfirm: null + }) + }, []) + + const showConfirm = useCallback((message: string, onConfirm: () => void, title: string = '确认操作') => { + setDialog({ + mode: 'confirm', + title, + message, + confirmText: '确定', + cancelText: '取消', + onConfirm + }) + }, []) + + const closeDialog = useCallback(() => { + setDialog(null) + }, []) + + const isLikelyThumbnailPreview = useCallback((path: string): boolean => { + const lower = String(path || '').toLowerCase() + if (!lower) return false + return lower.includes('_thumb') || lower.includes('_t.') || lower.includes('.t.') + }, []) + + const flushPreviewPatch = useCallback(() => { + const pathPatch = previewPatchRef.current + const updatePatch = updatePatchRef.current + previewPatchRef.current = {} + updatePatchRef.current = {} + previewPatchTimerRef.current = null + const hasPathPatch = Object.keys(pathPatch).length > 0 + const hasUpdatePatch = Object.keys(updatePatch).length > 0 + if (hasPathPatch) { + setPreviewPathMap((prev) => ({ ...prev, ...pathPatch })) + } + if (hasUpdatePatch) { + setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch })) + } + }, []) + + const queuePreviewPatch = useCallback((itemKey: string, localPath: string, hasUpdate: boolean) => { + if (!itemKey || !localPath) return + if (previewPathMapRef.current[itemKey] === localPath && previewUpdateMapRef.current[itemKey] === hasUpdate) return + previewPatchRef.current[itemKey] = localPath + updatePatchRef.current[itemKey] = hasUpdate + if (previewPatchTimerRef.current !== null) return + previewPatchTimerRef.current = window.setTimeout(flushPreviewPatch, 16) + }, [flushPreviewPatch]) + + const flushPosterPatch = useCallback(() => { + const patch = posterPatchRef.current + posterPatchRef.current = {} + posterPatchTimerRef.current = null + if (Object.keys(patch).length === 0) return + setVideoPosterMap((prev) => ({ ...prev, ...patch })) + }, []) + + const queuePosterPatch = useCallback((itemKey: string, posterPath: string) => { + if (!itemKey || !posterPath) return + if (videoPosterMapRef.current[itemKey] === posterPath) return + posterPatchRef.current[itemKey] = posterPath + if (posterPatchTimerRef.current !== null) return + posterPatchTimerRef.current = window.setTimeout(flushPosterPatch, 16) + }, [flushPosterPatch]) + + const flushAspectPatch = useCallback(() => { + const patch = aspectPatchRef.current + aspectPatchRef.current = {} + aspectPatchTimerRef.current = null + if (Object.keys(patch).length === 0) return + setImageAspectMap((prev) => ({ ...prev, ...patch })) + }, []) + + const queueAspectPatch = useCallback((itemKey: string, ratio: number) => { + const old = imageAspectMapRef.current[itemKey] + if (typeof old === 'number' && Math.abs(old - ratio) < 0.01) return + aspectPatchRef.current[itemKey] = ratio + if (aspectPatchTimerRef.current !== null) return + aspectPatchTimerRef.current = window.setTimeout(flushAspectPatch, 24) + }, [flushAspectPatch]) + + const loadStream = useCallback(async (reset: boolean) => { + if (reset) setLoading(true) + else setLoadingMore(true) + if (reset) { + setError('') + setActionMessage('') + } + + try { + await window.electronAPI.chat.connect() + const requestOffset = reset ? 0 : nextOffset + const streamResult = await window.electronAPI.chat.getMediaStream({ + sessionId: selectedContact === 'all' ? undefined : selectedContact, + mediaType: tab, + beginTimestamp: getRangeTimestampStart(dateStart), + endTimestamp: getRangeTimestampEnd(dateEnd), + offset: requestOffset, + limit: PAGE_SIZE + }) + + if (!streamResult.success) { + setError(streamResult.error || '加载失败') + if (reset) { + previewPatchRef.current = {} + updatePatchRef.current = {} + posterPatchRef.current = {} + aspectPatchRef.current = {} + if (previewPatchTimerRef.current !== null) { + window.clearTimeout(previewPatchTimerRef.current) + previewPatchTimerRef.current = null + } + if (posterPatchTimerRef.current !== null) { + window.clearTimeout(posterPatchTimerRef.current) + posterPatchTimerRef.current = null + } + if (aspectPatchTimerRef.current !== null) { + window.clearTimeout(aspectPatchTimerRef.current) + aspectPatchTimerRef.current = null + } + if (rangeTimerRef.current !== null) { + window.clearTimeout(rangeTimerRef.current) + rangeTimerRef.current = null + } + pendingRangeRef.current = null + resolvingImageCacheBatchRef.current = false + pendingImageResolveRangeRef.current = null + imagePreloadUntilRef.current = {} + imageCacheMissUntilRef.current = {} + resolvingVideoPosterKeysRef.current.clear() + attemptedVideoPosterKeysRef.current.clear() + resolvedVideoMd5Ref.current = {} + setItems([]) + setNextOffset(0) + setHasMore(false) + setSelectedKeys(new Set()) + setPreviewPathMap({}) + setPreviewUpdateMap({}) + setVideoPosterMap({}) + setImageAspectMap({}) + } + return + } + + const incoming = (streamResult.items || []) as MediaStreamItem[] + if (reset) { + previewPatchRef.current = {} + updatePatchRef.current = {} + posterPatchRef.current = {} + aspectPatchRef.current = {} + if (previewPatchTimerRef.current !== null) { + window.clearTimeout(previewPatchTimerRef.current) + previewPatchTimerRef.current = null + } + if (posterPatchTimerRef.current !== null) { + window.clearTimeout(posterPatchTimerRef.current) + posterPatchTimerRef.current = null + } + if (aspectPatchTimerRef.current !== null) { + window.clearTimeout(aspectPatchTimerRef.current) + aspectPatchTimerRef.current = null + } + if (rangeTimerRef.current !== null) { + window.clearTimeout(rangeTimerRef.current) + rangeTimerRef.current = null + } + pendingRangeRef.current = null + resolvingImageCacheBatchRef.current = false + pendingImageResolveRangeRef.current = null + imagePreloadUntilRef.current = {} + imageCacheMissUntilRef.current = {} + resolvingVideoPosterKeysRef.current.clear() + attemptedVideoPosterKeysRef.current.clear() + resolvedVideoMd5Ref.current = {} + setItems(incoming) + setSelectedKeys(new Set()) + setPreviewPathMap({}) + setPreviewUpdateMap({}) + setVideoPosterMap({}) + setImageAspectMap({}) + } else { + setItems((prev) => { + const map = new Map(prev.map((row) => [getItemKey(row), row])) + incoming.forEach((row) => map.set(getItemKey(row), row)) + return Array.from(map.values()) + }) + } + setNextOffset(Number(streamResult.nextOffset || requestOffset + incoming.length)) + setHasMore(Boolean(streamResult.hasMore)) + } catch (e) { + setError(String(e)) + } finally { + setLoading(false) + setLoadingMore(false) + } + }, [dateEnd, dateStart, nextOffset, selectedContact, tab]) + + useEffect(() => { + void loadStream(true) + }, [tab, selectedContact, dateStart, dateEnd]) + + useEffect(() => { + let cancelled = false + const run = async () => { + try { + await window.electronAPI.chat.connect() + const sessionResult = await window.electronAPI.chat.getSessions() + if (!cancelled && sessionResult.success && Array.isArray(sessionResult.sessions)) { + const initialNameMap: Record<string, string> = {} + sessionResult.sessions.forEach((session) => { + initialNameMap[session.username] = session.displayName || session.username + }) + setSessionNameMap(initialNameMap) + setContacts([ + { id: 'all', name: '全部联系人' }, + ...sessionResult.sessions.map((session) => ({ + id: session.username, + name: session.displayName || session.username + })) + ]) + } + } catch { + // ignore + } + } + void run() + return () => { + cancelled = true + } + }, []) + + const displayItems = useMemo(() => ( + items.map((item) => ({ + ...item, + sessionDisplayName: item.sessionDisplayName || sessionNameMap[item.sessionId] || item.sessionId + })) + ), [items, sessionNameMap]) + + useEffect(() => { + const imageKeySet = new Set( + displayItems + .filter((item) => item.mediaType === 'image') + .map((item) => getItemKey(item)) + ) + const videoKeySet = new Set( + displayItems + .filter((item) => item.mediaType === 'video') + .map((item) => getItemKey(item)) + ) + + setPreviewPathMap((prev) => { + let changed = false + const next: Record<string, string> = {} + for (const [key, value] of Object.entries(prev)) { + if (!imageKeySet.has(key)) continue + next[key] = value + } + if (Object.keys(next).length !== Object.keys(prev).length) changed = true + return changed ? next : prev + }) + + setPreviewUpdateMap((prev) => { + let changed = false + const next: Record<string, boolean> = {} + for (const [key, value] of Object.entries(prev)) { + if (!imageKeySet.has(key)) continue + next[key] = value + } + if (Object.keys(next).length !== Object.keys(prev).length) changed = true + return changed ? next : prev + }) + + setImageAspectMap((prev) => { + let changed = false + const next: Record<string, number> = {} + for (const [key, value] of Object.entries(prev)) { + if (!imageKeySet.has(key)) continue + next[key] = value + } + if (Object.keys(next).length !== Object.keys(prev).length) changed = true + return changed ? next : prev + }) + + setVideoPosterMap((prev) => { + let changed = false + const next: Record<string, string> = {} + for (const [key, value] of Object.entries(prev)) { + if (!videoKeySet.has(key)) continue + next[key] = value + } + if (Object.keys(next).length !== Object.keys(prev).length) changed = true + return changed ? next : prev + }) + + const validKeys = new Set<string>(displayItems.map((item) => getItemKey(item))) + const nextResolvedVideoMd5: Record<string, string> = {} + for (const [key, value] of Object.entries(resolvedVideoMd5Ref.current)) { + if (!validKeys.has(key)) continue + nextResolvedVideoMd5[key] = value + } + resolvedVideoMd5Ref.current = nextResolvedVideoMd5 + + const nextAttempted = new Set<string>() + attemptedVideoPosterKeysRef.current.forEach((key) => { + if (validKeys.has(key)) nextAttempted.add(key) + }) + attemptedVideoPosterKeysRef.current = nextAttempted + + const nextImageMissUntil: Record<string, number> = {} + for (const [key, value] of Object.entries(imageCacheMissUntilRef.current)) { + if (!validKeys.has(key)) continue + nextImageMissUntil[key] = value + } + imageCacheMissUntilRef.current = nextImageMissUntil + + const nextImagePreloadUntil: Record<string, number> = {} + for (const [key, value] of Object.entries(imagePreloadUntilRef.current)) { + if (!validKeys.has(key)) continue + nextImagePreloadUntil[key] = value + } + imagePreloadUntilRef.current = nextImagePreloadUntil + + }, [displayItems]) + + const resolveImageCacheRange = useCallback((start: number, end: number) => { + const from = Math.max(0, start) + const to = Math.min(displayItems.length - 1, end) + if (to < from) return + const now = Date.now() + const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = [] + const itemKeys: string[] = [] + for (let i = from; i <= to; i += 1) { + const item = displayItems[i] + if (!item || item.mediaType !== 'image') continue + const itemKey = getItemKey(item) + if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue + if (!item.imageMd5 && !item.imageDatName) continue + if ((imageCacheMissUntilRef.current[itemKey] || 0) > now) continue + payloads.push({ + sessionId: item.sessionId, + imageMd5: item.imageMd5 || undefined, + imageDatName: item.imageDatName || undefined + }) + itemKeys.push(itemKey) + if (payloads.length >= MAX_IMAGE_CACHE_RESOLVE_PER_TICK) break + } + if (payloads.length === 0) return + if (resolvingImageCacheBatchRef.current) { + pendingImageResolveRangeRef.current = { start: from, end: to } + return + } + + resolvingImageCacheBatchRef.current = true + void (async () => { + try { + const result = await window.electronAPI.image.resolveCacheBatch(payloads, { disableUpdateCheck: true }) + const rows = Array.isArray(result?.rows) ? result.rows : [] + const pathPatch: Record<string, string> = {} + const updatePatch: Record<string, boolean> = {} + const missUntil = Date.now() + 4500 + + for (let i = 0; i < itemKeys.length; i += 1) { + const itemKey = itemKeys[i] + const row = rows[i] + if (row?.success && row.localPath) { + delete imageCacheMissUntilRef.current[itemKey] + pathPatch[itemKey] = row.localPath + updatePatch[itemKey] = Boolean(row.hasUpdate) + } else { + imageCacheMissUntilRef.current[itemKey] = missUntil + } + } + + if (Object.keys(pathPatch).length > 0) { + setPreviewPathMap((prev) => ({ ...prev, ...pathPatch })) + } + if (Object.keys(updatePatch).length > 0) { + setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch })) + } + } catch { + const missUntil = Date.now() + 4500 + itemKeys.forEach((itemKey) => { + imageCacheMissUntilRef.current[itemKey] = missUntil + }) + } finally { + resolvingImageCacheBatchRef.current = false + const pending = pendingImageResolveRangeRef.current + pendingImageResolveRangeRef.current = null + if (pending) { + resolveImageCacheRange(pending.start, pending.end) + } + } + })() + }, [displayItems]) + + const preloadImageCacheRange = useCallback((start: number, end: number) => { + const from = Math.max(0, start) + const to = Math.min(displayItems.length - 1, end) + if (to < from) return + + const now = Date.now() + const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = [] + const dedup = new Set<string>() + for (let i = from; i <= to; i += 1) { + const item = displayItems[i] + if (!item || item.mediaType !== 'image') continue + const itemKey = getItemKey(item) + if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue + if (!item.imageMd5 && !item.imageDatName) continue + if ((imagePreloadUntilRef.current[itemKey] || 0) > now) continue + const dedupKey = `${item.sessionId || ''}|${item.imageMd5 || ''}|${item.imageDatName || ''}` + if (dedup.has(dedupKey)) continue + dedup.add(dedupKey) + imagePreloadUntilRef.current[itemKey] = now + 12000 + payloads.push({ + sessionId: item.sessionId, + imageMd5: item.imageMd5 || undefined, + imageDatName: item.imageDatName || undefined + }) + if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break + } + if (payloads.length === 0) return + void window.electronAPI.image.preload(payloads, { allowDecrypt: false }) + }, [displayItems]) + + const resolveItemVideoMd5 = useCallback(async (item: MediaStreamItem): Promise<string> => { + const itemKey = getItemKey(item) + const cached = resolvedVideoMd5Ref.current[itemKey] + if (cached) return cached + + let md5 = String(item.videoMd5 || '').trim().toLowerCase() + if (md5) { + resolvedVideoMd5Ref.current[itemKey] = md5 + return md5 + } + const parsed = await window.electronAPI.video.parseVideoMd5(String(item.content || '')) + if (parsed.success && parsed.md5) md5 = String(parsed.md5).trim().toLowerCase() + if (md5) resolvedVideoMd5Ref.current[itemKey] = md5 + return md5 + }, []) + + const resolveVideoPoster = useCallback(async (item: MediaStreamItem) => { + if (item.mediaType !== 'video') return + const itemKey = getItemKey(item) + if (videoPosterMapRef.current[itemKey]) return + if (attemptedVideoPosterKeysRef.current.has(itemKey)) return + if (resolvingVideoPosterKeysRef.current.has(itemKey)) return + + resolvingVideoPosterKeysRef.current.add(itemKey) + try { + const md5 = await resolveItemVideoMd5(item) + if (!md5) { + attemptedVideoPosterKeysRef.current.add(itemKey) + return + } + const info = await window.electronAPI.video.getVideoInfo(md5, { includePoster: true, posterFormat: 'fileUrl' }) + if (!info.success || !info.exists) { + attemptedVideoPosterKeysRef.current.add(itemKey) + return + } + const poster = String(info.coverUrl || info.thumbUrl || '') + if (!poster) { + attemptedVideoPosterKeysRef.current.add(itemKey) + return + } + queuePosterPatch(itemKey, poster) + attemptedVideoPosterKeysRef.current.add(itemKey) + } catch { + attemptedVideoPosterKeysRef.current.add(itemKey) + } finally { + resolvingVideoPosterKeysRef.current.delete(itemKey) + } + }, [queuePosterPatch, resolveItemVideoMd5]) + + const resolvePosterRange = useCallback((start: number, end: number) => { + const from = Math.max(0, start) + const to = Math.min(displayItems.length - 1, end) + if (to < from) return + let resolvedCount = 0 + for (let i = from; i <= to; i += 1) { + const item = displayItems[i] + if (!item || item.mediaType !== 'video') continue + void resolveVideoPoster(item) + resolvedCount += 1 + if (resolvedCount >= MAX_VIDEO_POSTER_RESOLVE_PER_TICK) break + } + }, [displayItems, resolveVideoPoster]) + + const flushRangeResolve = useCallback(() => { + rangeTimerRef.current = null + const pending = pendingRangeRef.current + if (!pending) return + pendingRangeRef.current = null + if (tab === 'image') { + preloadImageCacheRange(pending.start - 8, pending.end + 32) + resolveImageCacheRange(pending.start - 2, pending.end + 8) + return + } + resolvePosterRange(pending.start, pending.end) + }, [preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab]) + + const scheduleRangeResolve = useCallback((start: number, end: number) => { + pendingRangeRef.current = { start, end } + if (rangeTimerRef.current !== null) { + window.clearTimeout(rangeTimerRef.current) + rangeTimerRef.current = null + } + rangeTimerRef.current = window.setTimeout(flushRangeResolve, 120) + }, [flushRangeResolve]) + + useEffect(() => { + if (displayItems.length === 0) return + if (tab === 'image') { + preloadImageCacheRange(0, Math.min(displayItems.length - 1, 80)) + resolveImageCacheRange(0, Math.min(displayItems.length - 1, 20)) + return + } + resolvePosterRange(0, Math.min(displayItems.length - 1, 12)) + }, [displayItems, preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab]) + + const selectedItems = useMemo(() => { + if (selectedKeys.size === 0) return [] + return displayItems.filter((item) => selectedKeys.has(getItemKey(item))) + }, [displayItems, selectedKeys]) + + const toggleSelect = useCallback((item: MediaStreamItem) => { + const key = getItemKey(item) + setSelectedKeys((prev) => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + }, []) + + const onImageLoaded = useCallback((item: MediaStreamItem, width: number, height: number) => { + if (item.mediaType !== 'image') return + if (!width || !height) return + const ratio = height / width + if (!Number.isFinite(ratio) || ratio <= 0) return + const itemKey = getItemKey(item) + queueAspectPatch(itemKey, ratio) + }, [queueAspectPatch]) + + const deleteOne = useCallback((item: MediaStreamItem) => { + showConfirm('确认删除该原始记录?此操作不可恢复。', () => { + void (async () => { + const result = await window.electronAPI.chat.deleteMessage(item.sessionId, item.localId, item.createTime) + if (!result.success) { + showAlert(`删除失败:${result.error || '未知错误'}`, '删除失败') + return + } + + const key = getItemKey(item) + setItems((prev) => prev.filter((row) => getItemKey(row) !== key)) + setSelectedKeys((prev) => { + const next = new Set(prev) + next.delete(key) + return next + }) + setPreviewPathMap((prev) => { + if (!prev[key]) return prev + const next = { ...prev } + delete next[key] + return next + }) + setPreviewUpdateMap((prev) => { + if (prev[key] === undefined) return prev + const next = { ...prev } + delete next[key] + return next + }) + setActionMessage('删除成功') + })() + }, '删除确认') + }, [showAlert, showConfirm]) + + const batchDelete = useCallback(() => { + if (selectedItems.length === 0 || batchBusy) return + + showConfirm(`确认删除选中 ${selectedItems.length} 条记录?此操作不可恢复。`, () => { + void (async () => { + setBatchBusy(true) + let success = 0 + const deletedKeys = new Set<string>() + try { + for (const item of selectedItems) { + const result = await window.electronAPI.chat.deleteMessage(item.sessionId, item.localId, item.createTime) + if (result.success) { + success += 1 + deletedKeys.add(getItemKey(item)) + } + } + + setItems((prev) => prev.filter((item) => !deletedKeys.has(getItemKey(item)))) + setSelectedKeys(new Set()) + setPreviewPathMap((prev) => { + const next = { ...prev } + deletedKeys.forEach((key) => { delete next[key] }) + return next + }) + setPreviewUpdateMap((prev) => { + const next = { ...prev } + deletedKeys.forEach((key) => { delete next[key] }) + return next + }) + setActionMessage(`批量删除完成:成功 ${success},失败 ${selectedItems.length - success}`) + showAlert(`批量删除完成:成功 ${success},失败 ${selectedItems.length - success}`, '批量删除完成') + } finally { + setBatchBusy(false) + } + })() + }, '批量删除确认') + }, [batchBusy, selectedItems, showAlert, showConfirm]) + + const decryptImage = useCallback(async (item: MediaStreamItem): Promise<string | undefined> => { + if (item.mediaType !== 'image') return + + const key = getItemKey(item) + if (!item.imageMd5 && !item.imageDatName) { + showAlert('当前图片缺少解密所需字段(imageMd5/imageDatName)', '无法解密') + return + } + + setDecryptingKeys((prev) => { + const next = new Set(prev) + next.add(key) + return next + }) + + try { + const result = await window.electronAPI.image.decrypt({ + sessionId: item.sessionId, + imageMd5: item.imageMd5 || undefined, + imageDatName: item.imageDatName || undefined, + force: true + }) + if (!result?.success) { + showAlert(`解密失败:${result?.error || '未知错误'}`, '解密失败') + return undefined + } + + if (result.localPath) { + const localPath = result.localPath as string + setPreviewPathMap((prev) => ({ ...prev, [key]: localPath })) + setPreviewUpdateMap((prev) => ({ ...prev, [key]: isLikelyThumbnailPreview(localPath) })) + setActionMessage('图片解密完成') + return localPath + } + try { + const resolved = await window.electronAPI.image.resolveCache({ + sessionId: item.sessionId, + imageMd5: item.imageMd5 || undefined, + imageDatName: item.imageDatName || undefined + }) + if (resolved?.success && resolved.localPath) { + const localPath = resolved.localPath + setPreviewPathMap((prev) => ({ ...prev, [key]: localPath })) + setPreviewUpdateMap((prev) => ({ ...prev, [key]: Boolean(resolved.hasUpdate) })) + setActionMessage('图片解密完成') + return localPath + } + } catch { + // ignore + } + setActionMessage('图片解密完成') + return undefined + } catch (e) { + showAlert(`解密失败:${String(e)}`, '解密失败') + return undefined + } finally { + setDecryptingKeys((prev) => { + const next = new Set(prev) + next.delete(key) + return next + }) + } + }, [isLikelyThumbnailPreview, showAlert]) + + const onImagePreviewAction = useCallback(async (item: MediaStreamItem) => { + if (item.mediaType !== 'image') return + const key = getItemKey(item) + let localPath = previewPathMapRef.current[key] || previewPatchRef.current[key] || '' + + if (localPath) { + try { + const resolved = await window.electronAPI.image.resolveCache({ + sessionId: item.sessionId, + imageMd5: item.imageMd5 || undefined, + imageDatName: item.imageDatName || undefined + }) + if (resolved?.success && resolved.localPath) { + localPath = resolved.localPath + queuePreviewPatch(key, localPath, Boolean(resolved.hasUpdate)) + } + } catch { + // ignore + } + if (localPath) { + await window.electronAPI.window.openImageViewerWindow(localPath) + return + } + } + + try { + const resolved = await window.electronAPI.image.resolveCache({ + sessionId: item.sessionId, + imageMd5: item.imageMd5 || undefined, + imageDatName: item.imageDatName || undefined + }) + if (resolved?.success && resolved.localPath) { + localPath = resolved.localPath + queuePreviewPatch(key, localPath, Boolean(resolved.hasUpdate)) + await window.electronAPI.window.openImageViewerWindow(localPath) + return + } + } catch { + // ignore + } + + await decryptImage(item) + }, [decryptImage, queuePreviewPatch]) + + const updateImageQuality = useCallback(async (item: MediaStreamItem) => { + await decryptImage(item) + }, [decryptImage]) + + const batchDecryptImage = useCallback(async () => { + if (batchBusy) return + + const imageItems = selectedItems.filter((item) => item.mediaType === 'image') + if (imageItems.length === 0) { + showAlert('当前选中中没有图片资源', '无法批量解密') + return + } + + setBatchBusy(true) + let success = 0 + const previewPatch: Record<string, string> = {} + const updatePatch: Record<string, boolean> = {} + try { + for (const item of imageItems) { + if (!item.imageMd5 && !item.imageDatName) continue + const result = await window.electronAPI.image.decrypt({ + sessionId: item.sessionId, + imageMd5: item.imageMd5 || undefined, + imageDatName: item.imageDatName || undefined, + force: true + }) + if (!result?.success) continue + success += 1 + if (result.localPath) { + const key = getItemKey(item) + previewPatch[key] = result.localPath + updatePatch[key] = isLikelyThumbnailPreview(result.localPath) + } + } + + if (Object.keys(previewPatch).length > 0) { + setPreviewPathMap((prev) => ({ ...prev, ...previewPatch })) + } + if (Object.keys(updatePatch).length > 0) { + setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch })) + } + setActionMessage(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`) + showAlert(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`, '批量解密完成') + } finally { + setBatchBusy(false) + } + }, [batchBusy, isLikelyThumbnailPreview, selectedItems, showAlert]) + + const openVideo = useCallback(async (item: MediaStreamItem) => { + if (item.mediaType !== 'video') return + + const md5 = await resolveItemVideoMd5(item) + if (!md5) { + showAlert('未解析到视频资源标识', '无法播放') + return + } + + const info = await window.electronAPI.video.getVideoInfo(md5, { includePoster: false }) + if (!info.success || !info.exists || !info.videoUrl) { + showAlert(info.error || '未找到视频文件', '无法播放') + return + } + + await window.electronAPI.window.openVideoPlayerWindow(info.videoUrl) + }, [resolveItemVideoMd5, showAlert]) + + return ( + <div className="resources-page stream-rebuild"> + <header className="stream-toolbar"> + <div className="toolbar-left"> + <div className="media-tabs"> + <button type="button" className={tab === 'image' ? 'active' : ''} onClick={() => setTab('image')}>图片</button> + <button type="button" className={tab === 'video' ? 'active' : ''} onClick={() => setTab('video')}>视频</button> + </div> + <div className="filters"> + <label className="filter-field filter-select"> + <UserRound size={14} /> + <select + className="contact-select" + value={selectedContact} + onChange={(event) => setSelectedContact(event.target.value)} + > + {contacts.map((contact) => ( + <option key={contact.id} value={contact.id}>{contact.name}</option> + ))} + </select> + </label> + <label className="filter-field filter-date"> + <Calendar size={14} /> + <input + className="date-input" + type="date" + value={dateStart} + onChange={(event) => setDateStart(event.target.value)} + /> + </label> + <span className="sep">至</span> + <label className="filter-field filter-date"> + <Calendar size={14} /> + <input + className="date-input" + type="date" + value={dateEnd} + onChange={(event) => setDateEnd(event.target.value)} + /> + </label> + <button type="button" className="ghost reset-btn" onClick={() => { setDateStart(''); setDateEnd('') }}>重置时间</button> + </div> + </div> + <div className="toolbar-right"> + <button type="button" onClick={() => void loadStream(true)} disabled={loading || loadingMore}> + {loading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />} + 刷新 + </button> + {tab === 'image' && ( + <button type="button" onClick={() => void batchDecryptImage()} disabled={selectedKeys.size === 0 || batchBusy}> + 批量解密 + </button> + )} + <button type="button" className="danger" onClick={() => void batchDelete()} disabled={selectedKeys.size === 0 || batchBusy}> + 批量删除 + </button> + </div> + </header> + + <div className="stream-summary"> + <span>已加载 {items.length} 条</span> + <span>已选 {selectedKeys.size} 条</span> + <span>{tab === 'image' ? '图片按时间倒序流式展示' : '视频按时间倒序流式展示'}</span> + {actionMessage && <span className="action-message">{actionMessage}</span>} + </div> + + {error && ( + <div className="stream-state error">{error}</div> + )} + + {!error && items.length === 0 && (loading || loadingMore) && ( + <div className="stream-state"><Loader2 size={18} className="spin" /> 正在加载...</div> + )} + + {!error && items.length === 0 && !loading && !loadingMore && ( + <div className="stream-state">当前筛选条件下没有内容</div> + )} + + {!error && items.length > 0 && ( + <div className="stream-grid-wrap"> + <VirtuosoGrid + className="stream-grid" + overscan={48} + components={{ + List: GridList, + Item: GridItem + }} + data={displayItems} + computeItemKey={(_, item) => getItemKey(item)} + rangeChanged={(range) => { + scheduleRangeResolve(range.startIndex - 3, range.endIndex + 6) + }} + endReached={() => { + if (!hasMore || loading || loadingMore) return + void loadStream(false) + }} + itemContent={(_, item) => { + const itemKey = getItemKey(item) + const aspect = imageAspectMap[itemKey] || 0 + return ( + <MediaCard + item={item} + sessionName={item.sessionDisplayName || item.sessionId} + previewPath={previewPathMap[itemKey] || ''} + videoPosterPath={videoPosterMap[itemKey] || ''} + imageIsLong={aspect >= 2.8} + hasPreviewUpdate={Boolean(previewUpdateMap[itemKey])} + selected={selectedKeys.has(itemKey)} + decrypting={decryptingKeys.has(itemKey)} + onToggleSelect={toggleSelect} + onDelete={deleteOne} + onImagePreviewAction={onImagePreviewAction} + onUpdateImageQuality={updateImageQuality} + onOpenVideo={openVideo} + onImageLoaded={onImageLoaded} + /> + ) + }} + /> + {loadingMore && <div className="grid-loading-more"><Loader2 size={16} className="spin" /> 加载更多中...</div>} + {!hasMore && <div className="grid-end">已加载到底</div>} + </div> + )} + + {dialog && ( + <div className="resource-dialog-mask"> + <div className="resource-dialog" role="dialog" aria-modal="true" aria-label={dialog.title}> + <header className="dialog-header">{dialog.title}</header> + <div className="dialog-body">{dialog.message}</div> + <footer className="dialog-actions"> + {dialog.mode === 'confirm' && ( + <button type="button" className="dialog-btn ghost" onClick={closeDialog}> + {dialog.cancelText || '取消'} + </button> + )} + <button + type="button" + className="dialog-btn solid" + onClick={() => { + const callback = dialog.onConfirm + closeDialog() + callback?.() + }} + > + {dialog.confirmText || '确定'} + </button> + </footer> + </div> + </div> + )} + </div> + ) +} + +export default ResourcesPage diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index ab69631..6dea0d6 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -1265,6 +1265,52 @@ } } + .contact-selection-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 16px; + border-bottom: 1px dashed color-mix(in srgb, var(--border-color) 72%, transparent); + } + + .contact-selection-summary { + min-width: 0; + font-size: 11px; + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; + } + + .contact-selection-toggle { + flex-shrink: 0; + border: 1px solid color-mix(in srgb, var(--primary) 16%, var(--border-color)); + background: color-mix(in srgb, var(--bg-primary) 84%, rgba(var(--primary-rgb), 0.08)); + color: var(--text-secondary); + border-radius: 999px; + padding: 5px 10px; + font-size: 12px; + font-weight: 600; + line-height: 1.2; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease; + + &:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color)); + color: var(--primary); + } + + &.active { + border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color)); + background: rgba(var(--primary-rgb), 0.12); + color: var(--primary); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } + } + .contact-count-progress { padding: 8px 16px 10px; font-size: 11px; @@ -1642,6 +1688,202 @@ } } +.sns-cache-migration-dialog { + background: var(--bg-secondary); + border-radius: var(--sns-border-radius-lg); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18); + width: 540px; + max-width: 92vw; + display: flex; + flex-direction: column; + border: 1px solid var(--border-color); + overflow: hidden; + position: relative; + animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +.sns-cache-migration-close { + position: absolute; + right: 12px; + top: 12px; + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 6px; + border-radius: 6px; + display: flex; + + &:hover:not(:disabled) { + background: var(--bg-primary); + color: var(--text-primary); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } +} + +.sns-cache-migration-header { + padding: 18px 20px 12px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); +} + +.sns-cache-migration-title { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); +} + +.sns-cache-migration-subtitle { + margin-top: 6px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; +} + +.sns-cache-migration-body { + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.sns-cache-migration-meta { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 13px; + color: var(--text-secondary); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 10px 12px; + + strong { + font-size: 16px; + color: var(--text-primary); + } +} + +.sns-cache-migration-progress { + display: flex; + flex-direction: column; + gap: 8px; +} + +.sns-cache-migration-progress-bar { + width: 100%; + height: 8px; + border-radius: 999px; + background: var(--bg-tertiary); + overflow: hidden; +} + +.sns-cache-migration-progress-fill { + height: 100%; + background: linear-gradient(90deg, #34d399, #10b981); + transition: width 0.2s ease; +} + +.sns-cache-migration-progress-text { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--text-secondary); +} + +.sns-cache-migration-items { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 180px; + overflow-y: auto; + padding-right: 4px; +} + +.sns-cache-migration-item { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-tertiary); + padding: 8px 10px; +} + +.sns-cache-migration-item-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.sns-cache-migration-item-detail { + margin-top: 4px; + font-size: 12px; + color: var(--text-secondary); + word-break: break-all; + line-height: 1.45; +} + +.sns-cache-migration-error, +.sns-cache-migration-success { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + padding: 8px 10px; + border-radius: 8px; +} + +.sns-cache-migration-error { + background: rgba(244, 67, 54, 0.1); + color: var(--color-error, #f44336); +} + +.sns-cache-migration-success { + background: rgba(76, 175, 80, 0.1); + color: var(--color-success, #4caf50); +} + +.sns-cache-migration-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + padding: 0 20px 18px; +} + +.sns-cache-migration-btn { + min-width: 110px; + height: 38px; + border-radius: 9px; + border: 1px solid transparent; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + + &.primary { + background: var(--primary, #576b95); + color: #fff; + } + + &.secondary { + background: var(--bg-tertiary); + color: var(--text-secondary); + border-color: var(--border-color); + } + + &:hover:not(:disabled) { + filter: brightness(1.05); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + .author-timeline-dialog { background: var(--sns-card-bg); border-radius: var(--sns-border-radius-lg); diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 62c83aa..b9bf6b0 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -66,6 +66,33 @@ type OverviewStatsStatus = 'loading' | 'ready' | 'error' type SnsExportScope = { kind: 'all' } | { kind: 'selected'; usernames: string[] } const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1' +const SNS_CACHE_MIGRATION_PROMPT_SESSION_KEY = 'sns_cache_migration_prompted_v1' + +interface SnsCacheMigrationItem { + label: string + sourceDir: string + targetDir: string + fileCount: number +} + +interface SnsCacheMigrationStatus { + totalFiles: number + legacyBaseDir?: string + currentBaseDir?: string + items: SnsCacheMigrationItem[] +} + +interface SnsCacheMigrationProgress { + status: 'running' | 'done' | 'error' + phase: 'copying' | 'cleanup' | 'done' | 'error' + current: number + total: number + copied: number + skipped: number + remaining: number + message?: string + currentItemLabel?: string +} const readSidebarUserProfileCache = (): SidebarUserProfile | null => { try { @@ -162,6 +189,12 @@ export default function SnsPage() { const [triggerInstalled, setTriggerInstalled] = useState<boolean | null>(null) const [triggerLoading, setTriggerLoading] = useState(false) const [triggerMessage, setTriggerMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) + const [showCacheMigrationDialog, setShowCacheMigrationDialog] = useState(false) + const [cacheMigrationStatus, setCacheMigrationStatus] = useState<SnsCacheMigrationStatus | null>(null) + const [cacheMigrationProgress, setCacheMigrationProgress] = useState<SnsCacheMigrationProgress | null>(null) + const [cacheMigrationRunning, setCacheMigrationRunning] = useState(false) + const [cacheMigrationDone, setCacheMigrationDone] = useState(false) + const [cacheMigrationError, setCacheMigrationError] = useState<string | null>(null) const postsContainerRef = useRef<HTMLDivElement>(null) const jumpCalendarWrapRef = useRef<HTMLDivElement | null>(null) @@ -185,6 +218,7 @@ export default function SnsPage() { const contactsCountBatchTimerRef = useRef<number | null>(null) const jumpDateCountsCacheRef = useRef<Map<string, Record<string, number>>>(new Map()) const jumpDateRequestSeqRef = useRef(0) + const checkedCacheMigrationRef = useRef(false) // Sync posts ref useEffect(() => { @@ -595,6 +629,133 @@ export default function SnsPage() { } }, [persistSnsPageCache]) + const markCacheMigrationPrompted = useCallback(() => { + try { + window.sessionStorage.setItem(SNS_CACHE_MIGRATION_PROMPT_SESSION_KEY, '1') + } catch { + // ignore session storage failures + } + }, []) + + const hasCacheMigrationPrompted = useCallback(() => { + try { + return window.sessionStorage.getItem(SNS_CACHE_MIGRATION_PROMPT_SESSION_KEY) === '1' + } catch { + return false + } + }, []) + + const checkCacheMigrationStatus = useCallback(async () => { + if (checkedCacheMigrationRef.current) return + checkedCacheMigrationRef.current = true + if (hasCacheMigrationPrompted()) return + try { + const result = await window.electronAPI.sns.getCacheMigrationStatus() + if (!result?.success || !result.needed) return + const totalFiles = Math.max(0, Number(result.totalFiles || 0)) + const items = Array.isArray(result.items) + ? result.items.map((item) => ({ + label: String(item.label || '').trim(), + sourceDir: String(item.sourceDir || '').trim(), + targetDir: String(item.targetDir || '').trim(), + fileCount: Math.max(0, Number(item.fileCount || 0)) + })).filter((item) => item.label && item.sourceDir && item.targetDir && item.fileCount > 0) + : [] + if (totalFiles <= 0 || items.length === 0) return + setCacheMigrationStatus({ + totalFiles, + legacyBaseDir: result.legacyBaseDir, + currentBaseDir: result.currentBaseDir, + items + }) + setCacheMigrationProgress(null) + setCacheMigrationDone(false) + setCacheMigrationError(null) + setShowCacheMigrationDialog(true) + markCacheMigrationPrompted() + } catch (error) { + console.error('Failed to check SNS cache migration status:', error) + } + }, [hasCacheMigrationPrompted, markCacheMigrationPrompted]) + + const startCacheMigration = useCallback(async () => { + const total = Math.max(0, cacheMigrationStatus?.totalFiles || 0) + setCacheMigrationError(null) + setCacheMigrationDone(false) + setCacheMigrationRunning(true) + setCacheMigrationProgress({ + status: 'running', + phase: 'copying', + current: 0, + total, + copied: 0, + skipped: 0, + remaining: total, + message: '准备迁移...' + }) + + const removeProgress = window.electronAPI.sns.onCacheMigrationProgress((payload) => { + if (!payload) return + setCacheMigrationProgress({ + status: payload.status, + phase: payload.phase, + current: Math.max(0, Number(payload.current || 0)), + total: Math.max(0, Number(payload.total || 0)), + copied: Math.max(0, Number(payload.copied || 0)), + skipped: Math.max(0, Number(payload.skipped || 0)), + remaining: Math.max(0, Number(payload.remaining || 0)), + message: payload.message, + currentItemLabel: payload.currentItemLabel + }) + if (payload.status === 'done') { + setCacheMigrationDone(true) + setCacheMigrationError(null) + } else if (payload.status === 'error') { + setCacheMigrationError(payload.message || '迁移失败') + } + }) + + try { + const result = await window.electronAPI.sns.startCacheMigration() + if (!result?.success) { + setCacheMigrationError(result?.error || '迁移失败') + } else { + const totalFiles = Math.max(0, Number(result.totalFiles || 0)) + if (totalFiles === 0) { + setCacheMigrationDone(true) + setCacheMigrationProgress({ + status: 'done', + phase: 'done', + current: 0, + total: 0, + copied: 0, + skipped: 0, + remaining: 0, + message: result.message || '无需迁移' + }) + } else { + // 兜底:若 done 事件因时序原因未到达,仍以返回结果收敛到完成态。 + setCacheMigrationDone(true) + setCacheMigrationProgress((prev) => prev || { + status: 'done', + phase: 'done', + current: totalFiles, + total: totalFiles, + copied: Math.max(0, Number(result.copied || 0)), + skipped: Math.max(0, Number(result.skipped || 0)), + remaining: 0, + message: '迁移完成' + }) + } + } + } catch (error) { + setCacheMigrationError(String((error as Error)?.message || error || '迁移失败')) + } finally { + removeProgress() + setCacheMigrationRunning(false) + } + }, [cacheMigrationStatus?.totalFiles]) + const renderOverviewRangeText = () => { if (overviewStatsStatus === 'error') { return ( @@ -1237,6 +1398,24 @@ export default function SnsPage() { setSelectedContactUsernames([]) }, []) + const toggleSelectFilteredContacts = useCallback((usernames: string[], shouldSelect: boolean) => { + const normalizedTargets = Array.from(new Set( + usernames + .map((username) => String(username || '').trim()) + .filter(Boolean) + )) + if (normalizedTargets.length === 0) return + + setSelectedContactUsernames((prev) => { + if (shouldSelect) { + const next = new Set(prev) + normalizedTargets.forEach((username) => next.add(username)) + return Array.from(next) + } + return prev.filter((username) => !normalizedTargets.includes(username)) + }) + }, []) + const openSelectedContactsExport = useCallback(() => { if (selectedContactUsernames.length === 0) return openExportDialog({ kind: 'selected', usernames: [...selectedContactUsernames] }) @@ -1256,7 +1435,8 @@ export default function SnsPage() { void hydrateSnsPageCache() loadContacts() loadOverviewStats() - }, [hydrateSnsPageCache, loadContacts, loadOverviewStats]) + void checkCacheMigrationStatus() + }, [checkCacheMigrationStatus, hydrateSnsPageCache, loadContacts, loadOverviewStats]) useEffect(() => { const syncCurrentUserProfile = async () => { @@ -1621,6 +1801,7 @@ export default function SnsPage() { activeContactUsername={authorTimelineTarget?.username} onOpenContactTimeline={openContactTimeline} onToggleContactSelected={toggleContactSelected} + onToggleFilteredContacts={toggleSelectFilteredContacts} onClearSelectedContacts={clearSelectedContacts} onExportSelectedContacts={openSelectedContactsExport} /> @@ -1659,6 +1840,117 @@ export default function SnsPage() { </div> )} + {showCacheMigrationDialog && cacheMigrationStatus && ( + <div + className="modal-overlay" + onClick={() => { + if (cacheMigrationRunning) return + setShowCacheMigrationDialog(false) + }} + > + <div className="sns-cache-migration-dialog" onClick={(e) => e.stopPropagation()}> + <button + className="close-btn sns-cache-migration-close" + onClick={() => !cacheMigrationRunning && setShowCacheMigrationDialog(false)} + disabled={cacheMigrationRunning} + > + <X size={18} /> + </button> + + <div className="sns-cache-migration-header"> + <div className="sns-cache-migration-title">发现旧版朋友圈缓存</div> + <div className="sns-cache-migration-subtitle"> + 建议迁移到当前缓存目录,避免目录分散和重复占用空间 + </div> + </div> + + <div className="sns-cache-migration-body"> + <div className="sns-cache-migration-meta"> + <span>待处理文件</span> + <strong>{cacheMigrationStatus.totalFiles}</strong> + </div> + + {cacheMigrationProgress && ( + <div className="sns-cache-migration-progress"> + <div className="sns-cache-migration-progress-bar"> + <div + className="sns-cache-migration-progress-fill" + style={{ + width: cacheMigrationProgress.total > 0 + ? `${Math.min(100, Math.round((cacheMigrationProgress.current / cacheMigrationProgress.total) * 100))}%` + : '100%' + }} + /> + </div> + <div className="sns-cache-migration-progress-text"> + <span>{cacheMigrationProgress.message || '迁移中...'}</span> + <span> + 已迁移 {cacheMigrationProgress.copied},剩余 {cacheMigrationProgress.remaining},跳过重复 {cacheMigrationProgress.skipped} + </span> + </div> + </div> + )} + + {!cacheMigrationProgress && ( + <div className="sns-cache-migration-items"> + {cacheMigrationStatus.items.map((item, idx) => ( + <div className="sns-cache-migration-item" key={`${item.label}-${idx}`}> + <div className="sns-cache-migration-item-title">{item.label}</div> + <div className="sns-cache-migration-item-detail"> + {item.fileCount} 个文件 · {item.sourceDir} → {item.targetDir} + </div> + </div> + ))} + </div> + )} + + {cacheMigrationError && ( + <div className="sns-cache-migration-error"> + <AlertCircle size={14} /> + <span>{cacheMigrationError}</span> + </div> + )} + + {cacheMigrationDone && !cacheMigrationError && ( + <div className="sns-cache-migration-success"> + <CheckCircle size={14} /> + <span>迁移完成,旧目录已清理。</span> + </div> + )} + </div> + + <div className="sns-cache-migration-actions"> + {!cacheMigrationDone ? ( + <> + <button + className="sns-cache-migration-btn secondary" + onClick={() => setShowCacheMigrationDialog(false)} + disabled={cacheMigrationRunning} + > + 稍后再说 + </button> + <button + className="sns-cache-migration-btn primary" + onClick={() => { void startCacheMigration() }} + disabled={cacheMigrationRunning} + > + {cacheMigrationRunning ? '迁移中...' : '开始迁移'} + </button> + </> + ) : ( + <button + className="sns-cache-migration-btn primary" + onClick={() => setShowCacheMigrationDialog(false)} + disabled={cacheMigrationRunning} + > + 完成 + </button> + )} + </div> + </div> + </div> + )} + {/* 朋友圈防删除插件对话框 */} {showTriggerDialog && ( <div className="modal-overlay" onClick={() => { setShowTriggerDialog(false); setTriggerMessage(null) }}> diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 846ce73..9c4feef 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -16,6 +16,7 @@ const isLinux = navigator.userAgent.toLowerCase().includes('linux') const isWindows = !isMac && !isLinux const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录' +const DB_PATH_CHINESE_ERROR = '路径包含中文字符,迁移至全英文目录后再试' const dbPathPlaceholder = isMac ? '例如: ~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9' : isLinux @@ -221,10 +222,23 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { if (!path) return null // 检测中文字符和其他可能有问题的特殊字符 if (/[\u4e00-\u9fa5]/.test(path)) { - return '路径包含中文字符,请迁移至全英文目录' + return DB_PATH_CHINESE_ERROR } return null } + const dbPathValidationError = validatePath(dbPath) + + const handleDbPathChange = (value: string) => { + setDbPath(value) + const validationError = validatePath(value) + if (validationError) { + setError(validationError) + return + } + if (error === DB_PATH_CHINESE_ERROR) { + setError('') + } + } const handleSelectPath = async () => { try { @@ -236,10 +250,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { if (!result.canceled && result.filePaths.length > 0) { const selectedPath = result.filePaths[0] const validationError = validatePath(selectedPath) + setDbPath(selectedPath) if (validationError) { setError(validationError) } else { - setDbPath(selectedPath) setError('') } } @@ -256,10 +270,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { const result = await window.electronAPI.dbPath.autoDetect() if (result.success && result.path) { const validationError = validatePath(result.path) + setDbPath(result.path) if (validationError) { setError(validationError) } else { - setDbPath(result.path) setError('') } } else { @@ -426,7 +440,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { const canGoNext = () => { if (currentStep.id === 'intro') return true - if (currentStep.id === 'db') return Boolean(dbPath) + if (currentStep.id === 'db') return Boolean(dbPath) && !dbPathValidationError if (currentStep.id === 'cache') return true if (currentStep.id === 'key') return decryptKey.length === 64 && Boolean(wxid) if (currentStep.id === 'image') return true @@ -442,6 +456,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { const handleNext = () => { if (!canGoNext()) { if (currentStep.id === 'db' && !dbPath) setError('请先选择数据库目录') + else if (currentStep.id === 'db' && dbPathValidationError) setError(dbPathValidationError) if (currentStep.id === 'key') { if (decryptKey.length !== 64) setError('密钥长度必须为 64 个字符') else if (!wxid) setError('未能自动识别 wxid,请尝试重新获取或检查目录') @@ -664,7 +679,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { className="field-input" placeholder={dbPathPlaceholder} value={dbPath} - onChange={(e) => setDbPath(e.target.value)} + onChange={(e) => handleDbPathChange(e.target.value)} /> </div> <div className="action-row"> diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 8ef9277..3319b95 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -343,6 +343,52 @@ export interface ElectronAPI { }> getMessageDates: (sessionId: string) => Promise<{ success: boolean; dates?: string[]; error?: string }> getMessageDateCounts: (sessionId: string) => Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> + getResourceMessages: (options?: { + sessionId?: string + types?: Array<'image' | 'video' | 'voice' | 'file'> + beginTimestamp?: number + endTimestamp?: number + limit?: number + offset?: number + }) => Promise<{ + success: boolean + items?: Array<Message & { + sessionId: string + sessionDisplayName?: string + resourceType: 'image' | 'video' | 'voice' | 'file' + }> + total?: number + hasMore?: boolean + error?: string + }> + getMediaStream: (options?: { + sessionId?: string + mediaType?: 'image' | 'video' | 'all' + beginTimestamp?: number + endTimestamp?: number + limit?: number + offset?: number + }) => Promise<{ + success: boolean + items?: Array<{ + sessionId: string + sessionDisplayName?: string + mediaType: 'image' | 'video' + localId: number + serverId?: string + createTime: number + localType: number + senderUsername?: string + isSend?: number | null + imageMd5?: string + imageDatName?: string + videoMd5?: string + content?: string + }> + hasMore?: boolean + nextOffset?: number + error?: string + }> resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }> getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }> onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => () => void @@ -357,13 +403,33 @@ export interface ElectronAPI { image: { decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }> - resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }> - preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise<boolean> + resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }> + resolveCacheBatch: ( + payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, + options?: { disableUpdateCheck?: boolean } + ) => Promise<{ + success: boolean + rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> + error?: string + }> + preload: ( + payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, + options?: { allowDecrypt?: boolean } + ) => Promise<boolean> onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void + onDecryptProgress: (callback: (payload: { + cacheKey: string + imageMd5?: string + imageDatName?: string + stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed' + progress: number + status: 'running' | 'done' | 'error' + message?: string + }) => void) => () => void } video: { - getVideoInfo: (videoMd5: string) => Promise<{ + getVideoInfo: (videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => Promise<{ success: boolean exists: boolean videoUrl?: string @@ -881,6 +947,28 @@ export interface ElectronAPI { checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }> deleteSnsPost: (postId: string) => Promise<{ success: boolean; error?: string }> downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => Promise<{ success: boolean; localPath?: string; error?: string }> + getCacheMigrationStatus: () => Promise<{ + success: boolean + needed: boolean + inProgress?: boolean + totalFiles?: number + legacyBaseDir?: string + currentBaseDir?: string + items?: Array<{ label: string; sourceDir: string; targetDir: string; fileCount: number }> + error?: string + }> + startCacheMigration: () => Promise<{ success: boolean; copied?: number; skipped?: number; totalFiles?: number; message?: string; error?: string }> + onCacheMigrationProgress: (callback: (payload: { + status: 'running' | 'done' | 'error' + phase: 'copying' | 'cleanup' | 'done' | 'error' + current: number + total: number + copied: number + skipped: number + remaining: number + message?: string + currentItemLabel?: string + }) => void) => () => void } cloud: { init: () => Promise<void> diff --git a/vite.config.ts b/vite.config.ts index 04493f7..cd13106 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -41,7 +41,7 @@ export default defineConfig({ 'shelljs', 'exceljs', 'node-llama-cpp', - 'sudo-prompt' + '@vscode/sudo-prompt' ] } }