diff --git a/README.md b/README.md index 8c6c48a..01e7beb 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析 | 功能模块 | 说明 | |---------|------| | **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 | +| **消息防撤回** | 防止其他人发送的消息被撤回 | | **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 | | **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 | | **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 | diff --git a/electron/main.ts b/electron/main.ts index 080f247..ce578dc 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1867,6 +1867,18 @@ function registerIpcHandlers() { return chatService.deleteMessage(sessionId, localId, createTime, dbPathHint) }) + ipcMain.handle('chat:checkAntiRevokeTriggers', async (_, sessionIds: string[]) => { + return chatService.checkAntiRevokeTriggers(sessionIds) + }) + + ipcMain.handle('chat:installAntiRevokeTriggers', async (_, sessionIds: string[]) => { + return chatService.installAntiRevokeTriggers(sessionIds) + }) + + ipcMain.handle('chat:uninstallAntiRevokeTriggers', async (_, sessionIds: string[]) => { + return chatService.uninstallAntiRevokeTriggers(sessionIds) + }) + ipcMain.handle('chat:getContact', async (_, username: string) => { return await chatService.getContact(username) }) @@ -2299,10 +2311,47 @@ function registerIpcHandlers() { }) 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) + const PROGRESS_FORWARD_INTERVAL_MS = 180 + let pendingProgress: ExportProgress | null = null + let progressTimer: NodeJS.Timeout | null = null + let lastProgressSentAt = 0 + + const flushProgress = () => { + if (!pendingProgress) return + if (progressTimer) { + clearTimeout(progressTimer) + progressTimer = null } + if (!event.sender.isDestroyed()) { + event.sender.send('export:progress', pendingProgress) + } + pendingProgress = null + lastProgressSentAt = Date.now() + } + + const queueProgress = (progress: ExportProgress) => { + pendingProgress = progress + const force = progress.phase === 'complete' + if (force) { + flushProgress() + return + } + + const now = Date.now() + const elapsed = now - lastProgressSentAt + if (elapsed >= PROGRESS_FORWARD_INTERVAL_MS) { + flushProgress() + return + } + + if (progressTimer) return + progressTimer = setTimeout(() => { + flushProgress() + }, PROGRESS_FORWARD_INTERVAL_MS - elapsed) + } + + const onProgress = (progress: ExportProgress) => { + queueProgress(progress) } const runMainFallback = async (reason: string) => { @@ -2381,6 +2430,12 @@ function registerIpcHandlers() { return await runWorker() } catch (error) { return runMainFallback(error instanceof Error ? error.message : String(error)) + } finally { + flushProgress() + if (progressTimer) { + clearTimeout(progressTimer) + progressTimer = null + } } }) diff --git a/electron/preload.ts b/electron/preload.ts index db103ef..88385ce 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -190,6 +190,12 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent), deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint), + checkAntiRevokeTriggers: (sessionIds: string[]) => + ipcRenderer.invoke('chat:checkAntiRevokeTriggers', sessionIds), + installAntiRevokeTriggers: (sessionIds: string[]) => + ipcRenderer.invoke('chat:installAntiRevokeTriggers', sessionIds), + uninstallAntiRevokeTriggers: (sessionIds: string[]) => + ipcRenderer.invoke('chat:uninstallAntiRevokeTriggers', sessionIds), resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername), getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 6069c38..f4c63fa 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -1,4 +1,4 @@ -import { join, dirname, basename, extname } from 'path' +import { join, dirname, basename, extname } from 'path' import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch, promises as fsPromises } from 'fs' import * as path from 'path' import * as fs from 'fs' @@ -75,6 +75,7 @@ export interface Message { fileName?: string // 文件名 fileSize?: number // 文件大小 fileExt?: string // 文件扩展名 + fileMd5?: string // 文件 MD5 xmlType?: string // XML 中的 type 字段 appMsgKind?: string // 归一化 appmsg 类型 appMsgDesc?: string @@ -558,6 +559,51 @@ class ChatService { } } + async checkAntiRevokeTriggers(sessionIds: string[]): Promise<{ + success: boolean + rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }> + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) return { success: false, error: connectResult.error } + const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) + return await wcdbService.checkMessageAntiRevokeTriggers(normalizedIds) + } catch (e) { + return { success: false, error: String(e) } + } + } + + async installAntiRevokeTriggers(sessionIds: string[]): Promise<{ + success: boolean + rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }> + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) return { success: false, error: connectResult.error } + const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) + return await wcdbService.installMessageAntiRevokeTriggers(normalizedIds) + } catch (e) { + return { success: false, error: String(e) } + } + } + + async uninstallAntiRevokeTriggers(sessionIds: string[]): Promise<{ + success: boolean + rows?: Array<{ sessionId: string; success: boolean; error?: string }> + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) return { success: false, error: connectResult.error } + const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) + return await wcdbService.uninstallMessageAntiRevokeTriggers(normalizedIds) + } catch (e) { + return { success: false, error: String(e) } + } + } + /** * 获取会话列表(优化:先返回基础数据,不等待联系人信息加载) */ @@ -1773,18 +1819,9 @@ class ChatService { } private getMessageSourceInfo(row: Record): { dbName?: string; tableName?: string; dbPath?: string } { - const dbPath = String( - this.getRowField(row, ['_db_path', 'db_path', 'dbPath', 'database_path', 'databasePath', 'source_db_path']) - || '' - ).trim() - const explicitDbName = String( - this.getRowField(row, ['db_name', 'dbName', 'database_name', 'databaseName', 'db', 'database', 'source_db']) - || '' - ).trim() - const tableName = String( - this.getRowField(row, ['table_name', 'tableName', 'table', 'source_table', 'sourceTable']) - || '' - ).trim() + const dbPath = String(row._db_path || row.db_path || '').trim() + const explicitDbName = String(row.db_name || '').trim() + const tableName = String(row.table_name || '').trim() const dbName = explicitDbName || (dbPath ? basename(dbPath, extname(dbPath)) : '') return { dbName: dbName || undefined, @@ -3201,7 +3238,7 @@ class ChatService { if (!batch.success) break const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] for (const row of rows) { - const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) + const localType = this.getRowInt(row, ['local_type'], 1) if (localType === 50) { counters.callMessages += 1 continue @@ -3216,8 +3253,8 @@ class ChatService { } if (localType !== 49) continue - const rawMessageContent = this.getRowField(row, ['message_content', 'messageContent', 'msg_content', 'msgContent', 'content', 'WCDB_CT_message_content']) - const rawCompressContent = this.getRowField(row, ['compress_content', 'compressContent', 'compressed_content', 'compressedContent', 'WCDB_CT_compress_content']) + const rawMessageContent = row.message_content + const rawCompressContent = row.compress_content const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) const xmlType = this.extractType49XmlTypeForStats(content) if (xmlType === '2000') counters.transferMessages += 1 @@ -3270,7 +3307,7 @@ class ChatService { for (const row of rows) { stats.totalMessages += 1 - const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) + const localType = this.getRowInt(row, ['local_type'], 1) if (localType === 34) stats.voiceMessages += 1 if (localType === 3) stats.imageMessages += 1 if (localType === 43) stats.videoMessages += 1 @@ -3279,8 +3316,8 @@ class ChatService { if (localType === 8589934592049) stats.transferMessages += 1 if (localType === 8594229559345) stats.redPacketMessages += 1 if (localType === 49) { - const rawMessageContent = this.getRowField(row, ['message_content', 'messageContent', 'msg_content', 'msgContent', 'content', 'WCDB_CT_message_content']) - const rawCompressContent = this.getRowField(row, ['compress_content', 'compressContent', 'compressed_content', 'compressedContent', 'WCDB_CT_compress_content']) + const rawMessageContent = row.message_content + const rawCompressContent = row.compress_content const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) const xmlType = this.extractType49XmlTypeForStats(content) if (xmlType === '2000') stats.transferMessages += 1 @@ -3289,7 +3326,7 @@ class ChatService { const createTime = this.getRowInt( row, - ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], + ['create_time'], 0 ) if (createTime > 0) { @@ -3302,7 +3339,7 @@ class ChatService { } if (sessionId.endsWith('@chatroom')) { - const sender = String(this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || '').trim() + const sender = String(row.sender_username || '').trim() const senderKeys = this.buildIdentityKeys(sender) if (senderKeys.length > 0) { senderIdentities.add(senderKeys[0]) @@ -3310,7 +3347,7 @@ class ChatService { stats.groupMyMessages = (stats.groupMyMessages || 0) + 1 } } else { - const isSend = this.coerceRowNumber(this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])) + const isSend = this.coerceRowNumber(row.computed_is_send ?? row.is_send) if (Number.isFinite(isSend) && isSend === 1) { stats.groupMyMessages = (stats.groupMyMessages || 0) + 1 } @@ -3744,32 +3781,18 @@ class ChatService { const messages: Message[] = [] for (const row of rows) { const sourceInfo = this.getMessageSourceInfo(row) - const rawMessageContent = this.getRowField(row, [ - 'message_content', - 'messageContent', - 'content', - 'msg_content', - 'msgContent', - 'WCDB_CT_message_content', - 'WCDB_CT_messageContent' - ]); - const rawCompressContent = this.getRowField(row, [ - 'compress_content', - 'compressContent', - 'compressed_content', - 'WCDB_CT_compress_content', - 'WCDB_CT_compressContent' - ]); + const rawMessageContent = row.message_content + const rawCompressContent = row.compress_content const content = this.decodeMessageContent(rawMessageContent, rawCompressContent); - const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) - const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']) + const localType = this.getRowInt(row, ['local_type'], 1) + const isSendRaw = row.computed_is_send ?? row.is_send const parsedRawIsSend = isSendRaw === null ? null : parseInt(isSendRaw, 10) - const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) + const senderUsername = row.sender_username || this.extractSenderUsernameFromContent(content) || null const { isSend } = this.resolveMessageIsSend(parsedRawIsSend, senderUsername) - const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0) + const createTime = this.getRowInt(row, ['create_time'], 0) if (senderUsername && !myWxid) { // [DEBUG] Issue #34: 未配置 myWxid,无法判断是否发送 @@ -3796,6 +3819,7 @@ class ChatService { let fileName: string | undefined let fileSize: number | undefined let fileExt: string | undefined + let fileMd5: string | undefined let xmlType: string | undefined let appMsgKind: string | undefined let appMsgDesc: string | undefined @@ -3900,6 +3924,7 @@ class ChatService { fileName = type49Info.fileName fileSize = type49Info.fileSize fileExt = type49Info.fileExt + fileMd5 = type49Info.fileMd5 chatRecordTitle = type49Info.chatRecordTitle chatRecordList = type49Info.chatRecordList transferPayerUsername = type49Info.transferPayerUsername @@ -3923,6 +3948,7 @@ class ChatService { fileName = fileName || type49Info.fileName fileSize = fileSize ?? type49Info.fileSize fileExt = fileExt || type49Info.fileExt + fileMd5 = fileMd5 || type49Info.fileMd5 appMsgKind = appMsgKind || type49Info.appMsgKind appMsgDesc = appMsgDesc || type49Info.appMsgDesc appMsgAppName = appMsgAppName || type49Info.appMsgAppName @@ -3954,10 +3980,10 @@ class ChatService { if (!quotedSender && type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender } - const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0) - const serverIdRaw = this.normalizeUnsignedIntegerToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'])) - const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0) - const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime) + const localId = this.getRowInt(row, ['local_id'], 0) + const serverIdRaw = this.normalizeUnsignedIntegerToken(row.server_id) + const serverId = this.getRowInt(row, ['server_id'], 0) + const sortSeq = this.getRowInt(row, ['sort_seq'], createTime) messages.push({ messageKey: this.buildMessageKey({ @@ -3996,6 +4022,7 @@ class ChatService { fileName, fileSize, fileExt, + fileMd5, xmlType, appMsgKind, appMsgDesc, @@ -4404,18 +4431,7 @@ class ChatService { } private parseImageDatNameFromRow(row: Record): string | undefined { - const packed = this.getRowField(row, [ - 'packed_info_data', - 'packed_info', - 'packedInfoData', - 'packedInfo', - 'PackedInfoData', - 'PackedInfo', - 'WCDB_CT_packed_info_data', - 'WCDB_CT_packed_info', - 'WCDB_CT_PackedInfoData', - 'WCDB_CT_PackedInfo' - ]) + const packed = row.packed_info_data const buffer = this.decodePackedInfo(packed) if (!buffer || buffer.length === 0) return undefined const printable: number[] = [] @@ -4599,6 +4615,7 @@ class ChatService { fileName?: string fileSize?: number fileExt?: string + fileMd5?: string transferPayerUsername?: string transferReceiverUsername?: string chatRecordTitle?: string @@ -4795,6 +4812,7 @@ class ChatService { // 提取文件扩展名 const fileExt = this.extractXmlValue(content, 'fileext') + const fileMd5 = this.extractXmlValue(content, 'md5') || this.extractXmlValue(content, 'filemd5') if (fileExt) { result.fileExt = fileExt } else if (result.fileName) { @@ -4804,6 +4822,9 @@ class ChatService { result.fileExt = match[1] } } + if (fileMd5) { + result.fileMd5 = fileMd5.toLowerCase() + } break } @@ -5303,14 +5324,14 @@ class ChatService { row: Record, rawContent: string ): Promise { - const directSender = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) + const directSender = row.sender_username || this.extractSenderUsernameFromContent(rawContent) if (directSender) { return directSender } - const dbPath = this.getRowField(row, ['db_path', 'dbPath', '_db_path']) - const realSenderId = this.getRowField(row, ['real_sender_id', 'realSenderId']) + const dbPath = row._db_path + const realSenderId = row.real_sender_id if (!dbPath || realSenderId === null || realSenderId === undefined || String(realSenderId).trim() === '') { return null } @@ -5359,7 +5380,7 @@ class ChatService { 50: '[通话]', 10000: '[系统消息]', 244813135921: '[引用消息]', - 266287972401: '[拍一拍]', + 266287972401: '拍一拍', 81604378673: '[聊天记录]', 154618822705: '[小程序]', 8594229559345: '[红包]', @@ -5468,7 +5489,7 @@ class ChatService { * XML: "XX"拍了拍"XX"相信未来!... */ private cleanPatMessage(content: string): string { - if (!content) return '[拍一拍]' + if (!content) return '拍一拍' // 1. 优先从 XML 标签提取内容 const titleMatch = /<title>([\s\S]*?)<\/title>/i.exec(content) @@ -5478,14 +5499,14 @@ class ChatService { .replace(/\]\]>/g, '') .trim() if (title) { - return `[拍一拍] ${title}` + return title } } // 2. 尝试匹配标准的 "A拍了拍B" 格式 const match = /^(.+?拍了拍.+?)(?:[\r\n]|$|ງ|wxid_)/.exec(content) if (match) { - return `[拍一拍] ${match[1].trim()}` + return match[1].trim() } // 3. 如果匹配失败,尝试清理掉疑似的 garbage (wxid, 乱码) @@ -5499,10 +5520,10 @@ class ChatService { // 如果清理后还有内容,返回 if (cleaned && cleaned.length > 1 && !cleaned.includes('xml')) { - return `[拍一拍] ${cleaned}` + return cleaned } - return '[拍一拍]' + return '拍一拍' } /** @@ -7520,11 +7541,7 @@ class ChatService { for (const row of result.messages) { let message = await this.parseMessage(row, { source: 'search', sessionId }) - const resolvedSessionId = String( - sessionId || - this.getRowField(row, ['_session_id', 'session_id', 'sessionId', 'talker', 'username']) - || '' - ).trim() + const resolvedSessionId = String(sessionId || row._session_id || '').trim() const needsDetailHydration = isGroupSearch && Boolean(sessionId) && message.localId > 0 && @@ -7559,32 +7576,18 @@ class ChatService { private async parseMessage(row: any, options?: { source?: 'search' | 'detail'; sessionId?: string }): Promise<Message> { const sourceInfo = this.getMessageSourceInfo(row) const rawContent = this.decodeMessageContent( - this.getRowField(row, [ - 'message_content', - 'messageContent', - 'content', - 'msg_content', - 'msgContent', - 'WCDB_CT_message_content', - 'WCDB_CT_messageContent' - ]), - this.getRowField(row, [ - 'compress_content', - 'compressContent', - 'compressed_content', - 'WCDB_CT_compress_content', - 'WCDB_CT_compressContent' - ]) + row.message_content, + row.compress_content ) // 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的 // 实际项目中建议抽取 parseRawMessage(row) 供多处使用 - const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0) - const serverIdRaw = this.normalizeUnsignedIntegerToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'])) - const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0) - const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0) - const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0) - const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime) - const rawIsSend = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']) + const localId = this.getRowInt(row, ['local_id'], 0) + const serverIdRaw = this.normalizeUnsignedIntegerToken(row.server_id) + const serverId = this.getRowInt(row, ['server_id'], 0) + const localType = this.getRowInt(row, ['local_type'], 0) + const createTime = this.getRowInt(row, ['create_time'], 0) + const sortSeq = this.getRowInt(row, ['sort_seq'], createTime) + const rawIsSend = row.computed_is_send ?? row.is_send const senderUsername = await this.resolveSenderUsernameForMessageRow(row, rawContent) const sendState = this.resolveMessageIsSend(rawIsSend === null ? null : parseInt(rawIsSend, 10), senderUsername) const msg: Message = { @@ -7612,8 +7615,8 @@ class ChatService { } if (msg.localId === 0 || msg.createTime === 0) { - const rawLocalId = this.getRowField(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id']) - const rawCreateTime = this.getRowField(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time']) + const rawLocalId = row.local_id + const rawCreateTime = row.create_time console.warn('[ChatService] parseMessage raw keys', { rawLocalId, rawLocalIdType: rawLocalId ? typeof rawLocalId : 'null', diff --git a/electron/services/config.ts b/electron/services/config.ts index 3039412..d65d93b 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -61,6 +61,7 @@ interface ConfigSchema { windowCloseBehavior: 'ask' | 'tray' | 'quit' quoteLayout: 'quote-top' | 'quote-bottom' wordCloudExcludeWords: string[] + exportWriteLayout: 'A' | 'B' | 'C' } // 需要 safeStorage 加密的字段(普通模式) @@ -133,7 +134,8 @@ export class ConfigService { messagePushEnabled: false, windowCloseBehavior: 'ask', quoteLayout: 'quote-top', - wordCloudExcludeWords: [] + wordCloudExcludeWords: [], + exportWriteLayout: 'A' } const storeOptions: any = { diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 9e71159..76176cc 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -98,6 +98,8 @@ export interface ExportOptions { exportVoices?: boolean exportVideos?: boolean exportEmojis?: boolean + exportFiles?: boolean + maxFileSizeMb?: number exportVoiceAsText?: boolean excelCompactColumns?: boolean txtColumns?: string[] @@ -121,7 +123,7 @@ const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ interface MediaExportItem { relativePath: string - kind: 'image' | 'voice' | 'emoji' | 'video' + kind: 'image' | 'voice' | 'emoji' | 'video' | 'file' posterDataUrl?: string } @@ -136,6 +138,11 @@ interface ExportDisplayProfile { type MessageCollectMode = 'full' | 'text-fast' | 'media-fast' type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' +interface FileExportCandidate { + sourcePath: string + matchedBy: 'md5' | 'name' + yearMonth?: string +} export interface ExportProgress { current: number @@ -430,6 +437,8 @@ class ExportService { let lastSessionId = '' let lastCollected = 0 let lastExported = 0 + const MIN_PROGRESS_EMIT_INTERVAL_MS = 250 + const MESSAGE_PROGRESS_DELTA_THRESHOLD = 500 const commit = (progress: ExportProgress) => { onProgress(progress) @@ -454,9 +463,9 @@ class ExportService { const shouldEmit = force || phase !== lastPhase || sessionId !== lastSessionId || - collectedDelta >= 200 || - exportedDelta >= 200 || - (now - lastSentAt >= 120) + collectedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD || + exportedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD || + (now - lastSentAt >= MIN_PROGRESS_EMIT_INTERVAL_MS) if (shouldEmit && pending) { commit(pending) @@ -842,7 +851,7 @@ class ExportService { private isMediaExportEnabled(options: ExportOptions): boolean { return options.exportMedia === true && - Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) + Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles) } private isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean { @@ -880,7 +889,7 @@ class ExportService { if (options.exportImages) selected.add(3) if (options.exportVoices) selected.add(34) if (options.exportVideos) selected.add(43) - if (options.exportEmojis) selected.add(47) + if (options.exportFiles) selected.add(49) return selected } @@ -3416,6 +3425,8 @@ class ExportService { exportVoices?: boolean exportVideos?: boolean exportEmojis?: boolean + exportFiles?: boolean + maxFileSizeMb?: number exportVoiceAsText?: boolean includeVideoPoster?: boolean includeVoiceWithTranscript?: boolean @@ -3469,6 +3480,16 @@ class ExportService { ) } + if ((localType === 49 || localType === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') { + return this.exportFileAttachment( + msg, + mediaRootDir, + mediaRelativePrefix, + options.maxFileSizeMb, + options.dirCache + ) + } + return null } @@ -3537,20 +3558,11 @@ class ExportService { console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`) result.localPath = thumbResult.localPath } else { - console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'}`) - // 最后尝试:直接从 imageStore 获取缓存的缩略图 data URL - const { imageStore } = await import('../main') - const cachedThumb = imageStore?.getCachedImage(sessionId, imageMd5, imageDatName) - if (cachedThumb) { - console.log(`[Export] 从 imageStore 获取到缓存缩略图 (localId=${msg.localId})`) - result.localPath = cachedThumb - } else { - console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`) - if (missingRunCacheKey) { - this.mediaRunMissingImageKeys.add(missingRunCacheKey) - } - return null + console.log(`[Export] 缩略图也获取失败,所有方式均失败 → 将显示 [图片] 占位符`) + if (missingRunCacheKey) { + this.mediaRunMissingImageKeys.add(missingRunCacheKey) } + return null } } @@ -3559,7 +3571,7 @@ class ExportService { const imageKey = (imageMd5 || imageDatName || 'image').replace(/[^a-zA-Z0-9_-]/g, '') // 从 data URL 或 file URL 获取实际路径 - let sourcePath = result.localPath + let sourcePath: string = result.localPath! if (sourcePath.startsWith('data:')) { // 是 data URL,需要保存为文件 const base64Data = sourcePath.split(',')[1] @@ -3939,6 +3951,165 @@ class ExportService { return tagMatch?.[1]?.toLowerCase() } + private resolveFileAttachmentRoots(): string[] { + const dbPath = String(this.configService.get('dbPath') || '').trim() + const rawWxid = String(this.configService.get('myWxid') || '').trim() + const cleanedWxid = this.cleanAccountDirName(rawWxid) + if (!dbPath) return [] + + const normalized = dbPath.replace(/[\\/]+$/, '') + const roots = new Set<string>() + const tryAddRoot = (candidate: string) => { + const fileRoot = path.join(candidate, 'msg', 'file') + if (fs.existsSync(fileRoot)) { + roots.add(fileRoot) + } + } + + tryAddRoot(normalized) + if (rawWxid) tryAddRoot(path.join(normalized, rawWxid)) + if (cleanedWxid && cleanedWxid !== rawWxid) tryAddRoot(path.join(normalized, cleanedWxid)) + + const dbStoragePath = + this.resolveDbStoragePathForExport(normalized, cleanedWxid) || + this.resolveDbStoragePathForExport(normalized, rawWxid) + if (dbStoragePath) { + tryAddRoot(path.dirname(dbStoragePath)) + } + + return Array.from(roots) + } + + private buildPreferredFileYearMonths(createTime?: unknown): string[] { + const raw = Number(createTime) + if (!Number.isFinite(raw) || raw <= 0) return [] + const ts = raw > 1e12 ? raw : raw * 1000 + const date = new Date(ts) + if (Number.isNaN(date.getTime())) return [] + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + return [`${y}-${m}`] + } + + private async verifyFileHash(sourcePath: string, expectedMd5?: string): Promise<boolean> { + const normalizedExpected = String(expectedMd5 || '').trim().toLowerCase() + if (!normalizedExpected) return true + if (!/^[a-f0-9]{32}$/i.test(normalizedExpected)) return true + try { + const hash = crypto.createHash('md5') + await new Promise<void>((resolve, reject) => { + const stream = fs.createReadStream(sourcePath) + stream.on('data', chunk => hash.update(chunk)) + stream.on('end', () => resolve()) + stream.on('error', reject) + }) + return hash.digest('hex').toLowerCase() === normalizedExpected + } catch { + return false + } + } + + private async resolveFileAttachmentCandidates(msg: any): Promise<FileExportCandidate[]> { + const fileName = String(msg?.fileName || '').trim() + if (!fileName) return [] + + const roots = this.resolveFileAttachmentRoots() + if (roots.length === 0) return [] + + const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase() + const preferredMonths = this.buildPreferredFileYearMonths(msg?.createTime) + const candidates: FileExportCandidate[] = [] + const seen = new Set<string>() + + for (const root of roots) { + let monthDirs: string[] = [] + try { + monthDirs = fs.readdirSync(root) + .filter(entry => /^\d{4}-\d{2}$/.test(entry) && fs.existsSync(path.join(root, entry))) + .sort() + } catch { + continue + } + + const orderedMonths = Array.from(new Set([ + ...preferredMonths, + ...monthDirs.slice().reverse() + ])) + + for (const month of orderedMonths) { + const sourcePath = path.join(root, month, fileName) + if (!fs.existsSync(sourcePath)) continue + const resolvedPath = path.resolve(sourcePath) + if (seen.has(resolvedPath)) continue + seen.add(resolvedPath) + + if (normalizedMd5) { + const ok = await this.verifyFileHash(resolvedPath, normalizedMd5) + if (ok) { + candidates.unshift({ sourcePath: resolvedPath, matchedBy: 'md5', yearMonth: month }) + continue + } + } + + candidates.push({ sourcePath: resolvedPath, matchedBy: 'name', yearMonth: month }) + } + } + + return candidates + } + + private async exportFileAttachment( + msg: any, + mediaRootDir: string, + mediaRelativePrefix: string, + maxFileSizeMb?: number, + dirCache?: Set<string> + ): Promise<MediaExportItem | null> { + try { + const fileNameRaw = String(msg?.fileName || '').trim() + if (!fileNameRaw) return null + + const filesDir = path.join(mediaRootDir, mediaRelativePrefix, 'files') + if (!dirCache?.has(filesDir)) { + await fs.promises.mkdir(filesDir, { recursive: true }) + dirCache?.add(filesDir) + } + + const candidates = await this.resolveFileAttachmentCandidates(msg) + if (candidates.length === 0) return null + + const maxBytes = Number.isFinite(maxFileSizeMb) + ? Math.max(0, Math.floor(Number(maxFileSizeMb) * 1024 * 1024)) + : 0 + + const selected = candidates[0] + const stat = await fs.promises.stat(selected.sourcePath) + if (!stat.isFile()) return null + if (maxBytes > 0 && stat.size > maxBytes) return null + + const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase() + if (normalizedMd5 && selected.matchedBy !== 'md5') { + const verified = await this.verifyFileHash(selected.sourcePath, normalizedMd5) + if (!verified) return null + } + + const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file' + const messageId = String(msg?.localId || Date.now()) + const destFileName = `${messageId}_${safeBaseName}` + const destPath = path.join(filesDir, destFileName) + const copied = await this.copyFileOptimized(selected.sourcePath, destPath) + if (!copied.success) return null + + this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size }) + return { + relativePath: path.posix.join(mediaRelativePrefix, 'files', destFileName), + kind: 'file' + } + } catch { + return null + } + } + private extractLocationMeta(content: string, localType: number): { locationLat?: number locationLng?: number @@ -3995,7 +4166,7 @@ class ExportService { mediaRelativePrefix: string } { const exportMediaEnabled = options.exportMedia === true && - Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) + Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles) const outputDir = path.dirname(outputPath) const rawWriteLayout = this.configService.get('exportWriteLayout') const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C' @@ -4932,7 +5103,8 @@ class ExportService { return (t === 3 && options.exportImages) || // 图片 (t === 47 && options.exportEmojis) || // 表情 (t === 43 && options.exportVideos) || // 视频 - (t === 34 && options.exportVoices) // 语音文件 + (t === 34 && options.exportVoices) || // 语音文件 + ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') }) : [] @@ -4973,6 +5145,8 @@ class ExportService { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, @@ -5441,7 +5615,8 @@ class ExportService { return (t === 3 && options.exportImages) || (t === 47 && options.exportEmojis) || (t === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) + (t === 34 && options.exportVoices) || + ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') }) : [] @@ -5481,6 +5656,8 @@ class ExportService { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, @@ -6301,7 +6478,8 @@ class ExportService { return (t === 3 && options.exportImages) || (t === 47 && options.exportEmojis) || (t === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) + (t === 34 && options.exportVoices) || + ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') }) : [] @@ -6341,6 +6519,8 @@ class ExportService { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, @@ -7014,7 +7194,8 @@ class ExportService { return (t === 3 && options.exportImages) || (t === 47 && options.exportEmojis) || (t === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) + (t === 34 && options.exportVoices) || + ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') }) : [] @@ -7054,6 +7235,8 @@ class ExportService { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, @@ -7391,7 +7574,8 @@ class ExportService { return (t === 3 && options.exportImages) || (t === 47 && options.exportEmojis) || (t === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) + (t === 34 && options.exportVoices) || + ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') }) : [] @@ -7431,6 +7615,8 @@ class ExportService { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, @@ -7851,6 +8037,8 @@ class ExportService { exportImages: options.exportImages, exportVoices: options.exportVoices, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', includeVoiceWithTranscript: true, @@ -8389,22 +8577,22 @@ class ExportService { const metric = aggregatedData?.[sessionId] const totalCount = Number.isFinite(metric?.totalMessages) - ? Math.max(0, Math.floor(metric!.totalMessages)) + ? Math.max(0, Math.floor(metric?.totalMessages ?? 0)) : 0 const voiceCount = Number.isFinite(metric?.voiceMessages) - ? Math.max(0, Math.floor(metric!.voiceMessages)) + ? Math.max(0, Math.floor(metric?.voiceMessages ?? 0)) : 0 const imageCount = Number.isFinite(metric?.imageMessages) - ? Math.max(0, Math.floor(metric!.imageMessages)) + ? Math.max(0, Math.floor(metric?.imageMessages ?? 0)) : 0 const videoCount = Number.isFinite(metric?.videoMessages) - ? Math.max(0, Math.floor(metric!.videoMessages)) + ? Math.max(0, Math.floor(metric?.videoMessages ?? 0)) : 0 const emojiCount = Number.isFinite(metric?.emojiMessages) - ? Math.max(0, Math.floor(metric!.emojiMessages)) + ? Math.max(0, Math.floor(metric?.emojiMessages ?? 0)) : 0 const lastTimestamp = Number.isFinite(metric?.lastTimestamp) - ? Math.max(0, Math.floor(metric!.lastTimestamp)) + ? Math.max(0, Math.floor(metric?.lastTimestamp ?? 0)) : undefined const cachedCountRaw = Number(cachedVoiceCountMap[sessionId] || 0) const sessionCachedVoiceCount = Math.min( diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index b5d039b..f183cfe 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -1,4 +1,4 @@ -import { join, dirname, basename } from 'path' +import { join, dirname, basename } from 'path' import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' import { tmpdir } from 'os' @@ -92,6 +92,9 @@ export class WcdbCore { private wcdbResolveImageHardlinkBatch: any = null private wcdbResolveVideoHardlinkMd5: any = null private wcdbResolveVideoHardlinkMd5Batch: any = null + private wcdbInstallMessageAntiRevokeTrigger: any = null + private wcdbUninstallMessageAntiRevokeTrigger: any = null + private wcdbCheckMessageAntiRevokeTrigger: any = null private wcdbInstallSnsBlockDeleteTrigger: any = null private wcdbUninstallSnsBlockDeleteTrigger: any = null private wcdbCheckSnsBlockDeleteTrigger: any = null @@ -163,7 +166,7 @@ export class WcdbCore { pipePath = this.koffi.decode(namePtr[0], 'char', -1) this.wcdbFreeString(namePtr[0]) } - } catch {} + } catch { } } this.connectMonitorPipe(pipePath) return true @@ -181,7 +184,7 @@ export class WcdbCore { setTimeout(() => { if (!this.monitorCallback) return - this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {}) + this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => { }) let buffer = '' this.monitorPipeClient.on('data', (data: Buffer) => { @@ -273,7 +276,7 @@ export class WcdbCore { const isArm64 = process.arch === 'arm64' const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll' const subDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '') - + const envDllPath = process.env.WCDB_DLL_PATH if (envDllPath && envDllPath.length > 0) { return envDllPath @@ -313,7 +316,7 @@ export class WcdbCore { '-2302': 'WCDB 初始化异常,请重试', '-2303': 'WCDB 未能成功初始化', } - const msg = messages[String(code) as keyof typeof messages] + const msg = messages[String(code) as unknown as keyof typeof messages] return msg ? `${msg} (错误码: ${code})` : `操作失败,错误码: ${code}` } @@ -643,7 +646,7 @@ export class WcdbCore { const dllDir = dirname(dllPath) const isMac = process.platform === 'darwin' const isLinux = process.platform === 'linux' - + // 预加载依赖库 if (isMac) { const wcdbCorePath = join(dllDir, 'libWCDB.dylib') @@ -1077,6 +1080,27 @@ export class WcdbCore { this.wcdbResolveVideoHardlinkMd5Batch = null } + // wcdb_status wcdb_install_message_anti_revoke_trigger(wcdb_handle handle, const char* session_id, char** out_error) + try { + this.wcdbInstallMessageAntiRevokeTrigger = this.lib.func('int32 wcdb_install_message_anti_revoke_trigger(int64 handle, const char* sessionId, _Out_ void** outError)') + } catch { + this.wcdbInstallMessageAntiRevokeTrigger = null + } + + // wcdb_status wcdb_uninstall_message_anti_revoke_trigger(wcdb_handle handle, const char* session_id, char** out_error) + try { + this.wcdbUninstallMessageAntiRevokeTrigger = this.lib.func('int32 wcdb_uninstall_message_anti_revoke_trigger(int64 handle, const char* sessionId, _Out_ void** outError)') + } catch { + this.wcdbUninstallMessageAntiRevokeTrigger = null + } + + // wcdb_status wcdb_check_message_anti_revoke_trigger(wcdb_handle handle, const char* session_id, int32_t* out_installed) + try { + this.wcdbCheckMessageAntiRevokeTrigger = this.lib.func('int32 wcdb_check_message_anti_revoke_trigger(int64 handle, const char* sessionId, _Out_ int32* outInstalled)') + } catch { + this.wcdbCheckMessageAntiRevokeTrigger = null + } + // wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error) try { this.wcdbInstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_install_sns_block_delete_trigger(int64 handle, _Out_ void** outError)') @@ -1337,12 +1361,12 @@ export class WcdbCore { const raw = String(jsonStr || '') if (!raw) return [] // 热路径优化:仅在检测到 16+ 位整数字段时才进行字符串包裹,避免每批次多轮全量 replace。 - const needsInt64Normalize = /"(?:server_id|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*-?\d{16,}/.test(raw) + const needsInt64Normalize = /"server_id"\s*:\s*-?\d{16,}/.test(raw) if (!needsInt64Normalize) { return JSON.parse(raw) } const normalized = raw.replace( - /("(?:server_id|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*)(-?\d{16,})/g, + /("server_id"\s*:\s*)(-?\d{16,})/g, '$1"$2"' ) return JSON.parse(normalized) @@ -1655,6 +1679,9 @@ export class WcdbCore { const outCount = [0] const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount) if (result !== 0) { + if (result === -7) { + return { success: false, error: 'message schema mismatch:当前账号消息表结构与程序要求不一致' } + } return { success: false, error: `获取消息总数失败: ${result}` } } return { success: true, count: outCount[0] } @@ -1685,6 +1712,9 @@ export class WcdbCore { const sessionId = normalizedSessionIds[i] const outCount = [0] const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount) + if (result === -7) { + return { success: false, error: `message schema mismatch:会话 ${sessionId} 的消息表结构不匹配` } + } counts[sessionId] = result === 0 && Number.isFinite(outCount[0]) ? Math.max(0, Math.floor(outCount[0])) : 0 if (i > 0 && i % 160 === 0) { @@ -1704,6 +1734,9 @@ export class WcdbCore { const outPtr = [null as any] const result = this.wcdbGetSessionMessageCounts(this.handle, JSON.stringify(sessionIds || []), outPtr) if (result !== 0 || !outPtr[0]) { + if (result === -7) { + return { success: false, error: 'message schema mismatch:当前账号消息表结构与程序要求不一致' } + } return { success: false, error: `获取会话消息总数失败: ${result}` } } const jsonStr = this.decodeJsonPtr(outPtr[0]) @@ -2661,7 +2694,9 @@ export class WcdbCore { ) const hint = result === -3 ? `创建游标失败: ${result}(消息数据库未找到)。如果你最近重装过微信,请尝试重新指定数据目录后重试` - : `创建游标失败: ${result},请查看日志` + : result === -7 + ? 'message schema mismatch:当前账号消息表结构与程序要求不一致' + : `创建游标失败: ${result},请查看日志` return { success: false, error: hint } } return { success: true, cursor: outCursor[0] } @@ -2719,6 +2754,9 @@ export class WcdbCore { `openMessageCursorLite failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`, true ) + if (result === -7) { + return { success: false, error: 'message schema mismatch:当前账号消息表结构与程序要求不一致' } + } return { success: false, error: `创建游标失败: ${result},请查看日志` } } return { success: true, cursor: outCursor[0] } @@ -2790,14 +2828,14 @@ export class WcdbCore { if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' } const fallbackFlag = /fallback|diag|diagnostic/i.test(String(sql || '')) this.writeLog(`[audit:execQuery] kind=${kind} path=${path || ''} sql_len=${String(sql || '').length} fallback=${fallbackFlag ? 1 : 0}`) - + // 如果提供了参数,使用参数化查询(需要 C++ 层支持) // 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现 // TODO: 需要更新 C++ 层的 wcdb_exec_query 以支持参数绑定 if (params && params.length > 0) { console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL(可能存在注入风险)') } - + const normalizedKind = String(kind || '').toLowerCase() const isContactQuery = normalizedKind === 'contact' || /\bfrom\s+contact\b/i.test(String(sql)) let effectivePath = path || '' @@ -3481,6 +3519,122 @@ export class WcdbCore { return { success: false, error: String(e) } } } + + async installMessageAntiRevokeTrigger(sessionId: string): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbInstallMessageAntiRevokeTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' } + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return { success: false, error: 'sessionId 不能为空' } + try { + const outPtr = [null] + const status = this.wcdbInstallMessageAntiRevokeTrigger(this.handle, normalizedSessionId, outPtr) + let msg = '' + if (outPtr[0]) { + try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { } + try { this.wcdbFreeString(outPtr[0]) } catch { } + } + if (status === 1) { + return { success: true, alreadyInstalled: true } + } + if (status !== 0) { + return { success: false, error: msg || `DLL error ${status}` } + } + return { success: true, alreadyInstalled: false } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async uninstallMessageAntiRevokeTrigger(sessionId: string): Promise<{ success: boolean; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbUninstallMessageAntiRevokeTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' } + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return { success: false, error: 'sessionId 不能为空' } + try { + const outPtr = [null] + const status = this.wcdbUninstallMessageAntiRevokeTrigger(this.handle, normalizedSessionId, outPtr) + let msg = '' + if (outPtr[0]) { + try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { } + try { this.wcdbFreeString(outPtr[0]) } catch { } + } + if (status !== 0) { + return { success: false, error: msg || `DLL error ${status}` } + } + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async checkMessageAntiRevokeTrigger(sessionId: string): Promise<{ success: boolean; installed?: boolean; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbCheckMessageAntiRevokeTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' } + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return { success: false, error: 'sessionId 不能为空' } + try { + const outInstalled = [0] + const status = this.wcdbCheckMessageAntiRevokeTrigger(this.handle, normalizedSessionId, outInstalled) + if (status !== 0) { + return { success: false, error: `DLL error ${status}` } + } + return { success: true, installed: outInstalled[0] === 1 } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async checkMessageAntiRevokeTriggers(sessionIds: string[]): Promise<{ + success: boolean + rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }> + error?: string + }> { + if (!Array.isArray(sessionIds) || sessionIds.length === 0) { + return { success: true, rows: [] } + } + const uniqueIds = Array.from(new Set(sessionIds.map((id) => String(id || '').trim()).filter(Boolean))) + const rows: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }> = [] + for (const sessionId of uniqueIds) { + const result = await this.checkMessageAntiRevokeTrigger(sessionId) + rows.push({ sessionId, success: result.success, installed: result.installed, error: result.error }) + } + return { success: true, rows } + } + + async installMessageAntiRevokeTriggers(sessionIds: string[]): Promise<{ + success: boolean + rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }> + error?: string + }> { + if (!Array.isArray(sessionIds) || sessionIds.length === 0) { + return { success: true, rows: [] } + } + const uniqueIds = Array.from(new Set(sessionIds.map((id) => String(id || '').trim()).filter(Boolean))) + const rows: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }> = [] + for (const sessionId of uniqueIds) { + const result = await this.installMessageAntiRevokeTrigger(sessionId) + rows.push({ sessionId, success: result.success, alreadyInstalled: result.alreadyInstalled, error: result.error }) + } + return { success: true, rows } + } + + async uninstallMessageAntiRevokeTriggers(sessionIds: string[]): Promise<{ + success: boolean + rows?: Array<{ sessionId: string; success: boolean; error?: string }> + error?: string + }> { + if (!Array.isArray(sessionIds) || sessionIds.length === 0) { + return { success: true, rows: [] } + } + const uniqueIds = Array.from(new Set(sessionIds.map((id) => String(id || '').trim()).filter(Boolean))) + const rows: Array<{ sessionId: string; success: boolean; error?: string }> = [] + for (const sessionId of uniqueIds) { + const result = await this.uninstallMessageAntiRevokeTrigger(sessionId) + rows.push({ sessionId, success: result.success, error: result.error }) + } + return { success: true, rows } + } + /** * 为朋友圈安装删除 */ diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index f52de6c..491538b 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -561,6 +561,24 @@ export class WcdbService { return this.callWorker('getSnsExportStats', { myWxid }) } + async checkMessageAntiRevokeTriggers( + sessionIds: string[] + ): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>; error?: string }> { + return this.callWorker('checkMessageAntiRevokeTriggers', { sessionIds }) + } + + async installMessageAntiRevokeTriggers( + sessionIds: string[] + ): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>; error?: string }> { + return this.callWorker('installMessageAntiRevokeTriggers', { sessionIds }) + } + + async uninstallMessageAntiRevokeTriggers( + sessionIds: string[] + ): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; error?: string }>; error?: string }> { + return this.callWorker('uninstallMessageAntiRevokeTriggers', { sessionIds }) + } + /** * 安装朋友圈删除拦截 */ diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 898084d..57b1045 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -230,6 +230,15 @@ if (parentPort) { case 'getSnsExportStats': result = await core.getSnsExportStats(payload.myWxid) break + case 'checkMessageAntiRevokeTriggers': + result = await core.checkMessageAntiRevokeTriggers(payload.sessionIds) + break + case 'installMessageAntiRevokeTriggers': + result = await core.installMessageAntiRevokeTriggers(payload.sessionIds) + break + case 'uninstallMessageAntiRevokeTriggers': + result = await core.uninstallMessageAntiRevokeTriggers(payload.sessionIds) + break case 'installSnsBlockDeleteTrigger': result = await core.installSnsBlockDeleteTrigger() break diff --git a/resources/arm64/wcdb_api.dll b/resources/arm64/wcdb_api.dll index de6bce8..78747ac 100644 Binary files a/resources/arm64/wcdb_api.dll and b/resources/arm64/wcdb_api.dll differ diff --git a/resources/linux/libwcdb_api.so b/resources/linux/libwcdb_api.so index d3c686a..0fa218c 100755 Binary files a/resources/linux/libwcdb_api.so and b/resources/linux/libwcdb_api.so differ diff --git a/resources/macos/libwcdb_api.dylib b/resources/macos/libwcdb_api.dylib index d185cfc..26b44d2 100755 Binary files a/resources/macos/libwcdb_api.dylib and b/resources/macos/libwcdb_api.dylib differ diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index c9609ad..2ba487b 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/resources/wcdb_api_arm64.dll b/resources/wcdb_api_arm64.dll deleted file mode 100644 index de6bce8..0000000 Binary files a/resources/wcdb_api_arm64.dll and /dev/null differ diff --git a/src/components/Export/ExportDefaultsSettingsForm.tsx b/src/components/Export/ExportDefaultsSettingsForm.tsx index 17090e2..6824e5b 100644 --- a/src/components/Export/ExportDefaultsSettingsForm.tsx +++ b/src/components/Export/ExportDefaultsSettingsForm.tsx @@ -66,7 +66,8 @@ export function ExportDefaultsSettingsForm({ images: true, videos: true, voices: true, - emojis: true + emojis: true, + files: true }) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) @@ -94,7 +95,8 @@ export function ExportDefaultsSettingsForm({ images: true, videos: true, voices: true, - emojis: true + emojis: true, + files: true }) setExportDefaultVoiceAsText(savedVoiceAsText ?? false) setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) @@ -292,7 +294,7 @@ export function ExportDefaultsSettingsForm({ <div className="form-group media-setting-group"> <div className="form-copy"> <label>默认导出媒体内容</label> - <span className="form-hint">控制图片、视频、语音、表情包的默认导出开关</span> + <span className="form-hint">控制图片、视频、语音、表情包、文件的默认导出开关</span> </div> <div className="form-control"> <div className="media-default-grid"> @@ -352,6 +354,20 @@ export function ExportDefaultsSettingsForm({ /> 表情包 </label> + <label> + <input + type="checkbox" + checked={exportDefaultMedia.files} + onChange={async (e) => { + const next = { ...exportDefaultMedia, files: e.target.checked } + setExportDefaultMedia(next) + await configService.setExportDefaultMedia(next) + onDefaultsChanged?.({ media: next }) + notify(`已${e.target.checked ? '开启' : '关闭'}默认导出文件`, true) + }} + /> + 文件 + </label> </div> </div> </div> diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 79f415e..b1a959f 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2127,6 +2127,24 @@ display: block; box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08); -webkit-app-region: no-drag; + transition: opacity 0.18s ease; +} + +.image-message.pending { + opacity: 0; +} + +.image-message.ready { + opacity: 1; +} + +.image-stage { + display: inline-block; + -webkit-app-region: no-drag; +} + +.image-stage.locked { + overflow: hidden; } .image-message-wrapper { diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index a2d1fed..7cd8cb2 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { createPortal } from 'react-dom' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' @@ -64,6 +64,9 @@ const GLOBAL_MSG_LEGACY_CONCURRENCY = 6 const GLOBAL_MSG_SEARCH_CANCELED_ERROR = '__WEFLOW_GLOBAL_MSG_SEARCH_CANCELED__' const GLOBAL_MSG_SHADOW_COMPARE_SAMPLE_RATE = 0.2 const GLOBAL_MSG_SHADOW_COMPARE_STORAGE_KEY = 'weflow.debug.searchShadowCompare' +const MESSAGE_LIST_SCROLL_IDLE_MS = 160 +const MESSAGE_TOP_WHEEL_LOAD_COOLDOWN_MS = 160 +const MESSAGE_EDGE_TRIGGER_DISTANCE_PX = 96 function isGlobalMsgSearchCanceled(error: unknown): boolean { return String(error || '') === GLOBAL_MSG_SEARCH_CANCELED_ERROR @@ -210,6 +213,12 @@ function sortMessagesByCreateTimeDesc<T extends Pick<Message, 'createTime' | 'lo }) } +function isRenderableImageSrc(value?: string | null): boolean { + const src = String(value || '').trim() + if (!src) return false + return /^(https?:\/\/|data:image\/|blob:|file:\/\/|\/)/i.test(src) +} + function normalizeSearchIdentityText(value?: string | null): string | undefined { const normalized = String(value || '').trim() if (!normalized) return undefined @@ -1179,7 +1188,12 @@ function ChatPage(props: ChatPageProps) { const visibleMessageRangeRef = useRef<{ startIndex: number; endIndex: number }>({ startIndex: 0, endIndex: 0 }) const topRangeLoadLockRef = useRef(false) const bottomRangeLoadLockRef = useRef(false) + const topRangeLoadLastTriggerAtRef = useRef(0) const suppressAutoLoadLaterRef = useRef(false) + const suppressAutoScrollOnNextMessageGrowthRef = useRef(false) + const prependingHistoryRef = useRef(false) + const isMessageListScrollingRef = useRef(false) + const messageListScrollTimeoutRef = useRef<number | null>(null) const searchInputRef = useRef<HTMLInputElement>(null) const sidebarRef = useRef<HTMLDivElement>(null) const handleMessageListScrollParentRef = useCallback((node: HTMLDivElement | null) => { @@ -1400,6 +1414,18 @@ function ChatPage(props: ChatPageProps) { }, delayMs) }, []) + const markMessageListScrolling = useCallback(() => { + isMessageListScrollingRef.current = true + if (messageListScrollTimeoutRef.current !== null) { + window.clearTimeout(messageListScrollTimeoutRef.current) + messageListScrollTimeoutRef.current = null + } + messageListScrollTimeoutRef.current = window.setTimeout(() => { + isMessageListScrollingRef.current = false + messageListScrollTimeoutRef.current = null + }, MESSAGE_LIST_SCROLL_IDLE_MS) + }, []) + const isGroupChatSession = useCallback((username: string) => { return username.includes('@chatroom') }, []) @@ -3246,6 +3272,29 @@ function ChatPage(props: ChatPageProps) { runWarmup() }, [loadContactInfoBatch]) + const scheduleGroupSenderWarmup = useCallback((usernames: string[], defer = false) => { + if (!Array.isArray(usernames) || usernames.length === 0) return + const run = () => warmupGroupSenderProfiles(usernames, false) + if (!defer && !isMessageListScrollingRef.current) { + run() + return + } + + const runWhenIdle = () => { + if (isMessageListScrollingRef.current) { + window.setTimeout(runWhenIdle, MESSAGE_LIST_SCROLL_IDLE_MS) + return + } + run() + } + + if ('requestIdleCallback' in window) { + window.requestIdleCallback(runWhenIdle, { timeout: 1200 }) + } else { + window.setTimeout(runWhenIdle, MESSAGE_LIST_SCROLL_IDLE_MS) + } + }, [warmupGroupSenderProfiles]) + // 加载消息 const loadMessages = async ( sessionId: string, @@ -3255,6 +3304,10 @@ function ChatPage(props: ChatPageProps) { ascending = false, options: LoadMessagesOptions = {} ) => { + const isPrependHistoryLoad = offset > 0 && !ascending + if (isPrependHistoryLoad) { + prependingHistoryRef.current = true + } const listEl = messageListRef.current const session = sessionMapRef.current.get(sessionId) const unreadCount = session?.unreadCount ?? 0 @@ -3288,10 +3341,6 @@ function ChatPage(props: ChatPageProps) { Math.max(visibleRange.startIndex, 0), Math.max(messages.length - 1, 0) ) - const anchorMessageKeyBeforePrepend = offset > 0 && messages.length > 0 - ? getMessageKey(messages[visibleStartIndex]) - : null - // 记录加载前的第一条消息元素(非虚拟列表回退路径) const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null @@ -3340,12 +3389,11 @@ function ChatPage(props: ChatPageProps) { .map(m => m.senderUsername as string) )] if (unknownSenders.length > 0) { - warmupGroupSenderProfiles(unknownSenders, options.deferGroupSenderWarmup === true) + scheduleGroupSenderWarmup(unknownSenders, options.deferGroupSenderWarmup === true) } } // 日期跳转时滚动到顶部,否则滚动到底部 - const loadedMessages = result.messages requestAnimationFrame(() => { if (isDateJumpRef.current) { if (messageVirtuosoRef.current && resultMessages.length > 0) { @@ -3365,6 +3413,19 @@ function ChatPage(props: ChatPageProps) { } }) } else { + const existingMessageKeys = messageKeySetRef.current + const incomingSeen = new Set<string>() + let prependedInsertedCount = 0 + for (const row of resultMessages) { + const key = getMessageKey(row) + if (incomingSeen.has(key)) continue + incomingSeen.add(key) + if (!existingMessageKeys.has(key)) { + prependedInsertedCount += 1 + } + } + + suppressAutoScrollOnNextMessageGrowthRef.current = true appendMessages(resultMessages, true) // 加载更多也同样处理发送者信息预取 @@ -3375,24 +3436,20 @@ function ChatPage(props: ChatPageProps) { .map(m => m.senderUsername as string) )] if (unknownSenders.length > 0) { - warmupGroupSenderProfiles(unknownSenders, false) + scheduleGroupSenderWarmup(unknownSenders, false) } } // 加载更早消息后保持视口锚点,避免跳屏 - const appendedMessages = result.messages requestAnimationFrame(() => { if (messageVirtuosoRef.current) { - if (anchorMessageKeyBeforePrepend) { - const latestMessages = useChatStore.getState().messages || [] - const anchorIndex = latestMessages.findIndex((msg) => getMessageKey(msg) === anchorMessageKeyBeforePrepend) - if (anchorIndex >= 0) { - messageVirtuosoRef.current.scrollToIndex({ index: anchorIndex, align: 'start', behavior: 'auto' }) - return - } - } - if (resultMessages.length > 0) { - messageVirtuosoRef.current.scrollToIndex({ index: resultMessages.length, align: 'start', behavior: 'auto' }) + const latestMessages = useChatStore.getState().messages || [] + const anchorIndex = Math.min( + Math.max(visibleStartIndex + prependedInsertedCount, 0), + Math.max(latestMessages.length - 1, 0) + ) + if (latestMessages.length > 0) { + messageVirtuosoRef.current.scrollToIndex({ index: anchorIndex, align: 'start', behavior: 'auto' }) } return } @@ -3432,6 +3489,11 @@ function ChatPage(props: ChatPageProps) { setMessages([]) } } finally { + if (isPrependHistoryLoad) { + requestAnimationFrame(() => { + prependingHistoryRef.current = false + }) + } setLoadingMessages(false) setLoadingMore(false) if (offset === 0 && pendingSessionLoadRef.current === sessionId) { @@ -3462,9 +3524,11 @@ function ChatPage(props: ChatPageProps) { setCurrentOffset(0) setJumpStartTime(0) setJumpEndTime(end) + suppressAutoLoadLaterRef.current = true setShowJumpPopover(false) void loadMessages(targetSessionId, 0, 0, end, false, { - switchRequestSeq: options.switchRequestSeq + switchRequestSeq: options.switchRequestSeq, + forceInitialLimit: 120 }) }, [currentSessionId, loadMessages]) @@ -4380,36 +4444,6 @@ function ChatPage(props: ChatPageProps) { return } - if (range.endIndex >= Math.max(total - 2, 0)) { - isMessageListAtBottomRef.current = true - setShowScrollToBottom(prev => (prev ? false : prev)) - } - - if ( - range.startIndex <= 2 && - !topRangeLoadLockRef.current && - !isLoadingMore && - !isLoadingMessages && - hasMoreMessages && - currentSessionId - ) { - topRangeLoadLockRef.current = true - void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime) - } - - if ( - range.endIndex >= total - 3 && - !bottomRangeLoadLockRef.current && - !suppressAutoLoadLaterRef.current && - !isLoadingMore && - !isLoadingMessages && - hasMoreLater && - currentSessionId - ) { - bottomRangeLoadLockRef.current = true - void loadLaterMessages() - } - if (shouldWarmupVisibleGroupSenders) { const now = Date.now() if (now - lastVisibleSenderWarmupAtRef.current >= 180) { @@ -4428,27 +4462,18 @@ function ChatPage(props: ChatPageProps) { if (pendingUsernames.size >= 24) break } if (pendingUsernames.size > 0) { - warmupGroupSenderProfiles([...pendingUsernames], false) + scheduleGroupSenderWarmup([...pendingUsernames], false) } } } }, [ messages.length, - isLoadingMore, - isLoadingMessages, - hasMoreMessages, - hasMoreLater, currentSessionId, - currentOffset, - jumpStartTime, - jumpEndTime, isGroupChatSession, standaloneSessionWindow, normalizedInitialSessionId, normalizedStandaloneInitialContactType, - warmupGroupSenderProfiles, - loadMessages, - loadLaterMessages + scheduleGroupSenderWarmup ]) const handleMessageAtBottomStateChange = useCallback((atBottom: boolean) => { @@ -4462,9 +4487,8 @@ function ChatPage(props: ChatPageProps) { const distanceFromBottom = listEl ? (listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight)) : Number.POSITIVE_INFINITY - const nearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(messages.length - 2, 0) const nearBottomByDistance = distanceFromBottom <= 140 - const effectiveAtBottom = atBottom || nearBottomByRange || nearBottomByDistance + const effectiveAtBottom = atBottom || nearBottomByDistance isMessageListAtBottomRef.current = effectiveAtBottom if (!effectiveAtBottom) { @@ -4492,19 +4516,48 @@ function ChatPage(props: ChatPageProps) { }, [messages.length, isLoadingMessages, isLoadingMore, isSessionSwitching]) const handleMessageListWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => { - if (event.deltaY <= 18) return - if (!currentSessionId || isLoadingMore || isLoadingMessages || !hasMoreLater) return + markMessageListScrolling() + if (!currentSessionId || isLoadingMore || isLoadingMessages) return const listEl = messageListRef.current if (!listEl) return + const distanceFromTop = listEl.scrollTop const distanceFromBottom = listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight) - if (distanceFromBottom > 96) return + + if (event.deltaY <= -18) { + if (!hasMoreMessages) return + if (distanceFromTop > MESSAGE_EDGE_TRIGGER_DISTANCE_PX) return + if (topRangeLoadLockRef.current) return + const now = Date.now() + if (now - topRangeLoadLastTriggerAtRef.current < MESSAGE_TOP_WHEEL_LOAD_COOLDOWN_MS) return + topRangeLoadLastTriggerAtRef.current = now + topRangeLoadLockRef.current = true + isMessageListAtBottomRef.current = false + void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime) + return + } + + if (event.deltaY <= 18) return + if (!hasMoreLater) return + if (distanceFromBottom > MESSAGE_EDGE_TRIGGER_DISTANCE_PX) return if (bottomRangeLoadLockRef.current) return // 用户明确向下滚动时允许加载后续消息 suppressAutoLoadLaterRef.current = false bottomRangeLoadLockRef.current = true void loadLaterMessages() - }, [currentSessionId, hasMoreLater, isLoadingMessages, isLoadingMore, loadLaterMessages]) + }, [ + currentSessionId, + hasMoreLater, + hasMoreMessages, + isLoadingMessages, + isLoadingMore, + currentOffset, + jumpStartTime, + jumpEndTime, + markMessageListScrolling, + loadMessages, + loadLaterMessages + ]) const handleMessageAtTopStateChange = useCallback((atTop: boolean) => { if (!atTop) { @@ -4659,6 +4712,11 @@ function ChatPage(props: ChatPageProps) { if (sessionScrollTimeoutRef.current) { clearTimeout(sessionScrollTimeoutRef.current) } + if (messageListScrollTimeoutRef.current !== null) { + window.clearTimeout(messageListScrollTimeoutRef.current) + messageListScrollTimeoutRef.current = null + } + isMessageListScrollingRef.current = false contactUpdateQueueRef.current.clear() pendingSessionContactEnrichRef.current.clear() sessionContactEnrichAttemptAtRef.current.clear() @@ -4699,8 +4757,12 @@ function ChatPage(props: ChatPageProps) { lastObservedMessageCountRef.current = currentCount if (currentCount <= previousCount) return if (!currentSessionId || isLoadingMessages || isSessionSwitching) return - const wasNearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(previousCount - 2, 0) - if (!isMessageListAtBottomRef.current && !wasNearBottomByRange) return + if (suppressAutoScrollOnNextMessageGrowthRef.current || prependingHistoryRef.current) { + suppressAutoScrollOnNextMessageGrowthRef.current = false + return + } + if (!isMessageListAtBottomRef.current) return + if (suppressAutoLoadLaterRef.current) return suppressScrollToBottomButton(220) isMessageListAtBottomRef.current = true requestAnimationFrame(() => { @@ -6603,6 +6665,7 @@ function ChatPage(props: ChatPageProps) { <div className={`message-list ${hasInitialMessages ? 'loaded' : 'loading'}`} ref={handleMessageListScrollParentRef} + onScroll={markMessageListScrolling} onWheel={handleMessageListWheel} > {!isLoadingMessages && messages.length === 0 && !hasMoreMessages ? ( @@ -6616,8 +6679,12 @@ function ChatPage(props: ChatPageProps) { className="message-virtuoso" customScrollParent={messageListScrollParent ?? undefined} data={messages} - overscan={360} - followOutput={(atBottom) => (atBottom || isMessageListAtBottomRef.current ? 'auto' : false)} + overscan={220} + followOutput={(atBottom) => ( + prependingHistoryRef.current + ? false + : (atBottom && isMessageListAtBottomRef.current ? 'auto' : false) + )} atBottomThreshold={80} atBottomStateChange={handleMessageAtBottomStateChange} atTopStateChange={handleMessageAtTopStateChange} @@ -7659,6 +7726,8 @@ function MessageBubble({ // State variables... const [imageError, setImageError] = useState(false) const [imageLoading, setImageLoading] = useState(false) + const [imageLoaded, setImageLoaded] = useState(false) + const [imageStageLockHeight, setImageStageLockHeight] = useState<number | null>(null) const [imageHasUpdate, setImageHasUpdate] = useState(false) const [imageClicked, setImageClicked] = useState(false) const imageUpdateCheckedRef = useRef<string | null>(null) @@ -7704,6 +7773,11 @@ function MessageBubble({ const videoContainerRef = useRef<HTMLElement>(null) const [isVideoVisible, setIsVideoVisible] = useState(false) const [videoMd5, setVideoMd5] = useState<string | null>(null) + const imageStageLockStyle = useMemo<React.CSSProperties | undefined>(() => ( + imageStageLockHeight && imageStageLockHeight > 0 + ? { height: `${Math.round(imageStageLockHeight)}px` } + : undefined + ), [imageStageLockHeight]) // 解析视频 MD5 useEffect(() => { @@ -7847,6 +7921,14 @@ function MessageBubble({ captureResizeBaseline(imageContainerRef.current, imageResizeBaselineRef) }, [captureResizeBaseline]) + const lockImageStageHeight = useCallback(() => { + const host = imageContainerRef.current + if (!host) return + const height = host.getBoundingClientRect().height + if (!Number.isFinite(height) || height <= 0) return + setImageStageLockHeight(Math.round(height)) + }, []) + const captureEmojiResizeBaseline = useCallback(() => { captureResizeBaseline(emojiContainerRef.current, emojiResizeBaselineRef) }, [captureResizeBaseline]) @@ -7855,6 +7937,12 @@ function MessageBubble({ stabilizeScrollAfterResize(imageContainerRef.current, imageResizeBaselineRef) }, [stabilizeScrollAfterResize]) + const releaseImageStageLock = useCallback(() => { + window.requestAnimationFrame(() => { + setImageStageLockHeight(null) + }) + }, []) + const stabilizeEmojiScrollAfterResize = useCallback(() => { stabilizeScrollAfterResize(emojiContainerRef.current, emojiResizeBaselineRef) }, [stabilizeScrollAfterResize]) @@ -8008,6 +8096,7 @@ function MessageBubble({ imageDataUrlCache.set(imageCacheKey, result.localPath) if (imageLocalPath !== result.localPath) { captureImageResizeBaseline() + lockImageStageHeight() } setImageLocalPath(result.localPath) setImageHasUpdate(false) @@ -8023,6 +8112,7 @@ function MessageBubble({ imageDataUrlCache.set(imageCacheKey, dataUrl) if (imageLocalPath !== dataUrl) { captureImageResizeBaseline() + lockImageStageHeight() } setImageLocalPath(dataUrl) setImageHasUpdate(false) @@ -8036,7 +8126,7 @@ function MessageBubble({ imageDecryptPendingRef.current = false } return { success: false } - }, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline]) + }, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight]) const triggerForceHd = useCallback(() => { if (!message.imageMd5 && !message.imageDatName) return @@ -8099,6 +8189,7 @@ function MessageBubble({ imageDataUrlCache.set(imageCacheKey, resolved.localPath) if (imageLocalPath !== resolved.localPath) { captureImageResizeBaseline() + lockImageStageHeight() } setImageLocalPath(resolved.localPath) if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath) @@ -8113,6 +8204,7 @@ function MessageBubble({ imageLocalPath, imageCacheKey, captureImageResizeBaseline, + lockImageStageHeight, message.imageDatName, message.imageMd5, requestImageDecrypt, @@ -8127,6 +8219,16 @@ function MessageBubble({ } }, []) + useEffect(() => { + setImageLoaded(false) + }, [imageLocalPath]) + + useEffect(() => { + if (imageLoading) return + if (!imageError && imageLocalPath) return + setImageStageLockHeight(null) + }, [imageError, imageLoading, imageLocalPath]) + useEffect(() => { if (!isImage || imageLoading) return if (!message.imageMd5 && !message.imageDatName) return @@ -8143,6 +8245,7 @@ function MessageBubble({ imageDataUrlCache.set(imageCacheKey, result.localPath) if (!imageLocalPath || imageLocalPath !== result.localPath) { captureImageResizeBaseline() + lockImageStageHeight() setImageLocalPath(result.localPath) setImageError(false) } @@ -8153,7 +8256,7 @@ function MessageBubble({ return () => { cancelled = true } - }, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline]) + }, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline, lockImageStageHeight]) useEffect(() => { if (!isImage) return @@ -8187,6 +8290,7 @@ function MessageBubble({ } if (imageLocalPath !== payload.localPath) { captureImageResizeBaseline() + lockImageStageHeight() } setImageLocalPath((prev) => (prev === payload.localPath ? prev : payload.localPath)) setImageError(false) @@ -8195,7 +8299,7 @@ function MessageBubble({ return () => { unsubscribe?.() } - }, [isImage, imageCacheKey, imageLocalPath, message.imageDatName, message.imageMd5, captureImageResizeBaseline]) + }, [isImage, imageCacheKey, imageLocalPath, message.imageDatName, message.imageMd5, captureImageResizeBaseline, lockImageStageHeight]) // 图片进入视野前自动解密(懒加载) useEffect(() => { @@ -8578,6 +8682,19 @@ function MessageBubble({ appMsgTextCache.set(selector, value) return value }, [appMsgDoc, appMsgTextCache]) + const appMsgThumbRawCandidate = useMemo(() => ( + message.linkThumb || + message.appMsgThumbUrl || + queryAppMsgText('appmsg > thumburl') || + queryAppMsgText('appmsg > cdnthumburl') || + queryAppMsgText('appmsg > cover') || + queryAppMsgText('appmsg > coverurl') || + queryAppMsgText('thumburl') || + queryAppMsgText('cdnthumburl') || + queryAppMsgText('cover') || + queryAppMsgText('coverurl') || + '' + ).trim(), [message.linkThumb, message.appMsgThumbUrl, queryAppMsgText]) const quotedSenderUsername = resolveQuotedSenderUsername( queryAppMsgText('refermsg > fromusr'), queryAppMsgText('refermsg > chatusr') @@ -8711,6 +8828,17 @@ function MessageBubble({ // Selection mode handling removed from here to allow normal rendering // We will wrap the output instead if (isSystem) { + const isPatSystemMessage = message.localType === 266287972401 + const patTitleRaw = isPatSystemMessage + ? (queryAppMsgText('appmsg > title') || queryAppMsgText('title') || message.parsedContent || '') + : '' + const patDisplayText = isPatSystemMessage + ? cleanMessageContent(String(patTitleRaw).replace(/^\s*\[拍一拍\]\s*/i, '')) + : '' + const systemContentNode = isPatSystemMessage + ? renderTextWithEmoji(patDisplayText || '拍一拍') + : message.parsedContent + return ( <div className={`message-bubble system ${isSelectionMode ? 'selectable' : ''}`} @@ -8739,7 +8867,7 @@ function MessageBubble({ {isSelected && <Check size={14} strokeWidth={3} />} </div> )} - <div className="bubble-content">{message.parsedContent}</div> + <div className="bubble-content">{systemContentNode}</div> </div> ) } @@ -8748,7 +8876,11 @@ function MessageBubble({ const renderContent = () => { if (isImage) { return ( - <div ref={imageContainerRef}> + <div + ref={imageContainerRef} + className={`image-stage ${imageStageLockHeight ? 'locked' : ''}`} + style={imageStageLockStyle} + > {imageLoading ? ( <div className="image-loading"> <Loader2 size={20} className="spin" /> @@ -8770,15 +8902,19 @@ function MessageBubble({ <img src={imageLocalPath} alt="图片" - className="image-message" + className={`image-message ${imageLoaded ? 'ready' : 'pending'}`} onClick={() => { void handleOpenImageViewer() }} onLoad={() => { + setImageLoaded(true) setImageError(false) stabilizeImageScrollAfterResize() + releaseImageStageLock() }} onError={() => { imageResizeBaselineRef.current = null + setImageLoaded(false) setImageError(true) + releaseImageStageLock() }} /> {imageLiveVideoPath && ( @@ -9104,6 +9240,12 @@ function MessageBubble({ const xmlType = message.xmlType || q('appmsg > type') || q('type') + // type 62: 拍一拍(按普通文本渲染,支持 [烟花] 这类 emoji 占位符) + if (xmlType === '62') { + const patText = cleanMessageContent((q('title') || cleanedParsedContent || '').replace(/^\s*\[拍一拍\]\s*/i, '')) + return <div className="bubble-content">{renderTextWithEmoji(patText || '拍一拍')}</div> + } + // type 57: 引用回复消息,解析 refermsg 渲染为引用样式 if (xmlType === '57') { const replyText = q('title') || cleanedParsedContent || '' @@ -9147,7 +9289,8 @@ function MessageBubble({ const title = message.linkTitle || q('title') || cleanedParsedContent || 'Card' const desc = message.appMsgDesc || q('des') const url = message.linkUrl || q('url') - const thumbUrl = message.linkThumb || message.appMsgThumbUrl || q('thumburl') || q('cdnthumburl') || q('cover') || q('coverurl') + const fallbackThumbUrl = appMsgThumbRawCandidate + const thumbUrl = isRenderableImageSrc(fallbackThumbUrl) ? fallbackThumbUrl : '' const musicUrl = message.appMsgMusicUrl || message.appMsgDataUrl || q('musicurl') || q('playurl') || q('dataurl') || q('lowurl') const sourceName = message.appMsgSourceName || q('sourcename') const sourceDisplayName = q('sourcedisplayname') || '' @@ -9221,9 +9364,7 @@ function MessageBubble({ loading="lazy" referrerPolicy="no-referrer" /> - ) : ( - <div className={`link-thumb-placeholder ${cardKind}`}>{cardKind.slice(0, 2).toUpperCase()}</div> - )} + ) : null} </div> </div> ) @@ -9663,9 +9804,6 @@ function MessageBubble({ </div> <div className="link-body"> <div className="link-desc" title={desc}>{desc}</div> - <div className="link-thumb-placeholder"> - <Link size={24} /> - </div> </div> </div> ) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index ae5f9e7..a1d1f5c 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -67,7 +67,7 @@ import './ExportPage.scss' type ConversationTab = 'private' | 'group' | 'official' | 'former_friend' type TaskStatus = 'queued' | 'running' | 'success' | 'error' type TaskScope = 'single' | 'multi' | 'content' | 'sns' -type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' +type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file' type ContentCardType = ContentType | 'sns' type SnsRankMode = 'likes' | 'comments' @@ -88,6 +88,8 @@ interface ExportOptions { exportVoices: boolean exportVideos: boolean exportEmojis: boolean + exportFiles: boolean + maxFileSizeMb: number exportVoiceAsText: boolean excelCompactColumns: boolean txtColumns: string[] @@ -181,6 +183,7 @@ interface ExportDialogState { const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000 +const TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS = 900 const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10 const SESSION_MEDIA_METRIC_BATCH_SIZE = 8 const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48 @@ -195,7 +198,8 @@ const contentTypeLabels: Record<ContentType, string> = { voice: '语音', image: '图片', video: '视频', - emoji: '表情包' + emoji: '表情包', + file: '文件' } const backgroundTaskSourceLabels: Record<string, string> = { @@ -311,9 +315,7 @@ const cloneTaskPerformance = (performance?: TaskPerformance): TaskPerformance => write: performance?.stages.write || 0, other: performance?.stages.other || 0 }, - sessions: Object.fromEntries( - Object.entries(performance?.sessions || {}).map(([sessionId, session]) => [sessionId, { ...session }]) - ) + sessions: { ...(performance?.sessions || {}) } }) const resolveTaskSessionName = (task: ExportTask, sessionId: string, fallback?: string): string => { @@ -333,6 +335,18 @@ const applyProgressToTaskPerformance = ( const sessionId = String(payload.currentSessionId || '').trim() if (!sessionId) return task.performance || createEmptyTaskPerformance() + const currentPerformance = task.performance + const currentSession = currentPerformance?.sessions?.[sessionId] + if ( + payload.phase !== 'complete' && + currentSession && + currentSession.lastPhase === payload.phase && + typeof currentSession.lastPhaseStartedAt === 'number' && + now - currentSession.lastPhaseStartedAt < TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS + ) { + return currentPerformance + } + const performance = cloneTaskPerformance(task.performance) const sessionName = resolveTaskSessionName(task, sessionId, payload.currentSession || sessionId) const existing = performance.sessions[sessionId] @@ -368,7 +382,9 @@ const applyProgressToTaskPerformance = ( const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance | undefined => { if (!isTextBatchTask(task) || !task.performance) return task.performance const performance = cloneTaskPerformance(task.performance) - for (const session of Object.values(performance.sessions)) { + const nextSessions: Record<string, TaskSessionPerformance> = {} + for (const [sessionId, sourceSession] of Object.entries(performance.sessions)) { + const session: TaskSessionPerformance = { ...sourceSession } if (session.finishedAt) continue if (session.lastPhase && typeof session.lastPhaseStartedAt === 'number') { const delta = Math.max(0, now - session.lastPhaseStartedAt) @@ -378,7 +394,13 @@ const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance session.finishedAt = now session.lastPhase = undefined session.lastPhaseStartedAt = undefined + nextSessions[sessionId] = session } + for (const [sessionId, sourceSession] of Object.entries(performance.sessions)) { + if (nextSessions[sessionId]) continue + nextSessions[sessionId] = { ...sourceSession } + } + performance.sessions = nextSessions return performance } @@ -1598,7 +1620,8 @@ function ExportPage() { images: true, videos: true, voices: true, - emojis: true + emojis: true, + files: true }) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) @@ -1617,7 +1640,9 @@ function ExportPage() { exportImages: true, exportVoices: true, exportVideos: true, - exportEmojis: true, + exportEmojis: true, + exportFiles: true, + maxFileSizeMb: 200, exportVoiceAsText: false, excelCompactColumns: true, txtColumns: defaultTxtColumns, @@ -2281,7 +2306,8 @@ function ExportPage() { images: true, videos: true, voices: true, - emojis: true + emojis: true, + files: true }) setExportDefaultVoiceAsText(savedVoiceAsText ?? false) setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) @@ -2310,12 +2336,14 @@ function ExportPage() { (savedMedia?.images ?? prev.exportImages) || (savedMedia?.voices ?? prev.exportVoices) || (savedMedia?.videos ?? prev.exportVideos) || - (savedMedia?.emojis ?? prev.exportEmojis) + (savedMedia?.emojis ?? prev.exportEmojis) || + (savedMedia?.files ?? prev.exportFiles) ), exportImages: savedMedia?.images ?? prev.exportImages, exportVoices: savedMedia?.voices ?? prev.exportVoices, exportVideos: savedMedia?.videos ?? prev.exportVideos, exportEmojis: savedMedia?.emojis ?? prev.exportEmojis, + exportFiles: savedMedia?.files ?? prev.exportFiles, exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText, excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns, txtColumns, @@ -4088,12 +4116,15 @@ function ExportPage() { exportDefaultMedia.images || exportDefaultMedia.voices || exportDefaultMedia.videos || - exportDefaultMedia.emojis + exportDefaultMedia.emojis || + exportDefaultMedia.files ), exportImages: exportDefaultMedia.images, exportVoices: exportDefaultMedia.voices, exportVideos: exportDefaultMedia.videos, exportEmojis: exportDefaultMedia.emojis, + exportFiles: exportDefaultMedia.files, + maxFileSizeMb: prev.maxFileSizeMb, exportVoiceAsText: exportDefaultVoiceAsText, excelCompactColumns: exportDefaultExcelCompactColumns, exportConcurrency: exportDefaultConcurrency, @@ -4111,12 +4142,14 @@ function ExportPage() { next.exportVoices = false next.exportVideos = false next.exportEmojis = false + next.exportFiles = false } else { next.exportMedia = true next.exportImages = payload.contentType === 'image' next.exportVoices = payload.contentType === 'voice' next.exportVideos = payload.contentType === 'video' next.exportEmojis = payload.contentType === 'emoji' + next.exportFiles = payload.contentType === 'file' next.exportVoiceAsText = false } } @@ -4335,7 +4368,13 @@ function ExportPage() { const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => { const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared' - const exportMediaEnabled = Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) + const exportMediaEnabled = Boolean( + options.exportImages || + options.exportVoices || + options.exportVideos || + options.exportEmojis || + options.exportFiles + ) const base: ElectronExportOptions = { format: options.format, @@ -4345,6 +4384,8 @@ function ExportPage() { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, excelCompactColumns: options.excelCompactColumns, txtColumns: options.txtColumns, @@ -4375,7 +4416,8 @@ function ExportPage() { exportImages: false, exportVoices: false, exportVideos: false, - exportEmojis: false + exportEmojis: false, + exportFiles: false } } @@ -4387,6 +4429,7 @@ function ExportPage() { exportVoices: contentType === 'voice', exportVideos: contentType === 'video', exportEmojis: contentType === 'emoji', + exportFiles: contentType === 'file', exportVoiceAsText: false } } @@ -4452,6 +4495,7 @@ function ExportPage() { if (opts.exportVoices) labels.push('语音') if (opts.exportVideos) labels.push('视频') if (opts.exportEmojis) labels.push('表情包') + if (opts.exportFiles) labels.push('文件') } return Array.from(new Set(labels)).join('、') }, []) @@ -4507,6 +4551,7 @@ function ExportPage() { if (opts.exportImages) types.push('image') if (opts.exportVideos) types.push('video') if (opts.exportEmojis) types.push('emoji') + if (opts.exportFiles) types.push('file') } return types } @@ -4697,7 +4742,7 @@ function ExportPage() { queuedProgressTimer = window.setTimeout(() => { queuedProgressTimer = null flushQueuedProgress() - }, 100) + }, 180) }) } if (next.payload.scope === 'sns') { @@ -4937,7 +4982,8 @@ function ExportPage() { images: options.exportImages, voices: options.exportVoices, videos: options.exportVideos, - emojis: options.exportEmojis + emojis: options.exportEmojis, + files: options.exportFiles }) await configService.setExportDefaultVoiceAsText(options.exportVoiceAsText) await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns) @@ -6955,11 +7001,12 @@ function ExportPage() { setExportDefaultMedia(mediaPatch) setOptions(prev => ({ ...prev, - exportMedia: Boolean(mediaPatch.images || mediaPatch.voices || mediaPatch.videos || mediaPatch.emojis), + exportMedia: Boolean(mediaPatch.images || mediaPatch.voices || mediaPatch.videos || mediaPatch.emojis || mediaPatch.files), exportImages: mediaPatch.images, exportVoices: mediaPatch.voices, exportVideos: mediaPatch.videos, - exportEmojis: mediaPatch.emojis + exportEmojis: mediaPatch.emojis, + exportFiles: mediaPatch.files })) } if (typeof patch.voiceAsText === 'boolean') { @@ -8159,15 +8206,36 @@ function ExportPage() { <label><input type="checkbox" checked={options.exportVoices} onChange={event => setOptions(prev => ({ ...prev, exportVoices: event.target.checked }))} /> 语音</label> <label><input type="checkbox" checked={options.exportVideos} onChange={event => setOptions(prev => ({ ...prev, exportVideos: event.target.checked }))} /> 视频</label> <label><input type="checkbox" checked={options.exportEmojis} onChange={event => setOptions(prev => ({ ...prev, exportEmojis: event.target.checked }))} /> 表情包</label> + <label><input type="checkbox" checked={options.exportFiles} onChange={event => setOptions(prev => ({ ...prev, exportFiles: event.target.checked }))} /> 文件</label> </> )} </div> - {exportDialog.scope === 'sns' && ( - <div className="format-note">全不勾选时仅导出文本信息,不导出媒体文件。</div> + {exportDialog.scope !== 'sns' && options.exportFiles && ( + <div className="format-note">文件导出会优先使用消息里的 MD5 做校验;若设置了大小上限,则仅导出不超过该值的文件。</div> )} </div> )} + {shouldShowMediaSection && exportDialog.scope !== 'sns' && options.exportFiles && ( + <div className="dialog-section"> + <h4>文件大小上限</h4> + <div className="format-note">仅导出不超过该大小的文件,0 表示不限制。</div> + <div className="dialog-input-row"> + <input + type="number" + min={0} + step={10} + value={options.maxFileSizeMb} + onChange={event => { + const raw = Number(event.target.value) + setOptions(prev => ({ ...prev, maxFileSizeMb: Number.isFinite(raw) ? Math.max(0, Math.floor(raw)) : 0 })) + }} + /> + <span>MB</span> + </div> + </div> + )} + {shouldShowImageDeepSearchToggle && ( <div className="dialog-section"> <div className="dialog-switch-row"> diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 8ebad97..37eb6b1 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -2934,3 +2934,488 @@ } } } + +.anti-revoke-tab { + display: flex; + flex-direction: column; + gap: 14px; + + .anti-revoke-hero { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding: 18px; + border-radius: 18px; + border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--bg-secondary) 94%, var(--primary) 6%) 0%, + color-mix(in srgb, var(--bg-secondary) 96%, var(--bg-primary) 4%) 100% + ); + } + + .anti-revoke-hero-main { + min-width: 240px; + + h3 { + margin: 0; + font-size: 19px; + font-weight: 600; + line-height: 1.3; + color: var(--text-primary); + letter-spacing: 0.3px; + } + + p { + margin: 8px 0 0; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.6; + } + } + + .anti-revoke-metrics { + flex: 1; + display: grid; + grid-template-columns: repeat(4, minmax(112px, 1fr)); + gap: 10px; + min-width: 460px; + } + + .anti-revoke-metric { + display: flex; + flex-direction: column; + justify-content: center; + gap: 6px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent); + background: color-mix(in srgb, var(--bg-primary) 93%, var(--bg-secondary) 7%); + + .label { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.2; + letter-spacing: 0.2px; + } + + .value { + font-size: 30px; + font-weight: 700; + color: var(--text-primary); + line-height: 1; + font-variant-numeric: tabular-nums; + } + + &.is-total { + border-color: color-mix(in srgb, var(--border-color) 78%, var(--primary) 22%); + background: color-mix(in srgb, var(--bg-primary) 88%, var(--primary) 12%); + } + + &.is-installed { + border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color)); + background: color-mix(in srgb, var(--bg-primary) 90%, var(--primary) 10%); + + .value { + color: var(--primary); + } + } + + &.is-pending { + background: color-mix(in srgb, var(--bg-primary) 95%, var(--bg-secondary) 5%); + + .value { + color: color-mix(in srgb, var(--text-primary) 82%, var(--text-secondary)); + } + } + + &.is-error { + border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color)); + background: color-mix(in srgb, var(--danger) 6%, var(--bg-primary)); + + .value { + color: color-mix(in srgb, var(--danger) 65%, var(--text-primary) 35%); + } + } + } + + .anti-revoke-control-card { + border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent); + border-radius: 16px; + padding: 14px; + background: color-mix(in srgb, var(--bg-secondary) 95%, var(--bg-primary) 5%); + } + + .anti-revoke-toolbar { + display: flex; + gap: 14px; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + flex-wrap: wrap; + } + + .anti-revoke-search { + min-width: 280px; + flex: 1; + max-width: 420px; + border-radius: 10px; + background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary) 15%); + + input { + height: 36px; + font-size: 13px; + } + } + + .anti-revoke-toolbar-actions { + display: flex; + align-items: stretch; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; + margin-left: auto; + } + + .anti-revoke-btn-group { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + .anti-revoke-batch-actions { + display: flex; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; + justify-content: space-between; + border-top: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); + padding-top: 12px; + } + + .anti-revoke-selected-count { + display: inline-flex; + align-items: center; + gap: 14px; + font-size: 12px; + color: var(--text-secondary); + margin-left: auto; + padding: 8px 12px; + border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); + background: color-mix(in srgb, var(--bg-primary) 92%, var(--bg-secondary) 8%); + + span { + position: relative; + line-height: 1.2; + white-space: nowrap; + + strong { + color: var(--text-primary); + font-weight: 700; + font-variant-numeric: tabular-nums; + } + + &:not(:last-child)::after { + content: ''; + position: absolute; + right: -8px; + top: 50%; + width: 4px; + height: 4px; + border-radius: 50%; + background: color-mix(in srgb, var(--text-tertiary) 70%, transparent); + transform: translateY(-50%); + } + } + } + + .anti-revoke-toolbar-actions .btn, + .anti-revoke-batch-actions .btn { + border-radius: 10px; + padding-inline: 14px; + border-width: 1px; + min-height: 36px; + justify-content: center; + } + + .anti-revoke-summary { + padding: 11px 14px; + border-radius: 12px; + font-size: 13px; + border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent); + background: color-mix(in srgb, var(--bg-primary) 95%, var(--bg-secondary) 5%); + line-height: 1.5; + font-weight: 500; + + &.success { + color: color-mix(in srgb, var(--primary) 72%, var(--text-primary) 28%); + border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 9%, var(--bg-primary)); + } + + &.error { + color: color-mix(in srgb, var(--danger) 70%, var(--text-primary) 30%); + border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color)); + background: color-mix(in srgb, var(--danger) 7%, var(--bg-primary)); + } + } + + .anti-revoke-list { + border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent); + border-radius: 16px; + background: var(--bg-primary); + max-height: 460px; + overflow-y: auto; + overflow-x: hidden; + } + + .anti-revoke-list-header { + position: sticky; + top: 0; + z-index: 2; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: color-mix(in srgb, var(--bg-secondary) 93%, var(--bg-primary) 7%); + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent); + color: var(--text-tertiary); + font-size: 12px; + letter-spacing: 0.24px; + } + + .anti-revoke-empty { + padding: 44px 18px; + font-size: 13px; + color: var(--text-secondary); + text-align: center; + } + + .anti-revoke-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 13px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + transition: background-color 0.18s ease, box-shadow 0.18s ease; + + &:last-child { + border-bottom: none; + } + + &:hover { + background: color-mix(in srgb, var(--bg-secondary) 32%, var(--bg-primary) 68%); + } + + &.selected { + background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary)); + box-shadow: inset 2px 0 0 color-mix(in srgb, var(--primary) 70%, transparent); + } + } + + .anti-revoke-row-main { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; + cursor: pointer; + + .anti-revoke-check { + position: relative; + width: 18px; + height: 18px; + flex-shrink: 0; + + input[type='checkbox'] { + position: absolute; + inset: 0; + margin: 0; + opacity: 0; + cursor: pointer; + } + + .check-indicator { + width: 100%; + height: 100%; + border-radius: 6px; + border: 1px solid color-mix(in srgb, var(--border-color) 78%, var(--primary) 22%); + background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary) 14%); + color: var(--on-primary, #fff); + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.16s ease; + + svg { + opacity: 0; + transform: scale(0.75); + transition: opacity 0.16s ease, transform 0.16s ease; + } + } + + input[type='checkbox']:checked + .check-indicator { + background: var(--primary); + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); + + svg { + opacity: 1; + transform: scale(1); + } + } + + input[type='checkbox']:focus-visible + .check-indicator { + outline: 2px solid color-mix(in srgb, var(--primary) 42%, transparent); + outline-offset: 1px; + } + + input[type='checkbox']:disabled { + cursor: not-allowed; + } + + input[type='checkbox']:disabled + .check-indicator { + opacity: 0.55; + } + } + } + + .anti-revoke-row-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + + .name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.2; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + } + + .anti-revoke-row-status { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + max-width: 45%; + } + + .status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + line-height: 1.3; + font-weight: 500; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + color: var(--text-secondary); + background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary) 10%); + + .status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-tertiary); + flex-shrink: 0; + } + + &.installed { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 10%, var(--bg-secondary)); + + .status-dot { + background: var(--primary); + } + } + + &.not-installed { + color: var(--text-secondary); + border-color: color-mix(in srgb, var(--border-color) 84%, transparent); + background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary) 10%); + + .status-dot { + background: color-mix(in srgb, var(--text-tertiary) 86%, transparent); + } + } + + &.checking { + color: color-mix(in srgb, var(--primary) 70%, var(--text-primary) 30%); + border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 9%, var(--bg-secondary)); + + .status-dot { + background: var(--primary); + animation: pulse 1.2s ease-in-out infinite; + } + } + + &.error { + color: color-mix(in srgb, var(--danger) 72%, var(--text-primary) 28%); + border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color)); + background: color-mix(in srgb, var(--danger) 8%, var(--bg-secondary)); + + .status-dot { + background: var(--danger); + } + } + } + + .status-error { + font-size: 12px; + color: color-mix(in srgb, var(--danger) 66%, var(--text-primary) 34%); + max-width: 100%; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + @media (max-width: 980px) { + .anti-revoke-hero { + flex-direction: column; + } + + .anti-revoke-metrics { + width: 100%; + min-width: 0; + grid-template-columns: repeat(2, minmax(130px, 1fr)); + } + + .anti-revoke-batch-actions { + align-items: flex-start; + flex-direction: column; + } + + .anti-revoke-selected-count { + margin-left: 0; + width: 100%; + justify-content: flex-start; + overflow-x: auto; + } + + .anti-revoke-row { + align-items: flex-start; + flex-direction: column; + } + + .anti-revoke-row-status { + width: 100%; + flex-direction: row; + align-items: center; + justify-content: space-between; + max-width: none; + } + } +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 1b3eb9f..4200332 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -15,11 +15,12 @@ import { import { Avatar } from '../components/Avatar' import './SettingsPage.scss' -type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics' +type SettingsTab = 'appearance' | 'notification' | 'antiRevoke' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics' const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'appearance', label: '外观', icon: Palette }, { id: 'notification', label: '通知', icon: Bell }, + { id: 'antiRevoke', label: '防撤回', icon: RotateCcw }, { id: 'database', label: '数据库连接', icon: Database }, { id: 'models', label: '模型管理', icon: Mic }, { id: 'cache', label: '缓存', icon: HardDrive }, @@ -70,6 +71,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setShowUpdateDialog, } = useAppStore() + const chatSessions = useChatStore((state) => state.sessions) + const setChatSessions = useChatStore((state) => state.setSessions) const resetChatStore = useChatStore((state) => state.reset) const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const [systemDark, setSystemDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches) @@ -200,6 +203,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [isTogglingApi, setIsTogglingApi] = useState(false) const [showApiWarning, setShowApiWarning] = useState(false) const [messagePushEnabled, setMessagePushEnabled] = useState(false) + const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('') + const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState<Set<string>>(new Set()) + const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState<Record<string, { installed?: boolean; loading?: boolean; error?: string }>>({}) + const [isAntiRevokeRefreshing, setIsAntiRevokeRefreshing] = useState(false) + const [isAntiRevokeInstalling, setIsAntiRevokeInstalling] = useState(false) + const [isAntiRevokeUninstalling, setIsAntiRevokeUninstalling] = useState(false) + const [antiRevokeSummary, setAntiRevokeSummary] = useState<{ action: 'refresh' | 'install' | 'uninstall'; success: number; failed: number } | null>(null) const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache @@ -586,6 +596,248 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { }, 200) } + const normalizeSessionIds = (sessionIds: string[]): string[] => + Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) + + const getCurrentAntiRevokeSessionIds = (): string[] => + normalizeSessionIds(chatSessions.map((session) => session.username)) + + const ensureAntiRevokeSessionsLoaded = async (): Promise<string[]> => { + const current = getCurrentAntiRevokeSessionIds() + if (current.length > 0) return current + const sessionsResult = await window.electronAPI.chat.getSessions() + if (!sessionsResult.success || !sessionsResult.sessions) { + throw new Error(sessionsResult.error || '加载会话失败') + } + setChatSessions(sessionsResult.sessions) + return normalizeSessionIds(sessionsResult.sessions.map((session) => session.username)) + } + + const markAntiRevokeRowsLoading = (sessionIds: string[]) => { + setAntiRevokeStatusMap((prev) => { + const next = { ...prev } + for (const sessionId of sessionIds) { + next[sessionId] = { + ...(next[sessionId] || {}), + loading: true, + error: undefined + } + } + return next + }) + } + + const handleRefreshAntiRevokeStatus = async (sessionIds?: string[]) => { + if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return + setAntiRevokeSummary(null) + setIsAntiRevokeRefreshing(true) + try { + const targetIds = normalizeSessionIds( + sessionIds && sessionIds.length > 0 + ? sessionIds + : await ensureAntiRevokeSessionsLoaded() + ) + if (targetIds.length === 0) { + setAntiRevokeStatusMap({}) + showMessage('暂无可检查的会话', true) + return + } + markAntiRevokeRowsLoading(targetIds) + + const result = await window.electronAPI.chat.checkAntiRevokeTriggers(targetIds) + if (!result.success || !result.rows) { + const errorText = result.error || '防撤回状态检查失败' + setAntiRevokeStatusMap((prev) => { + const next = { ...prev } + for (const sessionId of targetIds) { + next[sessionId] = { + ...(next[sessionId] || {}), + loading: false, + error: errorText + } + } + return next + }) + showMessage(errorText, false) + return + } + + const rowMap = new Map<string, { sessionId: string; success: boolean; installed?: boolean; error?: string }>() + for (const row of result.rows || []) { + const sessionId = String(row.sessionId || '').trim() + if (!sessionId) continue + rowMap.set(sessionId, row) + } + const mergedRows = targetIds.map((sessionId) => ( + rowMap.get(sessionId) || { sessionId, success: false, error: '状态查询未返回结果' } + )) + const successCount = mergedRows.filter((row) => row.success).length + const failedCount = mergedRows.length - successCount + setAntiRevokeStatusMap((prev) => { + const next = { ...prev } + for (const row of mergedRows) { + const sessionId = String(row.sessionId || '').trim() + if (!sessionId) continue + next[sessionId] = { + installed: row.installed === true, + loading: false, + error: row.success ? undefined : (row.error || '状态查询失败') + } + } + return next + }) + setAntiRevokeSummary({ action: 'refresh', success: successCount, failed: failedCount }) + showMessage(`状态刷新完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0) + } catch (e: any) { + showMessage(`防撤回状态刷新失败: ${e?.message || String(e)}`, false) + } finally { + setIsAntiRevokeRefreshing(false) + } + } + + const handleInstallAntiRevokeTriggers = async () => { + if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return + const sessionIds = normalizeSessionIds(Array.from(antiRevokeSelectedIds)) + if (sessionIds.length === 0) { + showMessage('请先选择至少一个会话', false) + return + } + setAntiRevokeSummary(null) + setIsAntiRevokeInstalling(true) + try { + markAntiRevokeRowsLoading(sessionIds) + const result = await window.electronAPI.chat.installAntiRevokeTriggers(sessionIds) + if (!result.success || !result.rows) { + const errorText = result.error || '批量安装失败' + setAntiRevokeStatusMap((prev) => { + const next = { ...prev } + for (const sessionId of sessionIds) { + next[sessionId] = { + ...(next[sessionId] || {}), + loading: false, + error: errorText + } + } + return next + }) + showMessage(errorText, false) + return + } + + const rowMap = new Map<string, { sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>() + for (const row of result.rows || []) { + const sessionId = String(row.sessionId || '').trim() + if (!sessionId) continue + rowMap.set(sessionId, row) + } + const mergedRows = sessionIds.map((sessionId) => ( + rowMap.get(sessionId) || { sessionId, success: false, error: '安装未返回结果' } + )) + const successCount = mergedRows.filter((row) => row.success).length + const failedCount = mergedRows.length - successCount + setAntiRevokeStatusMap((prev) => { + const next = { ...prev } + for (const row of mergedRows) { + const sessionId = String(row.sessionId || '').trim() + if (!sessionId) continue + next[sessionId] = { + installed: row.success ? true : next[sessionId]?.installed, + loading: false, + error: row.success ? undefined : (row.error || '安装失败') + } + } + return next + }) + setAntiRevokeSummary({ action: 'install', success: successCount, failed: failedCount }) + showMessage(`批量安装完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0) + } catch (e: any) { + showMessage(`批量安装失败: ${e?.message || String(e)}`, false) + } finally { + setIsAntiRevokeInstalling(false) + } + } + + const handleUninstallAntiRevokeTriggers = async () => { + if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return + const sessionIds = normalizeSessionIds(Array.from(antiRevokeSelectedIds)) + if (sessionIds.length === 0) { + showMessage('请先选择至少一个会话', false) + return + } + setAntiRevokeSummary(null) + setIsAntiRevokeUninstalling(true) + try { + markAntiRevokeRowsLoading(sessionIds) + const result = await window.electronAPI.chat.uninstallAntiRevokeTriggers(sessionIds) + if (!result.success || !result.rows) { + const errorText = result.error || '批量卸载失败' + setAntiRevokeStatusMap((prev) => { + const next = { ...prev } + for (const sessionId of sessionIds) { + next[sessionId] = { + ...(next[sessionId] || {}), + loading: false, + error: errorText + } + } + return next + }) + showMessage(errorText, false) + return + } + + const rowMap = new Map<string, { sessionId: string; success: boolean; error?: string }>() + for (const row of result.rows || []) { + const sessionId = String(row.sessionId || '').trim() + if (!sessionId) continue + rowMap.set(sessionId, row) + } + const mergedRows = sessionIds.map((sessionId) => ( + rowMap.get(sessionId) || { sessionId, success: false, error: '卸载未返回结果' } + )) + const successCount = mergedRows.filter((row) => row.success).length + const failedCount = mergedRows.length - successCount + setAntiRevokeStatusMap((prev) => { + const next = { ...prev } + for (const row of mergedRows) { + const sessionId = String(row.sessionId || '').trim() + if (!sessionId) continue + next[sessionId] = { + installed: row.success ? false : next[sessionId]?.installed, + loading: false, + error: row.success ? undefined : (row.error || '卸载失败') + } + } + return next + }) + setAntiRevokeSummary({ action: 'uninstall', success: successCount, failed: failedCount }) + showMessage(`批量卸载完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0) + } catch (e: any) { + showMessage(`批量卸载失败: ${e?.message || String(e)}`, false) + } finally { + setIsAntiRevokeUninstalling(false) + } + } + + useEffect(() => { + if (activeTab !== 'antiRevoke') return + let canceled = false + ;(async () => { + try { + const sessionIds = await ensureAntiRevokeSessionsLoaded() + if (canceled) return + await handleRefreshAntiRevokeStatus(sessionIds) + } catch (e: any) { + if (!canceled) { + showMessage(`加载防撤回会话失败: ${e?.message || String(e)}`, false) + } + } + })() + return () => { + canceled = true + } + }, [activeTab]) + type WxidKeys = { decryptKey: string imageXorKey: number | null @@ -1319,11 +1571,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { ) const renderNotificationTab = () => { - const { sessions } = useChatStore.getState() - // 获取已过滤会话的信息 const getSessionInfo = (username: string) => { - const session = sessions.find(s => s.username === username) + const session = chatSessions.find(s => s.username === username) return { displayName: session?.displayName || username, avatarUrl: session?.avatarUrl || '' @@ -1348,7 +1598,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { } // 过滤掉已在列表中的会话,并根据搜索关键字过滤 - const availableSessions = sessions.filter(s => { + const availableSessions = chatSessions.filter(s => { if (notificationFilterList.includes(s.username)) return false if (filterSearchKeyword) { const keyword = filterSearchKeyword.toLowerCase() @@ -1564,6 +1814,199 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { ) } + const renderAntiRevokeTab = () => { + const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0)) + const keyword = antiRevokeSearchKeyword.trim().toLowerCase() + const filteredSessions = sortedSessions.filter((session) => { + if (!keyword) return true + const displayName = String(session.displayName || '').toLowerCase() + const username = String(session.username || '').toLowerCase() + return displayName.includes(keyword) || username.includes(keyword) + }) + const filteredSessionIds = filteredSessions.map((session) => session.username) + const selectedCount = antiRevokeSelectedIds.size + const selectedInFilteredCount = filteredSessionIds.filter((sessionId) => antiRevokeSelectedIds.has(sessionId)).length + const allFilteredSelected = filteredSessionIds.length > 0 && selectedInFilteredCount === filteredSessionIds.length + const busy = isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling + const statusStats = filteredSessions.reduce( + (acc, session) => { + const rowState = antiRevokeStatusMap[session.username] + if (rowState?.error) acc.failed += 1 + else if (rowState?.installed === true) acc.installed += 1 + else if (rowState?.installed === false) acc.notInstalled += 1 + return acc + }, + { installed: 0, notInstalled: 0, failed: 0 } + ) + + const toggleSelected = (sessionId: string) => { + setAntiRevokeSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(sessionId)) next.delete(sessionId) + else next.add(sessionId) + return next + }) + } + + const selectAllFiltered = () => { + if (filteredSessionIds.length === 0) return + setAntiRevokeSelectedIds((prev) => { + const next = new Set(prev) + for (const sessionId of filteredSessionIds) { + next.add(sessionId) + } + return next + }) + } + + const clearSelection = () => { + setAntiRevokeSelectedIds(new Set()) + } + + return ( + <div className="tab-content anti-revoke-tab"> + <div className="anti-revoke-hero"> + <div className="anti-revoke-hero-main"> + <h3>会话级防撤回触发器</h3> + <p>仅针对勾选会话执行批量安装或卸载,状态可随时刷新。</p> + </div> + <div className="anti-revoke-metrics"> + <div className="anti-revoke-metric is-total"> + <span className="label">筛选会话</span> + <span className="value">{filteredSessionIds.length}</span> + </div> + <div className="anti-revoke-metric is-installed"> + <span className="label">已安装</span> + <span className="value">{statusStats.installed}</span> + </div> + <div className="anti-revoke-metric is-pending"> + <span className="label">未安装</span> + <span className="value">{statusStats.notInstalled}</span> + </div> + <div className="anti-revoke-metric is-error"> + <span className="label">异常</span> + <span className="value">{statusStats.failed}</span> + </div> + </div> + </div> + + <div className="anti-revoke-control-card"> + <div className="anti-revoke-toolbar"> + <div className="filter-search-box anti-revoke-search"> + <Search size={14} /> + <input + type="text" + placeholder="搜索会话..." + value={antiRevokeSearchKeyword} + onChange={(e) => setAntiRevokeSearchKeyword(e.target.value)} + /> + </div> + <div className="anti-revoke-toolbar-actions"> + <div className="anti-revoke-btn-group"> + <button className="btn btn-secondary btn-sm" onClick={() => void handleRefreshAntiRevokeStatus()} disabled={busy}> + <RefreshCw size={14} /> {isAntiRevokeRefreshing ? '刷新中...' : '刷新状态'} + </button> + </div> + <div className="anti-revoke-btn-group"> + <button className="btn btn-secondary btn-sm" onClick={selectAllFiltered} disabled={busy || filteredSessionIds.length === 0 || allFilteredSelected}> + 全选 + </button> + <button className="btn btn-secondary btn-sm" onClick={clearSelection} disabled={busy || selectedCount === 0}> + 清空选择 + </button> + </div> + </div> + </div> + + <div className="anti-revoke-batch-actions"> + <div className="anti-revoke-btn-group anti-revoke-batch-btns"> + <button className="btn btn-primary btn-sm" onClick={() => void handleInstallAntiRevokeTriggers()} disabled={busy || selectedCount === 0}> + {isAntiRevokeInstalling ? '安装中...' : '批量安装'} + </button> + <button className="btn btn-secondary btn-sm" onClick={() => void handleUninstallAntiRevokeTriggers()} disabled={busy || selectedCount === 0}> + {isAntiRevokeUninstalling ? '卸载中...' : '批量卸载'} + </button> + </div> + <div className="anti-revoke-selected-count"> + <span>已选 <strong>{selectedCount}</strong> 个会话</span> + <span>筛选命中 <strong>{selectedInFilteredCount}</strong> / {filteredSessionIds.length}</span> + </div> + </div> + </div> + + {antiRevokeSummary && ( + <div className={`anti-revoke-summary ${antiRevokeSummary.failed > 0 ? 'error' : 'success'}`}> + {antiRevokeSummary.action === 'refresh' ? '刷新' : antiRevokeSummary.action === 'install' ? '安装' : '卸载'} + 完成:成功 {antiRevokeSummary.success},失败 {antiRevokeSummary.failed} + </div> + )} + + <div className="anti-revoke-list"> + {filteredSessions.length === 0 ? ( + <div className="anti-revoke-empty">{antiRevokeSearchKeyword ? '没有匹配的会话' : '暂无会话可配置'}</div> + ) : ( + <> + <div className="anti-revoke-list-header"> + <span>会话({filteredSessions.length})</span> + <span>状态</span> + </div> + {filteredSessions.map((session) => { + const rowState = antiRevokeStatusMap[session.username] + let statusClass = 'unknown' + let statusLabel = '未检查' + if (rowState?.loading) { + statusClass = 'checking' + statusLabel = '检查中' + } else if (rowState?.error) { + statusClass = 'error' + statusLabel = '失败' + } else if (rowState?.installed === true) { + statusClass = 'installed' + statusLabel = '已安装' + } else if (rowState?.installed === false) { + statusClass = 'not-installed' + statusLabel = '未安装' + } + return ( + <div key={session.username} className={`anti-revoke-row ${antiRevokeSelectedIds.has(session.username) ? 'selected' : ''}`}> + <label className="anti-revoke-row-main"> + <span className="anti-revoke-check"> + <input + type="checkbox" + checked={antiRevokeSelectedIds.has(session.username)} + onChange={() => toggleSelected(session.username)} + disabled={busy} + /> + <span className="check-indicator" aria-hidden="true"> + <Check size={12} /> + </span> + </span> + <Avatar + src={session.avatarUrl} + name={session.displayName || session.username} + size={30} + /> + <div className="anti-revoke-row-text"> + <span className="name">{session.displayName || session.username}</span> + </div> + </label> + <div className="anti-revoke-row-status"> + <span className={`status-badge ${statusClass}`}> + <i className="status-dot" aria-hidden="true" /> + {statusLabel} + </span> + {rowState?.error && <span className="status-error">{rowState.error}</span>} + </div> + </div> + ) + })} + </> + )} + </div> + </div> + ) + } + const renderDatabaseTab = () => ( <div className="tab-content"> <div className="form-group"> @@ -2687,6 +3130,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { <div className="settings-body"> {activeTab === 'appearance' && renderAppearanceTab()} {activeTab === 'notification' && renderNotificationTab()} + {activeTab === 'antiRevoke' && renderAntiRevokeTab()} {activeTab === 'database' && renderDatabaseTab()} {activeTab === 'models' && renderModelsTab()} {activeTab === 'cache' && renderCacheTab()} diff --git a/src/services/config.ts b/src/services/config.ts index 1f687e7..a0b7c54 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -94,6 +94,7 @@ export interface ExportDefaultMediaConfig { videos: boolean voices: boolean emojis: boolean + files: boolean } export type WindowCloseBehavior = 'ask' | 'tray' | 'quit' @@ -104,7 +105,8 @@ const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = { images: true, videos: true, voices: true, - emojis: true + emojis: true, + files: true } // 获取解密密钥 @@ -423,7 +425,8 @@ export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig images: value, videos: value, voices: value, - emojis: value + emojis: value, + files: value } } if (value && typeof value === 'object') { @@ -432,7 +435,8 @@ export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig images: typeof raw.images === 'boolean' ? raw.images : DEFAULT_EXPORT_MEDIA_CONFIG.images, videos: typeof raw.videos === 'boolean' ? raw.videos : DEFAULT_EXPORT_MEDIA_CONFIG.videos, voices: typeof raw.voices === 'boolean' ? raw.voices : DEFAULT_EXPORT_MEDIA_CONFIG.voices, - emojis: typeof raw.emojis === 'boolean' ? raw.emojis : DEFAULT_EXPORT_MEDIA_CONFIG.emojis + emojis: typeof raw.emojis === 'boolean' ? raw.emojis : DEFAULT_EXPORT_MEDIA_CONFIG.emojis, + files: typeof raw.files === 'boolean' ? raw.files : DEFAULT_EXPORT_MEDIA_CONFIG.files } } return null @@ -444,7 +448,8 @@ export async function setExportDefaultMedia(media: ExportDefaultMediaConfig): Pr images: media.images, videos: media.videos, voices: media.voices, - emojis: media.emojis + emojis: media.emojis, + files: media.files }) } diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts index b4c04f7..3ca03f1 100644 --- a/src/stores/chatStore.ts +++ b/src/stores/chatStore.ts @@ -1,6 +1,46 @@ import { create } from 'zustand' import type { ChatSession, Message, Contact } from '../types/models' +const messageAliasIndex = new Set<string>() + +function buildPrimaryMessageKey(message: Message): string { + if (message.messageKey) return String(message.messageKey) + return `fallback:${message.serverId || 0}:${message.createTime}:${message.sortSeq || 0}:${message.localId || 0}:${message.senderUsername || ''}:${message.localType || 0}` +} + +function buildMessageAliasKeys(message: Message): string[] { + const keys = [buildPrimaryMessageKey(message)] + const localId = Math.max(0, Number(message.localId || 0)) + const serverId = Math.max(0, Number(message.serverId || 0)) + const createTime = Math.max(0, Number(message.createTime || 0)) + const localType = Math.floor(Number(message.localType || 0)) + const sender = String(message.senderUsername || '') + const isSend = Number(message.isSend ?? -1) + + if (localId > 0) { + keys.push(`lid:${localId}`) + } + if (serverId > 0) { + keys.push(`sid:${serverId}`) + } + if (localType === 3) { + const imageIdentity = String(message.imageMd5 || message.imageDatName || '').trim() + if (imageIdentity) { + keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`) + } + } + + return keys +} + +function rebuildMessageAliasIndex(messages: Message[]): void { + messageAliasIndex.clear() + for (const message of messages) { + const aliasKeys = buildMessageAliasKeys(message) + aliasKeys.forEach((key) => messageAliasIndex.add(key)) + } +} + export interface ChatState { // 连接状态 isConnected: boolean @@ -69,59 +109,37 @@ export const useChatStore = create<ChatState>((set, get) => ({ setSessions: (sessions) => set({ sessions, filteredSessions: sessions }), setFilteredSessions: (sessions) => set({ filteredSessions: sessions }), - setCurrentSession: (sessionId, options) => set((state) => ({ - currentSessionId: sessionId, - messages: options?.preserveMessages ? state.messages : [], - hasMoreMessages: true, - hasMoreLater: false - })), + setCurrentSession: (sessionId, options) => set((state) => { + const nextMessages = options?.preserveMessages ? state.messages : [] + rebuildMessageAliasIndex(nextMessages) + return { + currentSessionId: sessionId, + messages: nextMessages, + hasMoreMessages: true, + hasMoreLater: false + } + }), setLoadingSessions: (loading) => set({ isLoadingSessions: loading }), - setMessages: (messages) => set({ messages }), + setMessages: (messages) => set(() => { + rebuildMessageAliasIndex(messages || []) + return { messages } + }), appendMessages: (newMessages, prepend = false) => set((state) => { - const buildPrimaryKey = (m: Message): string => { - if (m.messageKey) return String(m.messageKey) - return `fallback:${m.serverId || 0}:${m.createTime}:${m.sortSeq || 0}:${m.localId || 0}:${m.senderUsername || ''}:${m.localType || 0}` - } - const buildAliasKeys = (m: Message): string[] => { - const keys = [buildPrimaryKey(m)] - const localId = Math.max(0, Number(m.localId || 0)) - const serverId = Math.max(0, Number(m.serverId || 0)) - const createTime = Math.max(0, Number(m.createTime || 0)) - const localType = Math.floor(Number(m.localType || 0)) - const sender = String(m.senderUsername || '') - const isSend = Number(m.isSend ?? -1) - - if (localId > 0) { - keys.push(`lid:${localId}`) - } - if (serverId > 0) { - keys.push(`sid:${serverId}`) - } - if (localType === 3) { - const imageIdentity = String(m.imageMd5 || m.imageDatName || '').trim() - if (imageIdentity) { - keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`) - } - } - return keys - } - const currentMessages = state.messages || [] - const existingAliases = new Set<string>() - currentMessages.forEach((msg) => { - buildAliasKeys(msg).forEach((key) => existingAliases.add(key)) - }) + if (messageAliasIndex.size === 0 && currentMessages.length > 0) { + rebuildMessageAliasIndex(currentMessages) + } const filtered: Message[] = [] newMessages.forEach((msg) => { - const aliasKeys = buildAliasKeys(msg) - const exists = aliasKeys.some((key) => existingAliases.has(key)) + const aliasKeys = buildMessageAliasKeys(msg) + const exists = aliasKeys.some((key) => messageAliasIndex.has(key)) if (exists) return filtered.push(msg) - aliasKeys.forEach((key) => existingAliases.add(key)) + aliasKeys.forEach((key) => messageAliasIndex.add(key)) }) if (filtered.length === 0) return state @@ -150,20 +168,23 @@ export const useChatStore = create<ChatState>((set, get) => ({ setSearchKeyword: (keyword) => set({ searchKeyword: keyword }), - reset: () => set({ - isConnected: false, - isConnecting: false, - connectionError: null, - sessions: [], - filteredSessions: [], - currentSessionId: null, - isLoadingSessions: false, - messages: [], - isLoadingMessages: false, - isLoadingMore: false, - hasMoreMessages: true, - hasMoreLater: false, - contacts: new Map(), - searchKeyword: '' + reset: () => set(() => { + messageAliasIndex.clear() + return { + isConnected: false, + isConnecting: false, + connectionError: null, + sessions: [], + filteredSessions: [], + currentSessionId: null, + isLoadingSessions: false, + messages: [], + 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 19f33a5..8ef9277 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -226,6 +226,21 @@ export interface ElectronAPI { getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null> updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }> deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }> + checkAntiRevokeTriggers: (sessionIds: string[]) => Promise<{ + success: boolean + rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }> + error?: string + }> + installAntiRevokeTriggers: (sessionIds: string[]) => Promise<{ + success: boolean + rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }> + error?: string + }> + uninstallAntiRevokeTriggers: (sessionIds: string[]) => Promise<{ + success: boolean + rows?: Array<{ sessionId: string; success: boolean; error?: string }> + error?: string + }> resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }> getContacts: (options?: { lite?: boolean }) => Promise<{ success: boolean @@ -881,7 +896,7 @@ export interface ElectronAPI { export interface ExportOptions { format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' - contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' + contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file' dateRange?: { start: number; end: number } | null senderUsername?: string fileNameSuffix?: string @@ -891,6 +906,8 @@ export interface ExportOptions { exportVoices?: boolean exportVideos?: boolean exportEmojis?: boolean + exportFiles?: boolean + maxFileSizeMb?: number exportVoiceAsText?: boolean excelCompactColumns?: boolean txtColumns?: string[] diff --git a/src/types/models.ts b/src/types/models.ts index fccdba4..9d9ea0d 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -75,6 +75,7 @@ export interface Message { fileName?: string // 文件名 fileSize?: number // 文件大小 fileExt?: string // 文件扩展名 + fileMd5?: string // 文件 MD5 xmlType?: string // XML 中的 type 字段 appMsgKind?: string // 归一化 appmsg 类型 appMsgDesc?: string