diff --git a/electron/main.ts b/electron/main.ts index 8cade14..a04dc4c 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -16,6 +16,7 @@ import { annualReportService } from './services/annualReportService' import { exportService, ExportOptions } from './services/exportService' import { KeyService } from './services/keyService' import { voiceTranscribeService } from './services/voiceTranscribeService' +import { videoService } from './services/videoService' // 配置自动更新 @@ -200,6 +201,107 @@ function createOnboardingWindow() { return onboardingWindow } +/** + * 创建独立的视频播放窗口 + * 窗口大小会根据视频比例自动调整 + */ +function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHeight?: number) { + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev + ? join(__dirname, '../public/icon.ico') + : join(process.resourcesPath, 'icon.ico') + + // 获取屏幕尺寸 + const { screen } = require('electron') + const primaryDisplay = screen.getPrimaryDisplay() + const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize + + // 计算窗口尺寸,只有标题栏 40px,控制栏悬浮 + let winWidth = 854 + let winHeight = 520 + const titleBarHeight = 40 + + if (videoWidth && videoHeight && videoWidth > 0 && videoHeight > 0) { + const aspectRatio = videoWidth / videoHeight + + const maxWidth = Math.floor(screenWidth * 0.85) + const maxHeight = Math.floor(screenHeight * 0.85) + + if (aspectRatio >= 1) { + // 横向视频 + winWidth = Math.min(videoWidth, maxWidth) + winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight + + if (winHeight > maxHeight) { + winHeight = maxHeight + winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio) + } + } else { + // 竖向视频 + const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight) + winHeight = videoDisplayHeight + titleBarHeight + winWidth = Math.floor(videoDisplayHeight * aspectRatio) + + if (winWidth < 300) { + winWidth = 300 + winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight + } + } + + winWidth = Math.max(winWidth, 360) + winHeight = Math.max(winHeight, 280) + } + + const win = new BrowserWindow({ + width: winWidth, + height: winHeight, + minWidth: 360, + minHeight: 280, + icon: iconPath, + webPreferences: { + preload: join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + webSecurity: false + }, + titleBarStyle: 'hidden', + titleBarOverlay: { + color: '#1a1a1a', + symbolColor: '#ffffff', + height: 40 + }, + show: false, + backgroundColor: '#000000', + autoHideMenuBar: true + }) + + win.once('ready-to-show', () => { + win.show() + }) + + const videoParam = `videoPath=${encodeURIComponent(videoPath)}` + if (process.env.VITE_DEV_SERVER_URL) { + win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/video-player-window?${videoParam}`) + + win.webContents.on('before-input-event', (event, input) => { + if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { + if (win.webContents.isDevToolsOpened()) { + win.webContents.closeDevTools() + } else { + win.webContents.openDevTools() + } + event.preventDefault() + } + }) + } else { + win.loadFile(join(__dirname, '../dist/index.html'), { + hash: `/video-player-window?${videoParam}` + }) + } + + return win +} + function showMainWindow() { shouldShowMain = true if (mainWindowReady) { @@ -356,6 +458,79 @@ function registerIpcHandlers() { } }) + // 打开视频播放窗口 + ipcMain.handle('window:openVideoPlayerWindow', (_, videoPath: string, videoWidth?: number, videoHeight?: number) => { + createVideoPlayerWindow(videoPath, videoWidth, videoHeight) + }) + + // 根据视频尺寸调整窗口大小 + ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (!win || !videoWidth || !videoHeight) return + + const { screen } = require('electron') + const primaryDisplay = screen.getPrimaryDisplay() + const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize + + // 只有标题栏 40px,控制栏悬浮在视频上 + const titleBarHeight = 40 + const aspectRatio = videoWidth / videoHeight + + const maxWidth = Math.floor(screenWidth * 0.85) + const maxHeight = Math.floor(screenHeight * 0.85) + + let winWidth: number + let winHeight: number + + if (aspectRatio >= 1) { + // 横向视频 - 以宽度为基准 + winWidth = Math.min(videoWidth, maxWidth) + winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight + + if (winHeight > maxHeight) { + winHeight = maxHeight + winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio) + } + } else { + // 竖向视频 - 以高度为基准 + const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight) + winHeight = videoDisplayHeight + titleBarHeight + winWidth = Math.floor(videoDisplayHeight * aspectRatio) + + // 确保宽度不会太窄 + if (winWidth < 300) { + winWidth = 300 + winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight + } + } + + winWidth = Math.max(winWidth, 360) + winHeight = Math.max(winHeight, 280) + + // 调整窗口大小并居中 + win.setSize(winWidth, winHeight) + win.center() + }) + + // 视频相关 + ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string) => { + try { + const result = await videoService.getVideoInfo(videoMd5) + return { success: true, ...result } + } catch (e) { + return { success: false, error: String(e), exists: false } + } + }) + + ipcMain.handle('video:parseVideoMd5', async (_, content: string) => { + try { + const md5 = videoService.parseVideoMd5(content) + return { success: true, md5 } + } catch (e) { + return { success: false, error: String(e) } + } + }) + // 数据库路径相关 ipcMain.handle('dbpath:autoDetect', async () => { return dbPathService.autoDetect() @@ -446,8 +621,8 @@ function registerIpcHandlers() { return chatService.resolveVoiceCache(sessionId, msgId) }) - ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string) => { - return chatService.getVoiceTranscript(sessionId, msgId, (text) => { + ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => { + return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => { event.sender.send('chat:voiceTranscriptPartial', { msgId, text }) }) }) diff --git a/electron/preload.ts b/electron/preload.ts index 775e19a..f04e6f4 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -53,7 +53,11 @@ contextBridge.exposeInMainWorld('electronAPI', { openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'), completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'), openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'), - setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options) + setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options), + openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => + ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight), + resizeToFitVideo: (videoWidth: number, videoHeight: number) => + ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight) }, // 数据库路径 @@ -109,7 +113,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId), resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId), - getVoiceTranscript: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId), + getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime), onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => { const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload) ipcRenderer.on('chat:voiceTranscriptPartial', listener) @@ -137,6 +141,12 @@ contextBridge.exposeInMainWorld('electronAPI', { } }, + // 视频 + video: { + getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5), + parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content) + }, + // 数据分析 analytics: { getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index cfe251b..330cf57 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -42,6 +42,7 @@ export interface Message { senderUsername: string | null parsedContent: string rawContent: string + content?: string // 原始XML内容(与rawContent相同,供前端使用) // 表情包相关 emojiCdnUrl?: string emojiMd5?: string @@ -52,6 +53,7 @@ export interface Message { // 图片/视频相关 imageMd5?: string imageDatName?: string + videoMd5?: string aesKey?: string encrypVer?: number cdnThumbUrl?: string @@ -83,7 +85,18 @@ class ChatService { private voiceWavCache = new Map() private voiceTranscriptCache = new Map() private voiceTranscriptPending = new Map>() + private mediaDbsCache: string[] | null = null + private mediaDbsCacheTime = 0 + private readonly mediaDbsCacheTtl = 300000 // 5分钟 private readonly voiceCacheMaxEntries = 50 + // 缓存 media.db 的表结构信息 + private mediaDbSchemaCache = new Map() constructor() { this.configService = new ConfigService() @@ -140,6 +153,10 @@ class ChatService { } this.connected = true + + // 预热 listMediaDbs 缓存(后台异步执行,不阻塞连接) + this.warmupMediaDbsCache() + return { success: true } } catch (e) { console.error('ChatService: 连接数据库失败:', e) @@ -147,6 +164,21 @@ class ChatService { } } + /** + * 预热 media 数据库列表缓存(后台异步执行) + */ + private async warmupMediaDbsCache(): Promise { + try { + const result = await wcdbService.listMediaDbs() + if (result.success && result.data) { + this.mediaDbsCache = result.data as string[] + this.mediaDbsCacheTime = Date.now() + } + } catch (e) { + // 静默失败,不影响主流程 + } + } + private async ensureConnected(): Promise<{ success: boolean; error?: string }> { if (this.connected && wcdbService.isReady()) { return { success: true } @@ -382,8 +414,6 @@ class ChatService { const needNewCursor = !state || offset === 0 || state.batchSize !== batchSize if (needNewCursor) { - console.log(`[ChatService] 创建新游标: sessionId=${sessionId}, offset=${offset}, batchSize=${batchSize}`) - // 关闭旧游标 if (state) { try { @@ -440,7 +470,6 @@ class ChatService { } // 获取当前批次的消息 - console.log(`[ChatService] 获取消息批次: cursor=${state.cursor}, fetched=${state.fetched}`) const batch = await wcdbService.fetchMessageBatch(state.cursor) if (!batch.success) { console.error('[ChatService] 获取消息批次失败:', batch.error) @@ -716,6 +745,7 @@ class ChatService { let quotedSender: string | undefined let imageMd5: string | undefined let imageDatName: string | undefined + let videoMd5: string | undefined let aesKey: string | undefined let encrypVer: number | undefined let cdnThumbUrl: string | undefined @@ -732,6 +762,9 @@ class ChatService { encrypVer = imageInfo.encrypVer cdnThumbUrl = imageInfo.cdnThumbUrl imageDatName = this.parseImageDatNameFromRow(row) + } else if (localType === 43 && content) { + // 视频消息 + videoMd5 = this.parseVideoMd5(content) } else if (localType === 34 && content) { voiceDurationSeconds = this.parseVoiceDurationSeconds(content) } else if (localType === 244813135921 || (content && content.includes('57'))) { @@ -756,6 +789,7 @@ class ChatService { quotedSender, imageMd5, imageDatName, + videoMd5, voiceDurationSeconds, aesKey, encrypVer, @@ -937,6 +971,26 @@ class ChatService { } } + /** + * 解析视频MD5 + * 注意:提取 md5 字段用于查询 hardlink.db,获取实际视频文件名 + */ + private parseVideoMd5(content: string): string | undefined { + if (!content) return undefined + + try { + // 提取 md5,这是用于查询 hardlink.db 的值 + const md5 = + this.extractXmlAttribute(content, 'videomsg', 'md5') || + this.extractXmlValue(content, 'md5') || + undefined + + return md5?.toLowerCase() + } catch { + return undefined + } + } + /** * 解析通话消息 * 格式: 0/1... @@ -1419,21 +1473,22 @@ class ChatService { } private extractXmlAttribute(xml: string, tagName: string, attrName: string): string { - const tagRegex = new RegExp(`<${tagName}[^>]*>`, 'i') - const tagMatch = tagRegex.exec(xml) - if (!tagMatch) return '' - - const attrRegex = new RegExp(`${attrName}\\s*=\\s*['"]([^'"]*)['"]`, 'i') - const attrMatch = attrRegex.exec(tagMatch[0]) - return attrMatch ? attrMatch[1] : '' + // 匹配 + const regex = new RegExp(`<${tagName}[^>]*\\s${attrName}\\s*=\\s*['"]([^'"]*)['"']`, 'i') + const match = regex.exec(xml) + return match ? match[1] : '' } private cleanSystemMessage(content: string): string { - return content - .replace(/]*>/gi, '') - .replace(/<\/?[a-zA-Z0-9_]+[^>]*>/g, '') - .replace(/\s+/g, ' ') - .trim() || '[系统消息]' + // 移除 XML 声明 + let cleaned = content.replace(/<\?xml[^?]*\?>/gi, '') + // 移除所有 XML/HTML 标签 + cleaned = cleaned.replace(/<[^>]+>/g, '') + // 移除尾部的数字(如撤回消息后的时间戳) + cleaned = cleaned.replace(/\d+\s*$/, '') + // 清理多余空白 + cleaned = cleaned.replace(/\s+/g, ' ').trim() + return cleaned || '[系统消息]' } private stripSenderPrefix(content: string): string { @@ -1691,21 +1746,17 @@ class ChatService { // 增加 'self' 作为兜底标识符,微信有时将个人信息存储在 'self' 记录中 const fetchList = Array.from(new Set([myWxid, cleanedWxid, 'self'])) - console.log(`[ChatService] 尝试获取个人头像, wxids: ${JSON.stringify(fetchList)}`) const result = await wcdbService.getAvatarUrls(fetchList) if (result.success && result.map) { // 按优先级尝试匹配 const avatarUrl = result.map[myWxid] || result.map[cleanedWxid] || result.map['self'] if (avatarUrl) { - console.log(`[ChatService] 成功获取个人头像: ${avatarUrl.substring(0, 50)}...`) return { success: true, avatarUrl } } - console.warn(`[ChatService] 未能在 contact.db 中找到个人头像, 请求列表: ${JSON.stringify(fetchList)}`) return { success: true, avatarUrl: undefined } } - console.error(`[ChatService] 查询个人头像失败: ${result.error || '未知错误'}`) return { success: true, avatarUrl: undefined } } catch (e) { console.error('ChatService: 获取当前用户头像失败:', e) @@ -1716,6 +1767,19 @@ class ChatService { /** * 获取表情包缓存目录 */ + /** + * 获取语音缓存目录 + */ + private getVoiceCacheDir(): string { + const cachePath = this.configService.get('cachePath') + if (cachePath) { + return join(cachePath, 'Voices') + } + // 回退到默认目录 + const documentsPath = app.getPath('documents') + return join(documentsPath, 'WeFlow', 'Voices') + } + private getEmojiCacheDir(): string { const cachePath = this.configService.get('cachePath') if (cachePath) { @@ -2085,12 +2149,6 @@ class ChatService { return { success: false, error: '未找到消息' } } const msg = msgResult.message - console.info('[ChatService][Image] request', { - sessionId, - localId: msg.localId, - imageMd5: msg.imageMd5, - imageDatName: msg.imageDatName - }) // 2. 确定搜索的基础名 const baseName = msg.imageMd5 || msg.imageDatName || String(msg.localId) @@ -2107,7 +2165,6 @@ class ChatService { const datPath = await this.findDatFile(actualAccountDir, baseName, sessionId) if (!datPath) return { success: false, error: '未找到图片源文件 (.dat)' } - console.info('[ChatService][Image] dat path', datPath) // 4. 获取解密密钥 const xorKeyRaw = this.configService.get('imageXorKey') @@ -2135,7 +2192,6 @@ class ChatService { const aesKey = this.asciiKey16(trimmed) decrypted = this.decryptDatV4(data, xorKey, aesKey) } - console.info('[ChatService][Image] decrypted bytes', decrypted.length) // 返回 base64 return { success: true, data: decrypted.toString('base64') } @@ -2146,44 +2202,30 @@ class ChatService { } /** - * getVoiceData (优化的 C++ 实现 + 文件缓存) + * getVoiceData (绕过WCDB的buggy getVoiceData,直接用execQuery读取) */ async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number): Promise<{ success: boolean; data?: string; error?: string }> { - + const startTime = Date.now() try { const localId = parseInt(msgId, 10) if (isNaN(localId)) { return { success: false, error: '无效的消息ID' } } - // 检查文件缓存 - const cacheKey = this.getVoiceCacheKey(sessionId, msgId) - const cachedFile = this.getVoiceCacheFilePath(cacheKey) - if (existsSync(cachedFile)) { - try { - const wavData = readFileSync(cachedFile) - console.info('[ChatService][Voice] 使用缓存文件:', cachedFile) - return { success: true, data: wavData.toString('base64') } - } catch (e) { - console.error('[ChatService][Voice] 读取缓存失败:', e) - // 继续重新解密 - } - } - - // 1. 确定 createTime 和 svrId let msgCreateTime = createTime - let msgSvrId: string | number = serverId || 0 + let senderWxid: string | null = null - // 如果提供了传来的参数,验证其有效性 - if (!msgCreateTime || msgCreateTime === 0) { + // 如果前端没传 createTime,才需要查询消息(这个很慢) + if (!msgCreateTime) { + const t1 = Date.now() const msgResult = await this.getMessageByLocalId(sessionId, localId) + const t2 = Date.now() + console.log(`[Voice] getMessageByLocalId: ${t2 - t1}ms`) + if (msgResult.success && msgResult.message) { const msg = msgResult.message as any - msgCreateTime = msg.createTime || msg.create_time - // 尝试获取各种可能的 server id 列名 (只有在没有传入 serverId 时才查找) - if (!msgSvrId || msgSvrId === 0) { - msgSvrId = msg.serverId || msg.svr_id || msg.msg_svr_id || msg.message_id || 0 - } + msgCreateTime = msg.createTime + senderWxid = msg.senderUsername || null } } @@ -2191,54 +2233,84 @@ class ChatService { return { success: false, error: '未找到消息时间戳' } } - // 2. 构建查找候选 (sessionId, myWxid) + // 使用 sessionId + createTime 作为缓存key + const cacheKey = `${sessionId}_${msgCreateTime}` + + // 检查 WAV 内存缓存 + const wavCache = this.voiceWavCache.get(cacheKey) + if (wavCache) { + console.log(`[Voice] 内存缓存命中,总耗时: ${Date.now() - startTime}ms`) + return { success: true, data: wavCache.toString('base64') } + } + + // 检查 WAV 文件缓存 + const voiceCacheDir = this.getVoiceCacheDir() + const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`) + if (existsSync(wavFilePath)) { + try { + const wavData = readFileSync(wavFilePath) + // 同时缓存到内存 + this.cacheVoiceWav(cacheKey, wavData) + console.log(`[Voice] 文件缓存命中,总耗时: ${Date.now() - startTime}ms`) + return { success: true, data: wavData.toString('base64') } + } catch (e) { + console.error('[Voice] 读取缓存文件失败:', e) + } + } + + // 构建查找候选 const candidates: string[] = [] - if (sessionId) candidates.push(sessionId) const myWxid = this.configService.get('myWxid') as string + + // 如果有 senderWxid,优先使用(群聊中最重要) + if (senderWxid) { + candidates.push(senderWxid) + } + + // sessionId(1对1聊天时是对方wxid,群聊时是群id) + if (sessionId && !candidates.includes(sessionId)) { + candidates.push(sessionId) + } + + // 我的wxid(兜底) if (myWxid && !candidates.includes(myWxid)) { candidates.push(myWxid) } + const t3 = Date.now() + // 从数据库读取 silk 数据 + const silkData = await this.getVoiceDataFromMediaDb(msgCreateTime, candidates) + const t4 = Date.now() + console.log(`[Voice] getVoiceDataFromMediaDb: ${t4 - t3}ms`) - - // 3. 调用 C++ 接口获取语音 (Hex) - const voiceRes = await wcdbService.getVoiceData(sessionId, msgCreateTime, candidates, localId, msgSvrId) - if (!voiceRes.success || !voiceRes.hex) { - return { success: false, error: voiceRes.error || '未找到语音数据' } + if (!silkData) { + return { success: false, error: '未找到语音数据' } } + const t5 = Date.now() + // 使用 silk-wasm 解码 + const pcmData = await this.decodeSilkToPcm(silkData, 24000) + const t6 = Date.now() + console.log(`[Voice] decodeSilkToPcm: ${t6 - t5}ms`) - - // 4. Hex 转 Buffer (Silk) - const silkData = Buffer.from(voiceRes.hex, 'hex') - - // 5. 使用 silk-wasm 解码 - try { - const pcmData = await this.decodeSilkToPcm(silkData, 24000) - if (!pcmData) { - return { success: false, error: 'Silk 解码失败' } - } - - // PCM -> WAV - const wavData = this.createWavBuffer(pcmData, 24000) - - // 保存到文件缓存 - try { - this.saveVoiceCache(cacheKey, wavData) - console.info('[ChatService][Voice] 已保存缓存:', cachedFile) - } catch (e) { - console.error('[ChatService][Voice] 保存缓存失败:', e) - // 不影响返回 - } - - // 缓存 WAV 数据 (内存缓存) - this.cacheVoiceWav(cacheKey, wavData) - - return { success: true, data: wavData.toString('base64') } - } catch (e) { - console.error('[ChatService][Voice] decoding error:', e) - return { success: false, error: '语音解码失败: ' + String(e) } + if (!pcmData) { + return { success: false, error: 'Silk 解码失败' } } + + const t7 = Date.now() + // PCM -> WAV + const wavData = this.createWavBuffer(pcmData, 24000) + const t8 = Date.now() + console.log(`[Voice] createWavBuffer: ${t8 - t7}ms`) + + // 缓存 WAV 数据到内存 + this.cacheVoiceWav(cacheKey, wavData) + + // 缓存 WAV 数据到文件(异步,不阻塞返回) + this.cacheVoiceWavToFile(cacheKey, wavData) + + console.log(`[Voice] 总耗时: ${Date.now() - startTime}ms`) + return { success: true, data: wavData.toString('base64') } } catch (e) { console.error('ChatService: getVoiceData 失败:', e) return { success: false, error: String(e) } @@ -2246,26 +2318,228 @@ class ChatService { } /** - * 检查语音是否已有缓存 + * 缓存 WAV 数据到文件(异步) + */ + private async cacheVoiceWavToFile(cacheKey: string, wavData: Buffer): Promise { + try { + const voiceCacheDir = this.getVoiceCacheDir() + if (!existsSync(voiceCacheDir)) { + mkdirSync(voiceCacheDir, { recursive: true }) + } + + const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`) + writeFileSync(wavFilePath, wavData) + } catch (e) { + console.error('[Voice] 缓存文件失败:', e) + } + } + + /** + * 通过 WCDB 的 execQuery 直接查询 media.db(绕过有bug的getVoiceData接口) + * 策略:批量查询 + 多种兜底方案 + */ + private async getVoiceDataFromMediaDb(createTime: number, candidates: string[]): Promise { + const startTime = Date.now() + try { + const t1 = Date.now() + // 获取所有 media 数据库(永久缓存,直到应用重启) + let mediaDbFiles: string[] + if (this.mediaDbsCache) { + mediaDbFiles = this.mediaDbsCache + console.log(`[Voice] listMediaDbs (缓存): 0ms`) + } else { + const mediaDbsResult = await wcdbService.listMediaDbs() + const t2 = Date.now() + console.log(`[Voice] listMediaDbs: ${t2 - t1}ms`) + + if (!mediaDbsResult.success || !mediaDbsResult.data || mediaDbsResult.data.length === 0) { + return null + } + + mediaDbFiles = mediaDbsResult.data as string[] + this.mediaDbsCache = mediaDbFiles // 永久缓存 + } + + // 在所有 media 数据库中查找 + for (const dbPath of mediaDbFiles) { + try { + // 检查缓存 + let schema = this.mediaDbSchemaCache.get(dbPath) + + if (!schema) { + const t3 = Date.now() + // 第一次查询,获取表结构并缓存 + const tablesResult = await wcdbService.execQuery('media', dbPath, + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%'" + ) + const t4 = Date.now() + console.log(`[Voice] 查询VoiceInfo表: ${t4 - t3}ms`) + + if (!tablesResult.success || !tablesResult.rows || tablesResult.rows.length === 0) { + continue + } + + const voiceTable = tablesResult.rows[0].name + + const t5 = Date.now() + const columnsResult = await wcdbService.execQuery('media', dbPath, + `PRAGMA table_info('${voiceTable}')` + ) + const t6 = Date.now() + console.log(`[Voice] 查询表结构: ${t6 - t5}ms`) + + if (!columnsResult.success || !columnsResult.rows) { + continue + } + + // 创建列名映射(原始名称 -> 小写名称) + const columnMap = new Map() + for (const c of columnsResult.rows) { + const name = String(c.name || '') + if (name) { + columnMap.set(name.toLowerCase(), name) + } + } + + // 查找数据列(使用原始列名) + const dataColumnLower = ['voice_data', 'buf', 'voicebuf', 'data'].find(n => columnMap.has(n)) + const dataColumn = dataColumnLower ? columnMap.get(dataColumnLower) : undefined + + if (!dataColumn) { + continue + } + + // 查找 chat_name_id 列 + const chatNameIdColumnLower = ['chat_name_id', 'chatnameid', 'chat_nameid'].find(n => columnMap.has(n)) + const chatNameIdColumn = chatNameIdColumnLower ? columnMap.get(chatNameIdColumnLower) : undefined + + // 查找时间列 + const timeColumnLower = ['create_time', 'createtime', 'time'].find(n => columnMap.has(n)) + const timeColumn = timeColumnLower ? columnMap.get(timeColumnLower) : undefined + + const t7 = Date.now() + // 查找 Name2Id 表 + const name2IdTablesResult = await wcdbService.execQuery('media', dbPath, + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%'" + ) + const t8 = Date.now() + console.log(`[Voice] 查询Name2Id表: ${t8 - t7}ms`) + + const name2IdTable = (name2IdTablesResult.success && name2IdTablesResult.rows && name2IdTablesResult.rows.length > 0) + ? name2IdTablesResult.rows[0].name + : undefined + + schema = { + voiceTable, + dataColumn, + chatNameIdColumn, + timeColumn, + name2IdTable + } + + // 缓存表结构 + this.mediaDbSchemaCache.set(dbPath, schema) + } + + // 策略1: 通过 chat_name_id + create_time 查找(最准确) + if (schema.chatNameIdColumn && schema.timeColumn && schema.name2IdTable) { + const t9 = Date.now() + // 批量获取所有 candidates 的 chat_name_id(减少查询次数) + const candidatesStr = candidates.map(c => `'${c.replace(/'/g, "''")}'`).join(',') + const name2IdResult = await wcdbService.execQuery('media', dbPath, + `SELECT user_name, rowid FROM ${schema.name2IdTable} WHERE user_name IN (${candidatesStr})` + ) + const t10 = Date.now() + console.log(`[Voice] 查询chat_name_id: ${t10 - t9}ms`) + + if (name2IdResult.success && name2IdResult.rows && name2IdResult.rows.length > 0) { + // 构建 chat_name_id 列表 + const chatNameIds = name2IdResult.rows.map((r: any) => r.rowid) + const chatNameIdsStr = chatNameIds.join(',') + + const t11 = Date.now() + // 一次查询所有可能的语音 + const voiceResult = await wcdbService.execQuery('media', dbPath, + `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.chatNameIdColumn} IN (${chatNameIdsStr}) AND ${schema.timeColumn} = ${createTime} LIMIT 1` + ) + const t12 = Date.now() + console.log(`[Voice] 策略1查询语音: ${t12 - t11}ms`) + + if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { + const row = voiceResult.rows[0] + const silkData = this.decodeVoiceBlob(row.data) + if (silkData) { + console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`) + return silkData + } + } + } + } + + // 策略2: 只通过 create_time 查找(兜底) + if (schema.timeColumn) { + const t13 = Date.now() + const voiceResult = await wcdbService.execQuery('media', dbPath, + `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} = ${createTime} LIMIT 1` + ) + const t14 = Date.now() + console.log(`[Voice] 策略2查询语音: ${t14 - t13}ms`) + + if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { + const row = voiceResult.rows[0] + const silkData = this.decodeVoiceBlob(row.data) + if (silkData) { + console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`) + return silkData + } + } + } + + // 策略3: 时间范围查找(±5秒,处理时间戳不精确的情况) + if (schema.timeColumn) { + const t15 = Date.now() + const voiceResult = await wcdbService.execQuery('media', dbPath, + `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} BETWEEN ${createTime - 5} AND ${createTime + 5} ORDER BY ABS(${schema.timeColumn} - ${createTime}) LIMIT 1` + ) + const t16 = Date.now() + console.log(`[Voice] 策略3查询语音: ${t16 - t15}ms`) + + if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { + const row = voiceResult.rows[0] + const silkData = this.decodeVoiceBlob(row.data) + if (silkData) { + console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`) + return silkData + } + } + } + } catch (e) { + // 静默失败,继续尝试下一个数据库 + } + } + + return null + } catch (e) { + return null + } + } + + /** + * 检查语音是否已有缓存(只检查内存,不查询数据库) */ async resolveVoiceCache(sessionId: string, msgId: string): Promise<{ success: boolean; hasCache: boolean; data?: string }> { try { + // 直接用 msgId 生成 cacheKey,不查询数据库 + // 注意:这里的 cacheKey 可能不准确(因为没有 createTime),但只是用来快速检查缓存 + // 如果缓存未命中,用户点击时会重新用正确的 cacheKey 查询 const cacheKey = this.getVoiceCacheKey(sessionId, msgId) - // 1. 检查内存缓存 + // 检查内存缓存 const inMemory = this.voiceWavCache.get(cacheKey) if (inMemory) { return { success: true, hasCache: true, data: inMemory.toString('base64') } } - // 2. 检查文件缓存 - const cachedFile = this.getVoiceCacheFilePath(cacheKey) - if (existsSync(cachedFile)) { - const wavData = readFileSync(cachedFile) - this.cacheVoiceWav(cacheKey, wavData) // 回甜内存 - return { success: true, hasCache: true, data: wavData.toString('base64') } - } - return { success: true, hasCache: false } } catch (e) { return { success: false, hasCache: false } @@ -2460,60 +2734,133 @@ class ChatService { async getVoiceTranscript( sessionId: string, msgId: string, + createTime?: number, onPartial?: (text: string) => void ): Promise<{ success: boolean; transcript?: string; error?: string }> { - const cacheKey = this.getVoiceCacheKey(sessionId, msgId) - const cached = this.voiceTranscriptCache.get(cacheKey) - if (cached) { - return { success: true, transcript: cached } - } + const startTime = Date.now() + console.log(`[Transcribe] 开始转写: sessionId=${sessionId}, msgId=${msgId}, createTime=${createTime}`) - const pending = this.voiceTranscriptPending.get(cacheKey) - if (pending) { - return pending - } + try { + let msgCreateTime = createTime + let serverId: string | number | undefined - const task = (async () => { - try { - let wavData = this.voiceWavCache.get(cacheKey) - if (!wavData) { - // 获取消息详情以拿到 createTime 和 serverId - let cTime: number | undefined - let sId: string | number | undefined - const msgResult = await this.getMessageById(sessionId, parseInt(msgId, 10)) - if (msgResult.success && msgResult.message) { - cTime = msgResult.message.createTime - sId = msgResult.message.serverId - } + // 如果前端没传 createTime,才需要查询消息(这个很慢) + if (!msgCreateTime) { + const t1 = Date.now() + const msgResult = await this.getMessageById(sessionId, parseInt(msgId, 10)) + const t2 = Date.now() + console.log(`[Transcribe] getMessageById: ${t2 - t1}ms`) - const voiceResult = await this.getVoiceData(sessionId, msgId, cTime, sId) - if (!voiceResult.success || !voiceResult.data) { - return { success: false, error: voiceResult.error || '语音解码失败' } - } - wavData = Buffer.from(voiceResult.data, 'base64') + if (msgResult.success && msgResult.message) { + msgCreateTime = msgResult.message.createTime + serverId = msgResult.message.serverId + console.log(`[Transcribe] 获取到 createTime=${msgCreateTime}, serverId=${serverId}`) } - - const result = await voiceTranscribeService.transcribeWavBuffer(wavData, (text) => { - onPartial?.(text) - }) - if (result.success && result.transcript) { - this.cacheVoiceTranscript(cacheKey, result.transcript) - } - return result - } catch (error) { - return { success: false, error: String(error) } - } finally { - this.voiceTranscriptPending.delete(cacheKey) } - })() - this.voiceTranscriptPending.set(cacheKey, task) - return task + if (!msgCreateTime) { + console.error(`[Transcribe] 未找到消息时间戳`) + return { success: false, error: '未找到消息时间戳' } + } + + // 使用正确的 cacheKey(包含 createTime) + const cacheKey = this.getVoiceCacheKey(sessionId, msgId, msgCreateTime) + console.log(`[Transcribe] cacheKey=${cacheKey}`) + + // 检查转写缓存 + const cached = this.voiceTranscriptCache.get(cacheKey) + if (cached) { + console.log(`[Transcribe] 缓存命中,总耗时: ${Date.now() - startTime}ms`) + return { success: true, transcript: cached } + } + + // 检查是否正在转写 + const pending = this.voiceTranscriptPending.get(cacheKey) + if (pending) { + console.log(`[Transcribe] 正在转写中,等待结果`) + return pending + } + + const task = (async () => { + try { + // 检查内存中是否有 WAV 数据 + let wavData = this.voiceWavCache.get(cacheKey) + if (wavData) { + console.log(`[Transcribe] WAV内存缓存命中,大小: ${wavData.length} bytes`) + } else { + // 检查文件缓存 + const voiceCacheDir = this.getVoiceCacheDir() + const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`) + if (existsSync(wavFilePath)) { + try { + wavData = readFileSync(wavFilePath) + console.log(`[Transcribe] WAV文件缓存命中,大小: ${wavData.length} bytes`) + // 同时缓存到内存 + this.cacheVoiceWav(cacheKey, wavData) + } catch (e) { + console.error(`[Transcribe] 读取缓存文件失败:`, e) + } + } + } + + if (!wavData) { + console.log(`[Transcribe] WAV缓存未命中,调用 getVoiceData`) + const t3 = Date.now() + // 调用 getVoiceData 获取并解码 + const voiceResult = await this.getVoiceData(sessionId, msgId, msgCreateTime, serverId) + const t4 = Date.now() + console.log(`[Transcribe] getVoiceData: ${t4 - t3}ms, success=${voiceResult.success}`) + + if (!voiceResult.success || !voiceResult.data) { + console.error(`[Transcribe] 语音解码失败: ${voiceResult.error}`) + return { success: false, error: voiceResult.error || '语音解码失败' } + } + wavData = Buffer.from(voiceResult.data, 'base64') + console.log(`[Transcribe] WAV数据大小: ${wavData.length} bytes`) + } + + // 转写 + console.log(`[Transcribe] 开始调用 transcribeWavBuffer`) + const t5 = Date.now() + const result = await voiceTranscribeService.transcribeWavBuffer(wavData, (text) => { + console.log(`[Transcribe] 部分结果: ${text}`) + onPartial?.(text) + }) + const t6 = Date.now() + console.log(`[Transcribe] transcribeWavBuffer: ${t6 - t5}ms, success=${result.success}`) + + if (result.success && result.transcript) { + console.log(`[Transcribe] 转写成功: ${result.transcript}`) + this.cacheVoiceTranscript(cacheKey, result.transcript) + } else { + console.error(`[Transcribe] 转写失败: ${result.error}`) + } + + console.log(`[Transcribe] 总耗时: ${Date.now() - startTime}ms`) + return result + } catch (error) { + console.error(`[Transcribe] 异常:`, error) + return { success: false, error: String(error) } + } finally { + this.voiceTranscriptPending.delete(cacheKey) + } + })() + + this.voiceTranscriptPending.set(cacheKey, task) + return task + } catch (error) { + console.error(`[Transcribe] 外层异常:`, error) + return { success: false, error: String(error) } + } } - private getVoiceCacheKey(sessionId: string, msgId: string): string { + private getVoiceCacheKey(sessionId: string, msgId: string, createTime?: number): string { + // 优先使用 createTime 作为key,避免不同会话中localId相同导致的混乱 + if (createTime) { + return `${sessionId}_${createTime}` + } return `${sessionId}_${msgId}` } @@ -2525,32 +2872,6 @@ class ChatService { } } - /** - * 获取语音缓存文件路径 - */ - private getVoiceCacheFilePath(cacheKey: string): string { - const cachePath = this.configService.get('cachePath') as string | undefined - let baseDir: string - if (cachePath && cachePath.trim()) { - baseDir = join(cachePath, 'Voices') - } else { - const documentsPath = app.getPath('documents') - baseDir = join(documentsPath, 'WeFlow', 'Voices') - } - if (!existsSync(baseDir)) { - mkdirSync(baseDir, { recursive: true }) - } - return join(baseDir, `${cacheKey}.wav`) - } - - /** - * 保存语音到文件缓存 - */ - private saveVoiceCache(cacheKey: string, wavData: Buffer): void { - const filePath = this.getVoiceCacheFilePath(cacheKey) - writeFileSync(filePath, wavData) - } - private cacheVoiceTranscript(cacheKey: string, transcript: string): void { this.voiceTranscriptCache.set(cacheKey, transcript) if (this.voiceTranscriptCache.size > this.voiceCacheMaxEntries) { @@ -2561,8 +2882,6 @@ class ChatService { async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> { try { - console.info('[ChatService] getMessageById (SQL)', { sessionId, localId }) - // 1. 获取该会话所在的消息表 // 注意:这里使用 getMessageTableStats 而不是 getMessageTables,因为前者包含 db_path const tableStats = await wcdbService.getMessageTableStats(sessionId) @@ -2585,7 +2904,6 @@ class ChatService { const message = this.parseMessage(row) if (message.localId !== 0) { - console.info('[ChatService] getMessageById hit', { tableName, localId: message.localId }) return { success: true, message } } } @@ -2628,6 +2946,7 @@ class ChatService { isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0), senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null, rawContent: rawContent, + content: rawContent, // 添加原始内容供视频MD5解析使用 parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)) } diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index a3bfa4e..0a3f1cb 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -71,6 +71,7 @@ export interface ExportOptions { exportVoices?: boolean exportEmojis?: boolean exportVoiceAsText?: boolean + excelCompactColumns?: boolean } interface MediaExportItem { @@ -1250,13 +1251,18 @@ class ExportService { const sourceMatch = /[\s\S]*?<\/msgsource>/i.exec(msg.content || '') const source = sourceMatch ? sourceMatch[0] : '' + let content = this.parseMessageContent(msg.content, msg.localType) + if (msg.localType === 34 && options.exportVoiceAsText) { + content = await this.transcribeVoice(sessionId, String(msg.localId)) + } + allMessages.push({ localId: allMessages.length + 1, createTime: msg.createTime, formattedTime: this.formatTimestamp(msg.createTime), type: this.getMessageTypeName(msg.localType), localType: msg.localType, - content: this.parseMessageContent(msg.content, msg.localType), + content, isSend: msg.isSend ? 1 : 0, senderUsername: msg.senderUsername, senderDisplayName: senderInfo.displayName, @@ -1379,8 +1385,9 @@ class ExportService { let currentRow = 1 + const useCompactColumns = options.excelCompactColumns === true + // 第一行:会话信息标题 - worksheet.mergeCells(currentRow, 1, currentRow, 8) const titleCell = worksheet.getCell(currentRow, 1) titleCell.value = '会话信息' titleCell.font = { name: 'Calibri', bold: true, size: 11 } @@ -1436,7 +1443,9 @@ class ExportService { currentRow++ // 表头行 - const headers = ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容'] + const headers = useCompactColumns + ? ['序号', '时间', '发送者身份', '消息类型', '内容'] + : ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容'] const headerRow = worksheet.getRow(currentRow) headerRow.height = 22 @@ -1456,12 +1465,18 @@ class ExportService { // 设置列宽 worksheet.getColumn(1).width = 8 // 序号 worksheet.getColumn(2).width = 20 // 时间 - worksheet.getColumn(3).width = 18 // 发送者昵称 - worksheet.getColumn(4).width = 25 // 发送者微信ID - worksheet.getColumn(5).width = 18 // 发送者备注 - worksheet.getColumn(6).width = 15 // 发送者身份 - worksheet.getColumn(7).width = 12 // 消息类型 - worksheet.getColumn(8).width = 50 // 内容 + if (useCompactColumns) { + worksheet.getColumn(3).width = 18 // 发送者身份 + worksheet.getColumn(4).width = 12 // 消息类型 + worksheet.getColumn(5).width = 50 // 内容 + } else { + worksheet.getColumn(3).width = 18 // 发送者昵称 + worksheet.getColumn(4).width = 25 // 发送者微信ID + worksheet.getColumn(5).width = 18 // 发送者备注 + worksheet.getColumn(6).width = 15 // 发送者身份 + worksheet.getColumn(7).width = 12 // 消息类型 + worksheet.getColumn(8).width = 50 // 内容 + } // 填充数据 const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) @@ -1541,9 +1556,12 @@ class ExportService { row.height = 24 // 确定内容:如果有媒体文件导出成功则显示相对路径,否则显示解析后的内容 - const contentValue = mediaItem + let contentValue = mediaItem ? mediaItem.relativePath : (this.parseMessageContent(msg.content, msg.localType) || '') + if (!mediaItem && msg.localType === 34 && options.exportVoiceAsText) { + contentValue = await this.transcribeVoice(sessionId, String(msg.localId)) + } // 调试日志 if (msg.localType === 3 || msg.localType === 47) { @@ -1551,15 +1569,22 @@ class ExportService { worksheet.getCell(currentRow, 1).value = i + 1 worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime) - worksheet.getCell(currentRow, 3).value = senderNickname - worksheet.getCell(currentRow, 4).value = senderWxid - worksheet.getCell(currentRow, 5).value = senderRemark - worksheet.getCell(currentRow, 6).value = senderRole - worksheet.getCell(currentRow, 7).value = this.getMessageTypeName(msg.localType) - worksheet.getCell(currentRow, 8).value = contentValue + if (useCompactColumns) { + worksheet.getCell(currentRow, 3).value = senderRole + worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType) + worksheet.getCell(currentRow, 5).value = contentValue + } else { + worksheet.getCell(currentRow, 3).value = senderNickname + worksheet.getCell(currentRow, 4).value = senderWxid + worksheet.getCell(currentRow, 5).value = senderRemark + worksheet.getCell(currentRow, 6).value = senderRole + worksheet.getCell(currentRow, 7).value = this.getMessageTypeName(msg.localType) + worksheet.getCell(currentRow, 8).value = contentValue + } // 设置每个单元格的样式 - for (let col = 1; col <= 8; col++) { + const maxColumns = useCompactColumns ? 5 : 8 + for (let col = 1; col <= maxColumns; col++) { const cell = worksheet.getCell(currentRow, col) cell.font = { name: 'Calibri', size: 11 } cell.alignment = { vertical: 'middle', wrapText: false } @@ -1689,4 +1714,3 @@ class ExportService { } export const exportService = new ExportService() - diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 7b6dab8..aae33f9 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -68,14 +68,7 @@ export class ImageDecryptService { const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n` - // 同时输出到控制台 - if (meta) { - console.info(message, meta) - } else { - console.info(message) - } - - // 写入日志文件 + // 只写入文件,不输出到控制台 this.writeLog(logLine) } diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts new file mode 100644 index 0000000..5420076 --- /dev/null +++ b/electron/services/videoService.ts @@ -0,0 +1,256 @@ +import { join } from 'path' +import { existsSync, readdirSync, statSync, readFileSync } from 'fs' +import { ConfigService } from './config' +import Database from 'better-sqlite3' +import { wcdbService } from './wcdbService' + +export interface VideoInfo { + videoUrl?: string // 视频文件路径(用于 readFile) + coverUrl?: string // 封面 data URL + thumbUrl?: string // 缩略图 data URL + exists: boolean +} + +class VideoService { + private configService: ConfigService + + constructor() { + this.configService = new ConfigService() + } + + /** + * 获取数据库根目录 + */ + private getDbPath(): string { + return this.configService.get('dbPath') || '' + } + + /** + * 获取当前用户的wxid + */ + private getMyWxid(): string { + return this.configService.get('myWxid') || '' + } + + /** + * 获取缓存目录(解密后的数据库存放位置) + */ + private getCachePath(): string { + return this.configService.get('cachePath') || '' + } + + /** + * 清理 wxid 目录名(去掉后缀) + */ + private cleanWxid(wxid: string): string { + const trimmed = wxid.trim() + if (!trimmed) return trimmed + + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + if (match) return match[1] + return trimmed + } + + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + if (suffixMatch) return suffixMatch[1] + + return trimmed + } + + /** + * 从 video_hardlink_info_v4 表查询视频文件名 + * 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3) + * 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db + */ + private async queryVideoFileName(md5: string): Promise { + const cachePath = this.getCachePath() + const dbPath = this.getDbPath() + const wxid = this.getMyWxid() + const cleanedWxid = this.cleanWxid(wxid) + + if (!wxid) return undefined + + // 方法1:优先在 cachePath 下查找解密后的 hardlink.db + if (cachePath) { + const cacheDbPaths = [ + join(cachePath, cleanedWxid, 'hardlink.db'), + join(cachePath, wxid, 'hardlink.db'), + join(cachePath, 'hardlink.db'), + join(cachePath, 'databases', cleanedWxid, 'hardlink.db'), + join(cachePath, 'databases', wxid, 'hardlink.db') + ] + + for (const p of cacheDbPaths) { + if (existsSync(p)) { + try { + const db = new Database(p, { readonly: true }) + const row = db.prepare(` + SELECT file_name, md5 FROM video_hardlink_info_v4 + WHERE md5 = ? + LIMIT 1 + `).get(md5) as { file_name: string; md5: string } | undefined + db.close() + + if (row?.file_name) { + const realMd5 = row.file_name.replace(/\.[^.]+$/, '') + return realMd5 + } + } catch (e) { + // Silently fail + } + } + } + } + + // 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db + if (dbPath) { + const encryptedDbPaths = [ + join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'), + join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db') + ] + + for (const p of encryptedDbPaths) { + if (existsSync(p)) { + try { + const escapedMd5 = md5.replace(/'/g, "''") + + // 用 md5 字段查询,获取 file_name + const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1` + + const result = await wcdbService.execQuery('media', p, sql) + + if (result.success && result.rows && result.rows.length > 0) { + const row = result.rows[0] + if (row?.file_name) { + // 提取不带扩展名的文件名作为实际视频 MD5 + const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '') + return realMd5 + } + } + } catch (e) { + } + } + } + } + return undefined + } + + /** + * 将文件转换为 data URL + */ + private fileToDataUrl(filePath: string, mimeType: string): string | undefined { + try { + if (!existsSync(filePath)) return undefined + const buffer = readFileSync(filePath) + return `data:${mimeType};base64,${buffer.toString('base64')}` + } catch { + return undefined + } + } + + /** + * 根据视频MD5获取视频文件信息 + * 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/ + * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg + */ + async getVideoInfo(videoMd5: string): Promise { + + const dbPath = this.getDbPath() + const wxid = this.getMyWxid() + + if (!dbPath || !wxid || !videoMd5) { + return { exists: false } + } + + // 先尝试从数据库查询真正的视频文件名 + const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5 + + const videoBaseDir = join(dbPath, wxid, 'msg', 'video') + + if (!existsSync(videoBaseDir)) { + return { exists: false } + } + + // 遍历年月目录查找视频文件 + try { + const allDirs = readdirSync(videoBaseDir) + + // 支持多种目录格式: YYYY-MM, YYYYMM, 或其他 + const yearMonthDirs = allDirs + .filter(dir => { + const dirPath = join(videoBaseDir, dir) + return statSync(dirPath).isDirectory() + }) + .sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找 + + for (const yearMonth of yearMonthDirs) { + const dirPath = join(videoBaseDir, yearMonth) + + const videoPath = join(dirPath, `${realVideoMd5}.mp4`) + const coverPath = join(dirPath, `${realVideoMd5}.jpg`) + const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`) + + // 检查视频文件是否存在 + if (existsSync(videoPath)) { + return { + videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取 + coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), + thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), + exists: true + } + } + } + } catch (e) { + console.error('[VideoService] Error searching for video:', e) + } + + return { exists: false } + } + + /** + * 根据消息内容解析视频MD5 + */ + parseVideoMd5(content: string): string | undefined { + + // 打印前500字符看看 XML 结构 + + if (!content) return undefined + + try { + // 提取所有可能的 md5 值进行日志 + const allMd5s: string[] = [] + const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi + let match + while ((match = md5Regex.exec(content)) !== null) { + allMd5s.push(`${match[0]}`) + } + + // 提取 md5(用于查询 hardlink.db) + // 注意:不是 rawmd5,rawmd5 是另一个值 + // 格式: md5="xxx" 或 xxx + + // 尝试从videomsg标签中提取md5 + const videoMsgMatch = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (videoMsgMatch) { + return videoMsgMatch[1].toLowerCase() + } + + const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (attrMatch) { + return attrMatch[1].toLowerCase() + } + + const md5Match = /([a-fA-F0-9]+)<\/md5>/i.exec(content) + if (md5Match) { + return md5Match[1].toLowerCase() + } + } catch (e) { + console.error('[VideoService] 解析视频MD5失败:', e) + } + + return undefined + } +} + +export const videoService = new VideoService() diff --git a/electron/services/voiceTranscribeService.ts b/electron/services/voiceTranscribeService.ts index a23d0d2..5f594c2 100644 --- a/electron/services/voiceTranscribeService.ts +++ b/electron/services/voiceTranscribeService.ts @@ -224,13 +224,16 @@ export class VoiceTranscribeService { let finalTranscript = '' worker.on('message', (msg: any) => { + console.log('[VoiceTranscribe] Worker 消息:', msg) if (msg.type === 'partial') { onPartial?.(msg.text) } else if (msg.type === 'final') { finalTranscript = msg.text + console.log('[VoiceTranscribe] 最终文本:', finalTranscript) resolve({ success: true, transcript: finalTranscript }) worker.terminate() } else if (msg.type === 'error') { + console.error('[VoiceTranscribe] Worker 错误:', msg.error) resolve({ success: false, error: msg.error }) worker.terminate() } diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 1949e74..6144726 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -110,7 +110,7 @@ export class WcdbCore { private writeLog(message: string, force = false): void { if (!force && !this.isLogEnabled()) return const line = `[${new Date().toISOString()}] ${message}` - console.log(`[WCDB] ${line}`) + // 移除控制台日志,只写入文件 try { const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd() const dir = join(base, 'logs') @@ -382,6 +382,12 @@ export class WcdbCore { return { success: true, sessionCount: 0 } } + // 记录当前活动连接,用于在测试结束后恢复(避免影响聊天页等正在使用的连接) + const hadActiveConnection = this.handle !== null + const prevPath = this.currentPath + const prevKey = this.currentKey + const prevWxid = this.currentWxid + if (!this.initialized) { const initOk = await this.initialize() if (!initOk) { @@ -424,8 +430,8 @@ export class WcdbCore { return { success: false, error: '无效的数据库句柄' } } - // 测试成功,使用 shutdown 清理所有资源(包括测试句柄) - // 这会中断当前活动连接,但 testConnection 本应该是独立测试 + // 测试成功:使用 shutdown 清理资源(包括测试句柄) + // 注意:shutdown 会断开当前活动连接,因此需要在测试后尝试恢复之前的连接 try { this.wcdbShutdown() this.handle = null @@ -437,6 +443,15 @@ export class WcdbCore { console.error('关闭测试数据库时出错:', closeErr) } + // 恢复测试前的连接(如果之前有活动连接) + if (hadActiveConnection && prevPath && prevKey && prevWxid) { + try { + await this.open(prevPath, prevKey, prevWxid) + } catch { + // 恢复失败则保持断开,由调用方处理 + } + } + return { success: true, sessionCount: 0 } } catch (e) { console.error('测试连接异常:', e) @@ -620,7 +635,7 @@ export class WcdbCore { try { this.wcdbSetMyWxid(this.handle, wxid) } catch (e) { - console.warn('设置 wxid 失败:', e) + // 静默失败 } } if (this.isLogEnabled()) { @@ -799,7 +814,6 @@ export class WcdbCore { await new Promise(resolve => setImmediate(resolve)) if (result !== 0 || !outPtr[0]) { - console.warn(`[wcdbCore] getAvatarUrls DLL调用失败: result=${result}, usernames=${toFetch.length}`) if (Object.keys(resultMap).length > 0) { return { success: true, map: resultMap, error: `获取头像失败: ${result}` } } @@ -807,25 +821,18 @@ export class WcdbCore { } const jsonStr = this.decodeJsonPtr(outPtr[0]) if (!jsonStr) { - console.error('[wcdbCore] getAvatarUrls 解析JSON失败') return { success: false, error: '解析头像失败' } } const map = JSON.parse(jsonStr) as Record - let successCount = 0 - let emptyCount = 0 for (const username of toFetch) { const url = map[username] if (url && url.trim()) { resultMap[username] = url // 只缓存有效的URL this.avatarUrlCache.set(username, { url, updatedAt: now }) - successCount++ - } else { - emptyCount++ - // 不缓存空URL,下次可以重新尝试 } + // 不缓存空URL,下次可以重新尝试 } - console.log(`[wcdbCore] getAvatarUrls 成功: ${successCount}个, 空结果: ${emptyCount}个, 总请求: ${toFetch.length}`) return { success: true, map: resultMap } } catch (e) { console.error('[wcdbCore] getAvatarUrls 异常:', e) diff --git a/resources/silk_v3_decoder.exe b/resources/silk_v3_decoder.exe deleted file mode 100644 index f612751..0000000 Binary files a/resources/silk_v3_decoder.exe and /dev/null differ diff --git a/src/App.tsx b/src/App.tsx index ed93702..77aa32f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage' import DataManagementPage from './pages/DataManagementPage' import SettingsPage from './pages/SettingsPage' import ExportPage from './pages/ExportPage' +import VideoWindow from './pages/VideoWindow' import { useAppStore } from './stores/appStore' import { themes, useThemeStore, type ThemeId } from './stores/themeStore' @@ -29,6 +30,7 @@ function App() { const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const isAgreementWindow = location.pathname === '/agreement-window' const isOnboardingWindow = location.pathname === '/onboarding-window' + const isVideoPlayerWindow = location.pathname === '/video-player-window' const [themeHydrated, setThemeHydrated] = useState(false) // 协议同意状态 @@ -219,6 +221,11 @@ function App() { return } + // 独立视频播放窗口 + if (isVideoPlayerWindow) { + return + } + // 主窗口 - 完整布局 return (
diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index f9e4db1..33e89bb 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1,4 +1,4 @@ -.chat-page { +.chat-page { display: flex; height: 100%; gap: 16px; @@ -370,9 +370,23 @@ } .message-bubble { - max-width: 65%; + display: flex; + gap: 12px; + max-width: 80%; + margin-bottom: 4px; + align-items: flex-start; + + .bubble-body { + display: flex; + flex-direction: column; + max-width: 100%; + min-width: 0; // 允许收缩 + width: fit-content; // 让气泡宽度由内容决定 + } &.sent { + flex-direction: row-reverse; + .bubble-content { background: var(--primary-gradient); color: #fff; @@ -382,6 +396,10 @@ line-height: 1.5; box-shadow: 0 2px 10px var(--primary-light); } + + .bubble-body { + align-items: flex-end; + } } &.received { @@ -395,6 +413,10 @@ backdrop-filter: blur(10px); border: 1px solid var(--border-color); } + + .bubble-body { + align-items: flex-start; + } } &.system { @@ -428,6 +450,11 @@ font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; + // 防止名字撑开气泡宽度 + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .quoted-message { @@ -790,6 +817,99 @@ } // 右侧消息区域 +// ... (previous content) ... + +// 链接卡片消息样式 +.link-message { + cursor: pointer; + background: var(--card-bg); + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border-color); + transition: all 0.2s ease; + max-width: 300px; + margin-top: 4px; + + &:hover { + background: var(--bg-hover); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + } + + .link-header { + display: flex; + align-items: flex-start; + padding: 12px; + gap: 12px; + } + + .link-content { + flex: 1; + min-width: 0; + } + + .link-title { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 4px; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4; + } + + .link-desc { + font-size: 12px; + color: var(--text-secondary); + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4; + opacity: 0.8; + } + + .link-icon { + flex-shrink: 0; + width: 40px; + height: 40px; + background: var(--bg-tertiary); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + + svg { + opacity: 0.8; + } + } +} + +// 适配发送出去的消息中的链接卡片 +.message-bubble.sent .link-message { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + + .link-title, + .link-desc { + color: #fff; + } + + .link-icon { + background: rgba(255, 255, 255, 0.2); + color: #fff; + } + + &:hover { + background: rgba(255, 255, 255, 0.2); + } +} + .message-area { flex: 1 1 70%; display: flex; @@ -1485,6 +1605,11 @@ font-size: 12px; color: var(--text-tertiary); margin-bottom: 4px; + // 防止名字撑开气泡宽度 + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } // 引用消息样式 @@ -1533,7 +1658,11 @@ display: flex; flex-direction: column; max-width: 100%; + min-width: 0; // 允许收缩 -webkit-app-region: no-drag; + + // 让气泡宽度由内容决定,而不是被父容器撑开 + width: fit-content; } .bubble-content { @@ -1948,4 +2077,85 @@ width: 14px; height: 14px; } +} + +// 视频消息样式 +.video-thumb-wrapper { + position: relative; + max-width: 300px; + min-width: 200px; + border-radius: 12px; + overflow: hidden; + cursor: pointer; + background: var(--bg-tertiary); + transition: transform 0.2s; + + &:hover { + transform: scale(1.02); + + .video-play-button { + opacity: 1; + transform: translate(-50%, -50%) scale(1.1); + } + } + + .video-thumb { + width: 100%; + height: auto; + display: block; + } + + .video-thumb-placeholder { + width: 100%; + aspect-ratio: 16/9; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-hover); + color: var(--text-tertiary); + + svg { + width: 32px; + height: 32px; + } + } + + .video-play-button { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + opacity: 0.9; + transition: all 0.2s; + color: #fff; + filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5)); + } +} + +.video-placeholder, +.video-loading, +.video-unavailable { + min-width: 120px; + min-height: 80px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 16px; + border-radius: 12px; + background: var(--bg-tertiary); + color: var(--text-tertiary); + font-size: 13px; + + svg { + width: 24px; + height: 24px; + } +} + +.video-loading { + .spin { + animation: spin 1s linear infinite; + } } \ No newline at end of file diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 74e655c..174c02f 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, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link } from 'lucide-react' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' import type { ChatSession, Message } from '../types/models' @@ -1343,6 +1343,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o const isSystem = isSystemMessage(message.localType) const isEmoji = message.localType === 47 const isImage = message.localType === 3 + const isVideo = message.localType === 43 const isVoice = message.localType === 34 const isSent = message.isSend === 1 const [senderAvatarUrl, setSenderAvatarUrl] = useState(undefined) @@ -1371,6 +1372,56 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o const [voiceWaveform, setVoiceWaveform] = useState([]) const voiceAutoDecryptTriggered = useRef(false) + // 视频相关状态 + const [videoLoading, setVideoLoading] = useState(false) + const [videoInfo, setVideoInfo] = useState<{ videoUrl?: string; coverUrl?: string; thumbUrl?: string; exists: boolean } | null>(null) + const videoContainerRef = useRef(null) + const [isVideoVisible, setIsVideoVisible] = useState(false) + const [videoMd5, setVideoMd5] = useState(null) + + // 解析视频 MD5 + useEffect(() => { + if (!isVideo) return + + console.log('[Video Debug] Full message object:', JSON.stringify(message, null, 2)) + console.log('[Video Debug] Message keys:', Object.keys(message)) + console.log('[Video Debug] Message:', { + localId: message.localId, + localType: message.localType, + hasVideoMd5: !!message.videoMd5, + hasContent: !!message.content, + hasParsedContent: !!message.parsedContent, + hasRawContent: !!(message as any).rawContent, + contentPreview: message.content?.substring(0, 200), + parsedContentPreview: message.parsedContent?.substring(0, 200), + rawContentPreview: (message as any).rawContent?.substring(0, 200) + }) + + // 优先使用数据库中的 videoMd5 + if (message.videoMd5) { + console.log('[Video Debug] Using videoMd5 from message:', message.videoMd5) + setVideoMd5(message.videoMd5) + return + } + + // 尝试从多个可能的字段获取原始内容 + const contentToUse = message.content || (message as any).rawContent || message.parsedContent + if (contentToUse) { + console.log('[Video Debug] Parsing MD5 from content, length:', contentToUse.length) + window.electronAPI.video.parseVideoMd5(contentToUse).then((result) => { + console.log('[Video Debug] Parse result:', result) + if (result && result.success && result.md5) { + console.log('[Video Debug] Parsed MD5:', result.md5) + setVideoMd5(result.md5) + } else { + console.error('[Video Debug] Failed to parse MD5:', result) + } + }).catch((err) => { + console.error('[Video Debug] Parse error:', err) + }) + } + }, [isVideo, message.videoMd5, message.content, message.parsedContent]) + // 加载自动转文字配置 useEffect(() => { const loadConfig = async () => { @@ -1784,7 +1835,16 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o throw error } - const result = await window.electronAPI.chat.getVoiceTranscript(session.username, String(message.localId)) + const result = await window.electronAPI.chat.getVoiceTranscript( + session.username, + String(message.localId), + message.createTime + ) + console.log('[ChatPage] 调用转写:', { + sessionId: session.username, + msgId: message.localId, + createTime: message.createTime + }) if (result.success) { const transcriptText = (result.transcript || '').trim() voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText) @@ -1829,6 +1889,62 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o } }, [isVoice, message.localId, requestVoiceTranscript]) + // 视频懒加载 + useEffect(() => { + if (!isVideo || !videoContainerRef.current) return + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setIsVideoVisible(true) + observer.disconnect() + } + }) + }, + { + rootMargin: '200px 0px', + threshold: 0 + } + ) + + observer.observe(videoContainerRef.current) + + return () => observer.disconnect() + }, [isVideo]) + + // 加载视频信息 + useEffect(() => { + if (!isVideo || !isVideoVisible || videoInfo || videoLoading) return + if (!videoMd5) { + console.log('[Video Debug] No videoMd5 available yet') + return + } + + console.log('[Video Debug] Loading video info for MD5:', videoMd5) + setVideoLoading(true) + window.electronAPI.video.getVideoInfo(videoMd5).then((result) => { + console.log('[Video Debug] getVideoInfo result:', result) + if (result && result.success) { + setVideoInfo({ + exists: result.exists, + videoUrl: result.videoUrl, + coverUrl: result.coverUrl, + thumbUrl: result.thumbUrl + }) + } else { + console.error('[Video Debug] Video info failed:', result) + setVideoInfo({ exists: false }) + } + }).catch((err) => { + console.error('[Video Debug] getVideoInfo error:', err) + setVideoInfo({ exists: false }) + }).finally(() => { + setVideoLoading(false) + }) + }, [isVideo, isVideoVisible, videoInfo, videoLoading, videoMd5]) + + // 根据设置决定是否自动转写 const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false) @@ -1856,6 +1972,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o ) } + // 检测是否为链接卡片消息 + const isLinkMessage = String(message.localType) === '21474836529' || + (message.rawContent && (message.rawContent.includes(' 0 @@ -1959,6 +2080,72 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o ) } + // 视频消息 + if (isVideo) { + const handlePlayVideo = useCallback(async () => { + if (!videoInfo?.videoUrl) return + try { + await window.electronAPI.window.openVideoPlayerWindow(videoInfo.videoUrl) + } catch (e) { + console.error('打开视频播放窗口失败:', e) + } + }, [videoInfo?.videoUrl]) + + // 未进入可视区域时显示占位符 + if (!isVideoVisible) { + return ( +
+ + + + +
+ ) + } + + // 加载中 + if (videoLoading) { + return ( +
+ +
+ ) + } + + // 视频不存在 + if (!videoInfo?.exists || !videoInfo.videoUrl) { + return ( +
+ + + + + 视频不可用 +
+ ) + } + + // 默认显示缩略图,点击打开独立播放窗口 + const thumbSrc = videoInfo.thumbUrl || videoInfo.coverUrl + return ( +
+ {thumbSrc ? ( + 视频缩略图 + ) : ( +
+ + + + +
+ )} +
+ +
+
+ ) + } + if (isVoice) { const durationText = message.voiceDurationSeconds ? `${message.voiceDurationSeconds}"` : '' const handleToggle = async () => { @@ -2157,6 +2344,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o /> ) } + + // 解析引用消息(Links / App Messages) + // localType: 21474836529 corresponds to AppMessage which often contains links + // 带引用的消息 if (hasQuote) { return ( @@ -2169,6 +2360,68 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
) } + + // 解析引用消息(Links / App Messages) + // localType: 21474836529 corresponds to AppMessage which often contains links + if (isLinkMessage) { + try { + // 清理内容:移除可能的 wxid 前缀,找到 XML 起始位置 + let contentToParse = message.rawContent || message.parsedContent || ''; + const xmlStartIndex = contentToParse.indexOf('<'); + if (xmlStartIndex >= 0) { + contentToParse = contentToParse.substring(xmlStartIndex); + } + + // 处理 HTML 转义字符 + if (contentToParse.includes('<')) { + contentToParse = contentToParse + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'"); + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(contentToParse, "text/xml"); + const appMsg = doc.querySelector('appmsg'); + + if (appMsg) { + const title = doc.querySelector('title')?.textContent || '未命名链接'; + const des = doc.querySelector('des')?.textContent || '无描述'; + const url = doc.querySelector('url')?.textContent || ''; + + return ( +
{ + e.stopPropagation(); + if (url) { + // 优先使用 electron 接口打开外部浏览器 + if (window.electronAPI?.shell?.openExternal) { + window.electronAPI.shell.openExternal(url); + } else { + window.open(url, '_blank'); + } + } + }} + > +
+
+
{title}
+
{des}
+
+
+ +
+
+
+ ); + } + } catch (e) { + console.error('Failed to parse app message', e); + } + } // 普通消息 return
{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}
} diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index f60a588..19d8605 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -21,6 +21,7 @@ interface ExportOptions { exportVoices: boolean exportEmojis: boolean exportVoiceAsText: boolean + excelCompactColumns: boolean } interface ExportResult { @@ -45,20 +46,40 @@ function ExportPage() { const [selectingStart, setSelectingStart] = useState(true) const [options, setOptions] = useState({ - format: 'chatlab', + format: 'excel', dateRange: { - start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + start: new Date(new Date().setHours(0, 0, 0, 0)), end: new Date() }, - useAllTime: true, + useAllTime: false, exportAvatars: true, exportMedia: false, exportImages: true, exportVoices: true, exportEmojis: true, - exportVoiceAsText: false + exportVoiceAsText: true, + excelCompactColumns: true }) + const buildDateRangeFromPreset = (preset: string) => { + const now = new Date() + if (preset === 'all') { + return { useAllTime: true, dateRange: { start: now, end: now } } + } + let rangeMs = 0 + if (preset === '7d') rangeMs = 7 * 24 * 60 * 60 * 1000 + if (preset === '30d') rangeMs = 30 * 24 * 60 * 60 * 1000 + if (preset === '90d') rangeMs = 90 * 24 * 60 * 60 * 1000 + if (preset === 'today' || rangeMs === 0) { + const start = new Date(now) + start.setHours(0, 0, 0, 0) + return { useAllTime: false, dateRange: { start, end: now } } + } + const start = new Date(now.getTime() - rangeMs) + start.setHours(0, 0, 0, 0) + return { useAllTime: false, dateRange: { start, end: now } } + } + const loadSessions = useCallback(async () => { setIsLoading(true) try { @@ -94,10 +115,44 @@ function ExportPage() { } }, []) + const loadExportDefaults = useCallback(async () => { + try { + const [ + savedFormat, + savedRange, + savedMedia, + savedVoiceAsText, + savedExcelCompactColumns + ] = await Promise.all([ + configService.getExportDefaultFormat(), + configService.getExportDefaultDateRange(), + configService.getExportDefaultMedia(), + configService.getExportDefaultVoiceAsText(), + configService.getExportDefaultExcelCompactColumns() + ]) + + const preset = savedRange || 'today' + const rangeDefaults = buildDateRangeFromPreset(preset) + + setOptions((prev) => ({ + ...prev, + format: (savedFormat as ExportOptions['format']) || 'excel', + useAllTime: rangeDefaults.useAllTime, + dateRange: rangeDefaults.dateRange, + exportMedia: savedMedia ?? false, + exportVoiceAsText: savedVoiceAsText ?? true, + excelCompactColumns: savedExcelCompactColumns ?? true + })) + } catch (e) { + console.error('加载导出默认设置失败:', e) + } + }, []) + useEffect(() => { loadSessions() loadExportPath() - }, [loadSessions, loadExportPath]) + loadExportDefaults() + }, [loadSessions, loadExportPath, loadExportDefaults]) useEffect(() => { if (!searchKeyword.trim()) { @@ -161,6 +216,7 @@ function ExportPage() { exportVoices: options.exportMedia && options.exportVoices, exportEmojis: options.exportMedia && options.exportEmojis, exportVoiceAsText: options.exportVoiceAsText, // 独立于 exportMedia + excelCompactColumns: options.excelCompactColumns, dateRange: options.useAllTime ? null : options.dateRange ? { start: Math.floor(options.dateRange.start.getTime() / 1000), // 将结束日期设置为当天的 23:59:59,以包含当天的所有消息 diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index bc259d6..6fbe196 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef } from 'react' import { useAppStore } from '../stores/appStore' import { useThemeStore, themes } from '../stores/themeStore' import { useAnalyticsStore } from '../stores/analyticsStore' @@ -11,12 +11,13 @@ import { } from 'lucide-react' import './SettingsPage.scss' -type SettingsTab = 'appearance' | 'database' | 'whisper' | 'cache' | 'about' +type SettingsTab = 'appearance' | 'database' | 'whisper' | 'export' | 'cache' | 'about' const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'appearance', label: '外观', icon: Palette }, { id: 'database', label: '数据库连接', icon: Database }, { id: 'whisper', label: '语音识别模型', icon: Mic }, + { id: 'export', label: '导出', icon: Download }, { id: 'cache', label: '缓存', icon: HardDrive }, { id: 'about', label: '关于', icon: Info } ] @@ -49,6 +50,11 @@ function SettingsPage() { const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null) const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false) const [transcribeLanguages, setTranscribeLanguages] = useState(['zh']) + const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') + const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today') + const [exportDefaultMedia, setExportDefaultMedia] = useState(false) + const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true) + const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) const [isLoading, setIsLoadingState] = useState(false) const [isTesting, setIsTesting] = useState(false) @@ -114,6 +120,11 @@ function SettingsPage() { const savedWhisperModelDir = await configService.getWhisperModelDir() const savedAutoTranscribe = await configService.getAutoTranscribeVoice() const savedTranscribeLanguages = await configService.getTranscribeLanguages() + const savedExportDefaultFormat = await configService.getExportDefaultFormat() + const savedExportDefaultDateRange = await configService.getExportDefaultDateRange() + const savedExportDefaultMedia = await configService.getExportDefaultMedia() + const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText() + const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns() if (savedKey) setDecryptKey(savedKey) if (savedPath) setDbPath(savedPath) @@ -126,6 +137,11 @@ function SettingsPage() { setLogEnabled(savedLogEnabled) setAutoTranscribeVoice(savedAutoTranscribe) setTranscribeLanguages(savedTranscribeLanguages) + setExportDefaultFormat(savedExportDefaultFormat || 'excel') + setExportDefaultDateRange(savedExportDefaultDateRange || 'today') + setExportDefaultMedia(savedExportDefaultMedia ?? false) + setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true) + setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true) // 如果语言列表为空,保存默认值 if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) { @@ -468,15 +484,8 @@ function SettingsPage() { await configService.setTranscribeLanguages(transcribeLanguages) await configService.setOnboardingDone(true) - showMessage('配置保存成功,正在测试连接...', true) - const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid) - - if (result.success) { - setDbConnected(true, dbPath) - showMessage('配置保存成功!数据库连接正常', true) - } else { - showMessage(result.error || '数据库连接失败,请检查配置', false) - } + // 保存按钮只负责持久化配置,不做连接测试/重连,避免影响聊天页的活动连接 + showMessage('配置保存成功', true) } catch (e) { showMessage(`保存配置失败: ${e}`, false) } finally { @@ -853,6 +862,115 @@ function SettingsPage() { ) + + const renderExportTab = () => ( +
+
+ + 导出页面默认选中的格式 + +
+ +
+ + 控制导出页面的默认时间选择 + +
+ +
+ + 控制图片/语音/表情的默认导出开关 +
+ {exportDefaultMedia ? '已开启' : '已关闭'} + +
+
+ +
+ + 导出时默认将语音转写为文字 +
+ {exportDefaultVoiceAsText ? '已开启' : '已关闭'} + +
+
+ +
+ + 控制 Excel 导出的列字段 + +
+
+ ) const renderCacheTab = () => (

管理应用缓存数据

@@ -992,6 +1110,7 @@ function SettingsPage() { {activeTab === 'appearance' && renderAppearanceTab()} {activeTab === 'database' && renderDatabaseTab()} {activeTab === 'whisper' && renderWhisperTab()} + {activeTab === 'export' && renderExportTab()} {activeTab === 'cache' && renderCacheTab()} {activeTab === 'about' && renderAboutTab()}
@@ -1001,4 +1120,3 @@ function SettingsPage() { export default SettingsPage - diff --git a/src/pages/VideoWindow.scss b/src/pages/VideoWindow.scss new file mode 100644 index 0000000..592439a --- /dev/null +++ b/src/pages/VideoWindow.scss @@ -0,0 +1,216 @@ +.video-window-container { + width: 100vw; + height: 100vh; + background-color: #000; + display: flex; + flex-direction: column; + overflow: hidden; + user-select: none; + + .title-bar { + height: 40px; + min-height: 40px; + display: flex; + background: #1a1a1a; + padding-right: 140px; + position: relative; + z-index: 10; + + .window-drag-area { + flex: 1; + height: 100%; + -webkit-app-region: drag; + } + } + + .video-viewport { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + position: relative; + cursor: pointer; + background: #000; + overflow: hidden; + min-height: 0; // 重要:让 flex 子元素可以收缩 + + video { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + object-fit: contain; + } + + .video-loading-overlay, + .video-error-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + z-index: 5; + } + + .video-error-overlay { + color: #ff6b6b; + font-size: 14px; + } + + .spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(255, 255, 255, 0.2); + border-top-color: #fff; + border-radius: 50%; + animation: spin 1s linear infinite; + } + + .play-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.3); + opacity: 0; + transition: opacity 0.2s; + z-index: 4; + + svg { + filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5)); + } + } + + &:hover .play-overlay { + opacity: 1; + } + } + + .video-controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(to top, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.4) 60%, transparent); + padding: 40px 16px 12px; + opacity: 0; + transition: opacity 0.25s; + z-index: 6; + + .progress-bar { + height: 16px; + display: flex; + align-items: center; + cursor: pointer; + margin-bottom: 8px; + + .progress-track { + flex: 1; + height: 3px; + background: rgba(255, 255, 255, 0.3); + border-radius: 2px; + overflow: hidden; + transition: height 0.15s; + + .progress-fill { + height: 100%; + background: var(--primary, #4a9eff); + border-radius: 2px; + } + } + + &:hover .progress-track { + height: 5px; + } + } + + .controls-row { + display: flex; + align-items: center; + justify-content: space-between; + } + + .controls-left, + .controls-right { + display: flex; + align-items: center; + gap: 6px; + } + + button { + background: transparent; + border: none; + color: #fff; + cursor: pointer; + padding: 6px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; + + &:hover { + background: rgba(255, 255, 255, 0.15); + } + } + + .time-display { + color: rgba(255, 255, 255, 0.9); + font-size: 12px; + font-variant-numeric: tabular-nums; + margin-left: 4px; + } + + .volume-control { + display: flex; + align-items: center; + gap: 4px; + + .volume-slider { + width: 60px; + height: 3px; + appearance: none; + -webkit-appearance: none; + background: rgba(255, 255, 255, 0.3); + border-radius: 2px; + cursor: pointer; + + &::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + width: 10px; + height: 10px; + background: #fff; + border-radius: 50%; + cursor: pointer; + } + } + } + } + + // 鼠标悬停时显示控制栏 + &:hover .video-controls { + opacity: 1; + } + + // 播放时如果鼠标不动,隐藏控制栏 + &.hide-controls .video-controls { + opacity: 0; + } +} + +.video-window-empty { + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.6); + background-color: #000; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/src/pages/VideoWindow.tsx b/src/pages/VideoWindow.tsx new file mode 100644 index 0000000..5719548 --- /dev/null +++ b/src/pages/VideoWindow.tsx @@ -0,0 +1,199 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { useSearchParams } from 'react-router-dom' +import { Play, Pause, Volume2, VolumeX, RotateCcw } from 'lucide-react' +import './VideoWindow.scss' + +export default function VideoWindow() { + const [searchParams] = useSearchParams() + const videoPath = searchParams.get('videoPath') + const [isPlaying, setIsPlaying] = useState(false) + const [isMuted, setIsMuted] = useState(false) + const [currentTime, setCurrentTime] = useState(0) + const [duration, setDuration] = useState(0) + const [volume, setVolume] = useState(1) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const videoRef = useRef(null) + const progressRef = useRef(null) + + // 格式化时间 + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + //播放/暂停 + const togglePlay = useCallback(() => { + if (!videoRef.current) return + if (isPlaying) { + videoRef.current.pause() + } else { + videoRef.current.play() + } + }, [isPlaying]) + + // 静音切换 + const toggleMute = useCallback(() => { + if (!videoRef.current) return + videoRef.current.muted = !isMuted + setIsMuted(!isMuted) + }, [isMuted]) + + // 进度条点击 + const handleProgressClick = useCallback((e: React.MouseEvent) => { + if (!videoRef.current || !progressRef.current) return + e.stopPropagation() + const rect = progressRef.current.getBoundingClientRect() + const percent = (e.clientX - rect.left) / rect.width + videoRef.current.currentTime = percent * duration + }, [duration]) + + // 音量调节 + const handleVolumeChange = useCallback((e: React.ChangeEvent) => { + const newVolume = parseFloat(e.target.value) + setVolume(newVolume) + if (videoRef.current) { + videoRef.current.volume = newVolume + setIsMuted(newVolume === 0) + } + }, []) + + // 重新播放 + const handleReplay = useCallback(() => { + if (!videoRef.current) return + videoRef.current.currentTime = 0 + videoRef.current.play() + }, []) + + // 快捷键 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') window.electronAPI.window.close() + if (e.key === ' ') { + e.preventDefault() + togglePlay() + } + if (e.key === 'm' || e.key === 'M') toggleMute() + if (e.key === 'ArrowLeft' && videoRef.current) { + videoRef.current.currentTime -= 5 + } + if (e.key === 'ArrowRight' && videoRef.current) { + videoRef.current.currentTime += 5 + } + if (e.key === 'ArrowUp' && videoRef.current) { + videoRef.current.volume = Math.min(1, videoRef.current.volume + 0.1) + setVolume(videoRef.current.volume) + } + if (e.key === 'ArrowDown' && videoRef.current) { + videoRef.current.volume = Math.max(0, videoRef.current.volume - 0.1) + setVolume(videoRef.current.volume) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [togglePlay, toggleMute]) + + if (!videoPath) { + return ( +
+ 无效的视频路径 +
+ ) + } + + const progress = duration > 0 ? (currentTime / duration) * 100 : 0 + + return ( +
+
+
+
+ +
+ {isLoading && ( +
+
+
+ )} + {error && ( +
+ {error} +
+ )} +
+
+ ) +} diff --git a/src/services/config.ts b/src/services/config.ts index 4704d36..9bc8a5e 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -22,7 +22,12 @@ export const CONFIG_KEYS = { WHISPER_MODEL_DIR: 'whisperModelDir', WHISPER_DOWNLOAD_SOURCE: 'whisperDownloadSource', AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice', - TRANSCRIBE_LANGUAGES: 'transcribeLanguages' + TRANSCRIBE_LANGUAGES: 'transcribeLanguages', + EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat', + EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange', + EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia', + EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText', + EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns' } as const // 获取解密密钥 @@ -243,3 +248,61 @@ export async function getTranscribeLanguages(): Promise { export async function setTranscribeLanguages(languages: string[]): Promise { await config.set(CONFIG_KEYS.TRANSCRIBE_LANGUAGES, languages) } + +// 获取导出默认格式 +export async function getExportDefaultFormat(): Promise { + const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_FORMAT) + return (value as string) || null +} + +// 设置导出默认格式 +export async function setExportDefaultFormat(format: string): Promise { + await config.set(CONFIG_KEYS.EXPORT_DEFAULT_FORMAT, format) +} + +// 获取导出默认时间范围 +export async function getExportDefaultDateRange(): Promise { + const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE) + return (value as string) || null +} + +// 设置导出默认时间范围 +export async function setExportDefaultDateRange(range: string): Promise { + await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range) +} + +// 获取导出默认媒体设置 +export async function getExportDefaultMedia(): Promise { + const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA) + if (typeof value === 'boolean') return value + return null +} + +// 设置导出默认媒体设置 +export async function setExportDefaultMedia(enabled: boolean): Promise { + await config.set(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA, enabled) +} + +// 获取导出默认语音转文字 +export async function getExportDefaultVoiceAsText(): Promise { + const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_VOICE_AS_TEXT) + if (typeof value === 'boolean') return value + return null +} + +// 设置导出默认语音转文字 +export async function setExportDefaultVoiceAsText(enabled: boolean): Promise { + await config.set(CONFIG_KEYS.EXPORT_DEFAULT_VOICE_AS_TEXT, enabled) +} + +// 获取导出默认 Excel 列模式 +export async function getExportDefaultExcelCompactColumns(): Promise { + const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS) + if (typeof value === 'boolean') return value + return null +} + +// 设置导出默认 Excel 列模式 +export async function setExportDefaultExcelCompactColumns(enabled: boolean): Promise { + await config.set(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS, enabled) +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 65cff90..bacefb3 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -9,6 +9,8 @@ export interface ElectronAPI { completeOnboarding: () => Promise openOnboardingWindow: () => Promise setTitleBarOverlay: (options: { symbolColor: string }) => void + openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise + resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise } config: { get: (key: string) => Promise @@ -96,7 +98,7 @@ export interface ElectronAPI { getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }> resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }> - getVoiceTranscript: (sessionId: string, msgId: string) => Promise<{ success: boolean; transcript?: string; error?: string }> + getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }> onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void } @@ -107,6 +109,21 @@ export interface ElectronAPI { onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void } + video: { + getVideoInfo: (videoMd5: string) => Promise<{ + success: boolean + exists: boolean + videoUrl?: string + coverUrl?: string + thumbUrl?: string + error?: string + }> + parseVideoMd5: (content: string) => Promise<{ + success: boolean + md5?: string + error?: string + }> + } analytics: { getOverallStatistics: (force?: boolean) => Promise<{ success: boolean @@ -310,6 +327,11 @@ export interface ExportOptions { dateRange?: { start: number; end: number } | null exportMedia?: boolean exportAvatars?: boolean + exportImages?: boolean + exportVoices?: boolean + exportEmojis?: boolean + exportVoiceAsText?: boolean + excelCompactColumns?: boolean } export interface WxidInfo { diff --git a/src/types/models.ts b/src/types/models.ts index 09f6e41..46c3d42 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -33,11 +33,14 @@ export interface Message { isSend: number | null senderUsername: string | null parsedContent: string + rawContent?: string // 原始消息内容(保留用于兼容) + content?: string // 原始消息内容(XML) imageMd5?: string imageDatName?: string emojiCdnUrl?: string emojiMd5?: string voiceDurationSeconds?: number + videoMd5?: string // 引用消息 quotedContent?: string quotedSender?: string