diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1fbead4..91df761 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,8 @@ jobs: steps: - name: Check out git repository uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Install Node.js uses: actions/setup-node@v4 @@ -30,7 +32,37 @@ jobs: 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):.*", "flags": "i" } + } + ], + "ignore_labels": [], + "commitMode": true, + "empty_summary": "## 更新详情\n- 常规代码优化与维护" + } + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Package and Publish env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npx electron-builder --publish always \ No newline at end of file + run: | + npx electron-builder --publish always "-c.releaseInfo.releaseNotesFile=release-notes.md" \ No newline at end of file diff --git a/README.md b/README.md index 6bb1b1c..5fe50e3 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析 Issues - -License + +Telegram

@@ -92,7 +92,7 @@ WeFlow/ ## 致谢 -- [miyu](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架 +- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架 ## Star History diff --git a/electron/main.ts b/electron/main.ts index 45b9071..4c492a5 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -390,6 +390,10 @@ function registerIpcHandlers() { return chatService.getSessions() }) + ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => { + return chatService.enrichSessionsContactInfo(usernames) + }) + ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => { return chatService.getMessages(sessionId, offset, limit) }) diff --git a/electron/preload.ts b/electron/preload.ts index 22f20af..464c069 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -91,6 +91,8 @@ contextBridge.exposeInMainWorld('electronAPI', { chat: { connect: () => ipcRenderer.invoke('chat:connect'), 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), getLatestMessages: (sessionId: string, limit?: number) => diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index d260891..e711121 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -166,7 +166,7 @@ class ChatService { } /** - * 获取会话列表 + * 获取会话列表(优化:先返回基础数据,不等待联系人信息加载) */ async getSessions(): Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> { try { @@ -189,8 +189,10 @@ class ChatService { return { success: false, error: `会话表异常: ${detail}${tableInfo}${tables}${columns}` } } - // 转换为 ChatSession + // 转换为 ChatSession(先加载缓存,但不等待数据库查询) const sessions: ChatSession[] = [] + const now = Date.now() + for (const row of rows) { const username = row.username || @@ -225,6 +227,15 @@ class ChatService { const summary = this.cleanString(row.summary || row.digest || row.last_msg || row.lastMsg || '') const lastMsgType = parseInt(row.last_msg_type || row.lastMsgType || '0', 10) + // 先尝试从缓存获取联系人信息(快速路径) + let displayName = username + let avatarUrl: string | undefined = undefined + const cached = this.avatarCache.get(username) + if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) { + displayName = cached.displayName || username + avatarUrl = cached.avatarUrl + } + sessions.push({ username, type: parseInt(row.type || '0', 10), @@ -233,13 +244,13 @@ class ChatService { sortTimestamp: sortTs, lastTimestamp: lastTs, lastMsgType, - displayName: username + displayName, + avatarUrl }) } - // 获取联系人信息 - await this.enrichSessionsWithContacts(sessions) - + // 不等待联系人信息加载,直接返回基础会话列表 + // 前端可以异步调用 enrichSessionsWithContacts 来补充信息 return { success: true, sessions } } catch (e) { console.error('ChatService: 获取会话列表失败:', e) @@ -248,45 +259,85 @@ class ChatService { } /** - * 补充联系人信息 + * 异步补充会话列表的联系人信息(公开方法,供前端调用) + */ + async enrichSessionsContactInfo(usernames: string[]): Promise<{ + success: boolean + contacts?: Record + error?: string + }> { + try { + if (usernames.length === 0) { + return { success: true, contacts: {} } + } + + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error } + } + + const now = Date.now() + const missing: string[] = [] + const result: Record = {} + + // 检查缓存 + for (const username of usernames) { + const cached = this.avatarCache.get(username) + if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) { + result[username] = { + displayName: cached.displayName, + avatarUrl: cached.avatarUrl + } + } else { + missing.push(username) + } + } + + // 批量查询缺失的联系人信息 + if (missing.length > 0) { + const [displayNames, avatarUrls] = await Promise.all([ + wcdbService.getDisplayNames(missing), + wcdbService.getAvatarUrls(missing) + ]) + + 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 + + result[username] = { displayName, avatarUrl } + + // 更新缓存 + this.avatarCache.set(username, { + displayName: displayName || username, + avatarUrl, + updatedAt: now + }) + } + } + + return { success: true, contacts: result } + } catch (e) { + console.error('ChatService: 补充联系人信息失败:', e) + return { success: false, error: String(e) } + } + } + + /** + * 补充联系人信息(私有方法,保持向后兼容) */ private async enrichSessionsWithContacts(sessions: ChatSession[]): Promise { if (sessions.length === 0) return try { - const now = Date.now() - const missing: string[] = [] - - for (const session of sessions) { - const cached = this.avatarCache.get(session.username) - if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) { - if (cached.displayName) session.displayName = cached.displayName - if (cached.avatarUrl) { - session.avatarUrl = cached.avatarUrl - continue + const usernames = sessions.map(s => s.username) + const result = await this.enrichSessionsContactInfo(usernames) + if (result.success && result.contacts) { + for (const session of sessions) { + const contact = result.contacts![session.username] + if (contact) { + if (contact.displayName) session.displayName = contact.displayName + if (contact.avatarUrl) session.avatarUrl = contact.avatarUrl } } - missing.push(session.username) - } - - if (missing.length === 0) return - const missingSet = new Set(missing) - - const [displayNames, avatarUrls] = await Promise.all([ - wcdbService.getDisplayNames(missing), - wcdbService.getAvatarUrls(missing) - ]) - - for (const session of sessions) { - if (!missingSet.has(session.username)) continue - const displayName = displayNames.success && displayNames.map ? displayNames.map[session.username] : undefined - const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[session.username] : undefined - if (displayName) session.displayName = displayName - if (avatarUrl) session.avatarUrl = avatarUrl - this.avatarCache.set(session.username, { - displayName: session.displayName, - avatarUrl: session.avatarUrl, - updatedAt: now - }) } } catch (e) { console.error('ChatService: 获取联系人信息失败:', e) @@ -721,7 +772,7 @@ class ChatService { case 49: return this.parseType49(content) case 50: - return '[通话]' + return this.parseVoipMessage(content) case 10000: return this.cleanSystemMessage(content) case 244813135921: @@ -847,6 +898,67 @@ class ChatService { } } + /** + * 解析通话消息 + * 格式: 0/1... + * room_type: 0 = 语音通话, 1 = 视频通话 + * msg 状态: 通话时长 XX:XX, 对方无应答, 已取消, 已在其它设备接听, 对方已拒绝 等 + */ + private parseVoipMessage(content: string): string { + try { + if (!content) return '[通话]' + + // 提取 msg 内容(中文通话状态) + const msgMatch = /<\/msg>/i.exec(content) + const msg = msgMatch?.[1]?.trim() || '' + + // 提取 room_type(0=视频,1=语音) + const roomTypeMatch = /(\d+)<\/room_type>/i.exec(content) + const roomType = roomTypeMatch ? parseInt(roomTypeMatch[1], 10) : -1 + + // 构建通话类型标签 + let callType: string + if (roomType === 0) { + callType = '视频通话' + } else if (roomType === 1) { + callType = '语音通话' + } else { + callType = '通话' + } + + // 解析通话状态 + if (msg.includes('通话时长')) { + // 已接听的通话,提取时长 + const durationMatch = /通话时长\s*(\d{1,2}:\d{2}(?::\d{2})?)/i.exec(msg) + const duration = durationMatch?.[1] || '' + if (duration) { + return `[${callType}] ${duration}` + } + return `[${callType}] 已接听` + } else if (msg.includes('对方无应答')) { + return `[${callType}] 对方无应答` + } else if (msg.includes('已取消')) { + return `[${callType}] 已取消` + } else if (msg.includes('已在其它设备接听') || msg.includes('已在其他设备接听')) { + return `[${callType}] 已在其他设备接听` + } else if (msg.includes('对方已拒绝') || msg.includes('已拒绝')) { + return `[${callType}] 对方已拒绝` + } else if (msg.includes('忙线未接听') || msg.includes('忙线')) { + return `[${callType}] 忙线未接听` + } else if (msg.includes('未接听')) { + return `[${callType}] 未接听` + } else if (msg) { + // 其他状态直接使用 msg 内容 + return `[${callType}] ${msg}` + } + + return `[${callType}]` + } catch (e) { + console.error('[ChatService] Failed to parse VOIP message:', e) + return '[通话]' + } + } + private parseImageDatNameFromRow(row: Record): string | undefined { const packed = this.getRowField(row, [ 'packed_info_data', @@ -980,6 +1092,118 @@ class ChatService { } } + //手动查找 media_*.db 文件(当 WCDB DLL 不支持 listMediaDbs 时的 fallback) + private async findMediaDbsManually(): Promise { + try { + const dbPath = this.configService.get('dbPath') + const myWxid = this.configService.get('myWxid') + if (!dbPath || !myWxid) return [] + + // 可能的目录结构: + // 1. dbPath 直接指向 db_storage: D:\weixin\WeChat Files\wxid_xxx\db_storage + // 2. dbPath 指向账号目录: D:\weixin\WeChat Files\wxid_xxx + // 3. dbPath 指向 WeChat Files: D:\weixin\WeChat Files + // 4. dbPath 指向微信根目录: D:\weixin + // 5. dbPath 指向非标准目录: D:\weixin\xwechat_files + + const searchDirs: string[] = [] + + // 尝试1: dbPath 本身就是 db_storage + if (basename(dbPath).toLowerCase() === 'db_storage') { + searchDirs.push(dbPath) + } + + // 尝试2: dbPath/db_storage + const dbStorage1 = join(dbPath, 'db_storage') + if (existsSync(dbStorage1)) { + searchDirs.push(dbStorage1) + } + + // 尝试3: dbPath/WeChat Files/[wxid]/db_storage + const wechatFiles = join(dbPath, 'WeChat Files') + if (existsSync(wechatFiles)) { + const wxidDir = join(wechatFiles, myWxid) + if (existsSync(wxidDir)) { + const dbStorage2 = join(wxidDir, 'db_storage') + if (existsSync(dbStorage2)) { + searchDirs.push(dbStorage2) + } + } + } + + // 尝试4: 如果 dbPath 已经包含 WeChat Files,直接在其中查找 + if (dbPath.includes('WeChat Files')) { + const parts = dbPath.split(path.sep) + const wechatFilesIndex = parts.findIndex(p => p === 'WeChat Files') + if (wechatFilesIndex >= 0) { + const wechatFilesPath = parts.slice(0, wechatFilesIndex + 1).join(path.sep) + const wxidDir = join(wechatFilesPath, myWxid) + if (existsSync(wxidDir)) { + const dbStorage3 = join(wxidDir, 'db_storage') + if (existsSync(dbStorage3) && !searchDirs.includes(dbStorage3)) { + searchDirs.push(dbStorage3) + } + } + } + } + + // 尝试5: 直接尝试 dbPath/[wxid]/db_storage (适用于 xwechat_files 等非标准目录名) + const wxidDirDirect = join(dbPath, myWxid) + if (existsSync(wxidDirDirect)) { + const dbStorage5 = join(wxidDirDirect, 'db_storage') + if (existsSync(dbStorage5) && !searchDirs.includes(dbStorage5)) { + searchDirs.push(dbStorage5) + } + } + + // 在所有可能的目录中查找 media_*.db + const mediaDbFiles: string[] = [] + for (const dir of searchDirs) { + if (!existsSync(dir)) continue + + // 直接在当前目录查找 + const entries = readdirSync(dir) + for (const entry of entries) { + if (entry.toLowerCase().startsWith('media_') && entry.toLowerCase().endsWith('.db')) { + const fullPath = join(dir, entry) + if (existsSync(fullPath) && statSync(fullPath).isFile()) { + if (!mediaDbFiles.includes(fullPath)) { + mediaDbFiles.push(fullPath) + } + } + } + } + + // 也检查子目录(特别是 message 子目录) + for (const entry of entries) { + const subDir = join(dir, entry) + if (existsSync(subDir) && statSync(subDir).isDirectory()) { + try { + const subEntries = readdirSync(subDir) + for (const subEntry of subEntries) { + if (subEntry.toLowerCase().startsWith('media_') && subEntry.toLowerCase().endsWith('.db')) { + const fullPath = join(subDir, subEntry) + if (existsSync(fullPath) && statSync(fullPath).isFile()) { + if (!mediaDbFiles.includes(fullPath)) { + mediaDbFiles.push(fullPath) + } + } + } + } + } catch (e) { + // 忽略无法访问的子目录 + } + } + } + } + + return mediaDbFiles + } catch (e) { + console.error('[ChatService] 手动查找 media 数据库失败:', e) + return [] + } + } + private getVoiceLookupCandidates(sessionId: string, msg: Message): string[] { const candidates: string[] = [] const add = (value?: string | null) => { @@ -1833,13 +2057,20 @@ class ChatService { }) // 2. 查找所有的 media_*.db - const mediaDbs = await wcdbService.listMediaDbs() - if (!mediaDbs.success || !mediaDbs.data) return { success: false, error: '获取媒体库失败' } - console.info('[ChatService][Voice] media dbs', mediaDbs.data) + let mediaDbs = await wcdbService.listMediaDbs() + // Fallback: 如果 WCDB DLL 不支持 listMediaDbs,手动查找 + if (!mediaDbs.success || !mediaDbs.data || mediaDbs.data.length === 0) { + const manualMediaDbs = await this.findMediaDbsManually() + if (manualMediaDbs.length > 0) { + mediaDbs = { success: true, data: manualMediaDbs } + } else { + return { success: false, error: '未找到媒体库文件 (media_*.db)' } + } + } // 3. 在所有媒体库中查找该消息的语音数据 let silkData: Buffer | null = null - for (const dbPath of mediaDbs.data) { + for (const dbPath of (mediaDbs.data || [])) { const voiceTable = await this.resolveVoiceInfoTableName(dbPath) if (!voiceTable) { console.warn('[ChatService][Voice] voice table not found', dbPath) @@ -2165,7 +2396,7 @@ class ChatService { .prepare(`SELECT dir_name FROM ${state.dirTable} WHERE dir_id = ? AND username = ? LIMIT 1`) .get(dir2, sessionId) as { dir_name?: string } | undefined if (dirRow?.dir_name) dirName = dirRow.dir_name as string - } catch {} + } catch { } } const fullPath = join(accountDir, dir1, dirName, fileName) @@ -2173,7 +2404,7 @@ class ChatService { const withDat = `${fullPath}.dat` if (existsSync(withDat)) return withDat - } catch {} + } catch { } return null } diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 8a2613c..de87244 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -1,5 +1,8 @@ import * as fs from 'fs' import * as path from 'path' +import * as http from 'http' +import * as https from 'https' +import { fileURLToPath } from 'url' import { ConfigService } from './config' import { wcdbService } from './wcdbService' @@ -229,7 +232,7 @@ class ExportService { const title = this.extractXmlValue(content, 'title') return title || '[链接]' } - case 50: return '[通话]' + case 50: return this.parseVoipMessage(content) case 10000: return this.cleanSystemMessage(content) default: if (content.includes('57')) { @@ -261,6 +264,64 @@ class ExportService { .trim() || '[系统消息]' } + /** + * 解析通话消息 + * 格式: 0/1... + * room_type: 0 = 语音通话, 1 = 视频通话 + */ + private parseVoipMessage(content: string): string { + try { + if (!content) return '[通话]' + + // 提取 msg 内容(中文通话状态) + const msgMatch = /<\/msg>/i.exec(content) + const msg = msgMatch?.[1]?.trim() || '' + + // 提取 room_type(0=视频,1=语音) + const roomTypeMatch = /(\d+)<\/room_type>/i.exec(content) + const roomType = roomTypeMatch ? parseInt(roomTypeMatch[1], 10) : -1 + + // 构建通话类型标签 + let callType: string + if (roomType === 0) { + callType = '视频通话' + } else if (roomType === 1) { + callType = '语音通话' + } else { + callType = '通话' + } + + // 解析通话状态 + if (msg.includes('通话时长')) { + const durationMatch = /通话时长\s*(\d{1,2}:\d{2}(?::\d{2})?)/i.exec(msg) + const duration = durationMatch?.[1] || '' + if (duration) { + return `[${callType}] ${duration}` + } + return `[${callType}] 已接听` + } else if (msg.includes('对方无应答')) { + return `[${callType}] 对方无应答` + } else if (msg.includes('已取消')) { + return `[${callType}] 已取消` + } else if (msg.includes('已在其它设备接听') || msg.includes('已在其他设备接听')) { + return `[${callType}] 已在其他设备接听` + } else if (msg.includes('对方已拒绝') || msg.includes('已拒绝')) { + return `[${callType}] 对方已拒绝` + } else if (msg.includes('忙线未接听') || msg.includes('忙线')) { + return `[${callType}] 忙线未接听` + } else if (msg.includes('未接听')) { + return `[${callType}] 未接听` + } else if (msg) { + return `[${callType}] ${msg}` + } + + return `[${callType}]` + } catch (e) { + console.error('[ExportService] Failed to parse VOIP message:', e) + return '[通话]' + } + } + /** * 获取消息类型名称 */ @@ -298,9 +359,9 @@ class ExportService { sessionId: string, cleanedMyWxid: string, dateRange?: { start: number; end: number } | null - ): Promise<{ rows: any[]; memberSet: Map; firstTime: number | null; lastTime: number | null }> { + ): Promise<{ rows: any[]; memberSet: Map; firstTime: number | null; lastTime: number | null }> { const rows: any[] = [] - const memberSet = new Map() + const memberSet = new Map() let firstTime: number | null = null let lastTime: number | null = null @@ -336,8 +397,11 @@ class ExportService { const memberInfo = await this.getContactInfo(actualSender) if (!memberSet.has(actualSender)) { memberSet.set(actualSender, { - platformId: actualSender, - accountName: memberInfo.displayName + member: { + platformId: actualSender, + accountName: memberInfo.displayName + }, + avatarUrl: memberInfo.avatarUrl }) } @@ -361,6 +425,121 @@ class ExportService { return { rows, memberSet, firstTime, lastTime } } + private resolveAvatarFile(avatarUrl?: string): { data?: Buffer; sourcePath?: string; sourceUrl?: string; ext: string; mime?: string } | null { + if (!avatarUrl) return null + if (avatarUrl.startsWith('data:')) { + const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(avatarUrl) + if (!match) return null + const mime = match[1].toLowerCase() + const data = Buffer.from(match[2], 'base64') + const ext = mime.includes('png') ? '.png' + : mime.includes('gif') ? '.gif' + : mime.includes('webp') ? '.webp' + : '.jpg' + return { data, ext, mime } + } + if (avatarUrl.startsWith('file://')) { + try { + const sourcePath = fileURLToPath(avatarUrl) + const ext = path.extname(sourcePath) || '.jpg' + return { sourcePath, ext } + } catch { + return null + } + } + if (avatarUrl.startsWith('http://') || avatarUrl.startsWith('https://')) { + const url = new URL(avatarUrl) + const ext = path.extname(url.pathname) || '.jpg' + return { sourceUrl: avatarUrl, ext } + } + const sourcePath = avatarUrl + const ext = path.extname(sourcePath) || '.jpg' + return { sourcePath, ext } + } + + private async downloadToBuffer(url: string, remainingRedirects = 2): Promise<{ data: Buffer; mime?: string } | null> { + const client = url.startsWith('https:') ? https : http + return new Promise((resolve) => { + const request = client.get(url, (res) => { + const status = res.statusCode || 0 + if (status >= 300 && status < 400 && res.headers.location && remainingRedirects > 0) { + res.resume() + const redirectedUrl = new URL(res.headers.location, url).href + this.downloadToBuffer(redirectedUrl, remainingRedirects - 1) + .then(resolve) + return + } + if (status < 200 || status >= 300) { + res.resume() + resolve(null) + return + } + const chunks: Buffer[] = [] + res.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) + res.on('end', () => { + const data = Buffer.concat(chunks) + const mime = typeof res.headers['content-type'] === 'string' ? res.headers['content-type'] : undefined + resolve({ data, mime }) + }) + }) + request.on('error', () => resolve(null)) + request.setTimeout(15000, () => { + request.destroy() + resolve(null) + }) + }) + } + + private async exportAvatars( + members: Array<{ username: string; avatarUrl?: string }> + ): Promise> { + const result = new Map() + if (members.length === 0) return result + + for (const member of members) { + const fileInfo = this.resolveAvatarFile(member.avatarUrl) + if (!fileInfo) continue + try { + let data: Buffer | null = null + let mime = fileInfo.mime + if (fileInfo.data) { + data = fileInfo.data + } else if (fileInfo.sourcePath && fs.existsSync(fileInfo.sourcePath)) { + data = await fs.promises.readFile(fileInfo.sourcePath) + } else if (fileInfo.sourceUrl) { + const downloaded = await this.downloadToBuffer(fileInfo.sourceUrl) + if (downloaded) { + data = downloaded.data + mime = downloaded.mime || mime + } + } + if (!data) continue + const finalMime = mime || this.inferImageMime(fileInfo.ext) + const base64 = data.toString('base64') + result.set(member.username, `data:${finalMime};base64,${base64}`) + } catch { + continue + } + } + + return result + } + + private inferImageMime(ext: string): string { + switch (ext.toLowerCase()) { + case '.png': + return 'image/png' + case '.gif': + return 'image/gif' + case '.webp': + return 'image/webp' + case '.bmp': + return 'image/bmp' + default: + return 'image/jpeg' + } + } + /** * 导出单个会话为 ChatLab 格式 */ @@ -399,7 +578,7 @@ class ExportService { }) const chatLabMessages: ChatLabMessage[] = allMessages.map((msg) => { - const memberInfo = collected.memberSet.get(msg.senderUsername) || { + const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || { platformId: msg.senderUsername, accountName: msg.senderUsername } @@ -412,6 +591,23 @@ class ExportService { } }) + const avatarMap = options.exportAvatars + ? await this.exportAvatars( + [ + ...Array.from(collected.memberSet.entries()).map(([username, info]) => ({ + username, + avatarUrl: info.avatarUrl + })), + { username: sessionId, avatarUrl: sessionInfo.avatarUrl } + ] + ) + : new Map() + + const members = Array.from(collected.memberSet.values()).map((info) => { + const avatar = avatarMap.get(info.member.platformId) + return avatar ? { ...info.member, avatar } : info.member + }) + const chatLabExport: ChatLabExport = { chatlab: { version: '0.0.1', @@ -424,7 +620,7 @@ class ExportService { type: isGroup ? 'group' : 'private', ...(isGroup && { groupId: sessionId }) }, - members: Array.from(collected.memberSet.values()), + members, messages: chatLabMessages } @@ -538,6 +734,29 @@ class ExportService { messages: allMessages } + if (options.exportAvatars) { + const avatarMap = await this.exportAvatars( + [ + ...Array.from(collected.memberSet.entries()).map(([username, info]) => ({ + username, + avatarUrl: info.avatarUrl + })), + { username: sessionId, avatarUrl: sessionInfo.avatarUrl } + ] + ) + const avatars: Record = {} + for (const [username, relPath] of avatarMap.entries()) { + avatars[username] = relPath + } + if (Object.keys(avatars).length > 0) { + detailedExport.session = { + ...detailedExport.session, + avatar: avatars[sessionId] + } + ; (detailedExport as any).avatars = avatars + } + } + fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8') onProgress?.({ diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 971a79f..d78edbb 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -695,33 +695,41 @@ export class KeyService { } private getXorKey(templateFiles: string[]): number | null { - const counts = new Map() + const counts = new Map() + const tailSignatures = [ + Buffer.from([0xFF, 0xD9]), + Buffer.from([0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82]) + ] for (const file of templateFiles) { try { const bytes = readFileSync(file) - if (bytes.length < 2) continue - const x = bytes[bytes.length - 2] - const y = bytes[bytes.length - 1] - const key = `${x}_${y}` - counts.set(key, (counts.get(key) ?? 0) + 1) + for (const signature of tailSignatures) { + if (bytes.length < signature.length) continue + const tail = bytes.subarray(bytes.length - signature.length) + const xorKey = tail[0] ^ signature[0] + let valid = true + for (let i = 1; i < signature.length; i++) { + if ((tail[i] ^ xorKey) !== signature[i]) { + valid = false + break + } + } + if (valid) { + counts.set(xorKey, (counts.get(xorKey) ?? 0) + 1) + } + } } catch { } } if (!counts.size) return null - let mostKey = '' - let mostCount = 0 + let bestKey: number | null = null + let bestCount = 0 for (const [key, count] of counts) { - if (count > mostCount) { - mostCount = count - mostKey = key + if (count > bestCount) { + bestCount = count + bestKey = key } } - if (!mostKey) return null - const [xStr, yStr] = mostKey.split('_') - const x = Number(xStr) - const y = Number(yStr) - const xorKey = x ^ 0xFF - const check = y ^ 0xD9 - return xorKey === check ? xorKey : null + return bestKey } private getCiphertextFromTemplate(templateFiles: string[]): Buffer | null { @@ -766,7 +774,17 @@ export class KeyService { const decipher = crypto.createDecipheriv('aes-128-ecb', key, null) decipher.setAutoPadding(false) const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - return decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff + const isJpeg = decrypted.length >= 3 && decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff + const isPng = decrypted.length >= 8 && + decrypted[0] === 0x89 && + decrypted[1] === 0x50 && + decrypted[2] === 0x4e && + decrypted[3] === 0x47 && + decrypted[4] === 0x0d && + decrypted[5] === 0x0a && + decrypted[6] === 0x1a && + decrypted[7] === 0x0a + return isJpeg || isPng } catch { return false } diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 2e2a95b..7e9a5d5 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -49,6 +49,8 @@ export class WcdbService { private wcdbGetEmoticonCdnUrl: any = null private avatarUrlCache: Map = new Map() private readonly avatarCacheTtlMs = 10 * 60 * 1000 + private logTimer: NodeJS.Timeout | null = null + private lastLogTail: string | null = null setPaths(resourcesPath: string, userDataPath: string): void { this.resourcesPath = resourcesPath @@ -57,6 +59,11 @@ export class WcdbService { setLogEnabled(enabled: boolean): void { this.logEnabled = enabled + if (this.isLogEnabled() && this.initialized) { + this.startLogPolling() + } else { + this.stopLogPolling() + } } /** @@ -433,6 +440,49 @@ export class WcdbService { } } + private startLogPolling(): void { + if (this.logTimer || !this.isLogEnabled()) return + this.logTimer = setInterval(() => { + void this.pollLogs() + }, 2000) + } + + private stopLogPolling(): void { + if (this.logTimer) { + clearInterval(this.logTimer) + this.logTimer = null + } + this.lastLogTail = null + } + + private async pollLogs(): Promise { + try { + if (!this.wcdbGetLogs || !this.isLogEnabled()) return + const outPtr = [null as any] + const result = this.wcdbGetLogs(outPtr) + if (result !== 0 || !outPtr[0]) return + let jsonStr = '' + try { + jsonStr = this.koffi.decode(outPtr[0], 'char', -1) + } finally { + try { this.wcdbFreeString(outPtr[0]) } catch { } + } + const logs = JSON.parse(jsonStr) as string[] + if (!Array.isArray(logs) || logs.length === 0) return + let startIdx = 0 + if (this.lastLogTail) { + const idx = logs.lastIndexOf(this.lastLogTail) + if (idx >= 0) startIdx = idx + 1 + } + for (let i = startIdx; i < logs.length; i += 1) { + this.writeLog(`wcdb: ${logs[i]}`) + } + this.lastLogTail = logs[logs.length - 1] + } catch (e) { + // ignore polling errors + } + } + private decodeJsonPtr(outPtr: any): string | null { if (!outPtr) return null try { @@ -545,6 +595,9 @@ export class WcdbService { console.warn('设置 wxid 失败:', e) } } + if (this.isLogEnabled()) { + this.startLogPolling() + } this.writeLog(`open ok handle=${handle}`) return true } catch (e) { @@ -571,6 +624,7 @@ export class WcdbService { this.currentKey = null this.currentWxid = null this.initialized = false + this.stopLogPolling() } } @@ -594,8 +648,15 @@ export class WcdbService { return { success: false, error: 'WCDB 未连接' } } try { + // 使用 setImmediate 让事件循环有机会处理其他任务,避免长时间阻塞 + await new Promise(resolve => setImmediate(resolve)) + const outPtr = [null as any] const result = this.wcdbGetSessions(this.handle, outPtr) + + // DLL 调用后再次让出控制权 + await new Promise(resolve => setImmediate(resolve)) + if (result !== 0 || !outPtr[0]) { this.writeLog(`getSessions failed: code=${result}`) return { success: false, error: `获取会话失败: ${result}` } @@ -652,8 +713,15 @@ export class WcdbService { } if (usernames.length === 0) return { success: true, map: {} } try { + // 让出控制权,避免阻塞事件循环 + await new Promise(resolve => setImmediate(resolve)) + const outPtr = [null as any] const result = this.wcdbGetDisplayNames(this.handle, JSON.stringify(usernames), outPtr) + + // DLL 调用后再次让出控制权 + await new Promise(resolve => setImmediate(resolve)) + if (result !== 0 || !outPtr[0]) { return { success: false, error: `获取昵称失败: ${result}` } } @@ -692,8 +760,15 @@ export class WcdbService { return { success: true, map: resultMap } } + // 让出控制权,避免阻塞事件循环 + await new Promise(resolve => setImmediate(resolve)) + const outPtr = [null as any] const result = this.wcdbGetAvatarUrls(this.handle, JSON.stringify(toFetch), outPtr) + + // DLL 调用后再次让出控制权 + await new Promise(resolve => setImmediate(resolve)) + if (result !== 0 || !outPtr[0]) { if (Object.keys(resultMap).length > 0) { return { success: true, map: resultMap, error: `获取头像失败: ${result}` } diff --git a/package.json b/package.json index 16fa836..dff0f8e 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "weflow", - "version": "1.0.0", + "version": "1.0.4", "description": "WeFlow - 微信聊天记录查看工具", "main": "dist-electron/main.js", + "author": "cc", "scripts": { "dev": "vite", "build": "tsc && vite build && electron-builder", @@ -46,6 +47,10 @@ }, "build": { "appId": "com.WeFlow.app", + "publish": { + "provider": "github", + "releaseType": "release" + }, "productName": "WeFlow", "artifactName": "${productName}-${version}-Setup.${ext}", "directories": { @@ -59,6 +64,7 @@ }, "nsis": { "oneClick": false, + "differentialPackage": false, "allowToChangeInstallationDirectory": true, "createDesktopShortcut": true, "unicode": true, @@ -92,4 +98,4 @@ "dist-electron/**/*" ] } -} +} \ No newline at end of file diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 324246b..70e4eb7 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/pages/AnnualReportWindow.tsx b/src/pages/AnnualReportWindow.tsx index e29f684..bf105f3 100644 --- a/src/pages/AnnualReportWindow.tsx +++ b/src/pages/AnnualReportWindow.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react' -import { Loader2, Download, Image, Check, X } from 'lucide-react' +import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react' import html2canvas from 'html2canvas' import { useThemeStore } from '../stores/themeStore' import './AnnualReportWindow.scss' @@ -249,6 +249,7 @@ function AnnualReportWindow() { const [fabOpen, setFabOpen] = useState(false) const [loadingProgress, setLoadingProgress] = useState(0) const [loadingStage, setLoadingStage] = useState('正在初始化...') + const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate') const { currentTheme, themeMode } = useThemeStore() @@ -490,7 +491,7 @@ function AnnualReportWindow() { } // 导出整个报告为长图 - const exportFullReport = async () => { + const exportFullReport = async (filterIds?: Set) => { if (!containerRef.current) { return } @@ -516,6 +517,16 @@ function AnnualReportWindow() { el.style.padding = '40px 0' }) + // 如果有筛选,隐藏未选中的板块 + if (filterIds) { + const available = getAvailableSections() + available.forEach(s => { + if (!filterIds.has(s.id) && s.ref.current) { + s.ref.current.style.display = 'none' + } + }) + } + // 修复词云导出问题 const wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement const wordTags = container.querySelectorAll('.word-tag') as NodeListOf @@ -584,7 +595,7 @@ function AnnualReportWindow() { const dataUrl = outputCanvas.toDataURL('image/png') const link = document.createElement('a') - link.download = `${reportData?.year}年度报告.png` + link.download = `${reportData?.year}年度报告${filterIds ? '_自定义' : ''}.png` link.href = dataUrl document.body.appendChild(link) link.click() @@ -607,6 +618,13 @@ function AnnualReportWindow() { return } + if (exportMode === 'long') { + setShowExportModal(false) + await exportFullReport(selectedSections) + setSelectedSections(new Set()) + return + } + setIsExporting(true) setShowExportModal(false) @@ -735,9 +753,12 @@ function AnnualReportWindow() { {/* 浮动操作按钮 */}
- + @@ -765,7 +786,7 @@ function AnnualReportWindow() {
setShowExportModal(false)}>
e.stopPropagation()}>
-

选择要导出的板块

+

{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}

@@ -793,7 +814,7 @@ function AnnualReportWindow() { onClick={exportSelectedSections} disabled={selectedSections.size === 0} > - 导出 {selectedSections.size > 0 ? `(${selectedSections.size})` : ''} + {exportMode === 'long' ? '生成长图' : '导出'} {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
@@ -838,7 +859,7 @@ function AnnualReportWindow() { 你发出 {formatNumber(topFriend.sentCount)} 条 · TA发来 {formatNumber(topFriend.receivedCount)}

-
+

在一起,就可以

diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 9d36dbf..e53857c 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -883,6 +883,23 @@ min-height: 0; overflow: hidden; -webkit-app-region: no-drag; + position: relative; + + &.loading .message-list { + opacity: 0; + transform: translateY(8px); + pointer-events: none; + } + + &.loaded .message-list { + opacity: 1; + transform: translateY(0); + } + + &.loaded .loading-overlay { + opacity: 0; + pointer-events: none; + } } .message-list { @@ -898,6 +915,7 @@ background-color: var(--bg-tertiary); position: relative; -webkit-app-region: no-drag !important; + transition: opacity 240ms ease, transform 240ms ease; // 滚动条样式 &::-webkit-scrollbar { @@ -918,6 +936,19 @@ } } +.loading-messages.loading-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + background: rgba(10, 10, 10, 0.28); + backdrop-filter: blur(6px); + transition: opacity 200ms ease; + z-index: 2; +} + .message-list * { -webkit-app-region: no-drag !important; } @@ -1108,6 +1139,7 @@ font-size: 14px; line-height: 1.6; word-break: break-word; + white-space: pre-wrap; } // 表情包消息 @@ -1432,6 +1464,7 @@ .quoted-text { color: var(--text-secondary); + white-space: pre-wrap; } } diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 0004cd5..000e346 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback, useMemo } from 'react' +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon } from 'lucide-react' import { useChatStore } from '../stores/chatStore' import type { ChatSession, Message } from '../types/models' @@ -23,11 +23,128 @@ interface SessionDetail { messageTables: { dbName: string; tableName: string; count: number }[] } -// 头像组件 - 支持骨架屏加载 -function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: number }) { +// 全局头像加载队列管理器(限制并发,避免卡顿) +class AvatarLoadQueue { + private queue: Array<{ url: string; resolve: () => void; reject: () => void }> = [] + private loading = new Set() + private readonly maxConcurrent = 1 // 一次只加载1个头像,避免卡顿 + private readonly delayBetweenBatches = 100 // 批次间延迟100ms,给UI喘息时间 + + async enqueue(url: string): Promise { + // 如果已经在加载中,直接返回 + if (this.loading.has(url)) { + return Promise.resolve() + } + + return new Promise((resolve, reject) => { + this.queue.push({ url, resolve, reject }) + this.processQueue() + }) + } + + private async processQueue() { + // 如果已达到最大并发数,等待 + if (this.loading.size >= this.maxConcurrent) { + return + } + + // 如果队列为空,返回 + if (this.queue.length === 0) { + return + } + + // 取出一个任务 + const task = this.queue.shift() + if (!task) return + + this.loading.add(task.url) + + // 加载图片 + const img = new Image() + img.onload = () => { + this.loading.delete(task.url) + task.resolve() + // 延迟一下再处理下一个,避免一次性加载太多 + setTimeout(() => this.processQueue(), this.delayBetweenBatches) + } + img.onerror = () => { + this.loading.delete(task.url) + task.reject() + setTimeout(() => this.processQueue(), this.delayBetweenBatches) + } + img.src = task.url + } + + clear() { + this.queue = [] + this.loading.clear() + } +} + +const avatarLoadQueue = new AvatarLoadQueue() + +// 头像组件 - 支持骨架屏加载和懒加载(优化:限制并发,使用 memo 避免不必要的重渲染) +// 会话项组件(使用 memo 优化,避免不必要的重渲染) +const SessionItem = React.memo(function SessionItem({ + session, + isActive, + onSelect, + formatTime +}: { + session: ChatSession + isActive: boolean + onSelect: (session: ChatSession) => void + formatTime: (timestamp: number) => string +}) { + // 缓存格式化的时间 + const timeText = useMemo(() => + formatTime(session.lastTimestamp || session.sortTimestamp), + [formatTime, session.lastTimestamp, session.sortTimestamp] + ) + + return ( +
onSelect(session)} + > + +
+
+ {session.displayName || session.username} + {timeText} +
+
+ {session.summary || '暂无消息'} + {session.unreadCount > 0 && ( + + {session.unreadCount > 99 ? '99+' : session.unreadCount} + + )} +
+
+
+ ) +}, (prevProps, nextProps) => { + // 自定义比较:只在关键属性变化时重渲染 + return ( + prevProps.session.username === nextProps.session.username && + prevProps.session.displayName === nextProps.session.displayName && + prevProps.session.avatarUrl === nextProps.session.avatarUrl && + prevProps.session.summary === nextProps.session.summary && + prevProps.session.unreadCount === nextProps.session.unreadCount && + prevProps.session.lastTimestamp === nextProps.session.lastTimestamp && + prevProps.session.sortTimestamp === nextProps.session.sortTimestamp && + prevProps.isActive === nextProps.isActive + ) +}) + +const SessionAvatar = React.memo(function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: number }) { const [imageLoaded, setImageLoaded] = useState(false) const [imageError, setImageError] = useState(false) + const [shouldLoad, setShouldLoad] = useState(false) + const [isInQueue, setIsInQueue] = useState(false) const imgRef = useRef(null) + const containerRef = useRef(null) const isGroup = session.username.includes('@chatroom') const getAvatarLetter = (): string => { @@ -37,23 +154,63 @@ function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: nu return chars[0] || '?' } + // 使用 Intersection Observer 实现懒加载(优化性能) + useEffect(() => { + if (!containerRef.current || shouldLoad || isInQueue) return + if (!session.avatarUrl) { + // 没有头像URL,不需要加载 + return + } + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !isInQueue) { + // 加入加载队列,而不是立即加载 + setIsInQueue(true) + avatarLoadQueue.enqueue(session.avatarUrl!).then(() => { + setShouldLoad(true) + }).catch(() => { + setImageError(true) + }).finally(() => { + setIsInQueue(false) + }) + observer.disconnect() + } + }) + }, + { + rootMargin: '50px' // 减少预加载距离,只提前50px + } + ) + + observer.observe(containerRef.current) + + return () => { + observer.disconnect() + } + }, [session.avatarUrl, shouldLoad, isInQueue]) + // 当 avatarUrl 变化时重置状态 useEffect(() => { setImageLoaded(false) setImageError(false) + setShouldLoad(false) + setIsInQueue(false) }, [session.avatarUrl]) // 检查图片是否已经从缓存加载完成 useEffect(() => { - if (imgRef.current?.complete && imgRef.current?.naturalWidth > 0) { + if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) { setImageLoaded(true) } - }, [session.avatarUrl]) + }, [session.avatarUrl, shouldLoad]) - const hasValidUrl = session.avatarUrl && !imageError + const hasValidUrl = session.avatarUrl && !imageError && shouldLoad return (
@@ -67,6 +224,7 @@ function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: nu className={imageLoaded ? 'loaded' : ''} onLoad={() => setImageLoaded(true)} onError={() => setImageError(true)} + loading="lazy" /> ) : ( @@ -74,7 +232,15 @@ function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: nu )}
) -} +}, (prevProps, nextProps) => { + // 自定义比较函数,只在关键属性变化时重渲染 + return ( + prevProps.session.username === nextProps.session.username && + prevProps.session.displayName === nextProps.session.displayName && + prevProps.session.avatarUrl === nextProps.session.avatarUrl && + prevProps.size === nextProps.size + ) +}) function ChatPage(_props: ChatPageProps) { const { @@ -108,6 +274,8 @@ function ChatPage(_props: ChatPageProps) { const messageListRef = useRef(null) const searchInputRef = useRef(null) const sidebarRef = useRef(null) + const initialRevealTimerRef = useRef(null) + const sessionListRef = useRef(null) const [currentOffset, setCurrentOffset] = useState(0) const [myAvatarUrl, setMyAvatarUrl] = useState(undefined) const [showScrollToBottom, setShowScrollToBottom] = useState(false) @@ -118,6 +286,13 @@ function ChatPage(_props: ChatPageProps) { const [isLoadingDetail, setIsLoadingDetail] = useState(false) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState([]) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) + const [hasInitialMessages, setHasInitialMessages] = useState(false) + + // 联系人信息加载控制 + const isEnrichingRef = useRef(false) + const enrichCancelledRef = useRef(false) + const isScrollingRef = useRef(false) + const sessionScrollTimeoutRef = useRef(null) const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys]) @@ -126,6 +301,7 @@ function ChatPage(_props: ChatPageProps) { const sessionMapRef = useRef>(new Map()) const sessionsRef = useRef([]) const currentSessionRef = useRef(null) + const prevSessionRef = useRef(null) const isLoadingMessagesRef = useRef(false) const isLoadingMoreRef = useRef(false) const isConnectedRef = useRef(false) @@ -188,7 +364,7 @@ function ChatPage(_props: ChatPageProps) { } }, [loadMyAvatar]) - // 加载会话列表 + // 加载会话列表(优化:先返回基础数据,异步加载联系人信息) const loadSessions = async (options?: { silent?: boolean }) => { if (options?.silent) { setIsRefreshingSessions(true) @@ -198,8 +374,21 @@ function ChatPage(_props: ChatPageProps) { try { const result = await window.electronAPI.chat.getSessions() if (result.success && result.sessions) { - const nextSessions = options?.silent ? mergeSessions(result.sessions) : result.sessions - setSessions(nextSessions) + // 确保 sessions 是数组 + const sessionsArray = Array.isArray(result.sessions) ? result.sessions : [] + const nextSessions = options?.silent ? mergeSessions(sessionsArray) : sessionsArray + // 确保 nextSessions 也是数组 + if (Array.isArray(nextSessions)) { + setSessions(nextSessions) + // 延迟启动联系人信息加载,确保UI先渲染完成 + setTimeout(() => { + void enrichSessionsContactInfo(nextSessions) + }, 500) + } else { + console.error('mergeSessions returned non-array:', nextSessions) + setSessions(sessionsArray) + void enrichSessionsContactInfo(sessionsArray) + } } else if (!result.success) { setConnectionError(result.error || '获取会话失败') } @@ -215,6 +404,198 @@ function ChatPage(_props: ChatPageProps) { } } + // 分批异步加载联系人信息(优化性能:防止重复加载,滚动时暂停,只在空闲时加载) + const enrichSessionsContactInfo = async (sessions: ChatSession[]) => { + if (sessions.length === 0) return + + // 防止重复加载 + if (isEnrichingRef.current) { + console.log('[性能监控] 联系人信息正在加载中,跳过重复请求') + return + } + + isEnrichingRef.current = true + enrichCancelledRef.current = false + + console.log(`[性能监控] 开始加载联系人信息,会话数: ${sessions.length}`) + const totalStart = performance.now() + + // 延迟启动,等待UI渲染完成 + await new Promise(resolve => setTimeout(resolve, 500)) + + // 检查是否被取消 + if (enrichCancelledRef.current) { + isEnrichingRef.current = false + return + } + + try { + // 找出需要加载联系人信息的会话(没有缓存的) + const needEnrich = sessions.filter(s => !s.avatarUrl && (!s.displayName || s.displayName === s.username)) + if (needEnrich.length === 0) { + console.log('[性能监控] 所有联系人信息已缓存,跳过加载') + isEnrichingRef.current = false + return + } + + console.log(`[性能监控] 需要加载的联系人信息: ${needEnrich.length} 个`) + + // 进一步减少批次大小,每批3个,避免DLL调用阻塞 + const batchSize = 3 + let loadedCount = 0 + + for (let i = 0; i < needEnrich.length; i += batchSize) { + // 如果正在滚动,暂停加载 + if (isScrollingRef.current) { + console.log('[性能监控] 检测到滚动,暂停加载联系人信息') + // 等待滚动结束 + while (isScrollingRef.current && !enrichCancelledRef.current) { + await new Promise(resolve => setTimeout(resolve, 200)) + } + if (enrichCancelledRef.current) break + } + + // 检查是否被取消 + if (enrichCancelledRef.current) break + + const batchStart = performance.now() + const batch = needEnrich.slice(i, i + batchSize) + const usernames = batch.map(s => s.username) + + // 使用 requestIdleCallback 延迟执行,避免阻塞UI + await new Promise((resolve) => { + if ('requestIdleCallback' in window) { + window.requestIdleCallback(() => { + void loadContactInfoBatch(usernames).then(() => resolve()) + }, { timeout: 2000 }) + } else { + setTimeout(() => { + void loadContactInfoBatch(usernames).then(() => resolve()) + }, 300) + } + }) + + loadedCount += batch.length + const batchTime = performance.now() - batchStart + if (batchTime > 200) { + console.warn(`[性能监控] 批次 ${Math.floor(i / batchSize) + 1}/${Math.ceil(needEnrich.length / batchSize)} 耗时: ${batchTime.toFixed(2)}ms (已加载: ${loadedCount}/${needEnrich.length})`) + } + + // 批次间延迟,给UI更多时间(DLL调用可能阻塞,需要更长的延迟) + if (i + batchSize < needEnrich.length && !enrichCancelledRef.current) { + // 如果不在滚动,可以延迟短一点 + const delay = isScrollingRef.current ? 1000 : 800 + await new Promise(resolve => setTimeout(resolve, delay)) + } + } + + const totalTime = performance.now() - totalStart + if (!enrichCancelledRef.current) { + console.log(`[性能监控] 联系人信息加载完成,总耗时: ${totalTime.toFixed(2)}ms, 已加载: ${loadedCount}/${needEnrich.length}`) + } else { + console.log(`[性能监控] 联系人信息加载被取消,已加载: ${loadedCount}/${needEnrich.length}`) + } + } catch (e) { + console.error('加载联系人信息失败:', e) + } finally { + isEnrichingRef.current = false + } + } + + // 联系人信息更新队列(防抖批量更新,避免频繁重渲染) + const contactUpdateQueueRef = useRef>(new Map()) + const contactUpdateTimerRef = useRef(null) + const lastUpdateTimeRef = useRef(0) + + // 批量更新联系人信息(防抖,减少重渲染次数,增加延迟避免阻塞滚动) + const flushContactUpdates = useCallback(() => { + if (contactUpdateTimerRef.current) { + clearTimeout(contactUpdateTimerRef.current) + contactUpdateTimerRef.current = null + } + + // 增加防抖延迟到500ms,避免在滚动时频繁更新 + contactUpdateTimerRef.current = window.setTimeout(() => { + const updates = contactUpdateQueueRef.current + if (updates.size === 0) return + + const now = Date.now() + // 如果距离上次更新太近(小于1秒),继续延迟 + if (now - lastUpdateTimeRef.current < 1000) { + contactUpdateTimerRef.current = window.setTimeout(() => { + flushContactUpdates() + }, 1000 - (now - lastUpdateTimeRef.current)) + return + } + + const { sessions: currentSessions } = useChatStore.getState() + if (!Array.isArray(currentSessions)) return + + let hasChanges = false + const updatedSessions = currentSessions.map(session => { + const update = updates.get(session.username) + if (update) { + const newDisplayName = update.displayName || session.displayName || session.username + const newAvatarUrl = update.avatarUrl || session.avatarUrl + if (newDisplayName !== session.displayName || newAvatarUrl !== session.avatarUrl) { + hasChanges = true + return { + ...session, + displayName: newDisplayName, + avatarUrl: newAvatarUrl + } + } + } + return session + }) + + if (hasChanges) { + const updateStart = performance.now() + setSessions(updatedSessions) + lastUpdateTimeRef.current = Date.now() + const updateTime = performance.now() - updateStart + if (updateTime > 50) { + console.warn(`[性能监控] setSessions更新耗时: ${updateTime.toFixed(2)}ms, 更新了 ${updates.size} 个联系人`) + } + } + + updates.clear() + contactUpdateTimerRef.current = null + }, 500) // 500ms 防抖,减少更新频率 + }, [setSessions]) + + // 加载一批联系人信息并更新会话列表(优化:使用队列批量更新) + const loadContactInfoBatch = async (usernames: string[]) => { + const startTime = performance.now() + try { + // 在 DLL 调用前让出控制权(使用 setTimeout 0 代替 setImmediate) + await new Promise(resolve => setTimeout(resolve, 0)) + + const dllStart = performance.now() + const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) + const dllTime = performance.now() - dllStart + + // DLL 调用后再次让出控制权 + await new Promise(resolve => setTimeout(resolve, 0)) + + const totalTime = performance.now() - startTime + if (dllTime > 50 || totalTime > 100) { + console.warn(`[性能监控] DLL调用耗时: ${dllTime.toFixed(2)}ms, 总耗时: ${totalTime.toFixed(2)}ms, usernames: ${usernames.length}`) + } + + if (result.success && result.contacts) { + // 将更新加入队列,而不是立即更新 + for (const [username, contact] of Object.entries(result.contacts)) { + contactUpdateQueueRef.current.set(username, contact) + } + // 触发批量更新 + flushContactUpdates() + } + } catch (e) { + console.error('加载联系人信息批次失败:', e) + } + } + // 刷新会话列表 const handleRefresh = async () => { await loadSessions({ silent: true }) @@ -326,6 +707,10 @@ function ChatPage(_props: ChatPageProps) { // 搜索过滤 const handleSearch = (keyword: string) => { setSearchKeyword(keyword) + if (!Array.isArray(sessions)) { + setFilteredSessions([]) + return + } if (!keyword.trim()) { setFilteredSessions(sessions) return @@ -342,27 +727,37 @@ function ChatPage(_props: ChatPageProps) { // 关闭搜索框 const handleCloseSearch = () => { setSearchKeyword('') - setFilteredSessions(sessions) + setFilteredSessions(Array.isArray(sessions) ? sessions : []) } - // 滚动加载更多 + 显示/隐藏回到底部按钮 + // 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行) + const scrollTimeoutRef = useRef(null) const handleScroll = useCallback(() => { if (!messageListRef.current) return - const { scrollTop, clientHeight, scrollHeight } = messageListRef.current - - // 显示回到底部按钮:距离底部超过 300px - const distanceFromBottom = scrollHeight - scrollTop - clientHeight - setShowScrollToBottom(distanceFromBottom > 300) - - // 预加载:当滚动到顶部 30% 区域时开始加载 - if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) { - const threshold = clientHeight * 0.3 - if (scrollTop < threshold) { - loadMessages(currentSessionId, currentOffset) - } + // 节流:延迟执行,避免滚动时频繁计算 + if (scrollTimeoutRef.current) { + cancelAnimationFrame(scrollTimeoutRef.current) } - }, [isLoadingMore, isLoadingMessages, hasMoreMessages, currentSessionId, currentOffset]) + + scrollTimeoutRef.current = requestAnimationFrame(() => { + if (!messageListRef.current) return + + const { scrollTop, clientHeight, scrollHeight } = messageListRef.current + + // 显示回到底部按钮:距离底部超过 300px + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + setShowScrollToBottom(distanceFromBottom > 300) + + // 预加载:当滚动到顶部 30% 区域时开始加载 + if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) { + const threshold = clientHeight * 0.3 + if (scrollTop < threshold) { + loadMessages(currentSessionId, currentOffset) + } + } + }) + }, [isLoadingMore, isLoadingMessages, hasMoreMessages, currentSessionId, currentOffset, loadMessages]) const getMessageKey = useCallback((msg: Message): string => { if (msg.localId && msg.localId > 0) return `l:${msg.localId}` @@ -384,7 +779,14 @@ function ChatPage(_props: ChatPageProps) { }, []) const mergeSessions = useCallback((nextSessions: ChatSession[]) => { - if (sessionsRef.current.length === 0) return nextSessions + // 确保输入是数组 + if (!Array.isArray(nextSessions)) { + console.warn('mergeSessions: nextSessions is not an array:', nextSessions) + return Array.isArray(sessionsRef.current) ? sessionsRef.current : [] + } + if (!Array.isArray(sessionsRef.current) || sessionsRef.current.length === 0) { + return nextSessions + } const prevMap = new Map(sessionsRef.current.map((s) => [s.username, s])) return nextSessions.map((next) => { const prev = prevMap.get(next.username) @@ -440,6 +842,20 @@ function ChatPage(_props: ChatPageProps) { if (!isConnected && !isConnecting) { connect() } + + // 组件卸载时清理 + return () => { + avatarLoadQueue.clear() + if (contactUpdateTimerRef.current) { + clearTimeout(contactUpdateTimerRef.current) + } + if (sessionScrollTimeoutRef.current) { + clearTimeout(sessionScrollTimeoutRef.current) + } + contactUpdateQueueRef.current.clear() + enrichCancelledRef.current = true + isEnrichingRef.current = false + } }, []) useEffect(() => { @@ -496,14 +912,16 @@ function ChatPage(_props: ChatPageProps) { useEffect(() => { const nextMap = new Map() - for (const session of sessions) { - nextMap.set(session.username, session) + if (Array.isArray(sessions)) { + for (const session of sessions) { + nextMap.set(session.username, session) + } } sessionMapRef.current = nextMap }, [sessions]) useEffect(() => { - sessionsRef.current = sessions + sessionsRef.current = Array.isArray(sessions) ? sessions : [] }, [sessions]) useEffect(() => { @@ -511,6 +929,53 @@ function ChatPage(_props: ChatPageProps) { isLoadingMoreRef.current = isLoadingMore }, [isLoadingMessages, isLoadingMore]) + useEffect(() => { + if (initialRevealTimerRef.current !== null) { + window.clearTimeout(initialRevealTimerRef.current) + initialRevealTimerRef.current = null + } + if (!isLoadingMessages) { + if (messages.length === 0) { + setHasInitialMessages(true) + } else { + initialRevealTimerRef.current = window.setTimeout(() => { + setHasInitialMessages(true) + initialRevealTimerRef.current = null + }, 120) + } + } + }, [isLoadingMessages, messages.length]) + + useEffect(() => { + if (currentSessionId !== prevSessionRef.current) { + prevSessionRef.current = currentSessionId + if (initialRevealTimerRef.current !== null) { + window.clearTimeout(initialRevealTimerRef.current) + initialRevealTimerRef.current = null + } + if (messages.length === 0) { + setHasInitialMessages(false) + } else if (!isLoadingMessages) { + setHasInitialMessages(true) + } + } + }, [currentSessionId, messages.length, isLoadingMessages]) + + useEffect(() => { + if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore) { + loadMessages(currentSessionId, 0) + } + }, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore]) + + useEffect(() => { + return () => { + if (initialRevealTimerRef.current !== null) { + window.clearTimeout(initialRevealTimerRef.current) + initialRevealTimerRef.current = null + } + } + }, []) + useEffect(() => { isConnectedRef.current = isConnected }, [isConnected]) @@ -520,7 +985,14 @@ function ChatPage(_props: ChatPageProps) { }, [searchKeyword]) useEffect(() => { - if (!searchKeyword.trim()) return + if (!Array.isArray(sessions)) { + setFilteredSessions([]) + return + } + if (!searchKeyword.trim()) { + setFilteredSessions(sessions) + return + } const lower = searchKeyword.toLowerCase() const filtered = sessions.filter(s => s.displayName?.toLowerCase().includes(lower) || @@ -531,8 +1003,8 @@ function ChatPage(_props: ChatPageProps) { }, [sessions, searchKeyword, setFilteredSessions]) - // 格式化会话时间(相对时间)- 与原项目一致 - const formatSessionTime = (timestamp: number): string => { + // 格式化会话时间(相对时间)- 使用 useMemo 缓存,避免每次渲染都计算 + const formatSessionTime = useCallback((timestamp: number): string => { if (!Number.isFinite(timestamp) || timestamp <= 0) return '' const now = Date.now() @@ -555,10 +1027,10 @@ function ChatPage(_props: ChatPageProps) { } return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` - } + }, []) // 获取当前会话信息 - const currentSession = sessions.find(s => s.username === currentSessionId) + const currentSession = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined // 判断是否为群聊 const isGroupChat = (username: string) => username.includes('@chatroom') @@ -641,30 +1113,31 @@ function ChatPage(_props: ChatPageProps) {
))}
- ) : filteredSessions.length > 0 ? ( -
+ ) : Array.isArray(filteredSessions) && filteredSessions.length > 0 ? ( +
{ + // 标记正在滚动,暂停联系人信息加载 + isScrollingRef.current = true + if (sessionScrollTimeoutRef.current) { + clearTimeout(sessionScrollTimeoutRef.current) + } + // 滚动结束后200ms才认为滚动停止 + sessionScrollTimeoutRef.current = window.setTimeout(() => { + isScrollingRef.current = false + sessionScrollTimeoutRef.current = null + }, 200) + }} + > {filteredSessions.map(session => ( -
handleSelectSession(session)} - > - -
-
- {session.displayName || session.username} - {formatSessionTime(session.lastTimestamp || session.sortTimestamp)} -
-
- {session.summary || '暂无消息'} - {session.unreadCount > 0 && ( - - {session.unreadCount > 99 ? '99+' : session.unreadCount} - - )} -
-
-
+ session={session} + isActive={currentSessionId === session.username} + onSelect={handleSelectSession} + formatTime={formatSessionTime} + /> ))}
) : ( @@ -710,18 +1183,18 @@ function ChatPage(_props: ChatPageProps) {
-
- {isLoadingMessages ? ( -
+
+ {isLoadingMessages && !hasInitialMessages && ( +
加载消息中...
- ) : ( -
+ )} +
{hasMoreMessages && (
{isLoadingMore ? ( @@ -772,7 +1245,6 @@ function ChatPage(_props: ChatPageProps) { 回到底部
- )} {/* 会话详情面板 */} {showDetailPanel && ( diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 588535f..12fe9d2 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -379,29 +379,21 @@ border-radius: 10px; font-size: 14px; color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + } svg { color: var(--text-tertiary); + flex-shrink: 0; } span { flex: 1; } - - .change-btn { - background: none; - border: none; - padding: 4px; - cursor: pointer; - color: var(--text-tertiary); - display: flex; - align-items: center; - justify-content: center; - - &:hover { - color: var(--text-primary); - } - } } .media-options { @@ -471,6 +463,43 @@ margin: 8px 0 0; } + .select-folder-btn { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 16px; + margin-top: 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + border-color: var(--primary); + color: var(--primary); + + svg { + color: var(--primary); + } + } + + &:active { + transform: scale(0.98); + } + + svg { + color: var(--text-secondary); + transition: color 0.2s; + } + } + .export-action { padding: 20px 24px; border-top: 1px solid var(--border-color); @@ -649,9 +678,245 @@ } } } + + .date-picker-modal { + background: var(--card-bg); + padding: 28px 32px; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + min-width: 420px; + max-width: 500px; + + h3 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 20px; + } + + .quick-select { + display: flex; + gap: 8px; + margin-bottom: 20px; + + .quick-btn { + flex: 1; + padding: 10px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + border-color: var(--primary); + color: var(--primary); + } + + &:active { + transform: scale(0.98); + } + } + } + + .date-display { + display: flex; + align-items: center; + gap: 16px; + padding: 20px; + background: var(--bg-secondary); + border-radius: 12px; + margin-bottom: 24px; + + .date-display-item { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 12px; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: rgba(var(--primary-rgb), 0.05); + } + + &.active { + background: rgba(var(--primary-rgb), 0.1); + border: 1px solid var(--primary); + } + + .date-label { + font-size: 12px; + color: var(--text-tertiary); + font-weight: 500; + } + + .date-value { + font-size: 15px; + color: var(--text-primary); + font-weight: 600; + } + } + + .date-separator { + font-size: 14px; + color: var(--text-tertiary); + padding: 0 4px; + } + } + + .calendar-container { + margin-bottom: 20px; + } + + .calendar-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + padding: 0 4px; + + .calendar-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; + cursor: pointer; + color: var(--text-secondary); + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + border-color: var(--primary); + color: var(--primary); + } + + &:active { + transform: scale(0.95); + } + } + + .calendar-month { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + } + } + + .calendar-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + margin-bottom: 8px; + + .calendar-weekday { + text-align: center; + font-size: 12px; + font-weight: 500; + color: var(--text-tertiary); + padding: 8px 0; + } + } + + .calendar-days { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + + .calendar-day { + 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; + position: relative; + + &.empty { + cursor: default; + } + + &:not(.empty):hover { + background: var(--bg-hover); + } + + &.in-range { + background: rgba(var(--primary-rgb), 0.08); + } + + &.start, + &.end { + background: var(--primary); + color: #fff; + font-weight: 600; + + &:hover { + background: var(--primary-hover); + } + } + } + } + + .date-picker-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + + button { + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + + &:active { + transform: scale(0.98); + } + } + + .cancel-btn { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + + &:hover { + background: var(--bg-hover); + } + } + + .confirm-btn { + background: var(--primary); + color: #fff; + border: none; + + &:hover { + background: var(--primary-hover); + } + } + } + } } @keyframes exportSpin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index e39dd07..a8fc14f 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react' -import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react' +import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react' import * as configService from '../services/config' import './ExportPage.scss' @@ -15,6 +15,7 @@ interface ExportOptions { format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql' dateRange: { start: Date; end: Date } | null useAllTime: boolean + exportAvatars: boolean } interface ExportResult { @@ -34,14 +35,18 @@ function ExportPage() { const [isExporting, setIsExporting] = useState(false) const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' }) const [exportResult, setExportResult] = useState(null) - + const [showDatePicker, setShowDatePicker] = useState(false) + const [calendarDate, setCalendarDate] = useState(new Date()) + const [selectingStart, setSelectingStart] = useState(true) + const [options, setOptions] = useState({ format: 'chatlab', dateRange: { start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), end: new Date() }, - useAllTime: true + useAllTime: true, + exportAvatars: true }) const loadSessions = useCallback(async () => { @@ -140,9 +145,11 @@ function ExportPage() { const sessionList = Array.from(selectedSessions) const exportOptions = { format: options.format, + exportAvatars: options.exportAvatars, dateRange: options.useAllTime ? null : options.dateRange ? { start: Math.floor(options.dateRange.start.getTime() / 1000), - end: Math.floor(options.dateRange.end.getTime() / 1000) + // 将结束日期设置为当天的 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 } @@ -164,6 +171,54 @@ function ExportPage() { } } + 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 handleDateSelect = (day: number) => { + const year = calendarDate.getFullYear() + const month = calendarDate.getMonth() + const selectedDate = new Date(year, month, day) + + if (selectingStart) { + setOptions({ + ...options, + dateRange: options.dateRange ? { ...options.dateRange, start: selectedDate } : { start: selectedDate, end: new Date() } + }) + setSelectingStart(false) + } else { + setOptions({ + ...options, + dateRange: options.dateRange ? { ...options.dateRange, end: selectedDate } : { start: new Date(), end: selectedDate } + }) + setSelectingStart(true) + } + } + const formatOptions = [ { value: 'chatlab', label: 'ChatLab', icon: FileCode, desc: '标准格式,支持其他软件导入' }, { value: 'chatlab-jsonl', label: 'ChatLab JSONL', icon: FileCode, desc: '流式格式,适合大量消息' }, @@ -278,24 +333,55 @@ function ExportPage() { 导出全部时间 {!options.useAllTime && options.dateRange && ( -
+
setShowDatePicker(true)}> {formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)} - +
)}
+
+

导出头像

+
+ +
+
+

导出位置

{exportFolder || '未设置'}
-

可在设置页面修改导出目录

+
@@ -370,6 +456,130 @@ function ExportPage() {
)} + + {/* 日期选择弹窗 */} + {showDatePicker && ( +
setShowDatePicker(false)}> +
e.stopPropagation()}> +

选择时间范围

+
+ + + +
+
+
setSelectingStart(true)} + > + 开始日期 + + {options.dateRange?.start.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + })} + +
+ +
setSelectingStart(false)} + > + 结束日期 + + {options.dateRange?.end.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + })} + +
+
+
+
+ + + {calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月 + + +
+
+ {['日', '一', '二', '三', '四', '五', '六'].map(day => ( +
{day}
+ ))} +
+
+ {generateCalendar().map((day, index) => { + if (day === null) { + return
+ } + + const currentDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day) + const isStart = options.dateRange?.start.toDateString() === currentDate.toDateString() + const isEnd = options.dateRange?.end.toDateString() === currentDate.toDateString() + const isInRange = options.dateRange && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end + + return ( +
handleDateSelect(day)} + > + {day} +
+ ) + })} +
+
+
+ + +
+
+
+ )}
) } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 418faaa..2f80c91 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -3,19 +3,18 @@ import { useAppStore } from '../stores/appStore' import { useThemeStore, themes } from '../stores/themeStore' import { dialog } from '../services/ipc' import * as configService from '../services/config' -import { +import { Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, RotateCcw, Trash2, Save, Plug, Check, Sun, Moon, Palette, Database, Download, HardDrive, Info, RefreshCw } from 'lucide-react' import './SettingsPage.scss' -type SettingsTab = 'appearance' | 'database' | 'export' | 'cache' | 'about' +type SettingsTab = 'appearance' | 'database' | 'cache' | 'about' const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'appearance', label: '外观', icon: Palette }, { id: 'database', label: '数据库连接', icon: Database }, - { id: 'export', label: '导出', icon: Download }, { id: 'cache', label: '缓存', icon: HardDrive }, { id: 'about', label: '关于', icon: Info } ] @@ -31,10 +30,8 @@ function SettingsPage() { const [dbPath, setDbPath] = useState('') const [wxid, setWxid] = useState('') const [cachePath, setCachePath] = useState('') - const [exportPath, setExportPath] = useState('') - const [defaultExportPath, setDefaultExportPath] = useState('') const [logEnabled, setLogEnabled] = useState(false) - + const [isLoading, setIsLoadingState] = useState(false) const [isTesting, setIsTesting] = useState(false) const [isDetectingPath, setIsDetectingPath] = useState(false) @@ -53,7 +50,6 @@ function SettingsPage() { useEffect(() => { loadConfig() - loadDefaultExportPath() loadAppVersion() }, []) @@ -80,12 +76,11 @@ function SettingsPage() { const savedLogEnabled = await configService.getLogEnabled() const savedImageXorKey = await configService.getImageXorKey() const savedImageAesKey = await configService.getImageAesKey() - + if (savedKey) setDecryptKey(savedKey) if (savedPath) setDbPath(savedPath) if (savedWxid) setWxid(savedWxid) if (savedCachePath) setCachePath(savedCachePath) - if (savedExportPath) setExportPath(savedExportPath) if (savedImageXorKey != null) { setImageXorKey(`0x${savedImageXorKey.toString(16).toUpperCase().padStart(2, '0')}`) } @@ -96,14 +91,7 @@ function SettingsPage() { } } - const loadDefaultExportPath = async () => { - try { - const downloadsPath = await window.electronAPI.app.getDownloadsPath() - setDefaultExportPath(downloadsPath) - } catch (e) { - console.error('获取默认导出路径失败:', e) - } - } + const loadAppVersion = async () => { try { @@ -166,7 +154,7 @@ function SettingsPage() { setDbPath(result.path) await configService.setDbPath(result.path) showMessage(`自动检测成功:${result.path}`, true) - + const wxids = await window.electronAPI.dbPath.scanWxids(result.path) if (wxids.length === 1) { setWxid(wxids[0].wxid) @@ -230,18 +218,7 @@ function SettingsPage() { } } - const handleSelectExportPath = async () => { - try { - const result = await dialog.openFile({ title: '选择导出目录', properties: ['openDirectory'] }) - if (!result.canceled && result.filePaths.length > 0) { - setExportPath(result.filePaths[0]) - await configService.setExportPath(result.filePaths[0]) - showMessage('已设置导出目录', true) - } - } catch (e) { - showMessage('选择目录失败', false) - } - } + const handleAutoGetDbKey = async () => { if (isFetchingDbKey) return @@ -303,16 +280,7 @@ function SettingsPage() { } } - const handleResetExportPath = async () => { - try { - const downloadsPath = await window.electronAPI.app.getDownloadsPath() - setExportPath(downloadsPath) - await configService.setExportPath(downloadsPath) - showMessage('已恢复为下载目录', true) - } catch (e) { - showMessage('恢复默认失败', false) - } - } + const handleTestConnection = async () => { if (!dbPath) { showMessage('请先选择数据库目录', false); return } @@ -396,7 +364,6 @@ function SettingsPage() { setDbPath('') setWxid('') setCachePath('') - setExportPath('') setLogEnabled(false) setDbConnected(false) await window.electronAPI.window.openOnboardingWindow() @@ -562,19 +529,7 @@ function SettingsPage() { ) - const renderExportTab = () => ( -
-
- - 聊天记录导出的默认保存位置 - setExportPath(e.target.value)} /> -
- - -
-
-
- ) + const renderCacheTab = () => (
@@ -603,7 +558,7 @@ function SettingsPage() {

WeFlow

WeFlow

v{appVersion || '...'}

- +
{updateInfo?.hasUpdate ? ( <> @@ -672,7 +627,6 @@ function SettingsPage() {
{activeTab === 'appearance' && renderAppearanceTab()} {activeTab === 'database' && renderDatabaseTab()} - {activeTab === 'export' && renderExportTab()} {activeTab === 'cache' && renderCacheTab()} {activeTab === 'about' && renderAboutTab()}
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index eb5c106..35a0bdb 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -55,6 +55,11 @@ export interface ElectronAPI { chat: { connect: () => Promise<{ success: boolean; error?: string }> getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> + enrichSessionsContactInfo: (usernames: string[]) => Promise<{ + success: boolean + contacts?: Record + error?: string + }> getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{ success: boolean; messages?: Message[];