From 1eab8354588d0245bf9f1e3dbd78f9369e60c840 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 17:28:14 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dapi=20limit/chatlab/keywo?= =?UTF-8?q?rd=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 4d1632a9b9da30b3dc4efdc12a87d5fa74f8b0ff Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 17:42:42 +0800 Subject: [PATCH 2/7] =?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 54378a132f289c9d4ddbb6fce23301004e2e77d8 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 17:57:39 +0800 Subject: [PATCH 3/7] =?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 9cb0ada1b7ba398cbd3c5ac20a1b3635d4c54541 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 18:05:51 +0800 Subject: [PATCH 4/7] =?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 a54c95b6ac8822fd57701786ae2dc74c6027b823 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 18:25:48 +0800 Subject: [PATCH 5/7] =?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 9c76aa218963ea8df3cae93f745cbace3a8dcfaa Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 18:52:52 +0800 Subject: [PATCH 6/7] =?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 b156a08f0def647f2b743c05337938ae621433ab Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 19:14:49 +0800 Subject: [PATCH 7/7] =?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) {