diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a951b78..bbe70d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ jobs: - name: Inject Configuration shell: bash run: | - npm pkg set build.releaseInfo.releaseNotes="修复了一些已知问题" + npm pkg set build.releaseInfo.releaseNotes=$'仅适配微信 4.0 及以上版本\n\n修复了一些已知问题\n\n详情前往 Telegram 群查看' - name: Package and Publish env: diff --git a/README.md b/README.md index 2b7ad87..6e190f9 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析 - 导出聊天记录为 HTML 等格式 - 本地解密与数据库管理 +> [!NOTE] +> ⚠️ 本工具仅适配微信 **4.0 及以上**版本,请确保你的微信版本符合要求 + ## 快速开始 若你只想使用成品版本,可前往 Release 下载并安装。 diff --git a/electron/main.ts b/electron/main.ts index d0cb133..c59b487 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron' +import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron' import { Worker } from 'worker_threads' import { join, dirname } from 'path' import { autoUpdater } from 'electron-updater' @@ -17,6 +17,7 @@ import { exportService, ExportOptions, ExportProgress } from './services/exportS import { KeyService } from './services/keyService' import { voiceTranscribeService } from './services/voiceTranscribeService' import { videoService } from './services/videoService' +import { snsService } from './services/snsService' // 配置自动更新 @@ -614,8 +615,8 @@ function registerIpcHandlers() { return chatService.enrichSessionsContactInfo(usernames) }) - ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => { - return chatService.getMessages(sessionId, offset, limit) + ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => { + return chatService.getMessages(sessionId, offset, limit, startTime, endTime, ascending) }) ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => { @@ -672,6 +673,10 @@ function registerIpcHandlers() { return chatService.getMessageById(sessionId, localId) }) + ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => { + return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) + }) + // 私聊克隆 @@ -977,6 +982,17 @@ app.whenReady().then(() => { createOnboardingWindow() } + // 解决朋友圈图片无法加载问题(添加 Referer) + session.defaultSession.webRequest.onBeforeSendHeaders( + { + urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*'] + }, + (details, callback) => { + details.requestHeaders['Referer'] = 'https://wx.qq.com/' + callback({ requestHeaders: details.requestHeaders }) + } + ) + // 启动时检测更新 checkForUpdatesOnStartup() diff --git a/electron/preload.ts b/electron/preload.ts index e70b70b..6b02eba 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -98,8 +98,8 @@ contextBridge.exposeInMainWorld('electronAPI', { getSessions: () => ipcRenderer.invoke('chat:getSessions'), enrichSessionsContactInfo: (usernames: string[]) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), - getMessages: (sessionId: string, offset?: number, limit?: number) => - ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit), + getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => + ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending), getLatestMessages: (sessionId: string, limit?: number) => ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit), getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username), @@ -207,5 +207,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('whisper:downloadProgress') } + }, + + // 朋友圈 + sns: { + getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => + ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime) } }) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 330cf57..66f6795 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -74,7 +74,7 @@ const emojiDownloading: Map> = new Map() class ChatService { private configService: ConfigService private connected = false - private messageCursors: Map = new Map() + private messageCursors: Map = new Map() private readonly messageBatchDefault = 50 private avatarCache: Map private readonly avatarCacheTtlMs = 10 * 60 * 1000 @@ -326,7 +326,11 @@ class ChatService { // 检查缓存 for (const username of usernames) { const cached = this.avatarCache.get(username) - if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) { + // 如果缓存有效且有头像,直接使用;如果没有头像,也需要重新尝试获取 + // 额外检查:如果头像是无效的 hex 格式(以 ffd8 开头),也需要重新获取 + const isValidAvatar = cached?.avatarUrl && + !cached.avatarUrl.includes('base64,ffd8') // 检测错误的 hex 格式 + if (cached && now - cached.updatedAt < this.avatarCacheTtlMs && isValidAvatar) { result[username] = { displayName: cached.displayName, avatarUrl: cached.avatarUrl @@ -343,9 +347,17 @@ class ChatService { wcdbService.getAvatarUrls(missing) ]) + // 收集没有头像 URL 的用户名 + const missingAvatars: string[] = [] + for (const username of missing) { const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined - const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined + let avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined + + // 如果没有头像 URL,记录下来稍后从 head_image.db 获取 + if (!avatarUrl) { + missingAvatars.push(username) + } const cacheEntry: ContactCacheEntry = { displayName: displayName || username, @@ -357,6 +369,23 @@ class ChatService { this.avatarCache.set(username, cacheEntry) updatedEntries[username] = cacheEntry } + + // 从 head_image.db 获取缺失的头像 + if (missingAvatars.length > 0) { + const headImageAvatars = await this.getAvatarsFromHeadImageDb(missingAvatars) + for (const username of missingAvatars) { + const avatarUrl = headImageAvatars[username] + if (avatarUrl) { + result[username].avatarUrl = avatarUrl + const cached = this.avatarCache.get(username) + if (cached) { + cached.avatarUrl = avatarUrl + updatedEntries[username] = cached + } + } + } + } + if (Object.keys(updatedEntries).length > 0) { this.contactCacheService.setEntries(updatedEntries) } @@ -368,6 +397,81 @@ class ChatService { } } + /** + * 从 head_image.db 批量获取头像(转换为 base64 data URL) + */ + private async getAvatarsFromHeadImageDb(usernames: string[]): Promise> { + const result: Record = {} + if (usernames.length === 0) return result + + try { + const dbPath = this.configService.get('dbPath') + const wxid = this.configService.get('myWxid') + if (!dbPath || !wxid) return result + + const accountDir = this.resolveAccountDir(dbPath, wxid) + if (!accountDir) return result + + // head_image.db 可能在不同位置 + const headImageDbPaths = [ + join(accountDir, 'db_storage', 'head_image', 'head_image.db'), + join(accountDir, 'db_storage', 'head_image.db'), + join(accountDir, 'head_image.db') + ] + + let headImageDbPath: string | null = null + for (const path of headImageDbPaths) { + if (existsSync(path)) { + headImageDbPath = path + break + } + } + + if (!headImageDbPath) return result + + // 使用 wcdbService.execQuery 查询加密的 head_image.db + for (const username of usernames) { + try { + const escapedUsername = username.replace(/'/g, "''") + const queryResult = await wcdbService.execQuery( + 'media', + headImageDbPath, + `SELECT image_buffer FROM head_image WHERE username = '${escapedUsername}' LIMIT 1` + ) + + if (queryResult.success && queryResult.rows && queryResult.rows.length > 0) { + const row = queryResult.rows[0] as any + if (row?.image_buffer) { + let base64Data: string + if (typeof row.image_buffer === 'string') { + // WCDB 返回的 BLOB 是十六进制字符串,需要转换为 base64 + if (row.image_buffer.toLowerCase().startsWith('ffd8')) { + const buffer = Buffer.from(row.image_buffer, 'hex') + base64Data = buffer.toString('base64') + } else { + base64Data = row.image_buffer + } + } else if (Buffer.isBuffer(row.image_buffer)) { + base64Data = row.image_buffer.toString('base64') + } else if (Array.isArray(row.image_buffer)) { + base64Data = Buffer.from(row.image_buffer).toString('base64') + } else { + continue + } + result[username] = `data:image/jpeg;base64,${base64Data}` + } + } + } catch { + // 静默处理单个用户的错误 + } + } + } catch (e) { + console.error('从 head_image.db 获取头像失败:', e) + } + + return result + } + /** * 补充联系人信息(私有方法,保持向后兼容) */ @@ -396,7 +500,10 @@ class ChatService { async getMessages( sessionId: string, offset: number = 0, - limit: number = 50 + limit: number = 50, + startTime: number = 0, + endTime: number = 0, + ascending: boolean = false ): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> { try { const connectResult = await this.ensureConnected() @@ -411,7 +518,14 @@ class ChatService { // 1. 没有游标状态 // 2. offset 为 0 (重新加载会话) // 3. batchSize 改变 - const needNewCursor = !state || offset === 0 || state.batchSize !== batchSize + // 4. startTime 改变 + // 5. ascending 改变 + const needNewCursor = !state || + offset === 0 || + state.batchSize !== batchSize || + state.startTime !== startTime || + state.endTime !== endTime || + state.ascending !== ascending if (needNewCursor) { // 关闭旧游标 @@ -424,13 +538,16 @@ class ChatService { } // 创建新游标 - const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, false, 0, 0) + // 注意:WeFlow 数据库中的 create_time 是以秒为单位的 + const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime + const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime + const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, ascending, beginTimestamp, endTimestamp) if (!cursorResult.success || !cursorResult.cursor) { console.error('[ChatService] 打开消息游标失败:', cursorResult.error) return { success: false, error: cursorResult.error || '打开消息游标失败' } } - state = { cursor: cursorResult.cursor, fetched: 0, batchSize } + state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending } this.messageCursors.set(sessionId, state) // 如果需要跳过消息(offset > 0),逐批获取但不返回 @@ -1706,7 +1823,9 @@ class ChatService { const connectResult = await this.ensureConnected() if (!connectResult.success) return null const cached = this.avatarCache.get(username) - if (cached && cached.avatarUrl && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) { + // 检查缓存是否有效,且头像不是错误的 hex 格式 + const isValidAvatar = cached?.avatarUrl && !cached.avatarUrl.includes('base64,ffd8') + if (cached && isValidAvatar && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) { return { avatarUrl: cached.avatarUrl, displayName: cached.displayName } } @@ -2979,10 +3098,26 @@ class ChatService { private resolveAccountDir(dbPath: string, wxid: string): string | null { const normalized = dbPath.replace(/[\\\\/]+$/, '') + + // 如果 dbPath 本身指向 db_storage 目录下的文件(如某个 .db 文件) + // 则向上回溯到账号目录 + if (basename(normalized).toLowerCase() === 'db_storage') { + return dirname(normalized) + } const dir = dirname(normalized) - if (basename(normalized).toLowerCase() === 'db_storage') return dir - if (basename(dir).toLowerCase() === 'db_storage') return dirname(dir) - return dir // 兜底 + if (basename(dir).toLowerCase() === 'db_storage') { + return dirname(dir) + } + + // 否则,dbPath 应该是数据库根目录(如 xwechat_files) + // 账号目录应该是 {dbPath}/{wxid} + const accountDirWithWxid = join(normalized, wxid) + if (existsSync(accountDirWithWxid)) { + return accountDirWithWxid + } + + // 兜底:返回 dbPath 本身(可能 dbPath 已经是账号目录) + return normalized } private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise { diff --git a/electron/services/contactCacheService.ts b/electron/services/contactCacheService.ts index 0dad44f..da17886 100644 --- a/electron/services/contactCacheService.ts +++ b/electron/services/contactCacheService.ts @@ -34,6 +34,14 @@ export class ContactCacheService { const raw = readFileSync(this.cacheFilePath, 'utf8') const parsed = JSON.parse(raw) if (parsed && typeof parsed === 'object') { + // 清除无效的头像数据(hex 格式而非正确的 base64) + for (const key of Object.keys(parsed)) { + const entry = parsed[key] + if (entry?.avatarUrl && entry.avatarUrl.includes('base64,ffd8')) { + // 这是错误的 hex 格式,清除它 + entry.avatarUrl = undefined + } + } this.cache = parsed } } catch (error) { diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index eb2aa2e..76c3633 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -84,7 +84,32 @@ export interface ExportProgress { current: number total: number currentSession: string - phase: 'preparing' | 'exporting' | 'writing' | 'complete' + phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete' +} + +// 并发控制:限制同时执行的 Promise 数量 +async function parallelLimit( + items: T[], + limit: number, + fn: (item: T, index: number) => Promise +): Promise { + const results: R[] = new Array(items.length) + let currentIndex = 0 + + async function runNext(): Promise { + while (currentIndex < items.length) { + const index = currentIndex++ + results[index] = await fn(items[index], index) + } + } + + // 启动 limit 个并发任务 + const workers = Array(Math.min(limit, items.length)) + .fill(null) + .map(() => runNext()) + + await Promise.all(workers) + return results } class ExportService { @@ -1122,7 +1147,7 @@ class ExportService { } /** - * 导出单个会话为 ChatLab 格式 + * 导出单个会话为 ChatLab 格式(并行优化版本) */ async exportSessionToChatLab( sessionId: string, @@ -1154,51 +1179,101 @@ class ExportService { allMessages.sort((a, b) => a.createTime - b.createTime) + const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) + + // ========== 阶段1:并行导出媒体文件 ========== + const mediaMessages = exportMediaEnabled + ? allMessages.filter(msg => { + const t = msg.localType + return (t === 3 && options.exportImages) || // 图片 + (t === 47 && options.exportEmojis) || // 表情 + (t === 34 && options.exportVoices && !options.exportVoiceAsText) // 语音文件(非转文字) + }) + : [] + + const mediaCache = new Map() + + if (mediaMessages.length > 0) { + onProgress?.({ + current: 20, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media' + }) + + // 并行导出媒体,限制 8 个并发 + const MEDIA_CONCURRENCY = 8 + await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { + const mediaKey = `${msg.localType}_${msg.localId}` + if (!mediaCache.has(mediaKey)) { + const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { + exportImages: options.exportImages, + exportVoices: options.exportVoices, + exportEmojis: options.exportEmojis, + exportVoiceAsText: options.exportVoiceAsText + }) + mediaCache.set(mediaKey, mediaItem) + } + }) + } + + // ========== 阶段2:并行语音转文字 ========== + const voiceMessages = options.exportVoiceAsText + ? allMessages.filter(msg => msg.localType === 34) + : [] + + const voiceTranscriptMap = new Map() + + if (voiceMessages.length > 0) { + onProgress?.({ + current: 40, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice' + }) + + // 并行转写语音,限制 4 个并发(转写比较耗资源) + const VOICE_CONCURRENCY = 4 + await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { + const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) + voiceTranscriptMap.set(msg.localId, transcript) + }) + } + + // ========== 阶段3:构建消息列表 ========== onProgress?.({ - current: 50, + current: 60, total: 100, currentSession: sessionInfo.displayName, phase: 'exporting' }) - const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) - const mediaCache = new Map() - const chatLabMessages: ChatLabMessage[] = [] - for (const msg of allMessages) { - const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || { - platformId: msg.senderUsername, - accountName: msg.senderUsername, - groupNickname: undefined - } - - let content = this.parseMessageContent(msg.content, msg.localType) - if (exportMediaEnabled) { - const mediaKey = `${msg.localType}_${msg.localId}` - if (!mediaCache.has(mediaKey)) { - const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { - exportImages: options.exportImages, - exportVoices: options.exportVoices, - exportEmojis: options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText - }) - mediaCache.set(mediaKey, mediaItem) - } - } - if (msg.localType === 34 && options.exportVoiceAsText) { - // 如果是语音消息且开启了转文字 - content = await this.transcribeVoice(sessionId, String(msg.localId)) - } - - chatLabMessages.push({ - sender: msg.senderUsername, - accountName: memberInfo.accountName, - groupNickname: memberInfo.groupNickname, - timestamp: msg.createTime, - type: this.convertMessageType(msg.localType, msg.content), - content: content - }) + const chatLabMessages: ChatLabMessage[] = allMessages.map(msg => { + const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || { + platformId: msg.senderUsername, + accountName: msg.senderUsername, + groupNickname: undefined } + // 确定消息内容 + let content: string | null + if (msg.localType === 34 && options.exportVoiceAsText) { + // 使用预先转写的文字 + content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + } else { + content = this.parseMessageContent(msg.content, msg.localType) + } + + return { + sender: msg.senderUsername, + accountName: memberInfo.accountName, + groupNickname: memberInfo.groupNickname, + timestamp: msg.createTime, + type: this.convertMessageType(msg.localType, msg.content), + content: content + } + }) + const avatarMap = options.exportAvatars ? await this.exportAvatars( [ @@ -1265,7 +1340,7 @@ class ExportService { } /** - * 导出单个会话为详细 JSON 格式(原项目格式) + * 导出单个会话为详细 JSON 格式(原项目格式)- 并行优化版本 */ async exportSessionToDetailedJson( sessionId: string, @@ -1290,41 +1365,95 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) - const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) - const mediaCache = new Map() - const allMessages: any[] = [] + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) - for (const msg of collected.rows) { - const senderInfo = await this.getContactInfo(msg.senderUsername) - const sourceMatch = /[\s\S]*?<\/msgsource>/i.exec(msg.content || '') - const source = sourceMatch ? sourceMatch[0] : '' + // ========== 阶段1:并行导出媒体文件 ========== + const mediaMessages = exportMediaEnabled + ? collected.rows.filter(msg => { + const t = msg.localType + return (t === 3 && options.exportImages) || + (t === 47 && options.exportEmojis) || + (t === 34 && options.exportVoices && !options.exportVoiceAsText) + }) + : [] - let content = this.parseMessageContent(msg.content, msg.localType) - let mediaItem: MediaExportItem | null = null - if (exportMediaEnabled) { - const mediaKey = `${msg.localType}_${msg.localId}` - if (mediaCache.has(mediaKey)) { - mediaItem = mediaCache.get(mediaKey) || null - } else { - mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { - exportImages: options.exportImages, - exportVoices: options.exportVoices, - exportEmojis: options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText - }) - mediaCache.set(mediaKey, mediaItem) - } - } - if (mediaItem) { - content = mediaItem.relativePath - } else if (msg.localType === 34 && options.exportVoiceAsText) { - content = await this.transcribeVoice(sessionId, String(msg.localId)) + const mediaCache = new Map() + + if (mediaMessages.length > 0) { + onProgress?.({ + current: 15, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media' + }) + + const MEDIA_CONCURRENCY = 8 + await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { + const mediaKey = `${msg.localType}_${msg.localId}` + if (!mediaCache.has(mediaKey)) { + const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { + exportImages: options.exportImages, + exportVoices: options.exportVoices, + exportEmojis: options.exportEmojis, + exportVoiceAsText: options.exportVoiceAsText + }) + mediaCache.set(mediaKey, mediaItem) } + }) + } - allMessages.push({ - localId: allMessages.length + 1, - createTime: msg.createTime, + // ========== 阶段2:并行语音转文字 ========== + const voiceMessages = options.exportVoiceAsText + ? collected.rows.filter(msg => msg.localType === 34) + : [] + + const voiceTranscriptMap = new Map() + + if (voiceMessages.length > 0) { + onProgress?.({ + current: 35, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice' + }) + + const VOICE_CONCURRENCY = 4 + await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { + const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) + voiceTranscriptMap.set(msg.localId, transcript) + }) + } + + // ========== 阶段3:构建消息列表 ========== + onProgress?.({ + current: 55, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting' + }) + + const allMessages: any[] = [] + for (const msg of collected.rows) { + const senderInfo = await this.getContactInfo(msg.senderUsername) + const sourceMatch = /[\s\S]*?<\/msgsource>/i.exec(msg.content || '') + const source = sourceMatch ? sourceMatch[0] : '' + + let content: string | null + const mediaKey = `${msg.localType}_${msg.localId}` + const mediaItem = mediaCache.get(mediaKey) + + if (mediaItem) { + content = mediaItem.relativePath + } else if (msg.localType === 34 && options.exportVoiceAsText) { + content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + } else { + content = this.parseMessageContent(msg.content, msg.localType) + } + + allMessages.push({ + localId: allMessages.length + 1, + createTime: msg.createTime, formattedTime: this.formatTimestamp(msg.createTime), type: this.getMessageTypeName(msg.localType), localType: msg.localType, @@ -1550,20 +1679,31 @@ class ExportService { // 媒体导出设置 const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) - // 媒体导出缓存 + // ========== 并行预处理:媒体文件 ========== + const mediaMessages = exportMediaEnabled + ? sortedMessages.filter(msg => { + const t = msg.localType + return (t === 3 && options.exportImages) || + (t === 47 && options.exportEmojis) || + (t === 34 && options.exportVoices && !options.exportVoiceAsText) + }) + : [] + const mediaCache = new Map() - for (let i = 0; i < sortedMessages.length; i++) { - const msg = sortedMessages[i] + if (mediaMessages.length > 0) { + onProgress?.({ + current: 35, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media' + }) - // 导出媒体文件 - let mediaItem: MediaExportItem | null = null - if (exportMediaEnabled) { + const MEDIA_CONCURRENCY = 8 + await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` - if (mediaCache.has(mediaKey)) { - mediaItem = mediaCache.get(mediaKey) || null - } else { - mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { + if (!mediaCache.has(mediaKey)) { + const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportEmojis: options.exportEmojis, @@ -1571,7 +1711,45 @@ class ExportService { }) mediaCache.set(mediaKey, mediaItem) } - } + }) + } + + // ========== 并行预处理:语音转文字 ========== + const voiceMessages = options.exportVoiceAsText + ? sortedMessages.filter(msg => msg.localType === 34) + : [] + + const voiceTranscriptMap = new Map() + + if (voiceMessages.length > 0) { + onProgress?.({ + current: 50, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice' + }) + + const VOICE_CONCURRENCY = 4 + await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { + const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) + voiceTranscriptMap.set(msg.localId, transcript) + }) + } + + onProgress?.({ + current: 65, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting' + }) + + // ========== 写入 Excel 行 ========== + for (let i = 0; i < sortedMessages.length; i++) { + const msg = sortedMessages[i] + + // 从缓存获取媒体信息 + const mediaKey = `${msg.localType}_${msg.localId}` + const mediaItem = mediaCache.get(mediaKey) || null // 确定发送者信息 let senderRole: string @@ -1620,12 +1798,15 @@ class ExportService { const row = worksheet.getRow(currentRow) row.height = 24 - // 确定内容:如果有媒体文件导出成功则显示相对路径,否则显示解析后的内容 - let contentValue = mediaItem - ? mediaItem.relativePath - : (this.parseMessageContent(msg.content, msg.localType) || '') - if (!mediaItem && msg.localType === 34 && options.exportVoiceAsText) { - contentValue = await this.transcribeVoice(sessionId, String(msg.localId)) + // 确定内容:优先使用预处理的缓存 + let contentValue: string + if (mediaItem) { + contentValue = mediaItem.relativePath + } else if (msg.localType === 34 && options.exportVoiceAsText) { + // 使用预处理的语音转文字结果 + contentValue = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + } else { + contentValue = this.parseMessageContent(msg.content, msg.localType) || '' } // 调试日志 diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 08f730a..72ac8c2 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -629,6 +629,11 @@ export class KeyService { if (!ok) { const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : '' if (error) { + // 检测权限不足错误 (NTSTATUS 0xC0000022 = STATUS_ACCESS_DENIED) + if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) { + const friendlyError = '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行' + return { success: false, error: friendlyError } + } return { success: false, error } } const statusBuffer = Buffer.alloc(256) diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts new file mode 100644 index 0000000..bf674f4 --- /dev/null +++ b/electron/services/snsService.ts @@ -0,0 +1,64 @@ +import { wcdbService } from './wcdbService' +import { ConfigService } from './config' +import { ContactCacheService } from './contactCacheService' + +export interface SnsPost { + id: string + username: string + nickname: string + avatarUrl?: string + createTime: number + contentDesc: string + type?: number + media: { url: string; thumb: string }[] + likes: string[] + comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] +} + +class SnsService { + private contactCache: ContactCacheService + + constructor() { + const config = new ConfigService() + this.contactCache = new ContactCacheService(config.get('cachePath') as string) + } + + async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> { + console.log('[SnsService] getTimeline called with:', { limit, offset, usernames, keyword, startTime, endTime }) + + const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) + + console.log('[SnsService] getSnsTimeline result:', { + success: result.success, + timelineCount: result.timeline?.length, + error: result.error + }) + + if (result.success && result.timeline) { + const enrichedTimeline = result.timeline.map((post: any) => { + const contact = this.contactCache.get(post.username) + + // 修复媒体 URL,如果是 http 则尝试用 https (虽然 qpic 可能不支持强制 https,但通常支持) + const fixedMedia = post.media.map((m: any) => ({ + url: m.url.replace('http://', 'https://'), + thumb: m.thumb.replace('http://', 'https://') + })) + + return { + ...post, + avatarUrl: contact?.avatarUrl, + nickname: post.nickname || contact?.displayName || post.username, + media: fixedMedia + } + }) + + console.log('[SnsService] Returning enriched timeline with', enrichedTimeline.length, 'posts') + return { ...result, timeline: enrichedTimeline } + } + + console.log('[SnsService] Returning result:', result) + return result + } +} + +export const snsService = new SnsService() diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index e3c9baf..93952e8 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -55,6 +55,7 @@ export class WcdbCore { private wcdbGetEmoticonCdnUrl: any = null private wcdbGetDbStatus: any = null private wcdbGetVoiceData: any = null + private wcdbGetSnsTimeline: any = null private avatarUrlCache: Map = new Map() private readonly avatarCacheTtlMs = 10 * 60 * 1000 private logTimer: NodeJS.Timeout | null = null @@ -116,7 +117,8 @@ export class WcdbCore { private writeLog(message: string, force = false): void { if (!force && !this.isLogEnabled()) return const line = `[${new Date().toISOString()}] ${message}` - // 移除控制台日志,只写入文件 + // 同时输出到控制台和文件 + console.log('[WCDB]', message) try { const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd() const dir = join(base, 'logs') @@ -385,6 +387,13 @@ export class WcdbCore { this.wcdbGetVoiceData = null } + // wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json) + try { + this.wcdbGetSnsTimeline = this.lib.func('int32 wcdb_get_sns_timeline(int64 handle, int32 limit, int32 offset, const char* username, const char* keyword, int32 startTime, int32 endTime, _Out_ void** outJson)') + } catch { + this.wcdbGetSnsTimeline = null + } + // 初始化 const initResult = this.wcdbInit() if (initResult !== 0) { @@ -401,8 +410,8 @@ export class WcdbCore { this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true) lastDllInitError = errorMsg // 检查是否是常见的 VC++ 运行时缺失错误 - if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') || - errorMsg.includes('The specified module could not be found')) { + if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') || + errorMsg.includes('The specified module could not be found')) { lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。' } else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) { lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。' @@ -1388,4 +1397,32 @@ export class WcdbCore { return { success: false, error: String(e) } } } + + async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' } + try { + const outPtr = [null as any] + const usernamesJson = usernames && usernames.length > 0 ? JSON.stringify(usernames) : '' + const result = this.wcdbGetSnsTimeline( + this.handle, + limit, + offset, + usernamesJson, + keyword || '', + startTime || 0, + endTime || 0, + outPtr + ) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取朋友圈失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析朋友圈数据失败' } + const timeline = JSON.parse(jsonStr) + return { success: true, timeline } + } catch (e) { + return { success: false, error: String(e) } + } + } } diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index bdcd2bc..4a1d76c 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -362,6 +362,13 @@ export class WcdbService { return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId }) } + /** + * 获取朋友圈 + */ + async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> { + return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime }) + } + } export const wcdbService = new WcdbService() diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index d836a79..64f001d 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -116,6 +116,9 @@ if (parentPort) { console.error('[wcdbWorker] getVoiceData failed:', result.error) } break + case 'getSnsTimeline': + result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime) + break default: result = { success: false, error: `Unknown method: ${type}` } } diff --git a/package-lock.json b/package-lock.json index 784ab59..d4b7d0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "weflow", - "version": "1.3.1", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index 6af9cc3..eddd9ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "weflow", - "version": "1.2.0", + "version": "1.3.2", "description": "WeFlow", "main": "dist-electron/main.js", "author": "cc", @@ -105,6 +105,24 @@ "asarUnpack": [ "node_modules/silk-wasm/**/*", "node_modules/sherpa-onnx-node/**/*" + ], + "extraFiles": [ + { + "from": "resources/msvcp140.dll", + "to": "." + }, + { + "from": "resources/msvcp140_1.dll", + "to": "." + }, + { + "from": "resources/vcruntime140.dll", + "to": "." + }, + { + "from": "resources/vcruntime140_1.dll", + "to": "." + } ] } -} \ No newline at end of file +} diff --git a/resources/msvcp140.dll b/resources/msvcp140.dll new file mode 100644 index 0000000..554d2ff Binary files /dev/null and b/resources/msvcp140.dll differ diff --git a/resources/msvcp140_1.dll b/resources/msvcp140_1.dll new file mode 100644 index 0000000..184514f Binary files /dev/null and b/resources/msvcp140_1.dll differ diff --git a/resources/vcruntime140.dll b/resources/vcruntime140.dll new file mode 100644 index 0000000..950b587 Binary files /dev/null and b/resources/vcruntime140.dll differ diff --git a/resources/vcruntime140_1.dll b/resources/vcruntime140_1.dll new file mode 100644 index 0000000..a481970 Binary files /dev/null and b/resources/vcruntime140_1.dll differ diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 753d734..09af338 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 98fe58b..5bad3d5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import DataManagementPage from './pages/DataManagementPage' import SettingsPage from './pages/SettingsPage' import ExportPage from './pages/ExportPage' import VideoWindow from './pages/VideoWindow' +import SnsPage from './pages/SnsPage' import { useAppStore } from './stores/appStore' import { themes, useThemeStore, type ThemeId } from './stores/themeStore' @@ -205,11 +206,11 @@ function App() { // 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户 // 其他错误可能需要重新配置 const errorMsg = result.error || '' - if (errorMsg.includes('Visual C++') || - errorMsg.includes('DLL') || - errorMsg.includes('Worker') || - errorMsg.includes('126') || - errorMsg.includes('模块')) { + if (errorMsg.includes('Visual C++') || + errorMsg.includes('DLL') || + errorMsg.includes('Worker') || + errorMsg.includes('126') || + errorMsg.includes('模块')) { console.warn('检测到可能的运行时依赖问题:', errorMsg) // 不清除配置,让用户安装 VC++ 后重试 } @@ -336,6 +337,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/components/JumpToDateDialog.scss b/src/components/JumpToDateDialog.scss new file mode 100644 index 0000000..cd20c37 --- /dev/null +++ b/src/components/JumpToDateDialog.scss @@ -0,0 +1,238 @@ +.jump-date-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + animation: fadeIn 0.2s ease-out; +} + +.jump-date-modal { + background: var(--card-bg); + width: 340px; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + overflow: hidden; + animation: modalSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +.jump-date-header { + padding: 18px 20px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border-color); + + .title-area { + display: flex; + align-items: center; + gap: 10px; + color: var(--text-primary); + + svg { + color: var(--primary); + } + + h3 { + font-size: 16px; + font-weight: 600; + margin: 0; + } + } + + .close-btn { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 4px; + border-radius: 6px; + display: flex; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } +} + +.calendar-view { + padding: 20px; + + .calendar-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + + .current-month { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + } + + .nav-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + border-color: var(--primary); + color: var(--primary); + } + } + } +} + +.calendar-grid { + .weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + margin-bottom: 8px; + + .weekday { + text-align: center; + font-size: 12px; + font-weight: 500; + color: var(--text-tertiary); + padding: 4px 0; + } + } + + .days { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + + .day-cell { + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + color: var(--text-primary); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + + &.empty { + cursor: default; + } + + &:not(.empty):hover { + background: var(--bg-hover); + } + + &.selected { + background: var(--primary); + color: #fff; + font-weight: 600; + } + + &.today:not(.selected) { + color: var(--primary); + font-weight: 600; + background: var(--primary-light); + } + } + } +} + +.quick-options { + display: flex; + gap: 8px; + padding: 0 20px 16px; + + button { + flex: 1; + padding: 8px; + font-size: 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + color: var(--primary); + border-color: var(--primary); + } + } +} + +.dialog-footer { + padding: 16px 20px; + display: flex; + gap: 12px; + background: var(--bg-secondary); + + button { + flex: 1; + padding: 10px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + } + + .cancel-btn { + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-secondary); + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .confirm-btn { + background: var(--primary); + border: none; + color: #fff; + + &:hover { + background: var(--primary-hover); + } + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes modalSlideUp { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} \ No newline at end of file diff --git a/src/components/JumpToDateDialog.tsx b/src/components/JumpToDateDialog.tsx new file mode 100644 index 0000000..6876069 --- /dev/null +++ b/src/components/JumpToDateDialog.tsx @@ -0,0 +1,156 @@ +import React, { useState } from 'react' +import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react' +import './JumpToDateDialog.scss' + +interface JumpToDateDialogProps { + isOpen: boolean + onClose: () => void + onSelect: (date: Date) => void + currentDate?: Date +} + +const JumpToDateDialog: React.FC = ({ + isOpen, + onClose, + onSelect, + currentDate = new Date() +}) => { + const [calendarDate, setCalendarDate] = useState(new Date(currentDate)) + const [selectedDate, setSelectedDate] = useState(new Date(currentDate)) + + if (!isOpen) return null + + const getDaysInMonth = (date: Date) => { + const year = date.getFullYear() + const month = date.getMonth() + return new Date(year, month + 1, 0).getDate() + } + + const getFirstDayOfMonth = (date: Date) => { + const year = date.getFullYear() + const month = date.getMonth() + return new Date(year, month, 1).getDay() + } + + const generateCalendar = () => { + const daysInMonth = getDaysInMonth(calendarDate) + const firstDay = getFirstDayOfMonth(calendarDate) + const days: (number | null)[] = [] + + for (let i = 0; i < firstDay; i++) { + days.push(null) + } + + for (let i = 1; i <= daysInMonth; i++) { + days.push(i) + } + + return days + } + + const handleDateClick = (day: number) => { + const newDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day) + setSelectedDate(newDate) + } + + const handleConfirm = () => { + onSelect(selectedDate) + onClose() + } + + const isToday = (day: number) => { + const today = new Date() + return day === today.getDate() && + calendarDate.getMonth() === today.getMonth() && + calendarDate.getFullYear() === today.getFullYear() + } + + const isSelected = (day: number) => { + return day === selectedDate.getDate() && + calendarDate.getMonth() === selectedDate.getMonth() && + calendarDate.getFullYear() === selectedDate.getFullYear() + } + + const weekdays = ['日', '一', '二', '三', '四', '五', '六'] + const days = generateCalendar() + + return ( +
+
e.stopPropagation()}> +
+
+ +

跳转到日期

+
+ +
+ +
+
+ + + {calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月 + + +
+ +
+
+ {weekdays.map(d =>
{d}
)} +
+
+ {days.map((day, i) => ( +
day !== null && handleDateClick(day)} + > + {day} +
+ ))} +
+
+
+ +
+ + + +
+ +
+ + +
+
+
+ ) +} + +export default JumpToDateDialog diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b2fd84b..08188e5 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { NavLink, useLocation } from 'react-router-dom' -import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot } from 'lucide-react' +import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture } from 'lucide-react' import './Sidebar.scss' function Sidebar() { @@ -34,6 +34,16 @@ function Sidebar() { 聊天 + {/* 朋友圈 */} + + + 朋友圈 + + {/* 私聊分析 */} diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 33e89bb..80fb8ca 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -489,8 +489,21 @@ } .load-more-trigger { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 0; color: var(--text-tertiary); - font-size: 12px; + font-size: 13px; + + &.later { + padding: 24px 0 12px; + } + + svg { + animation: spin 1s linear infinite; + } } .empty-chat { @@ -1660,7 +1673,7 @@ max-width: 100%; min-width: 0; // 允许收缩 -webkit-app-region: no-drag; - + // 让气泡宽度由内容决定,而不是被父容器撑开 width: fit-content; } diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 174c02f..c5a4467 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -7,6 +7,7 @@ import { getEmojiPath } from 'wechat-emojis' import { ImagePreview } from '../components/ImagePreview' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' import { AnimatedStreamingText } from '../components/AnimatedStreamingText' +import JumpToDateDialog from '../components/JumpToDateDialog' import * as configService from '../services/config' import './ChatPage.scss' @@ -132,15 +133,25 @@ function ChatPage(_props: ChatPageProps) { setLoadingMessages, setLoadingMore, setHasMoreMessages, + hasMoreLater, + setHasMoreLater, setSearchKeyword } = useChatStore() const messageListRef = useRef(null) const searchInputRef = useRef(null) const sidebarRef = useRef(null) + + const getMessageKey = useCallback((msg: Message): string => { + if (msg.localId && msg.localId > 0) return `l:${msg.localId}` + return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}` + }, []) const initialRevealTimerRef = useRef(null) const sessionListRef = useRef(null) const [currentOffset, setCurrentOffset] = useState(0) + const [jumpStartTime, setJumpStartTime] = useState(0) + const [jumpEndTime, setJumpEndTime] = useState(0) + const [showJumpDialog, setShowJumpDialog] = useState(false) const [myAvatarUrl, setMyAvatarUrl] = useState(undefined) const [myWxid, setMyWxid] = useState(undefined) const [showScrollToBottom, setShowScrollToBottom] = useState(false) @@ -477,6 +488,9 @@ function ChatPage(_props: ChatPageProps) { // 刷新会话列表 const handleRefresh = async () => { + setJumpStartTime(0) + setJumpEndTime(0) + setHasMoreLater(false) await loadSessions({ silent: true }) } @@ -484,6 +498,9 @@ function ChatPage(_props: ChatPageProps) { const [isRefreshingMessages, setIsRefreshingMessages] = useState(false) const handleRefreshMessages = async () => { if (!currentSessionId || isRefreshingMessages) return + setJumpStartTime(0) + setJumpEndTime(0) + setHasMoreLater(false) setIsRefreshingMessages(true) try { // 获取最新消息并增量添加 @@ -518,7 +535,7 @@ function ChatPage(_props: ChatPageProps) { } // 加载消息 - const loadMessages = async (sessionId: string, offset = 0) => { + const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0) => { const listEl = messageListRef.current const session = sessionMapRef.current.get(sessionId) const unreadCount = session?.unreadCount ?? 0 @@ -535,7 +552,7 @@ function ChatPage(_props: ChatPageProps) { const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null try { - const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit) + const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime) if (result.success && result.messages) { if (offset === 0) { setMessages(result.messages) @@ -601,6 +618,14 @@ function ChatPage(_props: ChatPageProps) { } } setHasMoreMessages(result.hasMore ?? false) + // 如果是按 endTime 跳转加载,且结果刚好满批,可能后面(更晚)还有消息 + if (offset === 0) { + if (endTime > 0) { + setHasMoreLater(true) + } else { + setHasMoreLater(false) + } + } setCurrentOffset(offset + result.messages.length) } else if (!result.success) { setConnectionError(result.error || '加载消息失败') @@ -616,12 +641,41 @@ function ChatPage(_props: ChatPageProps) { } } + // 加载更晚的消息 + const loadLaterMessages = useCallback(async () => { + if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return + + setLoadingMore(true) + try { + const lastMsg = messages[messages.length - 1] + // 从最后一条消息的时间开始往后找 + const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true) + + if (result.success && result.messages) { + // 过滤掉已经在列表中的重复消息 + const existingKeys = messageKeySetRef.current + const newMsgs = result.messages.filter(m => !existingKeys.has(getMessageKey(m))) + + if (newMsgs.length > 0) { + appendMessages(newMsgs, false) + } + setHasMoreLater(result.hasMore ?? false) + } + } catch (e) { + console.error('加载后续消息失败:', e) + } finally { + setLoadingMore(false) + } + }, [currentSessionId, isLoadingMore, isLoadingMessages, messages, getMessageKey, appendMessages, setHasMoreLater, setLoadingMore]) + // 选择会话 const handleSelectSession = (session: ChatSession) => { if (session.username === currentSessionId) return setCurrentSession(session.username) setCurrentOffset(0) - loadMessages(session.username, 0) + setJumpStartTime(0) + setJumpEndTime(0) + loadMessages(session.username, 0, 0, 0) // 重置详情面板 setSessionDetail(null) if (showDetailPanel) { @@ -678,16 +732,21 @@ function ChatPage(_props: ChatPageProps) { if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) { const threshold = clientHeight * 0.3 if (scrollTop < threshold) { - loadMessages(currentSessionId, currentOffset) + loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime) + } + } + + // 预加载更晚的消息 + if (!isLoadingMore && !isLoadingMessages && hasMoreLater && currentSessionId) { + const threshold = clientHeight * 0.3 + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + if (distanceFromBottom < threshold) { + loadLaterMessages() } } }) - }, [isLoadingMore, isLoadingMessages, hasMoreMessages, currentSessionId, currentOffset, loadMessages]) + }, [isLoadingMore, isLoadingMessages, hasMoreMessages, hasMoreLater, currentSessionId, currentOffset, jumpStartTime, jumpEndTime, loadMessages, loadLaterMessages]) - const getMessageKey = useCallback((msg: Message): string => { - if (msg.localId && msg.localId > 0) return `l:${msg.localId}` - return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}` - }, []) const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => { return ( @@ -1102,6 +1161,25 @@ function ChatPage(_props: ChatPageProps) { )}
+ + setShowJumpDialog(false)} + onSelect={(date) => { + if (!currentSessionId) return + const end = Math.floor(date.setHours(23, 59, 59, 999) / 1000) + setCurrentOffset(0) + setJumpStartTime(0) + setJumpEndTime(end) + loadMessages(currentSessionId, 0, 0, end) + }} + />
+ )} + {/* 回到底部按钮 */}
diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 929c3a3..9783061 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -231,12 +231,12 @@ function ExportPage() { exportImages: options.exportMedia && options.exportImages, exportVoices: options.exportMedia && options.exportVoices, exportEmojis: options.exportMedia && options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText, // ?????????exportMedia + exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容 excelCompactColumns: options.excelCompactColumns, sessionLayout, dateRange: options.useAllTime ? null : options.dateRange ? { start: Math.floor(options.dateRange.start.getTime() / 1000), - // ?????????????????????????????????23:59:59,?????????????????????????????? + // 将结束日期设置为当天的 23:59:59,确保包含当天的所有记录 end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000) } : null } @@ -249,10 +249,10 @@ function ExportPage() { ) setExportResult(result) } else { - setExportResult({ success: false, error: `${options.format.toUpperCase()} ???????????????????????????...` }) + setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式目前暂未实现,请选择其他格式。` }) } } catch (e) { - console.error('????????????:', e) + console.error('导出过程中发生异常:', e) setExportResult({ success: false, error: String(e) }) } finally { setIsExporting(false) diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss new file mode 100644 index 0000000..005e2b4 --- /dev/null +++ b/src/pages/SnsPage.scss @@ -0,0 +1,579 @@ +.sns-page { + height: 100%; + background: var(--bg-primary); + color: var(--text-primary); + overflow: hidden; + + .sns-container { + display: flex; + height: 100%; + } + + .sns-sidebar { + width: 280px; + background: var(--bg-secondary); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + flex-shrink: 0; + + &.closed { + width: 0; + opacity: 0; + pointer-events: none; + } + + .sidebar-header { + padding: 16px 20px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border-color); + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + } + + .toggle-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + + &:hover { + background: var(--hover-bg); + color: var(--text-primary); + } + } + } + + .filter-content { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + + /* 自定义滚动条 */ + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 10px; + } + } + + .filter-group { + padding-bottom: 4px; + border-bottom: 1px solid var(--border-color); + } + + .filter-section { + padding: 10px 20px; + + label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 8px; + font-weight: 500; + } + + input[type="text"] { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 8px 12px; + color: var(--text-primary); + font-size: 13px; + outline: none; + + &:focus { + border-color: var(--accent-color); + } + } + + .date-inputs { + display: flex; + flex-direction: column; + gap: 6px; + + input[type="date"] { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 6px 10px; + color: var(--text-primary); + font-size: 12px; + outline: none; + width: 100%; + + &:focus { + border-color: var(--accent-color); + } + } + + span { + font-size: 11px; + color: var(--text-tertiary); + text-align: center; + } + } + } + + .contact-filter-section { + flex: 1; + display: flex; + flex-direction: column; + min-height: 200px; + padding: 12px 0 0 0; + + .section-header { + padding: 0 20px 8px 20px; + display: flex; + justify-content: space-between; + align-items: center; + + label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-secondary); + font-weight: 500; + } + + .selected-count { + font-size: 11px; + background: var(--accent-color); + color: white; + padding: 1px 6px; + border-radius: 10px; + } + } + + .contact-search { + margin: 0 20px 10px 20px; + position: relative; + + .search-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--text-tertiary); + } + + input { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 6px 10px 6px 28px; + font-size: 12px; + color: var(--text-primary); + outline: none; + + &:focus { + border-color: var(--accent-color); + } + } + } + + .contact-list { + flex: 1; + overflow-y: auto; + padding: 0 10px; + + .contact-item { + display: flex; + align-items: center; + padding: 8px 10px; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + gap: 10px; + margin-bottom: 2px; + position: relative; + + &:hover { + background: var(--hover-bg); + } + + &.active { + background: rgba(var(--accent-color-rgb), 0.1); + + .contact-name { + color: var(--accent-color); + font-weight: 600; + } + } + + .contact-name { + flex: 1; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-secondary); + } + + .check-mark { + color: var(--accent-color); + font-size: 12px; + font-weight: bold; + } + } + + .empty-contacts { + text-align: center; + padding: 20px; + font-size: 12px; + color: var(--text-tertiary); + } + } + } + + .sidebar-footer { + padding: 16px 20px; + border-top: 1px solid var(--border-color); + + .clear-btn { + width: 100%; + padding: 8px; + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-secondary); + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.2s; + + &:hover { + background: var(--hover-bg); + color: var(--text-primary); + } + } + } + } + + .sns-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + + .sns-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + height: 60px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); + + .header-left { + display: flex; + align-items: center; + gap: 12px; + + h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + } + } + + .icon-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 8px; + border-radius: 4px; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: var(--hover-bg); + color: var(--text-primary); + } + } + + .spinning { + animation: spin 1s linear infinite; + } + } + + .sns-content { + flex: 1; + overflow-y: auto; + padding: 24px; + scroll-behavior: smooth; + + .active-filters { + max-width: 680px; + margin: 0 auto 16px auto; + display: flex; + align-items: center; + justify-content: space-between; + background: rgba(var(--accent-color-rgb), 0.05); + border: 1px solid rgba(var(--accent-color-rgb), 0.2); + padding: 8px 16px; + border-radius: 8px; + font-size: 13px; + color: var(--accent-color); + + .clear-chip-btn { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + font-size: 12px; + text-decoration: underline; + + &:hover { + color: var(--text-secondary); + } + } + } + + .sns-post { + background: var(--bg-secondary); + border-radius: 12px; + padding: 20px; + margin-bottom: 24px; + max-width: 680px; + margin-left: auto; + margin-right: auto; + border: 1px solid var(--border-color); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + + .post-header { + display: flex; + align-items: center; + margin-bottom: 14px; + + .post-info { + margin-left: 12px; + + .nickname { + font-weight: 600; + margin-bottom: 2px; + color: var(--accent-color); + } + + .time { + font-size: 12px; + color: var(--text-tertiary); + } + } + } + + .post-body { + margin-bottom: 16px; + + .post-text { + margin-bottom: 12px; + white-space: pre-wrap; + line-height: 1.6; + font-size: 15px; + word-break: break-word; + } + + .post-media-grid { + display: grid; + gap: 4px; + width: fit-content; + max-width: 100%; + + &.media-count-1 { + grid-template-columns: 1fr; + + .media-item { + max-width: 400px; + aspect-ratio: unset; + } + + img { + height: auto; + max-height: 500px; + } + } + + &.media-count-2, + &.media-count-4 { + grid-template-columns: repeat(2, 1fr); + } + + &.media-count-3, + &.media-count-5, + &.media-count-6, + &.media-count-7, + &.media-count-8, + &.media-count-9 { + grid-template-columns: repeat(3, 1fr); + } + + .media-item { + aspect-ratio: 1; + background: var(--bg-tertiary); + border-radius: 4px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + &.error { + background: transparent; + border: 1px dashed var(--border-color); + color: var(--text-tertiary); + font-size: 12px; + min-width: 100px; + min-height: 100px; + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.85; + } + } + } + } + + .post-video-placeholder { + display: inline-flex; + align-items: center; + background: rgba(var(--accent-color-rgb), 0.1); + color: var(--accent-color); + padding: 6px 12px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + margin-bottom: 8px; + } + } + + .post-footer { + background: var(--bg-tertiary); + border-radius: 6px; + padding: 10px 12px; + font-size: 13.5px; + + .likes-section { + display: flex; + align-items: flex-start; + color: var(--accent-color); + padding-bottom: 8px; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + margin-bottom: 8px; + + &:last-child { + padding-bottom: 0; + border-bottom: none; + margin-bottom: 0; + } + + .icon { + margin-top: 3.5px; + margin-right: 8px; + flex-shrink: 0; + } + + .likes-list { + line-height: 1.5; + } + } + + .comments-section { + .comment-item { + margin-bottom: 6px; + line-height: 1.5; + + &:last-child { + margin-bottom: 0; + } + + .comment-user { + color: var(--accent-color); + font-weight: 600; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + + .reply-text { + color: var(--text-tertiary); + margin: 0 4px; + font-size: 13px; + } + + .comment-separator { + color: var(--text-secondary); + margin-left: -2px; + margin-right: 4px; + } + + .comment-content { + color: var(--text-secondary); + } + } + } + } + } + + .loading-more, + .no-more, + .no-results { + text-align: center; + padding: 40px 20px; + color: var(--text-tertiary); + font-size: 14px; + + .reset-inline { + margin-top: 12px; + background: var(--accent-color); + color: white; + border: none; + padding: 8px 20px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + box-shadow: 0 2px 6px rgba(var(--accent-color-rgb), 0.3); + } + } + } + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx new file mode 100644 index 0000000..04bf2d1 --- /dev/null +++ b/src/pages/SnsPage.tsx @@ -0,0 +1,421 @@ +import { useEffect, useState, useRef, useCallback } from 'react' +import { RefreshCw, Heart, Search, Calendar, User, X, Filter } from 'lucide-react' +import { Avatar } from '../components/Avatar' +import { ImagePreview } from '../components/ImagePreview' +import './SnsPage.scss' + +interface SnsPost { + id: string + username: string + nickname: string + avatarUrl?: string + createTime: number + contentDesc: string + type?: number + media: { url: string; thumb: string }[] + likes: string[] + comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] +} + +const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => { + const [error, setError] = useState(false); + + if (error) { + return ( +
+ 无法加载 +
+ ); + } + + return ( +
+ setError(true)} + /> +
+ ); +}; + +interface Contact { + username: string + displayName: string + avatarUrl?: string +} + +export default function SnsPage() { + const [posts, setPosts] = useState([]) + const [loading, setLoading] = useState(false) + const [offset, setOffset] = useState(0) + const [hasMore, setHasMore] = useState(true) + const loadingRef = useRef(false) + + // 筛选与搜索状态 + const [searchKeyword, setSearchKeyword] = useState('') + const [selectedUsernames, setSelectedUsernames] = useState([]) + const [startDate, setStartDate] = useState('') + const [endDate, setEndDate] = useState('') + const [isSidebarOpen, setIsSidebarOpen] = useState(true) + + // 联系人列表状态 + const [contacts, setContacts] = useState([]) + const [contactSearch, setContactSearch] = useState('') + const [contactsLoading, setContactsLoading] = useState(false) + const [previewImage, setPreviewImage] = useState(null) + + const loadPosts = useCallback(async (reset = false) => { + if (loadingRef.current) return + loadingRef.current = true + setLoading(true) + + try { + const currentOffset = reset ? 0 : offset + const limit = 20 + + // 转换日期为秒级时间戳 + const startTs = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined + const endTs = endDate ? Math.floor(new Date(endDate).getTime() / 1000) + 86399 : undefined // 包含当天 + + const result = await window.electronAPI.sns.getTimeline( + limit, + currentOffset, + selectedUsernames, + searchKeyword, + startTs, + endTs + ) + + if (result.success && result.timeline) { + if (reset) { + setPosts(result.timeline) + setOffset(limit) + setHasMore(result.timeline.length >= limit) + } else { + setPosts(prev => [...prev, ...result.timeline!]) + setOffset(prev => prev + limit) + if (result.timeline.length < limit) { + setHasMore(false) + } + } + } + } catch (error) { + console.error('Failed to load SNS timeline:', error) + } finally { + setLoading(false) + loadingRef.current = false + } + }, [offset, selectedUsernames, searchKeyword, startDate, endDate]) + + // 获取联系人列表 + const loadContacts = async () => { + setContactsLoading(true) + try { + const result = await window.electronAPI.chat.getSessions() + if (result.success && result.sessions) { + // 系统账号和特殊前缀 + const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder']; + + // 初步提取并过滤联系人 + const initialContacts = result.sessions + .filter((s: any) => { + if (!s.username) return false; + const u = s.username.toLowerCase(); + + // 1. 排除群聊 (WeChat 群组以 @chatroom 结尾) + if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) { + return false; + } + + // 2. 排除公众号 (通常以 gh_ 开头) + if (u.startsWith('gh_')) { + return false; + } + + // 3. 排除系统账号 + if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) { + return false; + } + + return true; + }) + .map((s: any) => ({ + username: s.username, + displayName: s.displayName || s.username, + avatarUrl: s.avatarUrl + })) + setContacts(initialContacts) + + // 异步进一步富化(获取更多准确的昵称和头像) + const usernames = initialContacts.map(c => c.username) + const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) + if (enriched.success && enriched.contacts) { + setContacts(prev => prev.map(c => { + const extra = enriched.contacts![c.username] + if (extra) { + return { + ...c, + displayName: extra.displayName || c.displayName, + avatarUrl: extra.avatarUrl || c.avatarUrl + } + } + return c + })) + } + } + } catch (error) { + console.error('Failed to load contacts:', error) + } finally { + setContactsLoading(false) + } + } + + useEffect(() => { + loadContacts() + }, []) + + useEffect(() => { + loadPosts(true) + }, [selectedUsernames, searchKeyword, startDate, endDate]) + + const handleScroll = (e: React.UIEvent) => { + const { scrollTop, clientHeight, scrollHeight } = e.currentTarget + if (scrollHeight - scrollTop - clientHeight < 200 && hasMore && !loading) { + loadPosts() + } + } + + const formatTime = (ts: number) => { + const date = new Date(ts * 1000) + const isCurrentYear = date.getFullYear() === new Date().getFullYear() + + return date.toLocaleString('zh-CN', { + year: isCurrentYear ? undefined : 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + } + + const toggleUserSelection = (username: string) => { + setSelectedUsernames(prev => { + if (prev.includes(username)) { + return prev.filter(u => u !== username) + } else { + return [...prev, username] + } + }) + } + + const clearFilters = () => { + setSearchKeyword('') + setSelectedUsernames([]) + setStartDate('') + setEndDate('') + } + + const filteredContacts = contacts.filter(c => + c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) || + c.username.toLowerCase().includes(contactSearch.toLowerCase()) + ) + + return ( +
+
+ {/* 侧边栏:过滤与搜索 */} + + +
+
+
+ {!isSidebarOpen && ( + + )} +

朋友圈

+
+
+ +
+
+ +
+ {selectedUsernames.length > 0 && ( +
+ 筛选中: {selectedUsernames.length} 位好友 + +
+ )} + + {posts.map(post => ( +
+
+ +
+
{post.nickname}
+
{formatTime(post.createTime)}
+
+
+ +
+ {post.contentDesc &&
{post.contentDesc}
} + + {post.type === 15 ? ( +
+ [视频] +
+ ) : post.media.length > 0 && ( +
+ {post.media.map((m, idx) => ( + setPreviewImage(m.url)} /> + ))} +
+ )} +
+ + {(post.likes.length > 0 || post.comments.length > 0) && ( +
+ {post.likes.length > 0 && ( +
+ + + {post.likes.join('、')} + +
+ )} + + {post.comments.length > 0 && ( +
+ {post.comments.map((c, idx) => ( +
+ {c.nickname} + {c.refNickname && ( + <> + 回复 + {c.refNickname} + + )} + : + {c.content} +
+ ))} +
+ )} +
+ )} +
+ ))} + + {loading &&
加载中...
} + {!hasMore && posts.length > 0 &&
没有更多了
} + {!loading && posts.length === 0 && ( +
+

没有找到符合条件的朋友圈

+ {selectedUsernames.length > 0 && ( + + )} +
+ )} +
+
+
+ {previewImage && ( + setPreviewImage(null)} /> + )} +
+ ) +} diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts index c296059..561f568 100644 --- a/src/stores/chatStore.ts +++ b/src/stores/chatStore.ts @@ -6,25 +6,26 @@ export interface ChatState { isConnected: boolean isConnecting: boolean connectionError: string | null - + // 会话列表 sessions: ChatSession[] filteredSessions: ChatSession[] currentSessionId: string | null isLoadingSessions: boolean - + // 消息 messages: Message[] isLoadingMessages: boolean isLoadingMore: boolean hasMoreMessages: boolean - + hasMoreLater: boolean + // 联系人缓存 contacts: Map - + // 搜索 searchKeyword: string - + // 操作 setConnected: (connected: boolean) => void setConnecting: (connecting: boolean) => void @@ -38,6 +39,7 @@ export interface ChatState { setLoadingMessages: (loading: boolean) => void setLoadingMore: (loading: boolean) => void setHasMoreMessages: (hasMore: boolean) => void + setHasMoreLater: (hasMore: boolean) => void setContacts: (contacts: Contact[]) => void addContact: (contact: Contact) => void setSearchKeyword: (keyword: string) => void @@ -56,48 +58,51 @@ export const useChatStore = create((set, get) => ({ isLoadingMessages: false, isLoadingMore: false, hasMoreMessages: true, + hasMoreLater: false, contacts: new Map(), searchKeyword: '', setConnected: (connected) => set({ isConnected: connected }), setConnecting: (connecting) => set({ isConnecting: connecting }), setConnectionError: (error) => set({ connectionError: error }), - + setSessions: (sessions) => set({ sessions, filteredSessions: sessions }), setFilteredSessions: (sessions) => set({ filteredSessions: sessions }), - - setCurrentSession: (sessionId) => set({ + + setCurrentSession: (sessionId) => set({ currentSessionId: sessionId, messages: [], - hasMoreMessages: true + hasMoreMessages: true, + hasMoreLater: false }), - + setLoadingSessions: (loading) => set({ isLoadingSessions: loading }), - + setMessages: (messages) => set({ messages }), - + appendMessages: (newMessages, prepend = false) => set((state) => ({ - messages: prepend + messages: prepend ? [...newMessages, ...state.messages] : [...state.messages, ...newMessages] })), - + setLoadingMessages: (loading) => set({ isLoadingMessages: loading }), setLoadingMore: (loading) => set({ isLoadingMore: loading }), setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }), - - setContacts: (contacts) => set({ - contacts: new Map(contacts.map(c => [c.username, c])) + setHasMoreLater: (hasMore) => set({ hasMoreLater: hasMore }), + + setContacts: (contacts) => set({ + contacts: new Map(contacts.map(c => [c.username, c])) }), - + addContact: (contact) => set((state) => { const newContacts = new Map(state.contacts) newContacts.set(contact.username, contact) return { contacts: newContacts } }), - + setSearchKeyword: (keyword) => set({ searchKeyword: keyword }), - + reset: () => set({ isConnected: false, isConnecting: false, @@ -110,6 +115,7 @@ export const useChatStore = create((set, get) => ({ isLoadingMessages: false, isLoadingMore: false, hasMoreMessages: true, + hasMoreLater: false, contacts: new Map(), searchKeyword: '' }) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 2298766..7893cf7 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -63,7 +63,7 @@ export interface ElectronAPI { contacts?: Record error?: string }> - getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{ + getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; @@ -321,6 +321,24 @@ export interface ElectronAPI { getModelStatus: () => Promise<{ success: boolean; exists?: boolean; modelPath?: string; tokensPath?: string; sizeBytes?: number; error?: string }> onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => () => void } + sns: { + getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => Promise<{ + success: boolean + timeline?: Array<{ + id: string + username: string + nickname: string + avatarUrl?: string + createTime: number + contentDesc: string + type?: number + media: Array<{ url: string; thumb: string }> + likes: Array + comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }> + }> + error?: string + }> + } } export interface ExportOptions {