diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 378a8ec..bbe70d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: - name: Check out git repository uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 0 - name: Install Node.js uses: actions/setup-node@v4 @@ -32,51 +32,20 @@ jobs: run: | VERSION=${GITHUB_REF_NAME#v} echo "Syncing package.json version to $VERSION" - npm version $VERSION --no-git-tag-version + npm version $VERSION --no-git-tag-version --allow-same-version - name: Build Frontend & Type Check run: | npx tsc npx vite build - - name: Build Changelog - id: build_changelog - uses: mikepenz/release-changelog-builder-action@v4 - with: - outputFile: "release-notes.md" - configurationJson: | - { - "template": "# v${{ github.ref_name }} 更新日志\n\n{{CHANGELOG}}\n\n---\n> 此更新由系统自动构建", - "categories": [ - { - "title": "## 新功能", - "filter": { "pattern": "^feat.*:.*", "flags": "i" } - }, - { - "title": "## 修复", - "filter": { "pattern": "^fix.*:.*", "flags": "i" } - }, - { - "title": "## 性能与维护", - "filter": { "pattern": "^(chore|docs|perf|refactor|ci|style|test).*:.*", "flags": "i" } - } - ], - "ignore_labels": [], - "commitMode": true, - "empty_summary": "## 更新详情\n- 常规代码优化与维护" - } - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Check Changelog Content + - name: Inject Configuration shell: bash run: | - echo "=== RELEASE NOTES CONTENT START ===" - cat release-notes.md - echo "=== RELEASE NOTES CONTENT END ===" + npm pkg set build.releaseInfo.releaseNotes=$'仅适配微信 4.0 及以上版本\n\n修复了一些已知问题\n\n详情前往 Telegram 群查看' - name: Package and Publish env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npx electron-builder --publish always -c.releaseInfo.releaseNotesFile=release-notes.md \ No newline at end of file + npx electron-builder --publish always \ No newline at end of file diff --git a/2wm.png b/2wm.png new file mode 100644 index 0000000..c2ff491 Binary files /dev/null and b/2wm.png differ diff --git a/README.md b/README.md index 5fe50e3..6e190f9 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,14 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析 > [!TIP] > 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/) +# 加入微信交流群 + +> 🎉 扫码加入微信群,与其他 WeFlow 用户一起交流问题和使用心得。 + +

+ WeFlow 微信交流群二维码 +

+ ## 主要功能 - 本地实时查看聊天记录 @@ -36,6 +44,9 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析 - 导出聊天记录为 HTML 等格式 - 本地解密与数据库管理 +> [!NOTE] +> ⚠️ 本工具仅适配微信 **4.0 及以上**版本,请确保你的微信版本符合要求 + ## 快速开始 若你只想使用成品版本,可前往 Release 下载并安装。 diff --git a/electron/main.ts b/electron/main.ts index a04dc4c..c59b487 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,6 +1,6 @@ -import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron' +import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron' import { Worker } from 'worker_threads' -import { join } from 'path' +import { join, dirname } from 'path' import { autoUpdater } from 'electron-updater' import { readFile, writeFile, mkdir } from 'fs/promises' import { existsSync } from 'fs' @@ -13,10 +13,11 @@ import { imagePreloadService } from './services/imagePreloadService' import { analyticsService } from './services/analyticsService' import { groupAnalyticsService } from './services/groupAnalyticsService' import { annualReportService } from './services/annualReportService' -import { exportService, ExportOptions } from './services/exportService' +import { exportService, ExportOptions, ExportProgress } from './services/exportService' import { KeyService } from './services/keyService' import { voiceTranscribeService } from './services/voiceTranscribeService' import { videoService } from './services/videoService' +import { snsService } from './services/snsService' // 配置自动更新 @@ -28,6 +29,47 @@ const AUTO_UPDATE_ENABLED = process.env.AUTO_UPDATE_ENABLED === '1' || (process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL) +// 使用白名单过滤 PATH,避免被第三方目录中的旧版 VC++ 运行库劫持。 +// 仅保留系统目录(Windows/System32/SysWOW64)和应用自身目录(可执行目录、resources)。 +function sanitizePathEnv() { + // 开发模式不做裁剪,避免影响本地工具链 + if (process.env.VITE_DEV_SERVER_URL) return + + const rawPath = process.env.PATH || process.env.Path + if (!rawPath) return + + const sep = process.platform === 'win32' ? ';' : ':' + const parts = rawPath.split(sep).filter(Boolean) + + const systemRoot = process.env.SystemRoot || process.env.WINDIR || '' + const safePrefixes = [ + systemRoot, + systemRoot ? join(systemRoot, 'System32') : '', + systemRoot ? join(systemRoot, 'SysWOW64') : '', + dirname(process.execPath), + process.resourcesPath, + join(process.resourcesPath || '', 'resources') + ].filter(Boolean) + + const normalize = (p: string) => p.replace(/\\/g, '/').toLowerCase() + const isSafe = (p: string) => { + const np = normalize(p) + return safePrefixes.some((prefix) => np.startsWith(normalize(prefix))) + } + + const filtered = parts.filter(isSafe) + if (filtered.length !== parts.length) { + const removed = parts.filter((p) => !isSafe(p)) + console.warn('[WeFlow] 使用白名单裁剪 PATH,移除目录:', removed) + const nextPath = filtered.join(sep) + process.env.PATH = nextPath + process.env.Path = nextPath + } +} + +// 启动时立即清理 PATH,后续创建的 worker 也能继承安全的环境 +sanitizePathEnv() + // 单例服务 let configService: ConfigService | null = null @@ -573,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) => { @@ -631,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) + }) + // 私聊克隆 @@ -646,8 +692,13 @@ function registerIpcHandlers() { }) // 导出相关 - ipcMain.handle('export:exportSessions', async (_, sessionIds: string[], outputDir: string, options: ExportOptions) => { - return exportService.exportSessions(sessionIds, outputDir, options) + ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => { + const onProgress = (progress: ExportProgress) => { + if (!event.sender.isDestroyed()) { + event.sender.send('export:progress', progress) + } + } + return exportService.exportSessions(sessionIds, outputDir, options, onProgress) }) ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => { @@ -931,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 f04e6f4..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), @@ -191,7 +191,11 @@ contextBridge.exposeInMainWorld('electronAPI', { exportSessions: (sessionIds: string[], outputDir: string, options: any) => ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options), exportSession: (sessionId: string, outputPath: string, options: any) => - ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options) + ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options), + onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => { + ipcRenderer.on('export:progress', (_, payload) => callback(payload)) + return () => ipcRenderer.removeAllListeners('export:progress') + } }, whisper: { @@ -203,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 0a3f1cb..875c961 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -72,8 +72,21 @@ export interface ExportOptions { exportEmojis?: boolean exportVoiceAsText?: boolean excelCompactColumns?: boolean + txtColumns?: string[] + sessionLayout?: 'shared' | 'per-session' } +const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ + { id: 'index', label: '序号' }, + { id: 'time', label: '时间' }, + { id: 'senderRole', label: '发送者身份' }, + { id: 'messageType', label: '消息类型' }, + { id: 'content', label: '内容' }, + { id: 'senderNickname', label: '发送者昵称' }, + { id: 'senderWxid', label: '发送者微信ID' }, + { id: 'senderRemark', label: '发送者备注' } +] + interface MediaExportItem { relativePath: string kind: 'image' | 'voice' | 'emoji' @@ -83,7 +96,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 { @@ -402,20 +440,32 @@ class ExportService { return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` } + private normalizeTxtColumns(columns?: string[] | null): string[] { + const fallback = ['index', 'time', 'senderRole', 'messageType', 'content'] + const selected = new Set((columns && columns.length > 0 ? columns : fallback).filter(Boolean)) + const ordered = TXT_COLUMN_DEFINITIONS.map((col) => col.id).filter((id) => selected.has(id)) + return ordered.length > 0 ? ordered : fallback + } + + private sanitizeTxtValue(value: string): string { + return value.replace(/\r?\n/g, ' ').replace(/\t/g, ' ').trim() + } + /** * 导出媒体文件到指定目录 */ private async exportMediaForMessage( msg: any, sessionId: string, - mediaDir: string, + mediaRootDir: string, + mediaRelativePrefix: string, options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean } ): Promise { const localType = msg.localType // 图片消息 if (localType === 3 && options.exportImages) { - const result = await this.exportImage(msg, sessionId, mediaDir) + const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix) if (result) { } return result @@ -429,13 +479,13 @@ class ExportService { } // 否则导出语音文件 if (options.exportVoices) { - return this.exportVoice(msg, sessionId, mediaDir) + return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix) } } // 动画表情 if (localType === 47 && options.exportEmojis) { - const result = await this.exportEmoji(msg, sessionId, mediaDir) + const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix) if (result) { } return result @@ -447,9 +497,14 @@ class ExportService { /** * 导出图片文件 */ - private async exportImage(msg: any, sessionId: string, mediaDir: string): Promise { + private async exportImage( + msg: any, + sessionId: string, + mediaRootDir: string, + mediaRelativePrefix: string + ): Promise { try { - const imagesDir = path.join(mediaDir, 'media', 'images') + const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images') if (!fs.existsSync(imagesDir)) { fs.mkdirSync(imagesDir, { recursive: true }) } @@ -494,7 +549,7 @@ class ExportService { fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64')) return { - relativePath: `media/images/${fileName}`, + relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), kind: 'image' } } else if (sourcePath.startsWith('file://')) { @@ -512,7 +567,7 @@ class ExportService { } return { - relativePath: `media/images/${fileName}`, + relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), kind: 'image' } } @@ -526,9 +581,14 @@ class ExportService { /** * 导出语音文件 */ - private async exportVoice(msg: any, sessionId: string, mediaDir: string): Promise { + private async exportVoice( + msg: any, + sessionId: string, + mediaRootDir: string, + mediaRelativePrefix: string + ): Promise { try { - const voicesDir = path.join(mediaDir, 'media', 'voices') + const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices') if (!fs.existsSync(voicesDir)) { fs.mkdirSync(voicesDir, { recursive: true }) } @@ -540,7 +600,7 @@ class ExportService { // 如果已存在则跳过 if (fs.existsSync(destPath)) { return { - relativePath: `media/voices/${fileName}`, + relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName), kind: 'voice' } } @@ -556,7 +616,7 @@ class ExportService { fs.writeFileSync(destPath, wavBuffer) return { - relativePath: `media/voices/${fileName}`, + relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName), kind: 'voice' } } catch (e) { @@ -582,9 +642,14 @@ class ExportService { /** * 导出表情文件 */ - private async exportEmoji(msg: any, sessionId: string, mediaDir: string): Promise { + private async exportEmoji( + msg: any, + sessionId: string, + mediaRootDir: string, + mediaRelativePrefix: string + ): Promise { try { - const emojisDir = path.join(mediaDir, 'media', 'emojis') + const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis') if (!fs.existsSync(emojisDir)) { fs.mkdirSync(emojisDir, { recursive: true }) } @@ -613,7 +678,7 @@ class ExportService { // 如果已存在则跳过 if (fs.existsSync(destPath)) { return { - relativePath: `media/emojis/${fileName}`, + relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), kind: 'emoji' } } @@ -621,13 +686,13 @@ class ExportService { // 下载表情 if (emojiUrl) { const downloaded = await this.downloadFile(emojiUrl, destPath) - if (downloaded) { - return { - relativePath: `media/emojis/${fileName}`, - kind: 'emoji' - } - } else { - } + if (downloaded) { + return { + relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), + kind: 'emoji' + } + } else { + } } return null @@ -704,6 +769,22 @@ class ExportService { return '.jpg' } + private getMediaLayout(outputPath: string, options: ExportOptions): { + exportMediaEnabled: boolean + mediaRootDir: string + mediaRelativePrefix: string + } { + const exportMediaEnabled = options.exportMedia === true && + Boolean(options.exportImages || options.exportVoices || options.exportEmojis) + const outputDir = path.dirname(outputPath) + const outputBaseName = path.basename(outputPath, path.extname(outputPath)) + const useSharedMediaLayout = options.sessionLayout === 'shared' + const mediaRelativePrefix = useSharedMediaLayout + ? path.posix.join('media', outputBaseName) + : 'media' + return { exportMediaEnabled, mediaRootDir: outputDir, mediaRelativePrefix } + } + /** * 下载文件 */ @@ -1089,7 +1170,7 @@ class ExportService { } /** - * 导出单个会话为 ChatLab 格式 + * 导出单个会话为 ChatLab 格式(并行优化版本) */ async exportSessionToChatLab( sessionId: string, @@ -1121,36 +1202,100 @@ 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 chatLabMessages: ChatLabMessage[] = [] - for (const msg of allMessages) { + const chatLabMessages: ChatLabMessage[] = allMessages.map(msg => { const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || { platformId: msg.senderUsername, accountName: msg.senderUsername, groupNickname: undefined } - let content = this.parseMessageContent(msg.content, msg.localType) - // 如果是语音消息且开启了转文字 + // 确定消息内容 + let content: string | null if (msg.localType === 34 && options.exportVoiceAsText) { - content = await this.transcribeVoice(sessionId, String(msg.localId)) + // 使用预先转写的文字 + content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + } else { + content = this.parseMessageContent(msg.content, msg.localType) } - chatLabMessages.push({ + 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( @@ -1218,7 +1363,7 @@ class ExportService { } /** - * 导出单个会话为详细 JSON 格式(原项目格式) + * 导出单个会话为详细 JSON 格式(原项目格式)- 并行优化版本 */ async exportSessionToDetailedJson( sessionId: string, @@ -1244,16 +1389,89 @@ class ExportService { }) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) - const allMessages: any[] = [] + const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) + // ========== 阶段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) + }) + : [] + + 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) + } + }) + } + + // ========== 阶段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 = this.parseMessageContent(msg.content, msg.localType) - if (msg.localType === 34 && options.exportVoiceAsText) { - content = await this.transcribeVoice(sessionId, String(msg.localId)) + 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({ @@ -1482,23 +1700,33 @@ class ExportService { const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) // 媒体导出设置 - const exportMediaEnabled = options.exportImages || options.exportVoices || options.exportEmojis - const sessionDir = path.dirname(outputPath) // 会话目录,用于媒体导出 + 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, sessionDir, { + if (!mediaCache.has(mediaKey)) { + const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportEmojis: options.exportEmojis, @@ -1506,7 +1734,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 @@ -1555,12 +1821,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) || '' } // 调试日志 @@ -1634,6 +1903,197 @@ class ExportService { } } + /** + * 导出单个会话为 TXT 格式(默认与 Excel 精简列一致) + */ + async exportSessionToTxt( + sessionId: string, + outputPath: string, + options: ExportOptions, + onProgress?: (progress: ExportProgress) => void + ): Promise<{ success: boolean; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } + + const cleanedMyWxid = conn.cleanedWxid + const isGroup = sessionId.includes('@chatroom') + const sessionInfo = await this.getContactInfo(sessionId) + const myInfo = await this.getContactInfo(cleanedMyWxid) + + onProgress?.({ + current: 0, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'preparing' + }) + + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) + + 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() + + if (mediaMessages.length > 0) { + onProgress?.({ + current: 25, + 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) + } + }) + } + + const voiceMessages = options.exportVoiceAsText + ? sortedMessages.filter(msg => msg.localType === 34) + : [] + const voiceTranscriptMap = new Map() + + if (voiceMessages.length > 0) { + onProgress?.({ + current: 45, + 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: 60, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting' + }) + + const columnOrder = this.normalizeTxtColumns(options.txtColumns) + const columnLabelMap = new Map(TXT_COLUMN_DEFINITIONS.map((col) => [col.id, col.label])) + const lines: string[] = [] + lines.push(columnOrder.map((id) => columnLabelMap.get(id) || id).join('\t')) + + 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 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) || '' + } + + let senderRole: string + let senderWxid: string + let senderNickname: string + let senderRemark = '' + + if (msg.isSend) { + senderRole = '我' + senderWxid = cleanedMyWxid + senderNickname = myInfo.displayName || cleanedMyWxid + } else if (isGroup && msg.senderUsername) { + senderWxid = msg.senderUsername + const contactDetail = await wcdbService.getContact(msg.senderUsername) + if (contactDetail.success && contactDetail.contact) { + senderNickname = contactDetail.contact.nickName || msg.senderUsername + senderRemark = contactDetail.contact.remark || '' + senderRole = senderRemark || senderNickname + } else { + senderNickname = msg.senderUsername + senderRole = msg.senderUsername + } + } else { + senderWxid = sessionId + const contactDetail = await wcdbService.getContact(sessionId) + if (contactDetail.success && contactDetail.contact) { + senderNickname = contactDetail.contact.nickName || sessionId + senderRemark = contactDetail.contact.remark || '' + senderRole = senderRemark || senderNickname + } else { + senderNickname = sessionInfo.displayName || sessionId + senderRole = senderNickname + } + } + + const values: Record = { + index: String(i + 1), + time: this.formatTimestamp(msg.createTime), + senderRole, + senderNickname, + senderWxid, + senderRemark, + messageType: this.getMessageTypeName(msg.localType), + content: contentValue + } + + const line = columnOrder + .map((id) => this.sanitizeTxtValue(values[id] ?? '')) + .join('\t') + lines.push(line) + + if ((i + 1) % 200 === 0) { + const progress = 60 + Math.floor((i + 1) / sortedMessages.length * 30) + onProgress?.({ + current: progress, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting' + }) + } + } + + onProgress?.({ + current: 92, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'writing' + }) + + fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8') + + onProgress?.({ + current: 100, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'complete' + }) + + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + /** * 批量导出多个会话 */ @@ -1656,9 +2116,15 @@ class ExportService { fs.mkdirSync(outputDir, { recursive: true }) } - for (let i = 0; i < sessionIds.length; i++) { - const sessionId = sessionIds[i] - const sessionInfo = await this.getContactInfo(sessionId) + const exportMediaEnabled = options.exportMedia === true && + Boolean(options.exportImages || options.exportVoices || options.exportEmojis) + const sessionLayout = exportMediaEnabled + ? (options.sessionLayout ?? 'per-session') + : 'shared' + + for (let i = 0; i < sessionIds.length; i++) { + const sessionId = sessionIds[i] + const sessionInfo = await this.getContactInfo(sessionId) onProgress?.({ current: i + 1, @@ -1667,17 +2133,18 @@ class ExportService { phase: 'exporting' }) - const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_') + const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_') + const useSessionFolder = sessionLayout === 'per-session' + const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir - // 为每个会话创建单独的文件夹 - const sessionDir = path.join(outputDir, safeName) - if (!fs.existsSync(sessionDir)) { - fs.mkdirSync(sessionDir, { recursive: true }) - } + if (useSessionFolder && !fs.existsSync(sessionDir)) { + fs.mkdirSync(sessionDir, { recursive: true }) + } let ext = '.json' if (options.format === 'chatlab-jsonl') ext = '.jsonl' else if (options.format === 'excel') ext = '.xlsx' + else if (options.format === 'txt') ext = '.txt' const outputPath = path.join(sessionDir, `${safeName}${ext}`) let result: { success: boolean; error?: string } @@ -1687,6 +2154,8 @@ class ExportService { result = await this.exportSessionToChatLab(sessionId, outputPath, options) } else if (options.format === 'excel') { result = await this.exportSessionToExcel(sessionId, outputPath, options) + } else if (options.format === 'txt') { + result = await this.exportSessionToTxt(sessionId, outputPath, options) } else { result = { success: false, error: `不支持的格式: ${options.format}` } } diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 8b51c8d..72ac8c2 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -33,6 +33,7 @@ export class KeyService { private ReadProcessMemory: any = null private MEMORY_BASIC_INFORMATION: any = null private TerminateProcess: any = null + private QueryFullProcessImageNameW: any = null // User32 private EnumWindows: any = null @@ -194,6 +195,7 @@ export class KeyService { this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32']) this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE']) this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32']) + this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['HANDLE', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')]) this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64']) this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))]) @@ -310,7 +312,46 @@ export class KeyService { } } + private async getProcessExecutablePath(pid: number): Promise { + if (!this.ensureKernel32()) return null + // 0x1000 = PROCESS_QUERY_LIMITED_INFORMATION + const hProcess = this.OpenProcess(0x1000, false, pid) + if (!hProcess) return null + + try { + const sizeBuf = Buffer.alloc(4) + sizeBuf.writeUInt32LE(1024, 0) + const pathBuf = Buffer.alloc(1024 * 2) + + const ret = this.QueryFullProcessImageNameW(hProcess, 0, pathBuf, sizeBuf) + if (ret) { + const len = sizeBuf.readUInt32LE(0) + return pathBuf.toString('ucs2', 0, len * 2) + } + return null + } catch (e) { + console.error('获取进程路径失败:', e) + return null + } finally { + this.CloseHandle(hProcess) + } + } + private async findWeChatInstallPath(): Promise { + // 0. 优先尝试获取正在运行的微信进程路径 + try { + const pid = await this.findWeChatPid() + if (pid) { + const runPath = await this.getProcessExecutablePath(pid) + if (runPath && existsSync(runPath)) { + console.log('发现正在运行的微信进程,使用路径:', runPath) + return runPath + } + } + } catch (e) { + console.error('尝试获取运行中微信路径失败:', e) + } + // 1. Registry - Uninstall Keys const uninstallKeys = [ 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall', @@ -588,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 7f8f32c..93952e8 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -1,6 +1,12 @@ import { join, dirname, basename } from 'path' import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' +// DLL 初始化错误信息,用于帮助用户诊断问题 +let lastDllInitError: string | null = null +export function getLastDllInitError(): string | null { + return lastDllInitError +} + export class WcdbCore { private resourcesPath: string | null = null private userDataPath: string | null = null @@ -49,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 @@ -110,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') @@ -208,6 +216,31 @@ export class WcdbCore { return false } + // 关键修复:显式预加载依赖库 WCDB.dll 和 SDL2.dll + // Windows 加载器默认不会查找子目录中的依赖,必须先将其加载到内存 + // 这可以解决部分用户因为 VC++ 运行时或 DLL 依赖问题导致的闪退 + const dllDir = dirname(dllPath) + const wcdbCorePath = join(dllDir, 'WCDB.dll') + if (existsSync(wcdbCorePath)) { + try { + this.koffi.load(wcdbCorePath) + this.writeLog('预加载 WCDB.dll 成功') + } catch (e) { + console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e) + this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`) + } + } + const sdl2Path = join(dllDir, 'SDL2.dll') + if (existsSync(sdl2Path)) { + try { + this.koffi.load(sdl2Path) + this.writeLog('预加载 SDL2.dll 成功') + } catch (e) { + console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e) + this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`) + } + } + this.lib = this.koffi.load(dllPath) // 定义类型 @@ -354,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) { @@ -362,9 +402,20 @@ export class WcdbCore { } this.initialized = true + lastDllInitError = null return true } catch (e) { - console.error('WCDB 初始化异常:', e) + const errorMsg = e instanceof Error ? e.message : String(e) + console.error('WCDB 初始化异常:', errorMsg) + this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true) + lastDllInitError = errorMsg + // 检查是否是常见的 VC++ 运行时缺失错误 + 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 位版本的应用程序。' + } return false } } @@ -382,10 +433,18 @@ export class WcdbCore { return { success: true, sessionCount: 0 } } + // 记录当前活动连接,用于在测试结束后恢复(避免影响聊天页等正在使用的连接) + const hadActiveConnection = this.handle !== null + const prevPath = this.currentPath + const prevKey = this.currentKey + const prevWxid = this.currentWxid + if (!this.initialized) { const initOk = await this.initialize() if (!initOk) { - return { success: false, error: 'WCDB 初始化失败' } + // 返回更详细的错误信息,帮助用户诊断问题 + const detailedError = lastDllInitError || 'WCDB 初始化失败' + return { success: false, error: detailedError } } } @@ -424,8 +483,8 @@ export class WcdbCore { return { success: false, error: '无效的数据库句柄' } } - // 测试成功,使用 shutdown 清理所有资源(包括测试句柄) - // 这会中断当前活动连接,但 testConnection 本应该是独立测试 + // 测试成功:使用 shutdown 清理资源(包括测试句柄) + // 注意:shutdown 会断开当前活动连接,因此需要在测试后尝试恢复之前的连接 try { this.wcdbShutdown() this.handle = null @@ -437,6 +496,15 @@ export class WcdbCore { console.error('关闭测试数据库时出错:', closeErr) } + // 恢复测试前的连接(如果之前有活动连接) + if (hadActiveConnection && prevPath && prevKey && prevWxid) { + try { + await this.open(prevPath, prevKey, prevWxid) + } catch { + // 恢复失败则保持断开,由调用方处理 + } + } + return { success: true, sessionCount: 0 } } catch (e) { console.error('测试连接异常:', e) @@ -1329,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 7628c67..4a1d76c 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -58,12 +58,24 @@ export class WcdbService { }) this.worker.on('error', (err) => { - // Worker error + // Worker 发生错误,需要 reject 所有 pending promises + console.error('WCDB Worker 错误:', err) + const errorMsg = err instanceof Error ? err.message : String(err) + for (const [id, p] of this.pending) { + p.reject(new Error(`Worker 错误: ${errorMsg}`)) + } + this.pending.clear() }) this.worker.on('exit', (code) => { + // Worker 退出,需要 reject 所有 pending promises if (code !== 0) { - // Worker exited with error + console.error('WCDB Worker 异常退出,退出码:', code) + const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是 DLL 加载失败,请检查是否安装了 Visual C++ Redistributable。` + for (const [id, p] of this.pending) { + p.reject(new Error(errorMsg)) + } + this.pending.clear() } this.worker = null }) @@ -350,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 a38e504..d4b7d0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "weflow", - "version": "1.2.0", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "weflow", - "version": "1.2.0", + "version": "1.3.1", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.5.0", @@ -8537,12 +8537,6 @@ "sherpa-onnx-win-x64": "^1.12.23" } }, - "node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-darwin-x64": { - "optional": true - }, - "node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-linux-arm64": { - "optional": true - }, "node_modules/sherpa-onnx-win-ia32": { "version": "1.12.23", "resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz", 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 de0b445..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 77aa32f..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' @@ -202,10 +203,22 @@ function App() { } } else { console.log('自动连接失败:', result.error) + // 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户 + // 其他错误可能需要重新配置 + const errorMsg = result.error || '' + if (errorMsg.includes('Visual C++') || + errorMsg.includes('DLL') || + errorMsg.includes('Worker') || + errorMsg.includes('126') || + errorMsg.includes('模块')) { + console.warn('检测到可能的运行时依赖问题:', errorMsg) + // 不清除配置,让用户安装 VC++ 后重试 + } } } } catch (e) { console.error('自动连接出错:', e) + // 捕获异常但不清除配置,防止循环重新引导 } } @@ -324,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.scss b/src/pages/ExportPage.scss index 4aa5b35..ddedbd9 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -602,6 +602,87 @@ } } + .export-layout-modal { + background: var(--card-bg); + padding: 28px 32px; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + text-align: center; + width: min(520px, 90vw); + + h3 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 8px; + } + + .layout-subtitle { + font-size: 14px; + color: var(--text-secondary); + margin: 0 0 20px; + } + + .layout-options { + display: grid; + gap: 12px; + } + + .layout-option-btn { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 18px; + border-radius: 12px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + text-align: left; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + } + + &.primary { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.12); + } + + .layout-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + } + + .layout-desc { + font-size: 12px; + color: var(--text-tertiary); + } + } + + .layout-actions { + margin-top: 18px; + display: flex; + justify-content: center; + } + + .layout-cancel-btn { + padding: 8px 20px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + } + } + } + .export-result-modal { background: var(--card-bg); padding: 32px 40px; @@ -1056,4 +1137,4 @@ input:checked + .slider::before { transform: translateX(20px); } -} \ No newline at end of file +} diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 19d8605..87f3bfa 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -22,6 +22,7 @@ interface ExportOptions { exportEmojis: boolean exportVoiceAsText: boolean excelCompactColumns: boolean + txtColumns: string[] } interface ExportResult { @@ -31,7 +32,10 @@ interface ExportResult { error?: string } +type SessionLayout = 'shared' | 'per-session' + function ExportPage() { + const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const [sessions, setSessions] = useState([]) const [filteredSessions, setFilteredSessions] = useState([]) const [selectedSessions, setSelectedSessions] = useState>(new Set()) @@ -44,6 +48,7 @@ function ExportPage() { const [showDatePicker, setShowDatePicker] = useState(false) const [calendarDate, setCalendarDate] = useState(new Date()) const [selectingStart, setSelectingStart] = useState(true) + const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false) const [options, setOptions] = useState({ format: 'excel', @@ -58,7 +63,8 @@ function ExportPage() { exportVoices: true, exportEmojis: true, exportVoiceAsText: true, - excelCompactColumns: true + excelCompactColumns: true, + txtColumns: defaultTxtColumns }) const buildDateRangeFromPreset = (preset: string) => { @@ -122,17 +128,20 @@ function ExportPage() { savedRange, savedMedia, savedVoiceAsText, - savedExcelCompactColumns + savedExcelCompactColumns, + savedTxtColumns ] = await Promise.all([ configService.getExportDefaultFormat(), configService.getExportDefaultDateRange(), configService.getExportDefaultMedia(), configService.getExportDefaultVoiceAsText(), - configService.getExportDefaultExcelCompactColumns() + configService.getExportDefaultExcelCompactColumns(), + configService.getExportDefaultTxtColumns() ]) const preset = savedRange || 'today' const rangeDefaults = buildDateRangeFromPreset(preset) + const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns setOptions((prev) => ({ ...prev, @@ -141,7 +150,8 @@ function ExportPage() { dateRange: rangeDefaults.dateRange, exportMedia: savedMedia ?? false, exportVoiceAsText: savedVoiceAsText ?? true, - excelCompactColumns: savedExcelCompactColumns ?? true + excelCompactColumns: savedExcelCompactColumns ?? true, + txtColumns })) } catch (e) { console.error('加载导出默认设置失败:', e) @@ -154,6 +164,19 @@ function ExportPage() { loadExportDefaults() }, [loadSessions, loadExportPath, loadExportDefaults]) + useEffect(() => { + const removeListener = window.electronAPI.export.onProgress?.((payload) => { + setExportProgress({ + current: payload.current, + total: payload.total, + currentName: payload.currentSession + }) + }) + return () => { + removeListener?.() + } + }, []) + useEffect(() => { if (!searchKeyword.trim()) { setFilteredSessions(sessions) @@ -199,7 +222,7 @@ function ExportPage() { } } - const startExport = async () => { + const runExport = async (sessionLayout: SessionLayout) => { if (selectedSessions.size === 0 || !exportFolder) return setIsExporting(true) @@ -215,16 +238,18 @@ 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, + txtColumns: options.txtColumns, + 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 } - if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel') { + if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt') { const result = await window.electronAPI.export.exportSessions( sessionList, exportFolder, @@ -232,16 +257,28 @@ 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) } } + const startExport = () => { + if (selectedSessions.size === 0 || !exportFolder) return + + if (options.exportMedia && selectedSessions.size > 1) { + setShowMediaLayoutPrompt(true) + return + } + + const layout: SessionLayout = options.exportMedia ? 'per-session' : 'shared' + runExport(layout) + } + const getDaysInMonth = (date: Date) => { const year = date.getFullYear() const month = date.getMonth() @@ -600,6 +637,43 @@ function ExportPage() {
+ {/* 媒体导出布局选择弹窗 */} + {showMediaLayoutPrompt && ( +
setShowMediaLayoutPrompt(false)}> +
e.stopPropagation()}> +

导出文件夹布局

+

检测到同时导出多个会话并包含媒体文件,请选择存放方式:

+
+ + +
+
+ +
+
+
+ )} + {/* 导出进度弹窗 */} {isExporting && (
diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index c6bb34b..e5a4bed 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -221,6 +221,100 @@ } } + .select-field { + position: relative; + margin-bottom: 10px; + } + + .select-trigger { + width: 100%; + padding: 10px 16px; + border: 1px solid var(--border-color); + border-radius: 9999px; + font-size: 14px; + background: var(--bg-primary); + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--text-tertiary); + } + + &.open { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); + } + } + + .select-value { + flex: 1; + min-width: 0; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .select-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary)); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 6px; + box-shadow: var(--shadow-md); + z-index: 20; + max-height: 320px; + overflow-y: auto; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + } + + .select-option { + width: 100%; + text-align: left; + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border: none; + border-radius: 10px; + background: transparent; + cursor: pointer; + transition: all 0.15s; + color: var(--text-primary); + font-size: 14px; + + &:hover { + background: var(--bg-tertiary); + } + + &.active { + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + } + } + + .option-label { + font-weight: 500; + } + + .option-desc { + font-size: 12px; + color: var(--text-tertiary); + } + + .select-option.active .option-desc { + color: var(--primary); + } + .input-with-toggle { position: relative; display: flex; @@ -1096,13 +1190,15 @@ left: 0; right: 0; margin-top: 4px; - background: var(--bg-secondary); + background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary)); border: 1px solid var(--border-primary); border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 100; max-height: 200px; overflow-y: auto; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); } .wxid-option { @@ -1216,4 +1312,4 @@ border-top: 1px solid var(--border-primary); display: flex; justify-content: flex-end; -} \ No newline at end of file +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 3f4e930..93c7484 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef } from 'react' import { useAppStore } from '../stores/appStore' import { useThemeStore, themes } from '../stores/themeStore' import { useAnalyticsStore } from '../stores/analyticsStore' @@ -41,6 +41,12 @@ function SettingsPage() { const [wxidOptions, setWxidOptions] = useState([]) const [showWxidSelect, setShowWxidSelect] = useState(false) const wxidDropdownRef = useRef(null) + const [showExportFormatSelect, setShowExportFormatSelect] = useState(false) + const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false) + const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) + const exportFormatDropdownRef = useRef(null) + const exportDateRangeDropdownRef = useRef(null) + const exportExcelColumnsDropdownRef = useRef(null) const [cachePath, setCachePath] = useState('') const [logEnabled, setLogEnabled] = useState(false) const [whisperModelName, setWhisperModelName] = useState('base') @@ -55,6 +61,7 @@ function SettingsPage() { const [exportDefaultMedia, setExportDefaultMedia] = useState(false) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) + const [exportDefaultTxtColumns, setExportDefaultTxtColumns] = useState(['index', 'time', 'senderRole', 'messageType', 'content']) const [isLoading, setIsLoadingState] = useState(false) const [isTesting, setIsTesting] = useState(false) @@ -85,13 +92,23 @@ function SettingsPage() { // 点击外部关闭下拉框 useEffect(() => { const handleClickOutside = (e: MouseEvent) => { - if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(e.target as Node)) { + const target = e.target as Node + if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(target)) { setShowWxidSelect(false) } + if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) { + setShowExportFormatSelect(false) + } + if (showExportDateRangeSelect && exportDateRangeDropdownRef.current && !exportDateRangeDropdownRef.current.contains(target)) { + setShowExportDateRangeSelect(false) + } + if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) { + setShowExportExcelColumnsSelect(false) + } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showWxidSelect]) + }, [showWxidSelect, showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect]) useEffect(() => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { @@ -125,6 +142,8 @@ function SettingsPage() { const savedExportDefaultMedia = await configService.getExportDefaultMedia() const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText() const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns() + const savedExportDefaultTxtColumns = await configService.getExportDefaultTxtColumns() + const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] if (savedKey) setDecryptKey(savedKey) if (savedPath) setDbPath(savedPath) @@ -142,6 +161,11 @@ function SettingsPage() { setExportDefaultMedia(savedExportDefaultMedia ?? false) setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true) setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true) + setExportDefaultTxtColumns( + savedExportDefaultTxtColumns && savedExportDefaultTxtColumns.length > 0 + ? savedExportDefaultTxtColumns + : defaultTxtColumns + ) // 如果语言列表为空,保存默认值 if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) { @@ -150,6 +174,10 @@ function SettingsPage() { await configService.setTranscribeLanguages(defaultLanguages) } + if (!savedExportDefaultTxtColumns || savedExportDefaultTxtColumns.length === 0) { + await configService.setExportDefaultTxtColumns(defaultTxtColumns) + } + if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir) } catch (e) { console.error('加载配置失败:', e) @@ -484,15 +512,8 @@ function SettingsPage() { await configService.setTranscribeLanguages(transcribeLanguages) await configService.setOnboardingDone(true) - showMessage('配置保存成功,正在测试连接...', true) - const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid) - - if (result.success) { - setDbConnected(true, dbPath) - showMessage('配置保存成功!数据库连接正常', true) - } else { - showMessage(result.error || '数据库连接失败,请检查配置', false) - } + // 保存按钮只负责持久化配置,不做连接测试/重连,避免影响聊天页的活动连接 + showMessage('配置保存成功', true) } catch (e) { showMessage(`保存配置失败: ${e}`, false) } finally { @@ -870,48 +891,124 @@ function SettingsPage() {
) - const renderExportTab = () => ( + const exportFormatOptions = [ + { value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' }, + { value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' }, + { value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' }, + { value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' }, + { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' }, + { value: 'txt', label: 'TXT', desc: '纯文本,通用格式' }, + { value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' } + ] + const exportDateRangeOptions = [ + { value: 'today', label: '今天' }, + { value: '7d', label: '最近7天' }, + { value: '30d', label: '最近30天' }, + { value: '90d', label: '最近90天' }, + { value: 'all', label: '全部时间' } + ] + const exportExcelColumnOptions = [ + { value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' }, + { value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' } + ] + const exportTxtColumnOptions = [ + { value: 'index', label: '序号' }, + { value: 'time', label: '时间' }, + { value: 'senderRole', label: '发送者身份' }, + { value: 'messageType', label: '消息类型' }, + { value: 'content', label: '内容' }, + { value: 'senderNickname', label: '发送者昵称' }, + { value: 'senderWxid', label: '发送者微信ID' }, + { value: 'senderRemark', label: '发送者备注' } + ] + + const getOptionLabel = (options: { value: string; label: string }[], value: string) => { + return options.find((option) => option.value === value)?.label ?? value + } + + const renderExportTab = () => { + const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full' + const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat) + const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange) + const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue) + + return (
导出页面默认选中的格式 - +
+ + {showExportFormatSelect && ( +
+ {exportFormatOptions.map((option) => ( + + ))} +
+ )} +
控制导出页面的默认时间选择 - +
+ + {showExportDateRangeSelect && ( +
+ {exportDateRangeOptions.map((option) => ( + + ))} +
+ )} +
@@ -963,21 +1060,80 @@ function SettingsPage() {
控制 Excel 导出的列字段 - +
+ + {showExportExcelColumnsSelect && ( +
+ {exportExcelColumnOptions.map((option) => ( + + ))} +
+ )} +
+
+ +
+ + 默认与 Excel 精简列一致,可多选调整输出字段 +
+ {exportTxtColumnOptions.map((column) => { + const checked = exportDefaultTxtColumns.includes(column.value) + return ( + + ) + })} +
- ) + ) + } const renderCacheTab = () => (

管理应用缓存数据

@@ -1126,4 +1282,3 @@ function SettingsPage() { } export default SettingsPage - 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/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index fb2878c..3f5ac52 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -441,7 +441,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { 浏览选择
-
建议选择包含 xwechat_files 的目录
+
请选择微信-设置-存储位置对应的目录
⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录
)} @@ -507,7 +507,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { {dbKeyStatus &&
{dbKeyStatus}
}
获取密钥会自动识别最近登录的账号
-
点击自动获取后微信将重新启动,当页面提示可以登录微信了再点击登录
+
点击自动获取后微信将重新启动,当页面提示hook安装成功,现在登录微信后再点击登录
)} @@ -533,7 +533,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'} {imageKeyStatus &&
{imageKeyStatus}
} -
如获取失败,请先打开朋友圈图片再重试
+
请在电脑微信中打开查看几个图片后再点击获取秘钥,如获取失败请重复以上操作
{isFetchingImageKey &&
正在扫描内存,请稍候...
} )} diff --git a/src/services/config.ts b/src/services/config.ts index 9bc8a5e..f8361dd 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -27,7 +27,8 @@ export const CONFIG_KEYS = { EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange', EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia', EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText', - EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns' + EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns', + EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns' } as const // 获取解密密钥 @@ -306,3 +307,14 @@ export async function getExportDefaultExcelCompactColumns(): Promise { await config.set(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS, enabled) } + +// 获取导出默认 TXT 列配置 +export async function getExportDefaultTxtColumns(): Promise { + const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS) + return Array.isArray(value) ? (value as string[]) : null +} + +// 设置导出默认 TXT 列配置 +export async function setExportDefaultTxtColumns(columns: string[]): Promise { + await config.set(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS, columns) +} 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 bacefb3..d489692 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; @@ -314,12 +314,31 @@ export interface ElectronAPI { success: boolean error?: string }> + onProgress: (callback: (payload: ExportProgress) => void) => () => void } whisper: { downloadModel: () => Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }> 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 { @@ -332,6 +351,15 @@ export interface ExportOptions { exportEmojis?: boolean exportVoiceAsText?: boolean excelCompactColumns?: boolean + txtColumns?: string[] + sessionLayout?: 'shared' | 'per-session' +} + +export interface ExportProgress { + current: number + total: number + currentSession: string + phase: 'preparing' | 'exporting' | 'writing' | 'complete' } export interface WxidInfo {