From 666a53f6bae5608f0f40cd68348c3cbfed72ce61 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 17:28:14 +0800 Subject: [PATCH 01/19] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dapi=20limit/chatlab/key?= =?UTF-8?q?word=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- electron/services/httpService.ts | 169 ++++++++++++++++++++++++++++++- 2 files changed, 166 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index de25ae8..bf9cadf 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可 - **访问地址**:`http://127.0.0.1:5031` - **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式 -📖 完整接口文档:[docs/HTTP-API.md](docs/HTTP-API.md) +📖 完整接口文档:[点击查看](docs/HTTP-API.md) ## 快速开始 diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index e620003..73b253f 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -200,18 +200,179 @@ class HttpService { } } + /** + * 批量获取消息(循环游标直到满足 limit) + * 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标 + */ + private async fetchMessagesBatch( + talker: string, + offset: number, + limit: number, + startTime: number, + endTime: number, + ascending: boolean + ): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> { + try { + // 使用固定 batch 大小(与 limit 相同或最大 500)来减少循环次数 + const batchSize = Math.min(limit, 500) + const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime + const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime + + const cursorResult = await wcdbService.openMessageCursor(talker, batchSize, ascending, beginTimestamp, endTimestamp) + if (!cursorResult.success || !cursorResult.cursor) { + return { success: false, error: cursorResult.error || '打开消息游标失败' } + } + + const cursor = cursorResult.cursor + try { + const allRows: Record[] = [] + let hasMore = true + let skipped = 0 + + // 循环获取消息,处理 offset 跳过 + limit 累积 + while (allRows.length < limit && hasMore) { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success || !batch.rows || batch.rows.length === 0) { + hasMore = false + break + } + + let rows = batch.rows + hasMore = batch.hasMore === true + + // 处理 offset: 跳过前 N 条 + if (skipped < offset) { + const remaining = offset - skipped + if (remaining >= rows.length) { + skipped += rows.length + continue + } + rows = rows.slice(remaining) + skipped = offset + } + + allRows.push(...rows) + } + + const trimmedRows = allRows.slice(0, limit) + const finalHasMore = hasMore || allRows.length > limit + const messages = this.mapRowsToMessagesSimple(trimmedRows) + return { success: true, messages, hasMore: finalHasMore } + } finally { + await wcdbService.closeMessageCursor(cursor) + } + } catch (e) { + console.error('[HttpService] fetchMessagesBatch error:', e) + return { success: false, error: String(e) } + } + } + + /** + * 简单的行数据到 Message 映射(用于 API 输出) + */ + private mapRowsToMessagesSimple(rows: Record[]): Message[] { + const myWxid = this.configService.get('myWxid') || '' + const messages: Message[] = [] + + for (const row of rows) { + const content = this.getField(row, ['message_content', 'messageContent', 'content', 'msg_content', 'WCDB_CT_message_content']) || '' + const localType = parseInt(this.getField(row, ['local_type', 'localType', 'type', 'msg_type', 'WCDB_CT_local_type']) || '1', 10) + const isSendRaw = this.getField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']) + const senderUsername = this.getField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || '' + const createTime = parseInt(this.getField(row, ['create_time', 'createTime', 'msg_create_time', 'WCDB_CT_create_time']) || '0', 10) + const localId = parseInt(this.getField(row, ['local_id', 'localId', 'WCDB_CT_local_id', 'rowid']) || '0', 10) + const serverId = this.getField(row, ['server_id', 'serverId', 'WCDB_CT_server_id']) || '' + + let isSend: number + if (isSendRaw !== null && isSendRaw !== undefined) { + isSend = parseInt(isSendRaw, 10) + } else if (senderUsername && myWxid) { + isSend = senderUsername.toLowerCase() === myWxid.toLowerCase() ? 1 : 0 + } else { + isSend = 0 + } + + // 解析消息内容中的特殊字段 + let parsedContent = content + let xmlType: string | undefined + let linkTitle: string | undefined + let fileName: string | undefined + let emojiCdnUrl: string | undefined + let emojiMd5: string | undefined + let imageMd5: string | undefined + let videoMd5: string | undefined + let cardNickname: string | undefined + + if (localType === 49 && content) { + // 提取 type 子标签 + const typeMatch = /(\d+)<\/type>/i.exec(content) + if (typeMatch) xmlType = typeMatch[1] + // 提取 title + const titleMatch = /([^<]*)<\/title>/i.exec(content) + if (titleMatch) linkTitle = titleMatch[1] + // 提取文件名 + const fnMatch = /<title>([^<]*)<\/title>/i.exec(content) + if (fnMatch) fileName = fnMatch[1] + } + + if (localType === 47 && content) { + const cdnMatch = /cdnurl\s*=\s*"([^"]+)"/i.exec(content) + if (cdnMatch) emojiCdnUrl = cdnMatch[1] + const md5Match = /md5\s*=\s*"([^"]+)"/i.exec(content) + if (md5Match) emojiMd5 = md5Match[1] + } + + messages.push({ + localId, + talker: '', + localType, + createTime, + sortSeq: createTime, + content: parsedContent, + isSend, + senderUsername, + serverId: serverId ? parseInt(serverId, 10) || 0 : 0, + rawContent: content, + parsedContent: content, + emojiCdnUrl, + emojiMd5, + imageMd5, + videoMd5, + xmlType, + linkTitle, + fileName, + cardNickname + } as Message) + } + + return messages + } + + /** + * 从行数据中获取字段值(兼容多种字段名) + */ + private getField(row: Record<string, any>, keys: string[]): string | null { + for (const key of keys) { + if (row[key] !== undefined && row[key] !== null) { + return String(row[key]) + } + } + return null + } + /** * 处理消息查询 * GET /api/v1/messages?talker=xxx&limit=100&start=20260101&chatlab=1 */ private async handleMessages(url: URL, res: http.ServerResponse): Promise<void> { const talker = url.searchParams.get('talker') - const limit = parseInt(url.searchParams.get('limit') || '100', 10) + const limit = Math.min(parseInt(url.searchParams.get('limit') || '100', 10), 10000) const offset = parseInt(url.searchParams.get('offset') || '0', 10) const startParam = url.searchParams.get('start') const endParam = url.searchParams.get('end') const chatlab = url.searchParams.get('chatlab') === '1' - const format = url.searchParams.get('format') || (chatlab ? 'chatlab' : 'json') + const formatParam = url.searchParams.get('format') + const format = formatParam || (chatlab ? 'chatlab' : 'json') if (!talker) { this.sendError(res, 400, 'Missing required parameter: talker') @@ -222,8 +383,8 @@ class HttpService { const startTime = this.parseTimeParam(startParam) const endTime = this.parseTimeParam(endParam, true) - // 获取消息 - const result = await chatService.getMessages(talker, offset, limit, startTime, endTime, true) + // 使用批量获取方法,绕过 chatService 的单 batch 限制 + const result = await this.fetchMessagesBatch(talker, offset, limit, startTime, endTime, true) if (!result.success || !result.messages) { this.sendError(res, 500, result.error || 'Failed to get messages') return From a19f2a57c3ffa38374c36dce998690f768e81013 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 17:42:42 +0800 Subject: [PATCH 02/19] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ChatPage.tsx | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 8d2ed72..482b6b7 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1546,6 +1546,28 @@ function ChatPage(_props: ChatPageProps) { ) } +// 全局语音播放管理器:同一时间只能播放一条语音 +const globalVoiceManager = { + currentAudio: null as HTMLAudioElement | null, + currentStopCallback: null as (() => void) | null, + play(audio: HTMLAudioElement, onStop: () => void) { + // 停止当前正在播放的语音 + if (this.currentAudio && this.currentAudio !== audio) { + this.currentAudio.pause() + this.currentAudio.currentTime = 0 + this.currentStopCallback?.() + } + this.currentAudio = audio + this.currentStopCallback = onStop + }, + stop(audio: HTMLAudioElement) { + if (this.currentAudio === audio) { + this.currentAudio = null + this.currentStopCallback = null + } + }, +} + // 前端表情包缓存 const emojiDataUrlCache = new Map<string, string>() const imageDataUrlCache = new Map<string, string>() @@ -1985,6 +2007,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o const handleEnded = () => { setIsVoicePlaying(false) setVoiceCurrentTime(0) + globalVoiceManager.stop(audio) } const handleTimeUpdate = () => { setVoiceCurrentTime(audio.currentTime) @@ -1999,6 +2022,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o audio.addEventListener('loadedmetadata', handleLoadedMetadata) return () => { audio.pause() + globalVoiceManager.stop(audio) audio.removeEventListener('play', handlePlay) audio.removeEventListener('pause', handlePause) audio.removeEventListener('ended', handleEnded) @@ -2433,6 +2457,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o if (isVoicePlaying) { audio.pause() audio.currentTime = 0 + globalVoiceManager.stop(audio) return } if (!voiceDataUrl) { @@ -2467,6 +2492,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o } audio.src = source try { + // 停止其他正在播放的语音,确保同一时间只播放一条 + globalVoiceManager.play(audio, () => { + audio.pause() + audio.currentTime = 0 + }) await audio.play() } catch { setVoiceError(true) From d3a1db4efeff95092effa8468a195086a05ba800 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 17:57:39 +0800 Subject: [PATCH 03/19] =?UTF-8?q?=E4=BB=8E=E5=AF=86=E8=AF=AD=E7=BB=99?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E8=AF=AD=E9=9F=B3=E8=BD=AC=E6=96=87=E5=AD=97?= =?UTF-8?q?=E6=90=AC=E8=BF=87=E6=9D=A5=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 3 + electron/preload.ts | 1 + electron/services/chatService.ts | 61 +++++ src/pages/ChatPage.scss | 419 +++++++++++++++++++++++++++++++ src/pages/ChatPage.tsx | 319 ++++++++++++++++++++++- src/types/electron.d.ts | 1 + 6 files changed, 803 insertions(+), 1 deletion(-) diff --git a/electron/main.ts b/electron/main.ts index 92564f8..98f9d92 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -896,6 +896,9 @@ function registerIpcHandlers() { ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => { return chatService.getVoiceData(sessionId, msgId, createTime, serverId) }) + ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => { + return chatService.getAllVoiceMessages(sessionId) + }) ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => { return chatService.resolveVoiceCache(sessionId, msgId) }) diff --git a/electron/preload.ts b/electron/preload.ts index 715d548..a3f3451 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -139,6 +139,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId), getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId), + getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId), resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId), getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime), onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => { diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index eade809..dea984d 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -3551,6 +3551,67 @@ class ChatService { } } + /** + * 获取某会话的所有语音消息(localType=34),用于批量转写 + */ + async getAllVoiceMessages(sessionId: string): Promise<{ success: boolean; messages?: Message[]; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + // 获取会话表信息 + let tables = this.sessionTablesCache.get(sessionId) + if (!tables) { + const tableStats = await wcdbService.getMessageTableStats(sessionId) + if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { + return { success: false, error: '未找到会话消息表' } + } + tables = tableStats.tables + .map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path })) + .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> + if (tables.length > 0) { + this.sessionTablesCache.set(sessionId, tables) + setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl) + } + } + + let allVoiceMessages: Message[] = [] + + for (const { tableName, dbPath } of tables) { + try { + const sql = `SELECT * FROM ${tableName} WHERE local_type = 34 ORDER BY create_time DESC` + const result = await wcdbService.execQuery('message', dbPath, sql) + if (result.success && result.rows && result.rows.length > 0) { + const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[]) + allVoiceMessages.push(...mapped) + } + } catch (e) { + console.error(`[ChatService] 查询语音消息失败 (${dbPath}):`, e) + } + } + + // 按 createTime 降序排序 + allVoiceMessages.sort((a, b) => b.createTime - a.createTime) + + // 去重 + const seen = new Set<string>() + allVoiceMessages = allVoiceMessages.filter(msg => { + const key = `${msg.serverId}-${msg.localId}-${msg.createTime}-${msg.sortSeq}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + + console.log(`[ChatService] 共找到 ${allVoiceMessages.length} 条语音消息(去重后)`) + return { success: true, messages: allVoiceMessages } + } catch (e) { + console.error('[ChatService] 获取所有语音消息失败:', e) + return { success: false, error: String(e) } + } + } + async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> { try { // 1. 尝试从缓存获取会话表信息 diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 4b11448..e9b6174 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2572,3 +2572,422 @@ } } } + +// 批量转写按钮 +.batch-transcribe-btn { + &:hover:not(:disabled) { + color: var(--primary-color); + } +} + +// 批量转写模态框基础样式 +.batch-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: batchFadeIn 0.2s ease-out; +} + +.batch-modal-content { + background: var(--bg-primary); + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + max-height: 90vh; + overflow-y: auto; + animation: batchSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes batchFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes batchSlideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +// 批量转写确认对话框 +.batch-confirm-modal { + width: 480px; + max-width: 90vw; + + .batch-modal-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); + + svg { color: var(--primary-color); } + + h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + } + } + + .batch-modal-body { + padding: 1.5rem; + + p { + margin: 0 0 1rem 0; + font-size: 14px; + color: var(--text-secondary); + line-height: 1.6; + } + + .batch-dates-list-wrap { + margin-bottom: 1rem; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + + .batch-dates-actions { + display: flex; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); + + .batch-dates-btn { + padding: 0.35rem 0.75rem; + font-size: 12px; + color: var(--primary-color); + background: transparent; + border: 1px solid var(--border-color); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + border-color: var(--primary-color); + } + } + } + + .batch-dates-list { + list-style: none; + margin: 0; + padding: 0; + max-height: 160px; + overflow-y: auto; + + li { + border-bottom: 1px solid var(--border-color); + &:last-child { border-bottom: none; } + } + + .batch-date-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + cursor: pointer; + transition: background 0.15s; + + &:hover { background: var(--bg-hover); } + + input[type="checkbox"] { + accent-color: var(--primary-color); + cursor: pointer; + flex-shrink: 0; + } + + .batch-date-label { + flex: 1; + font-size: 14px; + color: var(--text-primary); + font-weight: 500; + } + + .batch-date-count { + font-size: 12px; + color: var(--text-tertiary); + flex-shrink: 0; + } + } + } + } + + .batch-info { + background: var(--bg-tertiary); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; + + .info-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + + &:not(:last-child) { + border-bottom: 1px solid var(--border-color); + } + + .label { + font-size: 13px; + color: var(--text-secondary); + } + + .value { + font-size: 14px; + font-weight: 600; + color: var(--primary-color); + } + } + } + + .batch-warning { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem; + background: rgba(255, 152, 0, 0.1); + border-radius: 8px; + border: 1px solid rgba(255, 152, 0, 0.3); + + svg { + flex-shrink: 0; + margin-top: 2px; + color: #ff9800; + } + + span { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + } + } + + .batch-modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-color); + + button { + padding: 0.5rem 1.25rem; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 0.5rem; + border: none; + + &.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + &:hover { background: var(--border-color); } + } + + &.btn-primary, &.batch-transcribe-start-btn { + background: var(--primary-color); + color: white; + &:hover { opacity: 0.9; } + } + } + } +} + +// 批量转写进度对话框 +.batch-progress-modal { + width: 420px; + max-width: 90vw; + + .batch-modal-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); + + svg { color: var(--primary-color); } + + h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + } + } + + .batch-modal-body { + padding: 1.5rem; + + .progress-info { + .progress-text { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + font-size: 14px; + color: var(--text-secondary); + + .progress-percent { + font-weight: 600; + color: var(--primary-color); + font-size: 16px; + } + } + + .progress-bar { + height: 8px; + background: var(--bg-tertiary); + border-radius: 4px; + overflow: hidden; + margin-bottom: 1rem; + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--primary-color), var(--primary-color)); + border-radius: 4px; + transition: width 0.3s ease; + } + } + } + + .batch-tip { + display: flex; + align-items: center; + justify-content: center; + padding: 0.75rem; + background: var(--bg-tertiary); + border-radius: 8px; + + span { + font-size: 13px; + color: var(--text-secondary); + } + } + } +} + +// 批量转写结果对话框 +.batch-result-modal { + width: 420px; + max-width: 90vw; + + .batch-modal-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); + + svg { color: #4caf50; } + + h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + } + } + + .batch-modal-body { + padding: 1.5rem; + + .result-summary { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; + + .result-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + border-radius: 8px; + background: var(--bg-tertiary); + + svg { flex-shrink: 0; } + + .label { + font-size: 14px; + color: var(--text-secondary); + } + + .value { + margin-left: auto; + font-size: 18px; + font-weight: 600; + } + + &.success { + svg { color: #4caf50; } + .value { color: #4caf50; } + } + + &.fail { + svg { color: #f44336; } + .value { color: #f44336; } + } + } + } + + .result-tip { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem; + background: rgba(255, 152, 0, 0.1); + border-radius: 8px; + border: 1px solid rgba(255, 152, 0, 0.3); + + svg { + flex-shrink: 0; + margin-top: 2px; + color: #ff9800; + } + + span { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + } + } + + .batch-modal-footer { + display: flex; + justify-content: flex-end; + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-color); + + button { + padding: 0.5rem 1.5rem; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + border: none; + + &.btn-primary { + background: var(--primary-color); + color: white; + &:hover { opacity: 0.9; } + } + } + } +} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 482b6b7..63d127c 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, Link } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, XCircle } from 'lucide-react' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' import type { ChatSession, Message } from '../types/models' @@ -174,6 +174,18 @@ function ChatPage(_props: ChatPageProps) { const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false) const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null) + // 批量语音转文字相关状态 + const [isBatchTranscribing, setIsBatchTranscribing] = useState(false) + const [batchTranscribeProgress, setBatchTranscribeProgress] = useState({ current: 0, total: 0 }) + const [showBatchConfirm, setShowBatchConfirm] = useState(false) + const [batchVoiceCount, setBatchVoiceCount] = useState(0) + const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null) + const [batchVoiceDates, setBatchVoiceDates] = useState<string[]>([]) + const [batchSelectedDates, setBatchSelectedDates] = useState<Set<string>>(new Set()) + const [showBatchProgress, setShowBatchProgress] = useState(false) + const [showBatchResult, setShowBatchResult] = useState(false) + const [batchResult, setBatchResult] = useState({ success: 0, fail: 0 }) + // 联系人信息加载控制 const isEnrichingRef = useRef(false) const enrichCancelledRef = useRef(false) @@ -1183,6 +1195,155 @@ function ChatPage(_props: ChatPageProps) { setShowVoiceTranscribeDialog(true) }, []) + // 批量语音转文字 + const handleBatchTranscribe = useCallback(async () => { + if (!currentSessionId) return + const session = sessions.find(s => s.username === currentSessionId) + if (!session) { + alert('未找到当前会话') + return + } + if (isBatchTranscribing) return + + const result = await window.electronAPI.chat.getAllVoiceMessages(currentSessionId) + if (!result.success || !result.messages) { + alert(`获取语音消息失败: ${result.error || '未知错误'}`) + return + } + + const voiceMessages = result.messages + if (voiceMessages.length === 0) { + alert('当前会话没有语音消息') + return + } + + const dateSet = new Set<string>() + voiceMessages.forEach(m => dateSet.add(new Date(m.createTime * 1000).toISOString().slice(0, 10))) + const sortedDates = Array.from(dateSet).sort((a, b) => b.localeCompare(a)) + + setBatchVoiceMessages(voiceMessages) + setBatchVoiceCount(voiceMessages.length) + setBatchVoiceDates(sortedDates) + setBatchSelectedDates(new Set(sortedDates)) + setShowBatchConfirm(true) + }, [sessions, currentSessionId, isBatchTranscribing]) + + // 确认批量转写 + const confirmBatchTranscribe = useCallback(async () => { + if (!currentSessionId) return + + const selected = batchSelectedDates + if (selected.size === 0) { + alert('请至少选择一个日期') + return + } + + const messages = batchVoiceMessages + if (!messages || messages.length === 0) { + setShowBatchConfirm(false) + return + } + + const voiceMessages = messages.filter(m => + selected.has(new Date(m.createTime * 1000).toISOString().slice(0, 10)) + ) + if (voiceMessages.length === 0) { + alert('所选日期下没有语音消息') + return + } + + setShowBatchConfirm(false) + setBatchVoiceMessages(null) + setBatchVoiceDates([]) + setBatchSelectedDates(new Set()) + + const session = sessions.find(s => s.username === currentSessionId) + if (!session) return + + setIsBatchTranscribing(true) + setShowBatchProgress(true) + setBatchTranscribeProgress({ current: 0, total: voiceMessages.length }) + + // 检查模型状态 + const modelStatus = await window.electronAPI.whisper.getModelStatus() + if (!modelStatus?.exists) { + alert('SenseVoice 模型未下载,请先在设置中下载模型') + setIsBatchTranscribing(false) + setShowBatchProgress(false) + return + } + + let successCount = 0 + let failCount = 0 + let completedCount = 0 + const concurrency = 3 + + const transcribeOne = async (msg: Message) => { + try { + const result = await window.electronAPI.chat.getVoiceTranscript( + session.username, + String(msg.localId), + msg.createTime + ) + return { success: result.success } + } catch { + return { success: false } + } + } + + for (let i = 0; i < voiceMessages.length; i += concurrency) { + const batch = voiceMessages.slice(i, i + concurrency) + const results = await Promise.all(batch.map(msg => transcribeOne(msg))) + + results.forEach(result => { + if (result.success) successCount++ + else failCount++ + completedCount++ + setBatchTranscribeProgress({ current: completedCount, total: voiceMessages.length }) + }) + } + + setIsBatchTranscribing(false) + setShowBatchProgress(false) + setBatchResult({ success: successCount, fail: failCount }) + setShowBatchResult(true) + }, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages]) + + // 批量转写:按日期的消息数量 + const batchCountByDate = useMemo(() => { + const map = new Map<string, number>() + if (!batchVoiceMessages) return map + batchVoiceMessages.forEach(m => { + const d = new Date(m.createTime * 1000).toISOString().slice(0, 10) + map.set(d, (map.get(d) || 0) + 1) + }) + return map + }, [batchVoiceMessages]) + + // 批量转写:选中日期对应的语音条数 + const batchSelectedMessageCount = useMemo(() => { + if (!batchVoiceMessages) return 0 + return batchVoiceMessages.filter(m => + batchSelectedDates.has(new Date(m.createTime * 1000).toISOString().slice(0, 10)) + ).length + }, [batchVoiceMessages, batchSelectedDates]) + + const toggleBatchDate = useCallback((date: string) => { + setBatchSelectedDates(prev => { + const next = new Set(prev) + if (next.has(date)) next.delete(date) + else next.add(date) + return next + }) + }, []) + const selectAllBatchDates = useCallback(() => setBatchSelectedDates(new Set(batchVoiceDates)), [batchVoiceDates]) + const clearAllBatchDates = useCallback(() => setBatchSelectedDates(new Set()), []) + + const formatBatchDateLabel = useCallback((dateStr: string) => { + const [y, m, d] = dateStr.split('-').map(Number) + return `${y}年${m}月${d}日` + }, []) + return ( <div className={`chat-page ${isResizing ? 'resizing' : ''}`}> {/* 左侧会话列表 */} @@ -1293,6 +1454,18 @@ function ChatPage(_props: ChatPageProps) { )} </div> <div className="header-actions"> + <button + className="icon-btn batch-transcribe-btn" + onClick={handleBatchTranscribe} + disabled={isBatchTranscribing || !currentSessionId} + title={isBatchTranscribing ? `批量转写中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total})` : '批量语音转文字'} + > + {isBatchTranscribing ? ( + <Loader2 size={18} className="spin" /> + ) : ( + <Mic size={18} /> + )} + </button> <button className="icon-btn jump-to-time-btn" onClick={() => setShowJumpDialog(true)} @@ -1542,6 +1715,150 @@ function ChatPage(_props: ChatPageProps) { }} /> )} + + {/* 批量转写确认对话框 */} + {showBatchConfirm && createPortal( + <div className="batch-modal-overlay" onClick={() => setShowBatchConfirm(false)}> + <div className="batch-modal-content batch-confirm-modal" onClick={(e) => e.stopPropagation()}> + <div className="batch-modal-header"> + <Mic size={20} /> + <h3>批量语音转文字</h3> + </div> + <div className="batch-modal-body"> + <p>选择要转写的日期(仅显示有语音的日期),然后开始转写。</p> + {batchVoiceDates.length > 0 && ( + <div className="batch-dates-list-wrap"> + <div className="batch-dates-actions"> + <button type="button" className="batch-dates-btn" onClick={selectAllBatchDates}>全选</button> + <button type="button" className="batch-dates-btn" onClick={clearAllBatchDates}>取消全选</button> + </div> + <ul className="batch-dates-list"> + {batchVoiceDates.map(dateStr => { + const count = batchCountByDate.get(dateStr) ?? 0 + const checked = batchSelectedDates.has(dateStr) + return ( + <li key={dateStr}> + <label className="batch-date-row"> + <input + type="checkbox" + checked={checked} + onChange={() => toggleBatchDate(dateStr)} + /> + <span className="batch-date-label">{formatBatchDateLabel(dateStr)}</span> + <span className="batch-date-count">{count} 条语音</span> + </label> + </li> + ) + })} + </ul> + </div> + )} + <div className="batch-info"> + <div className="info-item"> + <span className="label">已选:</span> + <span className="value">{batchSelectedDates.size} 天有语音,共 {batchSelectedMessageCount} 条语音</span> + </div> + <div className="info-item"> + <span className="label">预计耗时:</span> + <span className="value">约 {Math.ceil(batchSelectedMessageCount * 2 / 60)} 分钟</span> + </div> + </div> + <div className="batch-warning"> + <AlertCircle size={16} /> + <span>批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。</span> + </div> + </div> + <div className="batch-modal-footer"> + <button className="btn-secondary" onClick={() => setShowBatchConfirm(false)}> + 取消 + </button> + <button className="btn-primary batch-transcribe-start-btn" onClick={confirmBatchTranscribe}> + <Mic size={16} /> + 开始转写 + </button> + </div> + </div> + </div>, + document.body + )} + + {/* 批量转写进度对话框 */} + {showBatchProgress && createPortal( + <div className="batch-modal-overlay"> + <div className="batch-modal-content batch-progress-modal" onClick={(e) => e.stopPropagation()}> + <div className="batch-modal-header"> + <Loader2 size={20} className="spin" /> + <h3>正在转写...</h3> + </div> + <div className="batch-modal-body"> + <div className="progress-info"> + <div className="progress-text"> + <span>已完成 {batchTranscribeProgress.current} / {batchTranscribeProgress.total} 条</span> + <span className="progress-percent"> + {batchTranscribeProgress.total > 0 + ? Math.round((batchTranscribeProgress.current / batchTranscribeProgress.total) * 100) + : 0}% + </span> + </div> + <div className="progress-bar"> + <div + className="progress-fill" + style={{ + width: `${batchTranscribeProgress.total > 0 + ? (batchTranscribeProgress.current / batchTranscribeProgress.total) * 100 + : 0}%` + }} + /> + </div> + </div> + <div className="batch-tip"> + <span>转写过程中可以继续使用其他功能</span> + </div> + </div> + </div> + </div>, + document.body + )} + + {/* 批量转写结果对话框 */} + {showBatchResult && createPortal( + <div className="batch-modal-overlay" onClick={() => setShowBatchResult(false)}> + <div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}> + <div className="batch-modal-header"> + <CheckCircle size={20} /> + <h3>转写完成</h3> + </div> + <div className="batch-modal-body"> + <div className="result-summary"> + <div className="result-item success"> + <CheckCircle size={18} /> + <span className="label">成功:</span> + <span className="value">{batchResult.success} 条</span> + </div> + {batchResult.fail > 0 && ( + <div className="result-item fail"> + <XCircle size={18} /> + <span className="label">失败:</span> + <span className="value">{batchResult.fail} 条</span> + </div> + )} + </div> + {batchResult.fail > 0 && ( + <div className="result-tip"> + <AlertCircle size={16} /> + <span>部分语音转写失败,可能是语音文件损坏或网络问题</span> + </div> + )} + </div> + <div className="batch-modal-footer"> + <button className="btn-primary" onClick={() => setShowBatchResult(false)}> + 确定 + </button> + </div> + </div> + </div>, + document.body + )} </div> ) } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 020b5c3..49aaf33 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -111,6 +111,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 }> + getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }> resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }> getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }> onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void From 6e05e74d5ed0ae76e640f1746eec8277f48bd7e4 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 18:05:51 +0800 Subject: [PATCH 04/19] =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E8=AF=A6=E6=83=85wxid?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=8D=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ChatPage.scss | 31 +++++++++++++++++++++++++++++++ src/pages/ChatPage.tsx | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index e9b6174..9d51e02 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2016,12 +2016,43 @@ text-align: right; color: var(--text-primary); word-break: break-all; + user-select: text; &.highlight { color: var(--primary); font-weight: 600; } } + + .copy-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s, color 0.15s, background 0.15s; + + &:hover { + background: var(--bg-secondary); + color: var(--text-primary); + } + + svg { + color: inherit; + } + } + + &:hover .copy-btn { + opacity: 1; + } } .table-list { diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 63d127c..94e96c5 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, Link, Mic, CheckCircle, XCircle } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, XCircle, Copy, Check } from 'lucide-react' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' import type { ChatSession, Message } from '../types/models' @@ -168,6 +168,7 @@ function ChatPage(_props: ChatPageProps) { const [showDetailPanel, setShowDetailPanel] = useState(false) const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null) const [isLoadingDetail, setIsLoadingDetail] = useState(false) + const [copiedField, setCopiedField] = useState<string | null>(null) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([]) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) const [hasInitialMessages, setHasInitialMessages] = useState(false) @@ -243,6 +244,25 @@ function ChatPage(_props: ChatPageProps) { setShowDetailPanel(!showDetailPanel) }, [showDetailPanel, currentSessionId, loadSessionDetail]) + // 复制字段值到剪贴板 + const handleCopyField = useCallback(async (text: string, field: string) => { + try { + await navigator.clipboard.writeText(text) + setCopiedField(field) + setTimeout(() => setCopiedField(null), 1500) + } catch { + // fallback + const textarea = document.createElement('textarea') + textarea.value = text + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) + setCopiedField(field) + setTimeout(() => setCopiedField(null), 1500) + } + }, []) + // 连接数据库 const connect = useCallback(async () => { setConnecting(true) @@ -1601,23 +1621,35 @@ function ChatPage(_props: ChatPageProps) { <Hash size={14} /> <span className="label">微信ID</span> <span className="value">{sessionDetail.wxid}</span> + <button className="copy-btn" title="复制" onClick={() => handleCopyField(sessionDetail.wxid, 'wxid')}> + {copiedField === 'wxid' ? <Check size={12} /> : <Copy size={12} />} + </button> </div> {sessionDetail.remark && ( <div className="detail-item"> <span className="label">备注</span> <span className="value">{sessionDetail.remark}</span> + <button className="copy-btn" title="复制" onClick={() => handleCopyField(sessionDetail.remark!, 'remark')}> + {copiedField === 'remark' ? <Check size={12} /> : <Copy size={12} />} + </button> </div> )} {sessionDetail.nickName && ( <div className="detail-item"> <span className="label">昵称</span> <span className="value">{sessionDetail.nickName}</span> + <button className="copy-btn" title="复制" onClick={() => handleCopyField(sessionDetail.nickName!, 'nickName')}> + {copiedField === 'nickName' ? <Check size={12} /> : <Copy size={12} />} + </button> </div> )} {sessionDetail.alias && ( <div className="detail-item"> <span className="label">微信号</span> <span className="value">{sessionDetail.alias}</span> + <button className="copy-btn" title="复制" onClick={() => handleCopyField(sessionDetail.alias!, 'alias')}> + {copiedField === 'alias' ? <Check size={12} /> : <Copy size={12} />} + </button> </div> )} </div> From bd995bc736ad3991a32384ee58d0156001b06fe2 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 18:25:48 +0800 Subject: [PATCH 05/19] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=BD=AC=E8=B4=A6?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E7=9A=84=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + electron/main.ts | 4 ++ electron/preload.ts | 2 + electron/services/chatService.ts | 77 +++++++++++++++++++++++++++++++- src/pages/ChatPage.scss | 7 +++ src/pages/ChatPage.tsx | 60 +++++++++++++++++-------- src/types/electron.d.ts | 1 + src/types/models.ts | 3 ++ 8 files changed, 134 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index cccee17..8b7210e 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ wcdb/ *info 概述.md chatlab-format.md +*.bak \ No newline at end of file diff --git a/electron/main.ts b/electron/main.ts index 98f9d92..308444c 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -864,6 +864,10 @@ function registerIpcHandlers() { return await chatService.getContactAvatar(username) }) + ipcMain.handle('chat:resolveTransferDisplayNames', async (_, chatroomId: string, payerUsername: string, receiverUsername: string) => { + return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername) + }) + ipcMain.handle('chat:getContacts', async () => { return await chatService.getContacts() }) diff --git a/electron/preload.ts b/electron/preload.ts index a3f3451..849e11d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -131,6 +131,8 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit), getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username), getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username), + resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => + ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername), getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'), downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5), getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index dea984d..a4614c5 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -1120,6 +1120,9 @@ class ChatService { // 名片消息 let cardUsername: string | undefined let cardNickname: string | undefined + // 转账消息 + let transferPayerUsername: string | undefined + let transferReceiverUsername: string | undefined // 聊天记录 let chatRecordTitle: string | undefined let chatRecordList: Array<{ @@ -1151,8 +1154,8 @@ class ChatService { const cardInfo = this.parseCardInfo(content) cardUsername = cardInfo.username cardNickname = cardInfo.nickname - } else if (localType === 49 && content) { - // Type 49 消息(链接、文件、小程序、转账等) + } else if ((localType === 49 || localType === 8589934592049) && content) { + // Type 49 消息(链接、文件、小程序、转账等),8589934592049 也是转账类型 const type49Info = this.parseType49Message(content) xmlType = type49Info.xmlType linkTitle = type49Info.linkTitle @@ -1163,6 +1166,8 @@ class ChatService { fileExt = type49Info.fileExt chatRecordTitle = type49Info.chatRecordTitle chatRecordList = type49Info.chatRecordList + transferPayerUsername = type49Info.transferPayerUsername + transferReceiverUsername = type49Info.transferReceiverUsername } else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) { const quoteInfo = this.parseQuoteMessage(content) quotedContent = quoteInfo.content @@ -1199,6 +1204,8 @@ class ChatService { xmlType, cardUsername, cardNickname, + transferPayerUsername, + transferReceiverUsername, chatRecordTitle, chatRecordList }) @@ -1663,6 +1670,8 @@ class ChatService { fileName?: string fileSize?: number fileExt?: string + transferPayerUsername?: string + transferReceiverUsername?: string chatRecordTitle?: string chatRecordList?: Array<{ datatype: number @@ -1786,6 +1795,16 @@ class ChatService { } else if (feedesc) { result.linkTitle = feedesc } + + // 提取转账双方 wxid + const payerUsername = this.extractXmlValue(content, 'payer_username') + const receiverUsername = this.extractXmlValue(content, 'receiver_username') + if (payerUsername) { + result.transferPayerUsername = payerUsername + } + if (receiverUsername) { + result.transferReceiverUsername = receiverUsername + } break } @@ -2388,6 +2407,60 @@ class ChatService { } } + /** + * 解析转账消息中的付款方和收款方显示名称 + * 优先使用群昵称,群昵称为空时回退到微信昵称/备注 + */ + async resolveTransferDisplayNames( + chatroomId: string, + payerUsername: string, + receiverUsername: string + ): Promise<{ payerName: string; receiverName: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { payerName: payerUsername, receiverName: receiverUsername } + } + + // 如果是群聊,尝试获取群昵称 + let groupNicknames: Record<string, string> = {} + if (chatroomId.endsWith('@chatroom')) { + const nickResult = await wcdbService.getGroupNicknames(chatroomId) + if (nickResult.success && nickResult.nicknames) { + groupNicknames = nickResult.nicknames + } + } + + // 解析付款方名称:群昵称 > 备注 > 昵称 > alias > wxid + const resolveName = async (username: string): Promise<string> => { + // 先查群昵称 + const groupNick = groupNicknames[username] + if (groupNick) return groupNick + + // 再查联系人信息 + const contact = await this.getContact(username) + if (contact) { + return contact.remark || contact.nickName || contact.alias || username + } + + // 兜底:查缓存 + const cached = this.avatarCache.get(username) + if (cached?.displayName) return cached.displayName + + return username + } + + const [payerName, receiverName] = await Promise.all([ + resolveName(payerUsername), + resolveName(receiverUsername) + ]) + + return { payerName, receiverName } + } catch { + return { payerName: payerUsername, receiverName: receiverUsername } + } + } + /** * 获取当前用户的头像 URL */ diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 9d51e02..3887409 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2489,6 +2489,13 @@ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); } + .transfer-desc { + font-size: 12px; + margin-bottom: 4px; + opacity: 0.9; + line-height: 1.4; + } + .transfer-memo { font-size: 13px; margin-bottom: 8px; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 94e96c5..a4e33f7 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1972,6 +1972,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o const [voiceWaveform, setVoiceWaveform] = useState<number[]>([]) const voiceAutoDecryptTriggered = useRef(false) + // 转账消息双方名称 + const [transferPayerName, setTransferPayerName] = useState<string | undefined>(undefined) + const [transferReceiverName, setTransferReceiverName] = useState<string | undefined>(undefined) + // 视频相关状态 const [videoLoading, setVideoLoading] = useState(false) const [videoInfo, setVideoInfo] = useState<{ videoUrl?: string; coverUrl?: string; thumbUrl?: string; exists: boolean } | null>(null) @@ -2136,6 +2140,26 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o } }, [isGroupChat, isSent, message.senderUsername, myAvatarUrl]) + // 解析转账消息的付款方和收款方显示名称 + useEffect(() => { + const payerWxid = (message as any).transferPayerUsername + const receiverWxid = (message as any).transferReceiverUsername + if (!payerWxid && !receiverWxid) return + // 仅对转账消息类型处理 + if (message.localType !== 49 && message.localType !== 8589934592049) return + + window.electronAPI.chat.resolveTransferDisplayNames( + session.username, + payerWxid || '', + receiverWxid || '' + ).then((result: { payerName: string; receiverName: string }) => { + if (result) { + setTransferPayerName(result.payerName) + setTransferReceiverName(result.receiverName) + } + }).catch(() => { }) + }, [(message as any).transferPayerUsername, (message as any).transferReceiverUsername, session.username]) + // 自动下载表情包 useEffect(() => { if (emojiLocalPath) return @@ -3002,6 +3026,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o let url = '' let appMsgType = '' let textAnnouncement = '' + let parsedDoc: Document | null = null try { const content = message.rawContent || message.parsedContent || '' @@ -3009,13 +3034,13 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o const xmlContent = content.substring(content.indexOf('<msg>')) const parser = new DOMParser() - const doc = parser.parseFromString(xmlContent, 'text/xml') + parsedDoc = parser.parseFromString(xmlContent, 'text/xml') - title = doc.querySelector('title')?.textContent || '链接' - desc = doc.querySelector('des')?.textContent || '' - url = doc.querySelector('url')?.textContent || '' - appMsgType = doc.querySelector('appmsg > type')?.textContent || doc.querySelector('type')?.textContent || '' - textAnnouncement = doc.querySelector('textannouncement')?.textContent || '' + title = parsedDoc.querySelector('title')?.textContent || '链接' + desc = parsedDoc.querySelector('des')?.textContent || '' + url = parsedDoc.querySelector('url')?.textContent || '' + appMsgType = parsedDoc.querySelector('appmsg > type')?.textContent || parsedDoc.querySelector('type')?.textContent || '' + textAnnouncement = parsedDoc.querySelector('textannouncement')?.textContent || '' } catch (e) { console.error('解析 AppMsg 失败:', e) } @@ -3143,19 +3168,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o // 转账消息 (type=2000) if (appMsgType === '2000') { try { - const content = message.rawContent || message.content || message.parsedContent || '' - - // 添加调试日志 - - - const parser = new DOMParser() - const doc = parser.parseFromString(content, 'text/xml') - - const feedesc = doc.querySelector('feedesc')?.textContent || '' - const payMemo = doc.querySelector('pay_memo')?.textContent || '' - const paysubtype = doc.querySelector('paysubtype')?.textContent || '1' - - + // 使用外层已解析好的 parsedDoc(已去除 wxid 前缀) + const feedesc = parsedDoc?.querySelector('feedesc')?.textContent || '' + const payMemo = parsedDoc?.querySelector('pay_memo')?.textContent || '' + const paysubtype = parsedDoc?.querySelector('paysubtype')?.textContent || '1' // paysubtype: 1=待收款, 3=已收款 const isReceived = paysubtype === '3' @@ -3163,6 +3179,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o // 如果 feedesc 为空,使用 title 作为降级 const displayAmount = feedesc || title || '微信转账' + // 构建转账描述:A 转账给 B + const transferDesc = transferPayerName && transferReceiverName + ? `${transferPayerName} 转账给 ${transferReceiverName}` + : undefined + return ( <div className={`transfer-message ${isReceived ? 'received' : ''}`}> <div className="transfer-icon"> @@ -3173,6 +3194,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o </div> <div className="transfer-info"> <div className="transfer-amount">{displayAmount}</div> + {transferDesc && <div className="transfer-desc">{transferDesc}</div>} {payMemo && <div className="transfer-memo">{payMemo}</div>} <div className="transfer-label">{isReceived ? '已收款' : '微信转账'}</div> </div> diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 49aaf33..371c9b9 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -85,6 +85,7 @@ export interface ElectronAPI { }> getContact: (username: string) => Promise<Contact | null> getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null> + resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }> getContacts: () => Promise<{ success: boolean contacts?: ContactInfo[] diff --git a/src/types/models.ts b/src/types/models.ts index a3b0963..986a694 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -64,6 +64,9 @@ export interface Message { fileSize?: number // 文件大小 fileExt?: string // 文件扩展名 xmlType?: string // XML 中的 type 字段 + // 转账消息 + transferPayerUsername?: string // 转账付款方 wxid + transferReceiverUsername?: string // 转账收款方 wxid // 名片消息 cardUsername?: string // 名片的微信ID cardNickname?: string // 名片的昵称 From 38169691cd984f8afef4fe529f166fecfff0b573 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 18:52:52 +0800 Subject: [PATCH 06/19] =?UTF-8?q?=E7=BB=99=E7=AE=AD=E5=A4=B4=E6=94=B9?= =?UTF-8?q?=E6=88=90=E5=AF=B9=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/chatService.ts | 17 ++++++++++++++++- src/pages/ChatPage.tsx | 15 +++++++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index a4614c5..c0568e2 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -2431,8 +2431,23 @@ class ChatService { } } - // 解析付款方名称:群昵称 > 备注 > 昵称 > alias > wxid + // 获取当前用户 wxid,用于识别"自己" + const myWxid = this.configService.get('myWxid') + const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : '' + + // 解析付款方名称:自己 > 群昵称 > 备注 > 昵称 > alias > wxid const resolveName = async (username: string): Promise<string> => { + // 特判:如果是当前用户自己(contact 表通常不包含自己) + if (myWxid && (username === myWxid || username === cleanedMyWxid)) { + // 先查群昵称中是否有自己 + const myGroupNick = groupNicknames[username] + if (myGroupNick) return myGroupNick + // 尝试从缓存获取自己的昵称 + const cached = this.avatarCache.get(username) || this.avatarCache.get(myWxid) + if (cached?.displayName) return cached.displayName + return '我' + } + // 先查群昵称 const groupNick = groupNicknames[username] if (groupNick) return groupNick diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index a4e33f7..bbbab33 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -3187,10 +3187,17 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o return ( <div className={`transfer-message ${isReceived ? 'received' : ''}`}> <div className="transfer-icon"> - <svg width="32" height="32" viewBox="0 0 40 40" fill="none"> - <circle cx="20" cy="20" r="18" stroke="white" strokeWidth="2" /> - <path d="M12 20h16M20 12l8 8-8 8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> - </svg> + {isReceived ? ( + <svg width="32" height="32" viewBox="0 0 40 40" fill="none"> + <circle cx="20" cy="20" r="18" stroke="white" strokeWidth="2" /> + <path d="M12 20l6 6 10-12" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" /> + </svg> + ) : ( + <svg width="32" height="32" viewBox="0 0 40 40" fill="none"> + <circle cx="20" cy="20" r="18" stroke="white" strokeWidth="2" /> + <path d="M12 20h16M20 12l8 8-8 8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> + </svg> + )} </div> <div className="transfer-info"> <div className="transfer-amount">{displayAmount}</div> From 494bd4f539ce1abb96830f539fa81ef11e064b6d Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 19:14:49 +0800 Subject: [PATCH 07/19] =?UTF-8?q?=E8=BD=AC=E8=B4=A6=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportService.ts | 138 +++++++++++++++++++++++++++-- 1 file changed, 132 insertions(+), 6 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index db7c7f4..82864e1 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -348,6 +348,51 @@ class ExportService { } } + /** + * 从转账消息 XML 中提取并解析 "谁转账给谁" 描述 + * @param content 原始消息内容 XML + * @param myWxid 当前用户 wxid + * @param groupNicknamesMap 群昵称映射 + * @param getContactName 联系人名称解析函数 + * @returns "A 转账给 B" 或 null + */ + private async resolveTransferDesc( + content: string, + myWxid: string, + groupNicknamesMap: Map<string, string>, + getContactName: (username: string) => Promise<string> + ): Promise<string | null> { + const xmlType = this.extractXmlValue(content, 'type') + if (xmlType !== '2000') return null + + const payerUsername = this.extractXmlValue(content, 'payer_username') + const receiverUsername = this.extractXmlValue(content, 'receiver_username') + if (!payerUsername || !receiverUsername) return null + + const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : '' + + const resolveName = async (username: string): Promise<string> => { + // 当前用户自己 + if (myWxid && (username === myWxid || username === cleanedMyWxid)) { + const groupNick = groupNicknamesMap.get(username) || groupNicknamesMap.get(username.toLowerCase()) + if (groupNick) return groupNick + return '我' + } + // 群昵称 + const groupNick = groupNicknamesMap.get(username) || groupNicknamesMap.get(username.toLowerCase()) + if (groupNick) return groupNick + // 联系人名称 + return getContactName(username) + } + + const [payerName, receiverName] = await Promise.all([ + resolveName(payerUsername), + resolveName(receiverUsername) + ]) + + return `${payerName} 转账给 ${receiverName}` + } + private looksLikeBase64(s: string): boolean { if (s.length % 4 !== 0) return false return /^[A-Za-z0-9+/=]+$/.test(s) @@ -2003,7 +2048,8 @@ class ExportService { phase: 'exporting' }) - const chatLabMessages: ChatLabMessage[] = allMessages.map(msg => { + const chatLabMessages: ChatLabMessage[] = [] + for (const msg of allMessages) { const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || { platformId: msg.senderUsername, accountName: msg.senderUsername, @@ -2024,6 +2070,22 @@ class ExportService { content = this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime) } + // 转账消息:追加 "谁转账给谁" 信息 + if (content && content.startsWith('[转账]') && msg.content) { + const transferDesc = await this.resolveTransferDesc( + msg.content, + cleanedMyWxid, + groupNicknamesMap, + async (username) => { + const info = await this.getContactInfo(username) + return info.displayName || username + } + ) + if (transferDesc) { + content = content.replace('[转账]', `[转账] (${transferDesc})`) + } + } + const message: ChatLabMessage = { sender: msg.senderUsername, accountName: memberInfo.accountName, @@ -2127,8 +2189,8 @@ class ExportService { message.chatRecords = chatRecords } - return message - }) + chatLabMessages.push(message) + } const avatarMap = options.exportAvatars ? await this.exportAvatars( @@ -2341,6 +2403,25 @@ class ExportService { content = this.parseMessageContent(msg.content, msg.localType) } + // 转账消息:追加 "谁转账给谁" 信息 + if (content && content.startsWith('[转账]') && msg.content) { + const transferDesc = await this.resolveTransferDesc( + msg.content, + cleanedMyWxid, + groupNicknamesMap, + async (username) => { + const c = await getContactCached(username) + if (c.success && c.contact) { + return c.contact.remark || c.contact.nickName || c.contact.alias || username + } + return username + } + ) + if (transferDesc) { + content = content.replace('[转账]', `[转账] (${transferDesc})`) + } + } + // 获取发送者信息用于名称显示 const senderWxid = msg.senderUsername const contact = await getContactCached(senderWxid) @@ -2784,6 +2865,26 @@ class ExportService { voiceTranscriptMap.get(msg.localId) )) + // 转账消息:追加 "谁转账给谁" 信息 + let enrichedContentValue = contentValue + if (contentValue.startsWith('[转账]') && msg.content) { + const transferDesc = await this.resolveTransferDesc( + msg.content, + cleanedMyWxid, + groupNicknamesMap, + async (username) => { + const c = await getContactCached(username) + if (c.success && c.contact) { + return c.contact.remark || c.contact.nickName || c.contact.alias || username + } + return username + } + ) + if (transferDesc) { + enrichedContentValue = contentValue.replace('[转账]', `[转账] (${transferDesc})`) + } + } + // 调试日志 if (msg.localType === 3 || msg.localType === 47) { } @@ -2793,7 +2894,7 @@ class ExportService { if (useCompactColumns) { worksheet.getCell(currentRow, 3).value = senderRole worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType) - worksheet.getCell(currentRow, 5).value = contentValue + worksheet.getCell(currentRow, 5).value = enrichedContentValue } else { worksheet.getCell(currentRow, 3).value = senderNickname worksheet.getCell(currentRow, 4).value = senderWxid @@ -2801,7 +2902,7 @@ class ExportService { worksheet.getCell(currentRow, 6).value = senderGroupNickname worksheet.getCell(currentRow, 7).value = senderRole worksheet.getCell(currentRow, 8).value = this.getMessageTypeName(msg.localType) - worksheet.getCell(currentRow, 9).value = contentValue + worksheet.getCell(currentRow, 9).value = enrichedContentValue } // 设置每个单元格的样式 @@ -2948,6 +3049,11 @@ class ExportService { senderUsernames.add(sessionId) await this.preloadContacts(senderUsernames, contactCache) + // 获取群昵称(用于转账描述等) + const groupNicknamesMap = isGroup + ? await this.getGroupNicknamesForRoom(sessionId) + : new Map<string, string>() + const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) @@ -3033,6 +3139,26 @@ class ExportService { voiceTranscriptMap.get(msg.localId) )) + // 转账消息:追加 "谁转账给谁" 信息 + let enrichedContentValue = contentValue + if (contentValue.startsWith('[转账]') && msg.content) { + const transferDesc = await this.resolveTransferDesc( + msg.content, + cleanedMyWxid, + groupNicknamesMap, + async (username) => { + const c = await getContactCached(username) + if (c.success && c.contact) { + return c.contact.remark || c.contact.nickName || c.contact.alias || username + } + return username + } + ) + if (transferDesc) { + enrichedContentValue = contentValue.replace('[转账]', `[转账] (${transferDesc})`) + } + } + let senderRole: string let senderWxid: string let senderNickname: string @@ -3067,7 +3193,7 @@ class ExportService { } lines.push(`${this.formatTimestamp(msg.createTime)} '${senderRole}'`) - lines.push(contentValue) + lines.push(enrichedContentValue) lines.push('') if ((i + 1) % 200 === 0) { From a5777027b176080b705bdb0b4c101361414196a4 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 19:20:55 +0800 Subject: [PATCH 08/19] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec2c90a..b475ada 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "weflow", - "version": "1.5.2", + "version": "1.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "weflow", - "version": "1.5.2", + "version": "1.5.4", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.5.0", diff --git a/package.json b/package.json index 828afe1..759e800 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "weflow", - "version": "1.5.3", + "version": "1.5.4", "description": "WeFlow", "main": "dist-electron/main.js", "author": "cc", From ca1a386146533553847487b888c0fc0d31237fc4 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 23:01:31 +0800 Subject: [PATCH 09/19] =?UTF-8?q?=E4=BC=98=E5=8C=96html=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportHtml.css | 156 ++++++++++---------------- electron/services/exportHtmlStyles.ts | 149 ++++++++++++------------ src/pages/ExportPage.tsx | 153 +++++++++++++++++++++++-- 3 files changed, 276 insertions(+), 182 deletions(-) diff --git a/electron/services/exportHtml.css b/electron/services/exportHtml.css index c7898d2..c03d459 100644 --- a/electron/services/exportHtml.css +++ b/electron/services/exportHtml.css @@ -25,83 +25,87 @@ body { .page { max-width: 1080px; - margin: 32px auto 60px; - padding: 0 20px; + margin: 0 auto; + padding: 8px 20px; + height: 100vh; + display: flex; + flex-direction: column; } .header { background: var(--card); - border-radius: var(--radius); - box-shadow: var(--shadow); - padding: 24px; - margin-bottom: 24px; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06); + padding: 12px 20px; + flex-shrink: 0; } .title { - font-size: 24px; + font-size: 16px; font-weight: 600; - margin: 0 0 8px; + margin: 0; + display: inline; } .meta { color: var(--muted); - font-size: 14px; - display: flex; - flex-wrap: wrap; - gap: 12px; + font-size: 13px; + display: inline; + margin-left: 12px; +} + +.meta span { + margin-right: 10px; } .controls { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 16px; - margin-top: 20px; -} - -.control { display: flex; - flex-direction: column; - gap: 6px; + align-items: center; + gap: 8px; + margin-top: 8px; + flex-wrap: wrap; } -.control label { - font-size: 13px; - color: var(--muted); -} - -.control input, -.control select, -.control button { - border-radius: 12px; +.controls input, +.controls button { + border-radius: 8px; border: 1px solid var(--border); - padding: 10px 12px; - font-size: 14px; + padding: 6px 10px; + font-size: 13px; font-family: inherit; } -.control button { +.controls input[type="search"] { + width: 200px; +} + +.controls input[type="datetime-local"] { + width: 200px; +} + +.controls button { background: var(--accent); color: #fff; border: none; cursor: pointer; - transition: transform 0.1s ease; + padding: 6px 14px; } -.control button:active { +.controls button:active { transform: scale(0.98); } .stats { font-size: 13px; color: var(--muted); - display: flex; - align-items: flex-end; + margin-left: auto; } .message-list { display: flex; flex-direction: column; - gap: 18px; + gap: 12px; + padding: 4px 0; } .message { @@ -248,50 +252,11 @@ body { cursor: zoom-out; } -body[data-theme="cloud-dancer"] { - --accent: #6b8cff; - --sent: #e0e7ff; - --received: #ffffff; - --border: #d8e0f7; - --bg: #f6f7fb; -} - -body[data-theme="corundum-blue"] { - --accent: #2563eb; - --sent: #dbeafe; - --received: #ffffff; - --border: #c7d2fe; - --bg: #eef2ff; -} - -body[data-theme="kiwi-green"] { - --accent: #16a34a; - --sent: #dcfce7; - --received: #ffffff; - --border: #bbf7d0; - --bg: #f0fdf4; -} - -body[data-theme="spicy-red"] { - --accent: #e11d48; - --sent: #ffe4e6; - --received: #ffffff; - --border: #fecdd3; - --bg: #fff1f2; -} - -body[data-theme="teal-water"] { - --accent: #0f766e; - --sent: #ccfbf1; - --received: #ffffff; - --border: #99f6e4; - --bg: #f0fdfa; -} - .highlight { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 18px; + transition: outline-color 0.3s; } .empty { @@ -300,32 +265,29 @@ body[data-theme="teal-water"] { padding: 40px; } -/* Virtual Scroll */ -.virtual-scroll-container { - height: calc(100vh - 180px); - /* Adjust based on header height */ +/* Scroll Container */ +.scroll-container { + flex: 1; + min-height: 0; overflow-y: auto; - position: relative; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); - margin-top: 20px; + margin-top: 8px; + margin-bottom: 8px; + padding: 12px; + -webkit-overflow-scrolling: touch; } -.virtual-scroll-spacer { - opacity: 0; - pointer-events: none; - width: 1px; +.scroll-container::-webkit-scrollbar { + width: 6px; } -.virtual-scroll-content { - position: absolute; - top: 0; - left: 0; - width: 100%; +.scroll-container::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; } -.message-list { - /* Override message-list to be inside virtual scroll */ - display: block; -} \ No newline at end of file +.load-sentinel { + height: 1px; +} diff --git a/electron/services/exportHtmlStyles.ts b/electron/services/exportHtmlStyles.ts index adb3e61..42d4e07 100644 --- a/electron/services/exportHtmlStyles.ts +++ b/electron/services/exportHtmlStyles.ts @@ -25,83 +25,87 @@ body { .page { max-width: 1080px; - margin: 32px auto 60px; - padding: 0 20px; + margin: 0 auto; + padding: 8px 20px; + height: 100vh; + display: flex; + flex-direction: column; } .header { background: var(--card); - border-radius: var(--radius); - box-shadow: var(--shadow); - padding: 24px; - margin-bottom: 24px; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06); + padding: 12px 20px; + flex-shrink: 0; } .title { - font-size: 24px; + font-size: 16px; font-weight: 600; - margin: 0 0 8px; + margin: 0; + display: inline; } .meta { color: var(--muted); - font-size: 14px; - display: flex; - flex-wrap: wrap; - gap: 12px; + font-size: 13px; + display: inline; + margin-left: 12px; +} + +.meta span { + margin-right: 10px; } .controls { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 16px; - margin-top: 20px; -} - -.control { display: flex; - flex-direction: column; - gap: 6px; + align-items: center; + gap: 8px; + margin-top: 8px; + flex-wrap: wrap; } -.control label { - font-size: 13px; - color: var(--muted); -} - -.control input, -.control select, -.control button { - border-radius: 12px; +.controls input, +.controls button { + border-radius: 8px; border: 1px solid var(--border); - padding: 10px 12px; - font-size: 14px; + padding: 6px 10px; + font-size: 13px; font-family: inherit; } -.control button { +.controls input[type="search"] { + width: 200px; +} + +.controls input[type="datetime-local"] { + width: 200px; +} + +.controls button { background: var(--accent); color: #fff; border: none; cursor: pointer; - transition: transform 0.1s ease; + padding: 6px 14px; } -.control button:active { +.controls button:active { transform: scale(0.98); } .stats { font-size: 13px; color: var(--muted); - display: flex; - align-items: flex-end; + margin-left: auto; } .message-list { display: flex; flex-direction: column; - gap: 18px; + gap: 12px; + padding: 4px 0; } .message { @@ -248,50 +252,11 @@ body { cursor: zoom-out; } -body[data-theme="cloud-dancer"] { - --accent: #6b8cff; - --sent: #e0e7ff; - --received: #ffffff; - --border: #d8e0f7; - --bg: #f6f7fb; -} - -body[data-theme="corundum-blue"] { - --accent: #2563eb; - --sent: #dbeafe; - --received: #ffffff; - --border: #c7d2fe; - --bg: #eef2ff; -} - -body[data-theme="kiwi-green"] { - --accent: #16a34a; - --sent: #dcfce7; - --received: #ffffff; - --border: #bbf7d0; - --bg: #f0fdf4; -} - -body[data-theme="spicy-red"] { - --accent: #e11d48; - --sent: #ffe4e6; - --received: #ffffff; - --border: #fecdd3; - --bg: #fff1f2; -} - -body[data-theme="teal-water"] { - --accent: #0f766e; - --sent: #ccfbf1; - --received: #ffffff; - --border: #99f6e4; - --bg: #f0fdfa; -} - .highlight { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 18px; + transition: outline-color 0.3s; } .empty { @@ -299,4 +264,32 @@ body[data-theme="teal-water"] { color: var(--muted); padding: 40px; } + +/* Scroll Container */ +.scroll-container { + flex: 1; + min-height: 0; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + margin-top: 8px; + margin-bottom: 8px; + padding: 12px; + -webkit-overflow-scrolling: touch; +} + +.scroll-container::-webkit-scrollbar { + width: 6px; +} + +.scroll-container::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.load-sentinel { + height: 1px; +} `; + diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index c94f12f..93ebdfc 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -46,13 +46,22 @@ function ExportPage() { const [searchKeyword, setSearchKeyword] = useState('') const [exportFolder, setExportFolder] = useState<string>('') const [isExporting, setIsExporting] = useState(false) - const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' }) + const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 }) const [exportResult, setExportResult] = useState<ExportResult | null>(null) const [showDatePicker, setShowDatePicker] = useState(false) const [calendarDate, setCalendarDate] = useState(new Date()) const [selectingStart, setSelectingStart] = useState(true) const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false) const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) + const [showPreExportDialog, setShowPreExportDialog] = useState(false) + const [preExportStats, setPreExportStats] = useState<{ + totalMessages: number; voiceMessages: number; cachedVoiceCount: number; + needTranscribeCount: number; mediaMessages: number; estimatedSeconds: number + } | null>(null) + const [isLoadingStats, setIsLoadingStats] = useState(false) + const [pendingLayout, setPendingLayout] = useState<SessionLayout>('shared') + const exportStartTime = useRef<number>(0) + const [elapsedSeconds, setElapsedSeconds] = useState(0) const displayNameDropdownRef = useRef<HTMLDivElement>(null) const [options, setOptions] = useState<ExportOptions>({ @@ -189,17 +198,30 @@ function ExportPage() { }, [loadSessions]) useEffect(() => { - const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string }) => { + const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string; phaseProgress?: number; phaseTotal?: number; phaseLabel?: string }) => { setExportProgress({ current: payload.current, total: payload.total, - currentName: payload.currentSession + currentName: payload.currentSession, + phaseLabel: payload.phaseLabel || '', + phaseProgress: payload.phaseProgress || 0, + phaseTotal: payload.phaseTotal || 0 }) }) return () => { removeListener?.() } }, []) + + // 导出计时器 + useEffect(() => { + if (!isExporting) return + const timer = setInterval(() => { + setElapsedSeconds(Math.floor((Date.now() - exportStartTime.current) / 1000)) + }, 1000) + return () => clearInterval(timer) + }, [isExporting]) + useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node @@ -278,8 +300,10 @@ function ExportPage() { if (selectedSessions.size === 0 || !exportFolder) return setIsExporting(true) - setExportProgress({ current: 0, total: selectedSessions.size, currentName: '' }) + setExportProgress({ current: 0, total: selectedSessions.size, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 }) setExportResult(null) + exportStartTime.current = Date.now() + setElapsedSeconds(0) try { const sessionList = Array.from(selectedSessions) @@ -322,9 +346,41 @@ function ExportPage() { } } - const startExport = () => { + const startExport = async () => { if (selectedSessions.size === 0 || !exportFolder) return + // 先获取预估统计 + setIsLoadingStats(true) + setShowPreExportDialog(true) + try { + const sessionList = Array.from(selectedSessions) + const exportOptions = { + format: options.format, + exportVoiceAsText: options.exportVoiceAsText, + exportMedia: options.exportMedia, + exportImages: options.exportMedia && options.exportImages, + exportVoices: options.exportMedia && options.exportVoices, + exportVideos: options.exportMedia && options.exportVideos, + exportEmojis: options.exportMedia && options.exportEmojis, + dateRange: options.useAllTime ? null : options.dateRange ? { + start: Math.floor(options.dateRange.start.getTime() / 1000), + end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000) + } : null + } + const stats = await window.electronAPI.export.getExportStats(sessionList, exportOptions) + setPreExportStats(stats) + } catch (e) { + console.error('获取导出统计失败:', e) + setPreExportStats(null) + } finally { + setIsLoadingStats(false) + } + } + + const confirmExport = () => { + setShowPreExportDialog(false) + setPreExportStats(null) + if (options.exportMedia && selectedSessions.size > 1) { setShowMediaLayoutPrompt(true) return @@ -814,6 +870,71 @@ function ExportPage() { </div> )} + {/* 导出前预估弹窗 */} + {showPreExportDialog && ( + <div className="export-overlay"> + <div className="export-layout-modal" onClick={e => e.stopPropagation()}> + <h3>导出预估</h3> + {isLoadingStats ? ( + <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '24px 0', justifyContent: 'center' }}> + <Loader2 size={20} className="spin" /> + <span style={{ fontSize: 14, color: 'var(--text-secondary)' }}>正在统计消息...</span> + </div> + ) : preExportStats ? ( + <div style={{ padding: '12px 0' }}> + <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px 24px', fontSize: 14 }}> + <div> + <span style={{ color: 'var(--text-secondary)' }}>会话数</span> + <div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{selectedSessions.size}</div> + </div> + <div> + <span style={{ color: 'var(--text-secondary)' }}>总消息</span> + <div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{preExportStats.totalMessages.toLocaleString()}</div> + </div> + {options.exportVoiceAsText && preExportStats.voiceMessages > 0 && ( + <> + <div> + <span style={{ color: 'var(--text-secondary)' }}>语音消息</span> + <div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{preExportStats.voiceMessages}</div> + </div> + <div> + <span style={{ color: 'var(--text-secondary)' }}>已有缓存</span> + <div style={{ fontWeight: 600, fontSize: 18, marginTop: 2, color: 'var(--primary)' }}>{preExportStats.cachedVoiceCount}</div> + </div> + </> + )} + </div> + {options.exportVoiceAsText && preExportStats.needTranscribeCount > 0 && ( + <div style={{ marginTop: 16, padding: '10px 12px', background: 'var(--bg-tertiary)', borderRadius: 8, fontSize: 13 }}> + <span style={{ color: 'var(--text-warning, #e6a23c)' }}>⚠</span> + {' '}需要转写 <b>{preExportStats.needTranscribeCount}</b> 条语音,预计耗时约 <b>{preExportStats.estimatedSeconds > 60 + ? `${Math.round(preExportStats.estimatedSeconds / 60)} 分钟` + : `${preExportStats.estimatedSeconds} 秒` + }</b> + </div> + )} + {options.exportVoiceAsText && preExportStats.voiceMessages > 0 && preExportStats.needTranscribeCount === 0 && ( + <div style={{ marginTop: 16, padding: '10px 12px', background: 'var(--bg-tertiary)', borderRadius: 8, fontSize: 13 }}> + <span style={{ color: 'var(--text-success, #67c23a)' }}>✓</span> + {' '}所有 {preExportStats.voiceMessages} 条语音已有转写缓存,无需重新转写 + </div> + )} + </div> + ) : ( + <p style={{ fontSize: 14, color: 'var(--text-secondary)', padding: '16px 0' }}>统计信息获取失败,仍可继续导出</p> + )} + <div className="layout-actions" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}> + <button className="layout-cancel-btn" onClick={() => { setShowPreExportDialog(false); setPreExportStats(null) }}> + 取消 + </button> + <button className="layout-option-btn primary" onClick={confirmExport} disabled={isLoadingStats}> + <span className="layout-title">开始导出</span> + </button> + </div> + </div> + </div> + )} + {/* 导出进度弹窗 */} {isExporting && ( <div className="export-overlay"> @@ -823,13 +944,31 @@ function ExportPage() { </div> <h3>正在导出</h3> <p className="progress-text">{exportProgress.currentName}</p> + {exportProgress.phaseLabel && ( + <p className="progress-phase-label" style={{ fontSize: 13, color: 'var(--text-secondary)', margin: '4px 0 8px' }}> + {exportProgress.phaseLabel} + </p> + )} + {exportProgress.phaseTotal > 0 && ( + <div className="progress-bar" style={{ marginBottom: 8 }}> + <div + className="progress-fill" + style={{ width: `${(exportProgress.phaseProgress / exportProgress.phaseTotal) * 100}%`, background: 'var(--primary-light, #79bbff)' }} + /> + </div> + )} <div className="progress-bar"> <div className="progress-fill" - style={{ width: `${(exportProgress.current / exportProgress.total) * 100}%` }} + style={{ width: `${exportProgress.total > 0 ? (exportProgress.current / exportProgress.total) * 100 : 0}%` }} /> </div> - <p className="progress-count">{exportProgress.current} / {exportProgress.total}</p> + <p className="progress-count"> + {exportProgress.current} / {exportProgress.total} 个会话 + <span style={{ marginLeft: 12, fontSize: 12, color: 'var(--text-secondary)' }}> + {elapsedSeconds > 0 && `已用 ${elapsedSeconds >= 60 ? `${Math.floor(elapsedSeconds / 60)}分${elapsedSeconds % 60}秒` : `${elapsedSeconds}秒`}`} + </span> + </p> </div> </div> )} From fe0e2e6592d0d5c049260671de1dd3e67cc544cf Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 23:09:01 +0800 Subject: [PATCH 10/19] =?UTF-8?q?=E6=89=B9=E9=87=8F=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E8=BD=AC=E6=96=87=E5=AD=97=E6=94=B9=E6=88=90=E5=8F=B3=E4=B8=8B?= =?UTF-8?q?=E8=A7=92=E5=B8=B8=E9=A9=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 4 + src/components/BatchTranscribeGlobal.tsx | 101 ++++++++++ src/pages/ChatPage.scss | 224 +-------------------- src/pages/ChatPage.tsx | 119 ++---------- src/stores/batchTranscribeStore.ts | 60 ++++++ src/styles/batchTranscribe.scss | 238 +++++++++++++++++++++++ 6 files changed, 428 insertions(+), 318 deletions(-) create mode 100644 src/components/BatchTranscribeGlobal.tsx create mode 100644 src/stores/batchTranscribeStore.ts create mode 100644 src/styles/batchTranscribe.scss diff --git a/src/App.tsx b/src/App.tsx index bcfdce9..2491727 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ import UpdateDialog from './components/UpdateDialog' import UpdateProgressCapsule from './components/UpdateProgressCapsule' import LockScreen from './components/LockScreen' import { GlobalSessionMonitor } from './components/GlobalSessionMonitor' +import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal' function App() { const navigate = useNavigate() @@ -360,6 +361,9 @@ function App() { {/* 全局会话监听与通知 */} <GlobalSessionMonitor /> + {/* 全局批量转写进度浮窗 */} + <BatchTranscribeGlobal /> + {/* 用户协议弹窗 */} {showAgreement && !agreementLoading && ( <div className="agreement-overlay"> diff --git a/src/components/BatchTranscribeGlobal.tsx b/src/components/BatchTranscribeGlobal.tsx new file mode 100644 index 0000000..b6f15b6 --- /dev/null +++ b/src/components/BatchTranscribeGlobal.tsx @@ -0,0 +1,101 @@ +import React from 'react' +import { createPortal } from 'react-dom' +import { Loader2, X, CheckCircle, XCircle, AlertCircle } from 'lucide-react' +import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' +import '../styles/batchTranscribe.scss' + +/** + * 全局批量转写进度浮窗 + 结果弹窗 + * 挂载在 App 层,切换页面时不会消失 + */ +export const BatchTranscribeGlobal: React.FC = () => { + const { + isBatchTranscribing, + progress, + showToast, + showResult, + result, + setShowToast, + setShowResult + } = useBatchTranscribeStore() + + return ( + <> + {/* 批量转写进度浮窗(非阻塞) */} + {showToast && isBatchTranscribing && createPortal( + <div className="batch-progress-toast"> + <div className="batch-progress-toast-header"> + <div className="batch-progress-toast-title"> + <Loader2 size={14} className="spin" /> + <span>批量转写中</span> + </div> + <button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化"> + <X size={14} /> + </button> + </div> + <div className="batch-progress-toast-body"> + <div className="progress-text"> + <span>{progress.current} / {progress.total}</span> + <span className="progress-percent"> + {progress.total > 0 + ? Math.round((progress.current / progress.total) * 100) + : 0}% + </span> + </div> + <div className="progress-bar"> + <div + className="progress-fill" + style={{ + width: `${progress.total > 0 + ? (progress.current / progress.total) * 100 + : 0}%` + }} + /> + </div> + </div> + </div>, + document.body + )} + + {/* 批量转写结果对话框 */} + {showResult && createPortal( + <div className="batch-modal-overlay" onClick={() => setShowResult(false)}> + <div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}> + <div className="batch-modal-header"> + <CheckCircle size={20} /> + <h3>转写完成</h3> + </div> + <div className="batch-modal-body"> + <div className="result-summary"> + <div className="result-item success"> + <CheckCircle size={18} /> + <span className="label">成功:</span> + <span className="value">{result.success} 条</span> + </div> + {result.fail > 0 && ( + <div className="result-item fail"> + <XCircle size={18} /> + <span className="label">失败:</span> + <span className="value">{result.fail} 条</span> + </div> + )} + </div> + {result.fail > 0 && ( + <div className="result-tip"> + <AlertCircle size={16} /> + <span>部分语音转写失败,可能是语音文件损坏或网络问题</span> + </div> + )} + </div> + <div className="batch-modal-footer"> + <button className="btn-primary" onClick={() => setShowResult(false)}> + 确定 + </button> + </div> + </div> + </div>, + document.body + )} + </> + ) +} diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 3887409..108352e 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2616,42 +2616,14 @@ &:hover:not(:disabled) { color: var(--primary-color); } + &.transcribing { + color: var(--primary-color); + cursor: pointer; + opacity: 1 !important; + } } -// 批量转写模态框基础样式 -.batch-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); - display: flex; - align-items: center; - justify-content: center; - z-index: 10000; - animation: batchFadeIn 0.2s ease-out; -} - -.batch-modal-content { - background: var(--bg-primary); - border-radius: 12px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); - max-height: 90vh; - overflow-y: auto; - animation: batchSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); -} - -@keyframes batchFadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes batchSlideUp { - from { opacity: 0; transform: translateY(20px); } - to { opacity: 1; transform: translateY(0); } -} +// 批量转写模态框基础样式(共享样式在 styles/batchTranscribe.scss) // 批量转写确认对话框 .batch-confirm-modal { @@ -2845,187 +2817,3 @@ } } } - -// 批量转写进度对话框 -.batch-progress-modal { - width: 420px; - max-width: 90vw; - - .batch-modal-header { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 1.5rem; - border-bottom: 1px solid var(--border-color); - - svg { color: var(--primary-color); } - - h3 { - margin: 0; - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - } - } - - .batch-modal-body { - padding: 1.5rem; - - .progress-info { - .progress-text { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.75rem; - font-size: 14px; - color: var(--text-secondary); - - .progress-percent { - font-weight: 600; - color: var(--primary-color); - font-size: 16px; - } - } - - .progress-bar { - height: 8px; - background: var(--bg-tertiary); - border-radius: 4px; - overflow: hidden; - margin-bottom: 1rem; - - .progress-fill { - height: 100%; - background: linear-gradient(90deg, var(--primary-color), var(--primary-color)); - border-radius: 4px; - transition: width 0.3s ease; - } - } - } - - .batch-tip { - display: flex; - align-items: center; - justify-content: center; - padding: 0.75rem; - background: var(--bg-tertiary); - border-radius: 8px; - - span { - font-size: 13px; - color: var(--text-secondary); - } - } - } -} - -// 批量转写结果对话框 -.batch-result-modal { - width: 420px; - max-width: 90vw; - - .batch-modal-header { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 1.5rem; - border-bottom: 1px solid var(--border-color); - - svg { color: #4caf50; } - - h3 { - margin: 0; - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - } - } - - .batch-modal-body { - padding: 1.5rem; - - .result-summary { - display: flex; - flex-direction: column; - gap: 0.75rem; - margin-bottom: 1rem; - - .result-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 1rem; - border-radius: 8px; - background: var(--bg-tertiary); - - svg { flex-shrink: 0; } - - .label { - font-size: 14px; - color: var(--text-secondary); - } - - .value { - margin-left: auto; - font-size: 18px; - font-weight: 600; - } - - &.success { - svg { color: #4caf50; } - .value { color: #4caf50; } - } - - &.fail { - svg { color: #f44336; } - .value { color: #f44336; } - } - } - } - - .result-tip { - display: flex; - align-items: flex-start; - gap: 0.5rem; - padding: 0.75rem; - background: rgba(255, 152, 0, 0.1); - border-radius: 8px; - border: 1px solid rgba(255, 152, 0, 0.3); - - svg { - flex-shrink: 0; - margin-top: 2px; - color: #ff9800; - } - - span { - font-size: 13px; - color: var(--text-secondary); - line-height: 1.5; - } - } - } - - .batch-modal-footer { - display: flex; - justify-content: flex-end; - padding: 1rem 1.5rem; - border-top: 1px solid var(--border-color); - - button { - padding: 0.5rem 1.5rem; - border-radius: 8px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - border: none; - - &.btn-primary { - background: var(--primary-color); - color: white; - &:hover { opacity: 0.9; } - } - } - } -} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index bbbab33..8d118c0 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,7 +1,8 @@ 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, Link, Mic, CheckCircle, XCircle, Copy, Check } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check } from 'lucide-react' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' +import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' import type { ChatSession, Message } from '../types/models' import { getEmojiPath } from 'wechat-emojis' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' @@ -175,17 +176,13 @@ function ChatPage(_props: ChatPageProps) { const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false) const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null) - // 批量语音转文字相关状态 - const [isBatchTranscribing, setIsBatchTranscribing] = useState(false) - const [batchTranscribeProgress, setBatchTranscribeProgress] = useState({ current: 0, total: 0 }) + // 批量语音转文字相关状态(进度/结果 由全局 store 管理) + const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore() const [showBatchConfirm, setShowBatchConfirm] = useState(false) const [batchVoiceCount, setBatchVoiceCount] = useState(0) const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null) const [batchVoiceDates, setBatchVoiceDates] = useState<string[]>([]) const [batchSelectedDates, setBatchSelectedDates] = useState<Set<string>>(new Set()) - const [showBatchProgress, setShowBatchProgress] = useState(false) - const [showBatchResult, setShowBatchResult] = useState(false) - const [batchResult, setBatchResult] = useState({ success: 0, fail: 0 }) // 联系人信息加载控制 const isEnrichingRef = useRef(false) @@ -1280,16 +1277,13 @@ function ChatPage(_props: ChatPageProps) { const session = sessions.find(s => s.username === currentSessionId) if (!session) return - setIsBatchTranscribing(true) - setShowBatchProgress(true) - setBatchTranscribeProgress({ current: 0, total: voiceMessages.length }) + startTranscribe(voiceMessages.length) // 检查模型状态 const modelStatus = await window.electronAPI.whisper.getModelStatus() if (!modelStatus?.exists) { alert('SenseVoice 模型未下载,请先在设置中下载模型') - setIsBatchTranscribing(false) - setShowBatchProgress(false) + finishTranscribe(0, 0) return } @@ -1319,15 +1313,12 @@ function ChatPage(_props: ChatPageProps) { if (result.success) successCount++ else failCount++ completedCount++ - setBatchTranscribeProgress({ current: completedCount, total: voiceMessages.length }) + updateProgress(completedCount, voiceMessages.length) }) } - setIsBatchTranscribing(false) - setShowBatchProgress(false) - setBatchResult({ success: successCount, fail: failCount }) - setShowBatchResult(true) - }, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages]) + finishTranscribe(successCount, failCount) + }, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, startTranscribe, updateProgress, finishTranscribe]) // 批量转写:按日期的消息数量 const batchCountByDate = useMemo(() => { @@ -1475,10 +1466,16 @@ function ChatPage(_props: ChatPageProps) { </div> <div className="header-actions"> <button - className="icon-btn batch-transcribe-btn" - onClick={handleBatchTranscribe} - disabled={isBatchTranscribing || !currentSessionId} - title={isBatchTranscribing ? `批量转写中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total})` : '批量语音转文字'} + className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`} + onClick={() => { + if (isBatchTranscribing) { + setShowBatchProgress(true) + } else { + handleBatchTranscribe() + } + }} + disabled={!currentSessionId} + title={isBatchTranscribing ? `批量转写中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total}),点击查看进度` : '批量语音转文字'} > {isBatchTranscribing ? ( <Loader2 size={18} className="spin" /> @@ -1813,84 +1810,6 @@ function ChatPage(_props: ChatPageProps) { </div>, document.body )} - - {/* 批量转写进度对话框 */} - {showBatchProgress && createPortal( - <div className="batch-modal-overlay"> - <div className="batch-modal-content batch-progress-modal" onClick={(e) => e.stopPropagation()}> - <div className="batch-modal-header"> - <Loader2 size={20} className="spin" /> - <h3>正在转写...</h3> - </div> - <div className="batch-modal-body"> - <div className="progress-info"> - <div className="progress-text"> - <span>已完成 {batchTranscribeProgress.current} / {batchTranscribeProgress.total} 条</span> - <span className="progress-percent"> - {batchTranscribeProgress.total > 0 - ? Math.round((batchTranscribeProgress.current / batchTranscribeProgress.total) * 100) - : 0}% - </span> - </div> - <div className="progress-bar"> - <div - className="progress-fill" - style={{ - width: `${batchTranscribeProgress.total > 0 - ? (batchTranscribeProgress.current / batchTranscribeProgress.total) * 100 - : 0}%` - }} - /> - </div> - </div> - <div className="batch-tip"> - <span>转写过程中可以继续使用其他功能</span> - </div> - </div> - </div> - </div>, - document.body - )} - - {/* 批量转写结果对话框 */} - {showBatchResult && createPortal( - <div className="batch-modal-overlay" onClick={() => setShowBatchResult(false)}> - <div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}> - <div className="batch-modal-header"> - <CheckCircle size={20} /> - <h3>转写完成</h3> - </div> - <div className="batch-modal-body"> - <div className="result-summary"> - <div className="result-item success"> - <CheckCircle size={18} /> - <span className="label">成功:</span> - <span className="value">{batchResult.success} 条</span> - </div> - {batchResult.fail > 0 && ( - <div className="result-item fail"> - <XCircle size={18} /> - <span className="label">失败:</span> - <span className="value">{batchResult.fail} 条</span> - </div> - )} - </div> - {batchResult.fail > 0 && ( - <div className="result-tip"> - <AlertCircle size={16} /> - <span>部分语音转写失败,可能是语音文件损坏或网络问题</span> - </div> - )} - </div> - <div className="batch-modal-footer"> - <button className="btn-primary" onClick={() => setShowBatchResult(false)}> - 确定 - </button> - </div> - </div> - </div>, - document.body - )} </div> ) } diff --git a/src/stores/batchTranscribeStore.ts b/src/stores/batchTranscribeStore.ts new file mode 100644 index 0000000..b8ae357 --- /dev/null +++ b/src/stores/batchTranscribeStore.ts @@ -0,0 +1,60 @@ +import { create } from 'zustand' + +export interface BatchTranscribeState { + /** 是否正在批量转写 */ + isBatchTranscribing: boolean + /** 转写进度 */ + progress: { current: number; total: number } + /** 是否显示进度浮窗 */ + showToast: boolean + /** 是否显示结果弹窗 */ + showResult: boolean + /** 转写结果 */ + result: { success: number; fail: number } + + // Actions + startTranscribe: (total: number) => void + updateProgress: (current: number, total: number) => void + finishTranscribe: (success: number, fail: number) => void + setShowToast: (show: boolean) => void + setShowResult: (show: boolean) => void + reset: () => void +} + +export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({ + isBatchTranscribing: false, + progress: { current: 0, total: 0 }, + showToast: false, + showResult: false, + result: { success: 0, fail: 0 }, + + startTranscribe: (total) => set({ + isBatchTranscribing: true, + showToast: true, + progress: { current: 0, total }, + showResult: false, + result: { success: 0, fail: 0 } + }), + + updateProgress: (current, total) => set({ + progress: { current, total } + }), + + finishTranscribe: (success, fail) => set({ + isBatchTranscribing: false, + showToast: false, + showResult: true, + result: { success, fail } + }), + + setShowToast: (show) => set({ showToast: show }), + setShowResult: (show) => set({ showResult: show }), + + reset: () => set({ + isBatchTranscribing: false, + progress: { current: 0, total: 0 }, + showToast: false, + showResult: false, + result: { success: 0, fail: 0 } + }) +})) diff --git a/src/styles/batchTranscribe.scss b/src/styles/batchTranscribe.scss new file mode 100644 index 0000000..175cc2c --- /dev/null +++ b/src/styles/batchTranscribe.scss @@ -0,0 +1,238 @@ +// 批量转写 - 共享基础样式(overlay / modal-content / animations) +// 被 ChatPage.scss 和 BatchTranscribeGlobal.tsx 同时使用 + +.batch-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: batchFadeIn 0.2s ease-out; +} + +.batch-modal-content { + background: var(--bg-primary); + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + max-height: 90vh; + overflow-y: auto; + animation: batchSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes batchFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes batchSlideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +// 批量转写进度浮窗(非阻塞 toast) +.batch-progress-toast { + position: fixed; + bottom: 24px; + right: 24px; + width: 320px; + background: var(--bg-primary); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18); + border: 1px solid var(--border-color); + z-index: 10000; + animation: batchToastSlideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); + overflow: hidden; + + .batch-progress-toast-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--border-color); + + .batch-progress-toast-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + + svg { color: var(--primary-color); } + } + } + + .batch-progress-toast-close { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: transparent; + border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; + transition: background 0.15s, color 0.15s; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } + } + + .batch-progress-toast-body { + padding: 12px 14px; + + .progress-text { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + font-size: 12px; + color: var(--text-secondary); + + .progress-percent { + font-weight: 600; + color: var(--primary-color); + font-size: 13px; + } + } + + .progress-bar { + height: 6px; + background: var(--bg-tertiary); + border-radius: 3px; + overflow: hidden; + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--primary-color), var(--primary-color)); + border-radius: 3px; + transition: width 0.3s ease; + } + } + } +} + +@keyframes batchToastSlideIn { + from { opacity: 0; transform: translateY(16px) scale(0.96); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +// 批量转写结果对话框 +.batch-result-modal { + width: 420px; + max-width: 90vw; + + .batch-modal-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); + + svg { color: #4caf50; } + + h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + } + } + + .batch-modal-body { + padding: 1.5rem; + + .result-summary { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; + + .result-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + border-radius: 8px; + background: var(--bg-tertiary); + + svg { flex-shrink: 0; } + + .label { + font-size: 14px; + color: var(--text-secondary); + } + + .value { + margin-left: auto; + font-size: 18px; + font-weight: 600; + } + + &.success { + svg { color: #4caf50; } + .value { color: #4caf50; } + } + + &.fail { + svg { color: #f44336; } + .value { color: #f44336; } + } + } + } + + .result-tip { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem; + background: rgba(255, 152, 0, 0.1); + border-radius: 8px; + border: 1px solid rgba(255, 152, 0, 0.3); + + svg { + flex-shrink: 0; + margin-top: 2px; + color: #ff9800; + } + + span { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + } + } + + .batch-modal-footer { + display: flex; + justify-content: flex-end; + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-color); + + button { + padding: 0.5rem 1.5rem; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + border: none; + + &.btn-primary { + background: var(--primary-color); + color: white; + &:hover { opacity: 0.9; } + } + } + } +} From 63ac715792be7f4fa081d4458df969d49c8a0bfc Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 23:09:20 +0800 Subject: [PATCH 11/19] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=86html=E5=AF=BC?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 4 + electron/preload.ts | 2 + electron/services/chatService.ts | 75 +++- electron/services/exportService.ts | 648 ++++++++++++++++++----------- src/types/electron.d.ts | 14 +- 5 files changed, 488 insertions(+), 255 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 308444c..ebfd428 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -959,6 +959,10 @@ function registerIpcHandlers() { }) // 导出相关 + ipcMain.handle('export:getExportStats', async (_, sessionIds: string[], options: any) => { + return exportService.getExportStats(sessionIds, options) + }) + ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => { const onProgress = (progress: ExportProgress) => { if (!event.sender.isDestroyed()) { diff --git a/electron/preload.ts b/electron/preload.ts index 849e11d..b6a8559 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -239,6 +239,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // 导出 export: { + getExportStats: (sessionIds: string[], options: any) => + ipcRenderer.invoke('export:getExportStats', sessionIds, options), exportSessions: (sessionIds: string[], outputDir: string, options: any) => ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options), exportSession: (sessionId: string, outputPath: string, options: any) => diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index c0568e2..2a20a06 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -117,10 +117,13 @@ class ChatService { private voiceWavCache = new Map<string, Buffer>() private voiceTranscriptCache = new Map<string, string>() private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>() + private transcriptCacheLoaded = false + private transcriptCacheDirty = false + private transcriptFlushTimer: ReturnType<typeof setTimeout> | null = null private mediaDbsCache: string[] | null = null private mediaDbsCacheTime = 0 private readonly mediaDbsCacheTtl = 300000 // 5分钟 - private readonly voiceCacheMaxEntries = 50 + private readonly voiceWavCacheMaxEntries = 50 // 缓存 media.db 的表结构信息 private mediaDbSchemaCache = new Map<string, { voiceTable: string @@ -3498,6 +3501,8 @@ class ChatService { ): Promise<{ success: boolean; transcript?: string; error?: string }> { const startTime = Date.now() + // 确保磁盘缓存已加载 + this.loadTranscriptCacheIfNeeded() try { let msgCreateTime = createTime @@ -3625,18 +3630,76 @@ class ChatService { private cacheVoiceWav(cacheKey: string, wavData: Buffer): void { this.voiceWavCache.set(cacheKey, wavData) - if (this.voiceWavCache.size > this.voiceCacheMaxEntries) { + if (this.voiceWavCache.size > this.voiceWavCacheMaxEntries) { const oldestKey = this.voiceWavCache.keys().next().value if (oldestKey) this.voiceWavCache.delete(oldestKey) } } + /** 获取持久化转写缓存文件路径 */ + private getTranscriptCachePath(): string { + const cachePath = this.configService.get('cachePath') + const base = cachePath || join(app.getPath('documents'), 'WeFlow') + return join(base, 'Voices', 'transcripts.json') + } + + /** 首次访问时从磁盘加载转写缓存 */ + private loadTranscriptCacheIfNeeded(): void { + if (this.transcriptCacheLoaded) return + this.transcriptCacheLoaded = true + try { + const filePath = this.getTranscriptCachePath() + if (existsSync(filePath)) { + const raw = readFileSync(filePath, 'utf-8') + const data = JSON.parse(raw) as Record<string, string> + for (const [k, v] of Object.entries(data)) { + if (typeof v === 'string') this.voiceTranscriptCache.set(k, v) + } + console.log(`[Transcribe] 从磁盘加载了 ${this.voiceTranscriptCache.size} 条转写缓存`) + } + } catch (e) { + console.error('[Transcribe] 加载转写缓存失败:', e) + } + } + + /** 将转写缓存持久化到磁盘(防抖 3 秒) */ + private scheduleTranscriptFlush(): void { + if (this.transcriptFlushTimer) return + this.transcriptFlushTimer = setTimeout(() => { + this.transcriptFlushTimer = null + this.flushTranscriptCache() + }, 3000) + } + + /** 立即写入转写缓存到磁盘 */ + flushTranscriptCache(): void { + if (!this.transcriptCacheDirty) return + try { + const filePath = this.getTranscriptCachePath() + const dir = dirname(filePath) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + const obj: Record<string, string> = {} + for (const [k, v] of this.voiceTranscriptCache) obj[k] = v + writeFileSync(filePath, JSON.stringify(obj), 'utf-8') + this.transcriptCacheDirty = false + } catch (e) { + console.error('[Transcribe] 写入转写缓存失败:', e) + } + } + private cacheVoiceTranscript(cacheKey: string, transcript: string): void { this.voiceTranscriptCache.set(cacheKey, transcript) - if (this.voiceTranscriptCache.size > this.voiceCacheMaxEntries) { - const oldestKey = this.voiceTranscriptCache.keys().next().value - if (oldestKey) this.voiceTranscriptCache.delete(oldestKey) - } + this.transcriptCacheDirty = true + this.scheduleTranscriptFlush() + } + + /** + * 检查某个语音消息是否已有缓存的转写结果 + */ + hasTranscriptCache(sessionId: string, msgId: string, createTime?: number): boolean { + this.loadTranscriptCacheIfNeeded() + const cacheKey = this.getVoiceCacheKey(sessionId, msgId, createTime) + return this.voiceTranscriptCache.has(cacheKey) } /** diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 82864e1..3a5c939 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -106,6 +106,9 @@ export interface ExportProgress { total: number currentSession: string phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete' + phaseProgress?: number + phaseTotal?: number + phaseLabel?: string } // 并发控制:限制同时执行的 Promise 数量 @@ -847,16 +850,30 @@ class ExportService { } private escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') + return value.replace(/[&<>"']/g, c => { + switch (c) { + case '&': return '&' + case '<': return '<' + case '>': return '>' + case '"': return '"' + case "'": return ''' + default: return c + } + }) } private escapeAttribute(value: string): string { - return this.escapeHtml(value).replace(/`/g, '`') + return value.replace(/[&<>"'`]/g, c => { + switch (c) { + case '&': return '&' + case '<': return '<' + case '>': return '>' + case '"': return '"' + case "'": return ''' + case '`': return '`' + default: return c + } + }) } private getAvatarFallback(name: string): string { @@ -997,7 +1014,9 @@ class ExportService { if (index % 2 === 1) { const emojiDataUrl = this.getInlineEmojiDataUrl(part) if (emojiDataUrl) { - return `<img class="inline-emoji" src="${this.escapeAttribute(emojiDataUrl)}" alt="[${this.escapeAttribute(part)}]" />` + // Cache full <img> tag to avoid re-escaping data URL every time + const escapedName = this.escapeAttribute(part) + return `<img class="inline-emoji" src="${emojiDataUrl}" alt="[${escapedName}]" />` } return this.escapeHtml(`[${part}]`) } @@ -1135,22 +1154,19 @@ class ExportService { } // 复制文件 - if (fs.existsSync(sourcePath)) { - const ext = path.extname(sourcePath) || '.jpg' - const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}` - const destPath = path.join(imagesDir, fileName) + if (!fs.existsSync(sourcePath)) return null + const ext = path.extname(sourcePath) || '.jpg' + const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}` + const destPath = path.join(imagesDir, fileName) - if (!fs.existsSync(destPath)) { - fs.copyFileSync(sourcePath, destPath) - } - - return { - relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), - kind: 'image' - } + if (!fs.existsSync(destPath)) { + fs.copyFileSync(sourcePath, destPath) } - return null + return { + relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), + kind: 'image' + } } catch (e) { return null } @@ -1771,9 +1787,10 @@ class ExportService { fs.mkdirSync(avatarsDir, { recursive: true }) } - for (const member of members) { + const AVATAR_CONCURRENCY = 8 + await parallelLimit(members, AVATAR_CONCURRENCY, async (member) => { const fileInfo = this.resolveAvatarFile(member.avatarUrl) - if (!fileInfo) continue + if (!fileInfo) return try { let data: Buffer | null = null let mime = fileInfo.mime @@ -1788,7 +1805,7 @@ class ExportService { mime = downloaded.mime || mime } } - if (!data) continue + if (!data) return // 优先使用内容检测出的 MIME 类型 const detectedMime = this.detectMimeType(data) @@ -1805,15 +1822,19 @@ class ExportService { const filename = `${sanitizedUsername}${ext}` const avatarPath = path.join(avatarsDir, filename) - // 保存头像文件 - await fs.promises.writeFile(avatarPath, data) + // 跳过已存在文件 + try { + await fs.promises.access(avatarPath) + } catch { + await fs.promises.writeFile(avatarPath, data) + } // 返回相对路径 result.set(member.username, `avatars/${filename}`) } catch { - continue + return } - } + }) return result } @@ -2001,11 +2022,15 @@ class ExportService { current: 20, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-media' + phase: 'exporting-media', + phaseProgress: 0, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 0/${mediaMessages.length}` }) // 并行导出媒体,并发数跟随导出设置 const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) + let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { @@ -2018,6 +2043,18 @@ class ExportService { }) mediaCache.set(mediaKey, mediaItem) } + mediaExported++ + if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { + onProgress?.({ + current: 20, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: mediaExported, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + }) + } }) } @@ -2029,14 +2066,28 @@ class ExportService { current: 40, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-voice' + phase: 'exporting-voice', + phaseProgress: 0, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 0/${voiceMessages.length}` }) // 并行转写语音,限制 4 个并发(转写比较耗资源) const VOICE_CONCURRENCY = 4 + let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscribed++ + onProgress?.({ + current: 40, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: voiceTranscribed, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` + }) }) } @@ -2335,10 +2386,14 @@ class ExportService { current: 15, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-media' + phase: 'exporting-media', + phaseProgress: 0, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 0/${mediaMessages.length}` }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) + let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { @@ -2351,6 +2406,18 @@ class ExportService { }) mediaCache.set(mediaKey, mediaItem) } + mediaExported++ + if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { + onProgress?.({ + current: 15, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: mediaExported, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + }) + } }) } @@ -2362,13 +2429,27 @@ class ExportService { current: 35, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-voice' + phase: 'exporting-voice', + phaseProgress: 0, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 0/${voiceMessages.length}` }) const VOICE_CONCURRENCY = 4 + let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscribed++ + onProgress?.({ + current: 35, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: voiceTranscribed, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` + }) }) } @@ -2744,10 +2825,14 @@ class ExportService { current: 35, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-media' + phase: 'exporting-media', + phaseProgress: 0, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 0/${mediaMessages.length}` }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) + let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { @@ -2760,6 +2845,18 @@ class ExportService { }) mediaCache.set(mediaKey, mediaItem) } + mediaExported++ + if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { + onProgress?.({ + current: 35, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: mediaExported, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + }) + } }) } @@ -2771,13 +2868,27 @@ class ExportService { current: 50, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-voice' + phase: 'exporting-voice', + phaseProgress: 0, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 0/${voiceMessages.length}` }) const VOICE_CONCURRENCY = 4 + let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscribed++ + onProgress?.({ + current: 50, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: voiceTranscribed, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` + }) }) } @@ -3074,10 +3185,14 @@ class ExportService { current: 25, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-media' + phase: 'exporting-media', + phaseProgress: 0, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 0/${mediaMessages.length}` }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) + let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { @@ -3090,6 +3205,18 @@ class ExportService { }) mediaCache.set(mediaKey, mediaItem) } + mediaExported++ + if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { + onProgress?.({ + current: 25, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: mediaExported, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + }) + } }) } @@ -3100,13 +3227,27 @@ class ExportService { current: 45, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-voice' + phase: 'exporting-voice', + phaseProgress: 0, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 0/${voiceMessages.length}` }) const VOICE_CONCURRENCY = 4 + let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscribed++ + onProgress?.({ + current: 45, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: voiceTranscribed, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` + }) }) } @@ -3231,158 +3372,83 @@ class ExportService { private getVirtualScrollScript(): string { return ` - class VirtualScroller { - constructor(container, list, data, renderItem) { + class ChunkedRenderer { + constructor(container, data, renderItem) { this.container = container; - this.list = list; this.data = data; this.renderItem = renderItem; - - this.rowHeight = 80; // Estimated height - this.buffer = 5; - this.heightCache = new Map(); - this.visibleItems = new Set(); - - this.spacer = document.createElement('div'); - this.spacer.className = 'virtual-scroll-spacer'; - this.content = document.createElement('div'); - this.content.className = 'virtual-scroll-content'; - - this.container.appendChild(this.spacer); - this.container.appendChild(this.content); - - this.container.addEventListener('scroll', () => this.onScroll()); - window.addEventListener('resize', () => this.onScroll()); - - this.updateTotalHeight(); - this.onScroll(); + this.batchSize = 100; + this.rendered = 0; + this.loading = false; + + this.list = document.createElement('div'); + this.list.className = 'message-list'; + this.container.appendChild(this.list); + + this.sentinel = document.createElement('div'); + this.sentinel.className = 'load-sentinel'; + this.container.appendChild(this.sentinel); + + this.renderBatch(); + + this.observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !this.loading) { + this.renderBatch(); + } + }, { root: this.container, rootMargin: '600px' }); + this.observer.observe(this.sentinel); + } + + renderBatch() { + if (this.rendered >= this.data.length) return; + this.loading = true; + const end = Math.min(this.rendered + this.batchSize, this.data.length); + const fragment = document.createDocumentFragment(); + for (let i = this.rendered; i < end; i++) { + const wrapper = document.createElement('div'); + wrapper.innerHTML = this.renderItem(this.data[i], i); + if (wrapper.firstElementChild) fragment.appendChild(wrapper.firstElementChild); + } + this.list.appendChild(fragment); + this.rendered = end; + this.loading = false; } setData(newData) { this.data = newData; - this.heightCache.clear(); - this.content.innerHTML = ''; + this.rendered = 0; + this.list.innerHTML = ''; this.container.scrollTop = 0; - this.updateTotalHeight(); - this.onScroll(); - - // Show/Hide empty state if (this.data.length === 0) { - this.content.innerHTML = '<div class="empty">暂无消息</div>'; + this.list.innerHTML = '<div class="empty">暂无消息</div>'; + return; } + this.renderBatch(); } - updateTotalHeight() { - let total = 0; - for (let i = 0; i < this.data.length; i++) { - total += this.heightCache.get(i) || this.rowHeight; - } - this.spacer.style.height = total + 'px'; - } - - onScroll() { - if (this.data.length === 0) return; - - const scrollTop = this.container.scrollTop; - const containerHeight = this.container.clientHeight; - - // Find start index - let currentY = 0; - let startIndex = 0; - for (let i = 0; i < this.data.length; i++) { - const h = this.heightCache.get(i) || this.rowHeight; - if (currentY + h > scrollTop) { - startIndex = i; - break; - } - currentY += h; - } - - // Find end index - let endIndex = startIndex; - let visibleHeight = 0; - for (let i = startIndex; i < this.data.length; i++) { - const h = this.heightCache.get(i) || this.rowHeight; - visibleHeight += h; - endIndex = i; - if (visibleHeight > containerHeight) break; - } - - const start = Math.max(0, startIndex - this.buffer); - const end = Math.min(this.data.length - 1, endIndex + this.buffer); - - this.renderRange(start, end, currentY); - } - - renderRange(start, end, startY) { - // Calculate offset for start item - let topOffset = 0; - for(let i=0; i<start; i++) { - topOffset += this.heightCache.get(i) || this.rowHeight; - } - - const newKeys = new Set(); - - // Create or update items - let currentTop = topOffset; - const fragment = document.createDocumentFragment(); - - for (let i = start; i <= end; i++) { - newKeys.add(i); - const itemData = this.data[i]; - - let el = this.content.querySelector(\`[data-index="\${i}"]\`); - if (!el) { - el = document.createElement('div'); - el.setAttribute('data-index', i); - el.className = 'virtual-item'; - el.style.position = 'absolute'; - el.style.left = '0'; - el.style.width = '100%'; - el.innerHTML = this.renderItem(itemData, i); - - // Measure height after render - this.content.appendChild(el); - const rect = el.getBoundingClientRect(); - const actualHeight = rect.height; - - if (Math.abs(actualHeight - (this.heightCache.get(i) || this.rowHeight)) > 1) { - this.heightCache.set(i, actualHeight); - // If height changed significantly, we might need to adjust total height - // But for performance, maybe just do it on next scroll or rarely? - // For now, let's keep it simple. If we update inline style top, we need to know exact previous heights. - } - } - - el.style.top = currentTop + 'px'; - currentTop += (this.heightCache.get(i) || this.rowHeight); - } - - // Cleanup - Array.from(this.content.children).forEach(child => { - if (child.classList.contains('empty')) return; - const idx = parseInt(child.getAttribute('data-index')); - if (!newKeys.has(idx)) { - child.remove(); - } - }); - - this.updateTotalHeight(); - } - scrollToTime(timestamp) { - const idx = this.data.findIndex(item => item.ts >= timestamp); - if (idx !== -1) { - this.scrollToIndex(idx); - } - } - - scrollToIndex(index) { - let top = 0; - for(let i=0; i<index; i++) { - top += this.heightCache.get(i) || this.rowHeight; + const idx = this.data.findIndex(item => item.t >= timestamp); + if (idx === -1) return; + // Ensure all messages up to target are rendered + while (this.rendered <= idx) { + this.renderBatch(); + } + const el = this.list.children[idx]; + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.classList.add('highlight'); + setTimeout(() => el.classList.remove('highlight'), 2500); + } + } + + scrollToIndex(index) { + while (this.rendered <= index) { + this.renderBatch(); + } + const el = this.list.children[index]; + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } - this.container.scrollTop = top; } } `; @@ -3447,10 +3513,14 @@ class ExportService { current: 20, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-media' + phase: 'exporting-media', + phaseProgress: 0, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 0/${mediaMessages.length}` }) const MEDIA_CONCURRENCY = 6 + let mediaExported = 0 await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { @@ -3464,6 +3534,18 @@ class ExportService { }) mediaCache.set(mediaKey, mediaItem) } + mediaExported++ + if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { + onProgress?.({ + current: 20, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: mediaExported, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + }) + } }) } @@ -3478,13 +3560,27 @@ class ExportService { current: 40, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-voice' + phase: 'exporting-voice', + phaseProgress: 0, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 0/${voiceMessages.length}` }) const VOICE_CONCURRENCY = 4 + let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscribed++ + onProgress?.({ + current: 40, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: voiceTranscribed, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` + }) }) } @@ -3535,43 +3631,23 @@ class ExportService { <body> <div class="page"> <div class="header"> - <h1 class="title">${this.escapeHtml(sessionInfo.displayName)} 的聊天记录</h1> + <h1 class="title">${this.escapeHtml(sessionInfo.displayName)}</h1> <div class="meta"> - <span>导出时间:${this.escapeHtml(this.formatTimestamp(exportMeta.chatlab.exportedAt))}</span> - <span>消息数量:${sortedMessages.length}</span> - <span>会话类型:${isGroup ? '群聊' : '私聊'}</span> + <span>${sortedMessages.length} 条消息</span> + <span>${isGroup ? '群聊' : '私聊'}</span> + <span>${this.escapeHtml(this.formatTimestamp(exportMeta.chatlab.exportedAt))}</span> </div> <div class="controls"> - <div class="control"> - <label for="searchInput">搜索内容 / 发送者</label> - <input id="searchInput" type="search" placeholder="输入关键词实时过滤" /> - </div> - <div class="control"> - <label for="timeInput">按时间跳转</label> - <input id="timeInput" type="datetime-local" /> - </div> - <div class="control"> - <label for="themeSelect">主题配色</label> - <select id="themeSelect"> - <option value="cloud-dancer">云舞蓝</option> - <option value="corundum-blue">珊瑚蓝</option> - <option value="kiwi-green">奇异绿</option> - <option value="spicy-red">热辣红</option> - <option value="teal-water">蓝绿水</option> - </select> - </div> - <div class="control"> - <label> </label> - <button id="jumpBtn" type="button">跳转到时间</button> - </div> + <input id="searchInput" type="search" placeholder="搜索消息..." /> + <input id="timeInput" type="datetime-local" /> + <button id="jumpBtn" type="button">跳转</button> <div class="stats"> <span id="resultCount">共 ${sortedMessages.length} 条</span> </div> </div> </div> - <!-- Virtual Scroll Container --> - <div id="virtualScrollContainer" class="virtual-scroll-container"></div> + <div id="scrollContainer" class="scroll-container"></div> </div> @@ -3584,7 +3660,23 @@ class ExportService { window.WEFLOW_DATA = [ `); - // Write messages in chunks + // Pre-build avatar HTML lookup to avoid per-message rebuilds + const avatarHtmlCache = new Map<string, string>() + const getAvatarHtml = (username: string, name: string): string => { + const cached = avatarHtmlCache.get(username) + if (cached !== undefined) return cached + const avatarData = avatarMap.get(username) + const html = avatarData + ? `<img src="${this.escapeAttribute(encodeURI(avatarData))}" alt="${this.escapeAttribute(name)}" />` + : `<span>${this.escapeHtml(this.getAvatarFallback(name))}</span>` + avatarHtmlCache.set(username, html) + return html + } + + // Write messages in buffered chunks + const WRITE_BATCH = 100 + let writeBuf: string[] = [] + for (let i = 0; i < sortedMessages.length; i++) { const msg = sortedMessages[i] const mediaKey = `${msg.localType}_${msg.localId}` @@ -3597,10 +3689,8 @@ class ExportService { : (isGroup ? (senderInfo?.groupNickname || senderInfo?.accountName || msg.senderUsername) : (sessionInfo.displayName || sessionId)) - const avatarData = avatarMap.get(isSenderMe ? cleanedMyWxid : msg.senderUsername) - const avatarHtml = avatarData - ? `<img src="${this.escapeAttribute(encodeURI(avatarData))}" alt="${this.escapeAttribute(senderName)}" />` - : `<span>${this.escapeHtml(this.getAvatarFallback(senderName))}</span>` + + const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, senderName) const timeText = this.formatTimestamp(msg.createTime) const typeName = this.getMessageTypeName(msg.localType) @@ -3634,14 +3724,7 @@ class ExportService { ? `<div class="sender-name">${this.escapeHtml(senderName)}</div>` : '' const timeHtml = `<div class="message-time">${this.escapeHtml(timeText)}</div>` - const messageBody = ` - ${timeHtml} - ${senderNameHtml} - <div class="message-content"> - ${mediaHtml} - ${textHtml} - </div> - ` + const messageBody = `${timeHtml}${senderNameHtml}<div class="message-content">${mediaHtml}${textHtml}</div>` // Compact JSON object const itemObj = { @@ -3652,8 +3735,15 @@ class ExportService { b: messageBody // body HTML } - const jsonStr = JSON.stringify(itemObj) - await writePromise(jsonStr + (i < sortedMessages.length - 1 ? ',\n' : '\n')) + writeBuf.push(JSON.stringify(itemObj)) + + // Flush buffer periodically + if (writeBuf.length >= WRITE_BATCH || i === sortedMessages.length - 1) { + const isLast = i === sortedMessages.length - 1 + const chunk = writeBuf.join(',\n') + (isLast ? '\n' : ',\n') + await writePromise(chunk) + writeBuf = [] + } // Report progress occasionally if ((i + 1) % 500 === 0) { @@ -3676,10 +3766,9 @@ class ExportService { const timeInput = document.getElementById('timeInput') const jumpBtn = document.getElementById('jumpBtn') const resultCount = document.getElementById('resultCount') - const themeSelect = document.getElementById('themeSelect') const imagePreview = document.getElementById('imagePreview') const imagePreviewTarget = document.getElementById('imagePreviewTarget') - const container = document.getElementById('virtualScrollContainer') + const container = document.getElementById('scrollContainer') let imageZoom = 1 // Initial Data @@ -3701,7 +3790,7 @@ class ExportService { \`; }; - const scroller = new VirtualScroller(container, [], currentList, renderItem); + const renderer = new ChunkedRenderer(container, currentList, renderItem); const updateCount = () => { resultCount.textContent = \`共 \${currentList.length} 条\` @@ -3716,14 +3805,11 @@ class ExportService { if (!keyword) { currentList = allData; } else { - // Simplified search: check raw html content (contains body text and sender name) - // Ideally we should search raw text, but we only have pre-rendered HTML in JSON 'b' (body) - // 'b' contains message content and sender name. currentList = allData.filter(item => { return item.b.toLowerCase().includes(keyword); }); } - scroller.setData(currentList); + renderer.setData(currentList); updateCount(); }, 300); }) @@ -3733,21 +3819,7 @@ class ExportService { const value = timeInput.value if (!value) return const target = Math.floor(new Date(value).getTime() / 1000) - // Find in current list - scroller.scrollToTime(target); - }) - - // Theme Logic - const applyTheme = (value) => { - document.body.setAttribute('data-theme', value) - localStorage.setItem('weflow-export-theme', value) - } - const storedTheme = localStorage.getItem('weflow-export-theme') || 'cloud-dancer' - themeSelect.value = storedTheme - applyTheme(storedTheme) - - themeSelect.addEventListener('change', (event) => { - applyTheme(event.target.value) + renderer.scrollToTime(target); }) // Image Preview (Delegation) @@ -3788,7 +3860,6 @@ class ExportService { }) updateCount() - console.log('WeFlow Export Loaded', allData.length); </script> </body> </html>`); @@ -3811,6 +3882,77 @@ class ExportService { } } + /** + * 获取导出前的预估统计信息 + */ + async getExportStats( + sessionIds: string[], + options: ExportOptions + ): Promise<{ + totalMessages: number + voiceMessages: number + cachedVoiceCount: number + needTranscribeCount: number + mediaMessages: number + estimatedSeconds: number + sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> + }> { + const conn = await this.ensureConnected() + if (!conn.success || !conn.cleanedWxid) { + return { totalMessages: 0, voiceMessages: 0, cachedVoiceCount: 0, needTranscribeCount: 0, mediaMessages: 0, estimatedSeconds: 0, sessions: [] } + } + const cleanedMyWxid = conn.cleanedWxid + const sessionsStats: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> = [] + let totalMessages = 0 + let voiceMessages = 0 + let cachedVoiceCount = 0 + let mediaMessages = 0 + + for (const sessionId of sessionIds) { + const sessionInfo = await this.getContactInfo(sessionId) + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + const msgs = collected.rows + const voiceMsgs = msgs.filter(m => m.localType === 34) + const mediaMsgs = msgs.filter(m => { + const t = m.localType + return (t === 3) || (t === 47) || (t === 43) || (t === 34) + }) + + // 检查已缓存的转写数量 + let cached = 0 + for (const msg of voiceMsgs) { + if (chatService.hasTranscriptCache(sessionId, String(msg.localId), msg.createTime)) { + cached++ + } + } + + totalMessages += msgs.length + voiceMessages += voiceMsgs.length + cachedVoiceCount += cached + mediaMessages += mediaMsgs.length + sessionsStats.push({ + sessionId, + displayName: sessionInfo.displayName, + totalCount: msgs.length, + voiceCount: voiceMsgs.length + }) + } + + const needTranscribeCount = voiceMessages - cachedVoiceCount + // 预估:每条语音转文字约 2 秒 + const estimatedSeconds = needTranscribeCount * 2 + + return { + totalMessages, + voiceMessages, + cachedVoiceCount, + needTranscribeCount, + mediaMessages, + estimatedSeconds, + sessions: sessionsStats + } + } + /** * 批量导出多个会话 */ @@ -3850,7 +3992,17 @@ class ExportService { await parallelLimit(sessionIds, sessionConcurrency, async (sessionId) => { const sessionInfo = await this.getContactInfo(sessionId) - onProgress?.({ + // 创建包装后的进度回调,自动附加会话级信息 + const sessionProgress = (progress: ExportProgress) => { + onProgress?.({ + ...progress, + current: completedCount, + total: sessionIds.length, + currentSession: sessionInfo.displayName + }) + } + + sessionProgress({ current: completedCount, total: sessionIds.length, currentSession: sessionInfo.displayName, @@ -3874,15 +4026,15 @@ class ExportService { let result: { success: boolean; error?: string } if (options.format === 'json') { - result = await this.exportSessionToDetailedJson(sessionId, outputPath, options) + result = await this.exportSessionToDetailedJson(sessionId, outputPath, options, sessionProgress) } else if (options.format === 'chatlab' || options.format === 'chatlab-jsonl') { - result = await this.exportSessionToChatLab(sessionId, outputPath, options) + result = await this.exportSessionToChatLab(sessionId, outputPath, options, sessionProgress) } else if (options.format === 'excel') { - result = await this.exportSessionToExcel(sessionId, outputPath, options) + result = await this.exportSessionToExcel(sessionId, outputPath, options, sessionProgress) } else if (options.format === 'txt') { - result = await this.exportSessionToTxt(sessionId, outputPath, options) + result = await this.exportSessionToTxt(sessionId, outputPath, options, sessionProgress) } else if (options.format === 'html') { - result = await this.exportSessionToHtml(sessionId, outputPath, options) + result = await this.exportSessionToHtml(sessionId, outputPath, options, sessionProgress) } else { result = { success: false, error: `不支持的格式: ${options.format}` } } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 371c9b9..e66004c 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -403,6 +403,15 @@ export interface ElectronAPI { onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void } export: { + getExportStats: (sessionIds: string[], options: any) => Promise<{ + totalMessages: number + voiceMessages: number + cachedVoiceCount: number + needTranscribeCount: number + mediaMessages: number + estimatedSeconds: number + sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> + }> exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{ success: boolean successCount?: number @@ -494,7 +503,10 @@ export interface ExportProgress { current: number total: number currentSession: string - phase: 'preparing' | 'exporting' | 'writing' | 'complete' + phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete' + phaseProgress?: number + phaseTotal?: number + phaseLabel?: string } export interface WxidInfo { From c988e4accf427f685449866592968d4a0b28d2a4 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 23:11:03 +0800 Subject: [PATCH 12/19] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E8=BD=AC=E5=86=99=E7=9A=84=E6=98=BE=E7=A4=BA=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/BatchTranscribeGlobal.tsx | 3 ++- src/pages/ChatPage.tsx | 2 +- src/stores/batchTranscribeStore.ts | 13 +++++++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/components/BatchTranscribeGlobal.tsx b/src/components/BatchTranscribeGlobal.tsx index b6f15b6..51932be 100644 --- a/src/components/BatchTranscribeGlobal.tsx +++ b/src/components/BatchTranscribeGlobal.tsx @@ -15,6 +15,7 @@ export const BatchTranscribeGlobal: React.FC = () => { showToast, showResult, result, + sessionName, setShowToast, setShowResult } = useBatchTranscribeStore() @@ -27,7 +28,7 @@ export const BatchTranscribeGlobal: React.FC = () => { <div className="batch-progress-toast-header"> <div className="batch-progress-toast-title"> <Loader2 size={14} className="spin" /> - <span>批量转写中</span> + <span>批量转写中{sessionName ? `(${sessionName})` : ''}</span> </div> <button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化"> <X size={14} /> diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 8d118c0..9c5b94a 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1277,7 +1277,7 @@ function ChatPage(_props: ChatPageProps) { const session = sessions.find(s => s.username === currentSessionId) if (!session) return - startTranscribe(voiceMessages.length) + startTranscribe(voiceMessages.length, session.displayName || session.username) // 检查模型状态 const modelStatus = await window.electronAPI.whisper.getModelStatus() diff --git a/src/stores/batchTranscribeStore.ts b/src/stores/batchTranscribeStore.ts index b8ae357..b96f085 100644 --- a/src/stores/batchTranscribeStore.ts +++ b/src/stores/batchTranscribeStore.ts @@ -11,9 +11,11 @@ export interface BatchTranscribeState { showResult: boolean /** 转写结果 */ result: { success: number; fail: number } + /** 当前转写的会话名 */ + sessionName: string // Actions - startTranscribe: (total: number) => void + startTranscribe: (total: number, sessionName: string) => void updateProgress: (current: number, total: number) => void finishTranscribe: (success: number, fail: number) => void setShowToast: (show: boolean) => void @@ -27,13 +29,15 @@ export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({ showToast: false, showResult: false, result: { success: 0, fail: 0 }, + sessionName: '', - startTranscribe: (total) => set({ + startTranscribe: (total, sessionName) => set({ isBatchTranscribing: true, showToast: true, progress: { current: 0, total }, showResult: false, - result: { success: 0, fail: 0 } + result: { success: 0, fail: 0 }, + sessionName }), updateProgress: (current, total) => set({ @@ -55,6 +59,7 @@ export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({ progress: { current: 0, total: 0 }, showToast: false, showResult: false, - result: { success: 0, fail: 0 } + result: { success: 0, fail: 0 }, + sessionName: '' }) })) From 0393e7aff76d0ec47c87a7a4031428e6156ff230 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 23:19:12 +0800 Subject: [PATCH 13/19] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8B=8D=E4=B8=80?= =?UTF-8?q?=E6=8B=8D=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/chatService.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 2a20a06..0d88680 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -2190,19 +2190,32 @@ class ChatService { /** * 清理拍一拍消息 - * 格式示例: 我拍了拍 "梨绒" ງ໐໐໓ ຖiງht620000wxid_... + * 格式示例: + * 纯文本: 我拍了拍 "梨绒" ງ໐໐໓ ຖiງht620000wxid_... + * XML: <msg><appmsg...><title>"有幸"拍了拍"浩天空"相信未来!... */ private cleanPatMessage(content: string): string { if (!content) return '[拍一拍]' - // 1. 尝试匹配标准的 "A拍了拍B" 格式 - // 这里的正则比较宽泛,为了兼容不同的语言环境 + // 1. 优先从 XML 标签提取内容 + const titleMatch = /<title>([\s\S]*?)<\/title>/i.exec(content) + if (titleMatch) { + const title = titleMatch[1] + .replace(/<!\[CDATA\[/g, '') + .replace(/\]\]>/g, '') + .trim() + if (title) { + return `[拍一拍] ${title}` + } + } + + // 2. 尝试匹配标准的 "A拍了拍B" 格式 const match = /^(.+?拍了拍.+?)(?:[\r\n]|$|ງ|wxid_)/.exec(content) if (match) { return `[拍一拍] ${match[1].trim()}` } - // 2. 如果匹配失败,尝试清理掉疑似的 garbage (wxid, 乱码) + // 3. 如果匹配失败,尝试清理掉疑似的 garbage (wxid, 乱码) let cleaned = content.replace(/wxid_[a-zA-Z0-9_-]+/g, '') // 移除 wxid cleaned = cleaned.replace(/[ງ໐໓ຖiht]+/g, ' ') // 移除已知的乱码字符 cleaned = cleaned.replace(/\d{6,}/g, '') // 移除长数字 From e56ee1ff4a5bfa1da87f3b2b728d5fa839e1a96b Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 23:24:42 +0800 Subject: [PATCH 14/19] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E6=97=B6=E6=8B=8D=E4=B8=80=E6=8B=8D=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportService.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 3a5c939..0dcb97f 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -704,7 +704,7 @@ class ExportService { return revokeMatch[1].trim() } - // 3. 提取 pat 拍一拍消息 + // 3. 提取 pat 拍一拍消息(sysmsg 内的 template 格式) const patMatch = /<template><!\[CDATA\[(.*?)\]\]><\/template>/i.exec(content) if (patMatch) { // 移除模板变量占位符 @@ -717,6 +717,15 @@ class ExportService { .trim() } + // 3.5 提取 <title> 内容(适用于 appmsg 格式的拍一拍等消息) + const titleMatch = /<title>([\s\S]*?)<\/title>/i.exec(content) + if (titleMatch) { + const title = titleMatch[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim() + if (title) { + return title + } + } + // 4. 处理 CDATA 内容 content = content.replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '') From 1aab8dfc4e62461a5821c9a673b0df1a4f602bbb Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 23:37:50 +0800 Subject: [PATCH 15/19] =?UTF-8?q?=E8=81=8A=E5=A4=A9=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AF=BC=E5=87=BA=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ChatPage.tsx | 24 ++++++++++++++++++++++-- src/pages/ExportPage.tsx | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 9c5b94a..691b048 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,5 +1,6 @@ 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, Link, Mic, CheckCircle, Copy, Check } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, Download } from 'lucide-react' +import { useNavigate } from 'react-router-dom' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' @@ -117,6 +118,8 @@ const SessionItem = React.memo(function SessionItem({ function ChatPage(_props: ChatPageProps) { + const navigate = useNavigate() + const { isConnected, isConnecting, @@ -1228,7 +1231,7 @@ function ChatPage(_props: ChatPageProps) { return } - const voiceMessages = result.messages + const voiceMessages: Message[] = result.messages if (voiceMessages.length === 0) { alert('当前会话没有语音消息') return @@ -1245,6 +1248,15 @@ function ChatPage(_props: ChatPageProps) { setShowBatchConfirm(true) }, [sessions, currentSessionId, isBatchTranscribing]) + const handleExportCurrentSession = useCallback(() => { + if (!currentSessionId) return + navigate('/export', { + state: { + preselectSessionIds: [currentSessionId] + } + }) + }, [currentSessionId, navigate]) + // 确认批量转写 const confirmBatchTranscribe = useCallback(async () => { if (!currentSessionId) return @@ -1465,6 +1477,14 @@ function ChatPage(_props: ChatPageProps) { )} </div> <div className="header-actions"> + <button + className="icon-btn export-session-btn" + onClick={handleExportCurrentSession} + disabled={!currentSessionId} + title="导出当前会话" + > + <Download size={18} /> + </button> <button className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`} onClick={() => { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 93ebdfc..6f6cdf1 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useCallback, useRef, useMemo } from 'react' +import { useLocation } from 'react-router-dom' import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react' import * as configService from '../services/config' import './ExportPage.scss' @@ -38,6 +39,7 @@ interface ExportResult { type SessionLayout = 'shared' | 'per-session' function ExportPage() { + const location = useLocation() const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const [sessions, setSessions] = useState<ChatSession[]>([]) const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([]) @@ -63,6 +65,19 @@ function ExportPage() { const exportStartTime = useRef<number>(0) const [elapsedSeconds, setElapsedSeconds] = useState(0) const displayNameDropdownRef = useRef<HTMLDivElement>(null) + const preselectAppliedRef = useRef(false) + + const preselectSessionIds = useMemo(() => { + const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null + const rawList = Array.isArray(state?.preselectSessionIds) + ? state?.preselectSessionIds + : (typeof state?.preselectSessionId === 'string' ? [state.preselectSessionId] : []) + + return rawList + .filter((item): item is string => typeof item === 'string') + .map(item => item.trim()) + .filter(Boolean) + }, [location.state]) const [options, setOptions] = useState<ExportOptions>({ format: 'excel', @@ -184,6 +199,24 @@ function ExportPage() { loadExportDefaults() }, [loadSessions, loadExportPath, loadExportDefaults]) + useEffect(() => { + preselectAppliedRef.current = false + }, [location.key, preselectSessionIds]) + + useEffect(() => { + if (preselectAppliedRef.current) return + if (sessions.length === 0 || preselectSessionIds.length === 0) return + + const exists = new Set(sessions.map(session => session.username)) + const matched = preselectSessionIds.filter(id => exists.has(id)) + preselectAppliedRef.current = true + + if (matched.length > 0) { + setSelectedSessions(new Set(matched)) + setSearchKeyword('') + } + }, [sessions, preselectSessionIds]) + useEffect(() => { const handleChange = () => { setSelectedSessions(new Set()) From d334a214a452c013329b8251de2485d11a4e4144 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 23:53:16 +0800 Subject: [PATCH 16/19] =?UTF-8?q?=E7=BE=A4=E8=81=8A=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=BE=A4=E8=81=8A=E5=88=86=E6=9E=90=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ChatPage.tsx | 20 +++++++++++++++++- src/pages/GroupAnalyticsPage.tsx | 35 +++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 691b048..c5d40a7 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, Link, Mic, CheckCircle, Copy, Check, Download } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, Download, BarChart3 } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' @@ -1257,6 +1257,15 @@ function ChatPage(_props: ChatPageProps) { }) }, [currentSessionId, navigate]) + const handleGroupAnalytics = useCallback(() => { + if (!currentSessionId || !isGroupChat(currentSessionId)) return + navigate('/group-analytics', { + state: { + preselectGroupIds: [currentSessionId] + } + }) + }, [currentSessionId, navigate]) + // 确认批量转写 const confirmBatchTranscribe = useCallback(async () => { if (!currentSessionId) return @@ -1477,6 +1486,15 @@ function ChatPage(_props: ChatPageProps) { )} </div> <div className="header-actions"> + {isGroupChat(currentSession.username) && ( + <button + className="icon-btn group-analytics-btn" + onClick={handleGroupAnalytics} + title="群聊分析" + > + <BarChart3 size={18} /> + </button> + )} <button className="icon-btn export-session-btn" onClick={handleExportCurrentSession} diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index c37f0f4..cac6edd 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect, useRef, useCallback } from 'react' +import { useState, useEffect, useRef, useCallback, useMemo } from 'react' +import { useLocation } from 'react-router-dom' import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react' import { Avatar } from '../components/Avatar' import ReactECharts from 'echarts-for-react' @@ -30,6 +31,7 @@ interface GroupMessageRank { type AnalysisFunction = 'members' | 'ranking' | 'activeHours' | 'mediaStats' function GroupAnalyticsPage() { + const location = useLocation() const [groups, setGroups] = useState<GroupChatInfo[]>([]) const [filteredGroups, setFilteredGroups] = useState<GroupChatInfo[]>([]) const [isLoading, setIsLoading] = useState(true) @@ -58,11 +60,28 @@ function GroupAnalyticsPage() { const [sidebarWidth, setSidebarWidth] = useState(300) const [isResizing, setIsResizing] = useState(false) const containerRef = useRef<HTMLDivElement>(null) + const preselectAppliedRef = useRef(false) + + const preselectGroupIds = useMemo(() => { + const state = location.state as { preselectGroupIds?: unknown; preselectGroupId?: unknown } | null + const rawList = Array.isArray(state?.preselectGroupIds) + ? state.preselectGroupIds + : (typeof state?.preselectGroupId === 'string' ? [state.preselectGroupId] : []) + + return rawList + .filter((item): item is string => typeof item === 'string') + .map(item => item.trim()) + .filter(Boolean) + }, [location.state]) useEffect(() => { loadGroups() }, []) + useEffect(() => { + preselectAppliedRef.current = false + }, [location.key, preselectGroupIds]) + useEffect(() => { if (searchQuery) { setFilteredGroups(groups.filter(g => g.displayName.toLowerCase().includes(searchQuery.toLowerCase()))) @@ -71,6 +90,20 @@ function GroupAnalyticsPage() { } }, [searchQuery, groups]) + useEffect(() => { + if (preselectAppliedRef.current) return + if (groups.length === 0 || preselectGroupIds.length === 0) return + + const matchedGroup = groups.find(group => preselectGroupIds.includes(group.username)) + preselectAppliedRef.current = true + + if (matchedGroup) { + setSelectedGroup(matchedGroup) + setSelectedFunction(null) + setSearchQuery('') + } + }, [groups, preselectGroupIds]) + // 拖动调整宽度 useEffect(() => { const handleMouseMove = (e: MouseEvent) => { From 52b26533a29dd5ae1bdf6bb319e73093cd263f9d Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sat, 7 Feb 2026 00:12:56 +0800 Subject: [PATCH 17/19] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=89=93=E5=BC=80=E7=9A=84=E6=83=85=E5=86=B5=E4=B8=8B?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E6=8B=96=E5=8A=A8=E7=AA=97=E5=8F=A3=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.scss | 11 +++++++++++ src/App.tsx | 1 + 2 files changed, 12 insertions(+) diff --git a/src/App.scss b/src/App.scss index 02be79a..2613fa3 100644 --- a/src/App.scss +++ b/src/App.scss @@ -6,6 +6,17 @@ animation: appFadeIn 0.35s ease-out; } +.window-drag-region { + position: fixed; + top: 0; + left: 0; + right: 150px; // 预留系统最小化/最大化/关闭按钮区域 + height: 40px; + -webkit-app-region: drag; + pointer-events: auto; + z-index: 2000; +} + .main-layout { flex: 1; display: flex; diff --git a/src/App.tsx b/src/App.tsx index 2491727..6bbf9dc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -346,6 +346,7 @@ function App() { // 主窗口 - 完整布局 return ( <div className="app-container"> + <div className="window-drag-region" aria-hidden="true" /> {isLocked && ( <LockScreen onUnlock={() => setLocked(false)} From 5640db9cbd1bd201329b20d04fe4d5450c2e1d26 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sat, 7 Feb 2026 00:44:50 +0800 Subject: [PATCH 18/19] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BE=A4=E8=81=8A?= =?UTF-8?q?=E5=88=86=E6=9E=90=E7=BE=A4=E6=98=B5=E7=A7=B0=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/groupAnalyticsService.ts | 321 ++++++++++++++++++--- 1 file changed, 286 insertions(+), 35 deletions(-) diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index eb6bb68..4886f0e 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -105,19 +105,166 @@ class GroupAnalyticsService { /** * 从 DLL 获取群成员的群昵称 */ - private async getGroupNicknamesForRoom(chatroomId: string): Promise<Map<string, string>> { + private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> { try { - const result = await wcdbService.getGroupNicknames(chatroomId) - if (result.success && result.nicknames) { - return new Map(Object.entries(result.nicknames)) + const escapedChatroomId = chatroomId.replace(/'/g, "''") + const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1` + const result = await wcdbService.execQuery('contact', null, sql) + if (!result.success || !result.rows || result.rows.length === 0) { + return new Map<string, string>() } - return new Map<string, string>() + + const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer) + if (!extBuffer) return new Map<string, string>() + return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates) } catch (e) { console.error('getGroupNicknamesForRoom error:', e) return new Map<string, string>() } } + private looksLikeHex(s: string): boolean { + if (s.length % 2 !== 0) return false + return /^[0-9a-fA-F]+$/.test(s) + } + + private looksLikeBase64(s: string): boolean { + if (s.length % 4 !== 0) return false + return /^[A-Za-z0-9+/=]+$/.test(s) + } + + private decodeExtBuffer(value: unknown): Buffer | null { + if (!value) return null + if (Buffer.isBuffer(value)) return value + if (value instanceof Uint8Array) return Buffer.from(value) + + if (typeof value === 'string') { + const raw = value.trim() + if (!raw) return null + + if (this.looksLikeHex(raw)) { + try { return Buffer.from(raw, 'hex') } catch { } + } + if (this.looksLikeBase64(raw)) { + try { return Buffer.from(raw, 'base64') } catch { } + } + + try { return Buffer.from(raw, 'hex') } catch { } + try { return Buffer.from(raw, 'base64') } catch { } + try { return Buffer.from(raw, 'utf8') } catch { } + return null + } + + return null + } + + private readVarint(buffer: Buffer, offset: number, limit: number = buffer.length): { value: number; next: number } | null { + let value = 0 + let shift = 0 + let pos = offset + while (pos < limit && shift <= 53) { + const byte = buffer[pos] + value += (byte & 0x7f) * Math.pow(2, shift) + pos += 1 + if ((byte & 0x80) === 0) return { value, next: pos } + shift += 7 + } + return null + } + + private isLikelyMemberId(value: string): boolean { + const id = String(value || '').trim() + if (!id) return false + if (id.includes('@chatroom')) return false + if (id.length < 4 || id.length > 80) return false + return /^[A-Za-z][A-Za-z0-9_.@-]*$/.test(id) + } + + private isLikelyNickname(value: string): boolean { + const cleaned = this.normalizeGroupNickname(value) + if (!cleaned) return false + if (/^wxid_[a-z0-9_]+$/i.test(cleaned)) return false + if (cleaned.includes('@chatroom')) return false + if (!/[\u4E00-\u9FFF\u3400-\u4DBF\w]/.test(cleaned)) return false + if (cleaned.length === 1) { + const code = cleaned.charCodeAt(0) + const isCjk = code >= 0x3400 && code <= 0x9fff + if (!isCjk) return false + } + return true + } + + private parseGroupNicknamesFromExtBuffer(buffer: Buffer, candidates: string[] = []): Map<string, string> { + const nicknameMap = new Map<string, string>() + if (!buffer || buffer.length === 0) return nicknameMap + + try { + const candidateSet = new Set(this.buildIdCandidates(candidates).map((id) => id.toLowerCase())) + + for (let i = 0; i < buffer.length - 2; i += 1) { + if (buffer[i] !== 0x0a) continue + + const idLenInfo = this.readVarint(buffer, i + 1) + if (!idLenInfo) continue + const idLen = idLenInfo.value + if (!Number.isFinite(idLen) || idLen <= 0 || idLen > 96) continue + + const idStart = idLenInfo.next + const idEnd = idStart + idLen + if (idEnd > buffer.length) continue + + const memberId = buffer.toString('utf8', idStart, idEnd).trim() + if (!this.isLikelyMemberId(memberId)) continue + + const memberIdLower = memberId.toLowerCase() + if (candidateSet.size > 0 && !candidateSet.has(memberIdLower)) { + i = idEnd - 1 + continue + } + + const cursor = idEnd + if (cursor >= buffer.length || buffer[cursor] !== 0x12) { + i = idEnd - 1 + continue + } + + const nickLenInfo = this.readVarint(buffer, cursor + 1) + if (!nickLenInfo) { + i = idEnd - 1 + continue + } + + const nickLen = nickLenInfo.value + if (!Number.isFinite(nickLen) || nickLen <= 0 || nickLen > 128) { + i = idEnd - 1 + continue + } + + const nickStart = nickLenInfo.next + const nickEnd = nickStart + nickLen + if (nickEnd > buffer.length) { + i = idEnd - 1 + continue + } + + const rawNick = buffer.toString('utf8', nickStart, nickEnd) + const nickname = this.normalizeGroupNickname(rawNick.replace(/[\x00-\x1F\x7F]/g, '').trim()) + if (!this.isLikelyNickname(nickname)) { + i = nickEnd - 1 + continue + } + + if (!nicknameMap.has(memberId)) nicknameMap.set(memberId, nickname) + if (!nicknameMap.has(memberIdLower)) nicknameMap.set(memberIdLower, nickname) + i = nickEnd - 1 + } + } catch (e) { + console.error('Failed to parse chat_room.ext_buffer:', e) + } + + return nicknameMap + } + private escapeCsvValue(value: string): string { if (value == null) return '' const str = String(value) @@ -127,14 +274,54 @@ class GroupAnalyticsService { return str } - private normalizeGroupNickname(value: string, wxid: string, fallback: string): string { + private normalizeGroupNickname(value: string): string { const trimmed = (value || '').trim() - if (!trimmed) return fallback - if (/^["'@]+$/.test(trimmed)) return fallback - if (trimmed.toLowerCase() === (wxid || '').toLowerCase()) return fallback + if (!trimmed) return '' + if (/^["'@]+$/.test(trimmed)) return '' return trimmed } + private buildIdCandidates(values: Array<string | undefined | null>): string[] { + const set = new Set<string>() + for (const rawValue of values) { + const raw = String(rawValue || '').trim() + if (!raw) continue + set.add(raw) + const cleaned = this.cleanAccountDirName(raw) + if (cleaned && cleaned !== raw) { + set.add(cleaned) + } + } + return Array.from(set) + } + + private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string { + const idCandidates = this.buildIdCandidates(candidates) + if (idCandidates.length === 0) return '' + + for (const id of idCandidates) { + const exact = this.normalizeGroupNickname(groupNicknames.get(id) || '') + if (exact) return exact + } + + for (const id of idCandidates) { + const lower = id.toLowerCase() + let found = '' + let matched = 0 + for (const [key, value] of groupNicknames.entries()) { + if (String(key || '').toLowerCase() !== lower) continue + const normalized = this.normalizeGroupNickname(value || '') + if (!normalized) continue + found = normalized + matched += 1 + if (matched > 1) return '' + } + if (matched === 1 && found) return found + } + + return '' + } + private sanitizeWorksheetName(name: string): string { const cleaned = (name || '').replace(/[*?:\\/\\[\\]]/g, '_').trim() const limited = cleaned.slice(0, 31) @@ -219,15 +406,24 @@ class GroupAnalyticsService { return { success: false, error: result.error || '获取群成员失败' } } - const members = result.members as { username: string; avatarUrl?: string }[] + const members = result.members as Array<{ + username: string + avatarUrl?: string + originalName?: string + }> const usernames = members.map((m) => m.username).filter(Boolean) - const [displayNames, groupNicknames] = await Promise.all([ - wcdbService.getDisplayNames(usernames), - this.getGroupNicknamesForRoom(chatroomId) - ]) + const displayNamesPromise = wcdbService.getDisplayNames(usernames) - const contactMap = new Map<string, { remark?: string; nickName?: string; alias?: string }>() + const contactMap = new Map<string, { + remark?: string + nickName?: string + alias?: string + username?: string + userName?: string + encryptUsername?: string + encryptUserName?: string + }>() const concurrency = 6 await this.parallelLimit(usernames, concurrency, async (username) => { const contactResult = await wcdbService.getContact(username) @@ -236,13 +432,29 @@ class GroupAnalyticsService { contactMap.set(username, { remark: contact.remark || '', nickName: contact.nickName || contact.nick_name || '', - alias: contact.alias || '' + alias: contact.alias || '', + username: contact.username || '', + userName: contact.userName || contact.user_name || '', + encryptUsername: contact.encryptUsername || contact.encrypt_username || '', + encryptUserName: contact.encryptUserName || '' }) } else { contactMap.set(username, { remark: '', nickName: '', alias: '' }) } }) + const displayNames = await displayNamesPromise + const nicknameCandidates = this.buildIdCandidates([ + ...members.map((m) => m.username), + ...members.map((m) => m.originalName), + ...Array.from(contactMap.values()).map((c) => c?.username), + ...Array.from(contactMap.values()).map((c) => c?.userName), + ...Array.from(contactMap.values()).map((c) => c?.encryptUsername), + ...Array.from(contactMap.values()).map((c) => c?.encryptUserName), + ...Array.from(contactMap.values()).map((c) => c?.alias) + ]) + const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates) + const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '') const data: GroupMember[] = members.map((m) => { const wxid = m.username || '' @@ -251,13 +463,20 @@ class GroupAnalyticsService { const nickname = contact?.nickName || '' const remark = contact?.remark || '' const alias = contact?.alias || '' - const rawGroupNickname = groupNicknames.get(wxid.toLowerCase()) || '' const normalizedWxid = this.cleanAccountDirName(wxid) - const groupNickname = this.normalizeGroupNickname( - rawGroupNickname, - normalizedWxid === myWxid ? myWxid : wxid, - '' - ) + const lookupCandidates = this.buildIdCandidates([ + wxid, + m.originalName, + contact?.username, + contact?.userName, + contact?.encryptUsername, + contact?.encryptUserName, + alias + ]) + if (normalizedWxid === myWxid) { + lookupCandidates.push(myWxid) + } + const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates) return { username: wxid, @@ -418,18 +637,27 @@ class GroupAnalyticsService { return { success: false, error: membersResult.error || '获取群成员失败' } } - const members = membersResult.members as { username: string; avatarUrl?: string }[] + const members = membersResult.members as Array<{ + username: string + avatarUrl?: string + originalName?: string + }> if (members.length === 0) { return { success: false, error: '群成员为空' } } const usernames = members.map((m) => m.username).filter(Boolean) - const [displayNames, groupNicknames] = await Promise.all([ - wcdbService.getDisplayNames(usernames), - this.getGroupNicknamesForRoom(chatroomId) - ]) + const displayNamesPromise = wcdbService.getDisplayNames(usernames) - const contactMap = new Map<string, { remark?: string; nickName?: string; alias?: string }>() + const contactMap = new Map<string, { + remark?: string + nickName?: string + alias?: string + username?: string + userName?: string + encryptUsername?: string + encryptUserName?: string + }>() const concurrency = 6 await this.parallelLimit(usernames, concurrency, async (username) => { const result = await wcdbService.getContact(username) @@ -438,7 +666,11 @@ class GroupAnalyticsService { contactMap.set(username, { remark: contact.remark || '', nickName: contact.nickName || contact.nick_name || '', - alias: contact.alias || '' + alias: contact.alias || '', + username: contact.username || '', + userName: contact.userName || contact.user_name || '', + encryptUsername: contact.encryptUsername || contact.encrypt_username || '', + encryptUserName: contact.encryptUserName || '' }) } else { contactMap.set(username, { remark: '', nickName: '', alias: '' }) @@ -453,6 +685,18 @@ class GroupAnalyticsService { const rows: string[][] = [infoTitleRow, infoRow, metaRow, header] const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '') + const displayNames = await displayNamesPromise + const nicknameCandidates = this.buildIdCandidates([ + ...members.map((m) => m.username), + ...members.map((m) => m.originalName), + ...Array.from(contactMap.values()).map((c) => c?.username), + ...Array.from(contactMap.values()).map((c) => c?.userName), + ...Array.from(contactMap.values()).map((c) => c?.encryptUsername), + ...Array.from(contactMap.values()).map((c) => c?.encryptUserName), + ...Array.from(contactMap.values()).map((c) => c?.alias) + ]) + const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates) + for (const member of members) { const wxid = member.username const normalizedWxid = this.cleanAccountDirName(wxid || '') @@ -460,13 +704,20 @@ class GroupAnalyticsService { const fallbackName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || '') : '' const nickName = contact?.nickName || fallbackName || '' const remark = contact?.remark || '' - const rawGroupNickname = groupNicknames.get(wxid.toLowerCase()) || '' const alias = contact?.alias || '' - const groupNickname = this.normalizeGroupNickname( - rawGroupNickname, - normalizedWxid === myWxid ? myWxid : wxid, - '' - ) + const lookupCandidates = this.buildIdCandidates([ + wxid, + member.originalName, + contact?.username, + contact?.userName, + contact?.encryptUsername, + contact?.encryptUserName, + alias + ]) + if (normalizedWxid === myWxid) { + lookupCandidates.push(myWxid) + } + const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates) rows.push([nickName, remark, groupNickname, wxid, alias]) } From 004ee5bbf03f6df8892c40530e0c1c801b670f1c Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sat, 7 Feb 2026 00:52:49 +0800 Subject: [PATCH 19/19] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E7=BE=A4=E6=98=B5=E7=A7=B0=E9=94=99=E8=AF=AF=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportService.ts | 234 ++++++++++++++++++++++++++--- 1 file changed, 214 insertions(+), 20 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 0dcb97f..ef19b1f 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -213,21 +213,149 @@ class ExportService { } /** - * 从 DLL 获取群成员的群昵称 + * 通过 contact.chat_room.ext_buffer 解析群昵称(纯 SQL) */ - async getGroupNicknamesForRoom(chatroomId: string): Promise<Map<string, string>> { + async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> { try { - const result = await wcdbService.getGroupNicknames(chatroomId) - if (result.success && result.nicknames) { - return new Map(Object.entries(result.nicknames)) + const escapedChatroomId = chatroomId.replace(/'/g, "''") + const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1` + const result = await wcdbService.execQuery('contact', null, sql) + if (!result.success || !result.rows || result.rows.length === 0) { + return new Map<string, string>() } - return new Map<string, string>() + + const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer) + if (!extBuffer) return new Map<string, string>() + return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates) } catch (e) { console.error('getGroupNicknamesForRoom error:', e) return new Map<string, string>() } } + private decodeExtBuffer(value: unknown): Buffer | null { + if (!value) return null + if (Buffer.isBuffer(value)) return value + if (value instanceof Uint8Array) return Buffer.from(value) + + if (typeof value === 'string') { + const raw = value.trim() + if (!raw) return null + + if (this.looksLikeHex(raw)) { + try { return Buffer.from(raw, 'hex') } catch { } + } + if (this.looksLikeBase64(raw)) { + try { return Buffer.from(raw, 'base64') } catch { } + } + + try { return Buffer.from(raw, 'hex') } catch { } + try { return Buffer.from(raw, 'base64') } catch { } + try { return Buffer.from(raw, 'utf8') } catch { } + return null + } + + return null + } + + private readVarint(buffer: Buffer, offset: number, limit: number = buffer.length): { value: number; next: number } | null { + let value = 0 + let shift = 0 + let pos = offset + while (pos < limit && shift <= 53) { + const byte = buffer[pos] + value += (byte & 0x7f) * Math.pow(2, shift) + pos += 1 + if ((byte & 0x80) === 0) return { value, next: pos } + shift += 7 + } + return null + } + + private isLikelyGroupMemberId(value: string): boolean { + const id = String(value || '').trim() + if (!id) return false + if (id.includes('@chatroom')) return false + if (id.length < 4 || id.length > 80) return false + return /^[A-Za-z][A-Za-z0-9_.@-]*$/.test(id) + } + + private parseGroupNicknamesFromExtBuffer(buffer: Buffer, candidates: string[] = []): Map<string, string> { + const nicknameMap = new Map<string, string>() + if (!buffer || buffer.length === 0) return nicknameMap + + try { + const candidateSet = new Set(this.buildGroupNicknameIdCandidates(candidates).map((id) => id.toLowerCase())) + + for (let i = 0; i < buffer.length - 2; i += 1) { + if (buffer[i] !== 0x0a) continue + + const idLenInfo = this.readVarint(buffer, i + 1) + if (!idLenInfo) continue + const idLen = idLenInfo.value + if (!Number.isFinite(idLen) || idLen <= 0 || idLen > 96) continue + + const idStart = idLenInfo.next + const idEnd = idStart + idLen + if (idEnd > buffer.length) continue + + const memberId = buffer.toString('utf8', idStart, idEnd).trim() + if (!this.isLikelyGroupMemberId(memberId)) continue + + const memberIdLower = memberId.toLowerCase() + if (candidateSet.size > 0 && !candidateSet.has(memberIdLower)) { + i = idEnd - 1 + continue + } + + const cursor = idEnd + if (cursor >= buffer.length || buffer[cursor] !== 0x12) { + i = idEnd - 1 + continue + } + + const nickLenInfo = this.readVarint(buffer, cursor + 1) + if (!nickLenInfo) { + i = idEnd - 1 + continue + } + const nickLen = nickLenInfo.value + if (!Number.isFinite(nickLen) || nickLen <= 0 || nickLen > 128) { + i = idEnd - 1 + continue + } + + const nickStart = nickLenInfo.next + const nickEnd = nickStart + nickLen + if (nickEnd > buffer.length) { + i = idEnd - 1 + continue + } + + const rawNick = buffer.toString('utf8', nickStart, nickEnd) + const nickname = this.normalizeGroupNickname(rawNick.replace(/[\x00-\x1F\x7F]/g, '').trim()) + if (!nickname) { + i = nickEnd - 1 + continue + } + + const aliases = this.buildGroupNicknameIdCandidates([memberId]) + for (const alias of aliases) { + if (!alias) continue + if (!nicknameMap.has(alias)) nicknameMap.set(alias, nickname) + const lower = alias.toLowerCase() + if (!nicknameMap.has(lower)) nicknameMap.set(lower, nickname) + } + + i = nickEnd - 1 + } + } catch (e) { + console.error('Failed to parse chat_room.ext_buffer in exportService:', e) + } + + return nicknameMap + } + /** * 转换微信消息类型到 ChatLab 类型 */ @@ -329,6 +457,47 @@ class ExportService { return cleaned } + private buildGroupNicknameIdCandidates(values: Array<string | undefined | null>): string[] { + const set = new Set<string>() + for (const rawValue of values) { + const raw = String(rawValue || '').trim() + if (!raw) continue + set.add(raw) + const cleaned = this.cleanAccountDirName(raw) + if (cleaned && cleaned !== raw) set.add(cleaned) + } + return Array.from(set) + } + + private resolveGroupNicknameByCandidates(groupNicknamesMap: Map<string, string>, candidates: Array<string | undefined | null>): string { + const idCandidates = this.buildGroupNicknameIdCandidates(candidates) + if (idCandidates.length === 0) return '' + + for (const id of idCandidates) { + const exact = this.normalizeGroupNickname(groupNicknamesMap.get(id) || '') + if (exact) return exact + const lower = this.normalizeGroupNickname(groupNicknamesMap.get(id.toLowerCase()) || '') + if (lower) return lower + } + + for (const id of idCandidates) { + const lower = id.toLowerCase() + let found = '' + let matched = 0 + for (const [key, value] of groupNicknamesMap.entries()) { + if (String(key || '').toLowerCase() !== lower) continue + const normalized = this.normalizeGroupNickname(value || '') + if (!normalized) continue + found = normalized + matched += 1 + if (matched > 1) return '' + } + if (matched === 1 && found) return found + } + + return '' + } + /** * 根据用户偏好获取显示名称 */ @@ -377,12 +546,12 @@ class ExportService { const resolveName = async (username: string): Promise<string> => { // 当前用户自己 if (myWxid && (username === myWxid || username === cleanedMyWxid)) { - const groupNick = groupNicknamesMap.get(username) || groupNicknamesMap.get(username.toLowerCase()) + const groupNick = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [username, myWxid, cleanedMyWxid]) if (groupNick) return groupNick return '我' } // 群昵称 - const groupNick = groupNicknamesMap.get(username) || groupNicknamesMap.get(username.toLowerCase()) + const groupNick = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [username]) if (groupNick) return groupNick // 联系人名称 return getContactName(username) @@ -1992,17 +2161,22 @@ class ExportService { } // ========== 获取群昵称并更新到 memberSet ========== + const groupNicknameCandidates = isGroup + ? this.buildGroupNicknameIdCandidates([ + ...Array.from(collected.memberSet.keys()), + ...allMessages.map(msg => msg.senderUsername), + cleanedMyWxid + ]) + : [] const groupNicknamesMap = isGroup - ? await this.getGroupNicknamesForRoom(sessionId) + ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates) : new Map<string, string>() // 将群昵称更新到 memberSet 中 if (isGroup && groupNicknamesMap.size > 0) { for (const [username, info] of collected.memberSet) { // 尝试多种方式查找群昵称(支持大小写) - const groupNickname = groupNicknamesMap.get(username) - || groupNicknamesMap.get(username.toLowerCase()) - || '' + const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [username]) || '' if (groupNickname) { info.member.groupNickname = groupNickname } @@ -2117,8 +2291,8 @@ class ExportService { } // 如果 memberInfo 中没有群昵称,尝试从 groupNicknamesMap 获取 - const groupNickname = memberInfo.groupNickname - || (isGroup ? (groupNicknamesMap.get(msg.senderUsername) || groupNicknamesMap.get(msg.senderUsername?.toLowerCase()) || '') : '') + const groupNickname = memberInfo.groupNickname + || (isGroup ? this.resolveGroupNicknameByCandidates(groupNicknamesMap, [msg.senderUsername]) : '') || '' // 确定消息内容 @@ -2463,8 +2637,15 @@ class ExportService { } // ========== 预加载群昵称(用于名称显示偏好) ========== + const groupNicknameCandidates = isGroup + ? this.buildGroupNicknameIdCandidates([ + ...Array.from(senderUsernames.values()), + ...collected.rows.map(msg => msg.senderUsername), + cleanedMyWxid + ]) + : [] const groupNicknamesMap = isGroup - ? await this.getGroupNicknamesForRoom(sessionId) + ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates) : new Map<string, string>() // ========== 阶段3:构建消息列表 ========== @@ -2519,7 +2700,7 @@ class ExportService { ? contact.contact.nickName : (senderInfo.displayName || senderWxid) const senderRemark = contact.success && contact.contact?.remark ? contact.contact.remark : '' - const senderGroupNickname = this.normalizeGroupNickname(groupNicknamesMap.get(senderWxid?.toLowerCase() || '') || '') + const senderGroupNickname = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [senderWxid]) // 使用用户偏好的显示名称 const senderDisplayName = this.getPreferredDisplayName( @@ -2565,7 +2746,7 @@ class ExportService { ? sessionContact.contact.remark : '' const sessionGroupNickname = isGroup - ? this.normalizeGroupNickname(groupNicknamesMap.get(sessionId.toLowerCase()) || '') + ? this.resolveGroupNicknameByCandidates(groupNicknamesMap, [sessionId]) : '' // 使用用户偏好的显示名称 @@ -2805,8 +2986,14 @@ class ExportService { } // 预加载群昵称 (仅群聊且完整列模式) + const groupNicknameCandidates = (isGroup && !useCompactColumns) + ? this.buildGroupNicknameIdCandidates([ + ...collected.rows.map(msg => msg.senderUsername), + cleanedMyWxid + ]) + : [] const groupNicknamesMap = (isGroup && !useCompactColumns) - ? await this.getGroupNicknamesForRoom(sessionId) + ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates) : new Map<string, string>() @@ -2960,7 +3147,7 @@ class ExportService { // 获取群昵称 (仅群聊且完整列模式) if (isGroup && !useCompactColumns && senderWxid) { - senderGroupNickname = this.normalizeGroupNickname(groupNicknamesMap.get(senderWxid.toLowerCase()) || '') + senderGroupNickname = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [senderWxid]) } @@ -3170,8 +3357,15 @@ class ExportService { await this.preloadContacts(senderUsernames, contactCache) // 获取群昵称(用于转账描述等) + const groupNicknameCandidates = isGroup + ? this.buildGroupNicknameIdCandidates([ + ...Array.from(senderUsernames.values()), + ...collected.rows.map(msg => msg.senderUsername), + cleanedMyWxid + ]) + : [] const groupNicknamesMap = isGroup - ? await this.getGroupNicknamesForRoom(sessionId) + ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates) : new Map<string, string>() const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)