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..0d88680 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -117,10 +117,13 @@ class ChatService { private voiceWavCache = new Map() private voiceTranscriptCache = new Map() private voiceTranscriptPending = new Map>() + private transcriptCacheLoaded = false + private transcriptCacheDirty = false + private transcriptFlushTimer: ReturnType | 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"有幸"拍了拍"浩天空"相信未来!... */ 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, '') // 移除长数字 @@ -3498,6 +3514,8 @@ class ChatService { ): Promise<{ success: boolean; transcript?: string; error?: string }> { const startTime = Date.now() + // 确保磁盘缓存已加载 + this.loadTranscriptCacheIfNeeded() try { let msgCreateTime = createTime @@ -3625,18 +3643,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/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/electron/services/exportService.ts b/electron/services/exportService.ts index 82864e1..ef19b1f 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 数量 @@ -210,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 类型 */ @@ -326,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 '' + } + /** * 根据用户偏好获取显示名称 */ @@ -374,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) @@ -701,7 +873,7 @@ class ExportService { return revokeMatch[1].trim() } - // 3. 提取 pat 拍一拍消息 + // 3. 提取 pat 拍一拍消息(sysmsg 内的 template 格式) const patMatch = /<template><!\[CDATA\[(.*?)\]\]><\/template>/i.exec(content) if (patMatch) { // 移除模板变量占位符 @@ -714,6 +886,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, '') @@ -847,16 +1028,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 +1192,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 +1332,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 +1965,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 +1983,7 @@ class ExportService { mime = downloaded.mime || mime } } - if (!data) continue + if (!data) return // 优先使用内容检测出的 MIME 类型 const detectedMime = this.detectMimeType(data) @@ -1805,15 +2000,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 } @@ -1962,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 } @@ -2001,11 +2205,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 +2226,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 +2249,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}` + }) }) } @@ -2057,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]) : '') || '' // 确定消息内容 @@ -2335,10 +2569,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 +2589,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,19 +2612,40 @@ 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}` + }) }) } // ========== 预加载群昵称(用于名称显示偏好) ========== + 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:构建消息列表 ========== @@ -2429,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( @@ -2475,7 +2746,7 @@ class ExportService { ? sessionContact.contact.remark : '' const sessionGroupNickname = isGroup - ? this.normalizeGroupNickname(groupNicknamesMap.get(sessionId.toLowerCase()) || '') + ? this.resolveGroupNicknameByCandidates(groupNicknamesMap, [sessionId]) : '' // 使用用户偏好的显示名称 @@ -2715,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>() @@ -2744,10 +3021,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 +3041,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 +3064,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}` + }) }) } @@ -2840,7 +3147,7 @@ class ExportService { // 获取群昵称 (仅群聊且完整列模式) if (isGroup && !useCompactColumns && senderWxid) { - senderGroupNickname = this.normalizeGroupNickname(groupNicknamesMap.get(senderWxid.toLowerCase()) || '') + senderGroupNickname = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [senderWxid]) } @@ -3050,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) @@ -3074,10 +3388,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 +3408,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 +3430,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 +3575,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 +3716,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 +3737,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 +3763,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 +3834,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 +3863,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 +3892,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 +3927,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 +3938,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 +3969,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 +3993,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 +4008,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 +4022,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 +4063,6 @@ class ExportService { }) updateCount() - console.log('WeFlow Export Loaded', allData.length); </script> </body> </html>`); @@ -3811,6 +4085,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 +4195,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 +4229,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/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]) } 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 bcfdce9..6bbf9dc 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() @@ -345,6 +346,7 @@ function App() { // 主窗口 - 完整布局 return ( <div className="app-container"> + <div className="window-drag-region" aria-hidden="true" /> {isLocked && ( <LockScreen onUnlock={() => setLocked(false)} @@ -360,6 +362,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..51932be --- /dev/null +++ b/src/components/BatchTranscribeGlobal.tsx @@ -0,0 +1,102 @@ +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, + sessionName, + 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>批量转写中{sessionName ? `(${sessionName})` : ''}</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..c5d40a7 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,7 +1,9 @@ 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, Download, BarChart3 } from 'lucide-react' +import { useNavigate } from 'react-router-dom' 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' @@ -116,6 +118,8 @@ const SessionItem = React.memo(function SessionItem({ function ChatPage(_props: ChatPageProps) { + const navigate = useNavigate() + const { isConnected, isConnecting, @@ -175,17 +179,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) @@ -1231,7 +1231,7 @@ function ChatPage(_props: ChatPageProps) { return } - const voiceMessages = result.messages + const voiceMessages: Message[] = result.messages if (voiceMessages.length === 0) { alert('当前会话没有语音消息') return @@ -1248,6 +1248,24 @@ function ChatPage(_props: ChatPageProps) { setShowBatchConfirm(true) }, [sessions, currentSessionId, isBatchTranscribing]) + const handleExportCurrentSession = useCallback(() => { + if (!currentSessionId) return + navigate('/export', { + state: { + preselectSessionIds: [currentSessionId] + } + }) + }, [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 @@ -1280,16 +1298,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, session.displayName || session.username) // 检查模型状态 const modelStatus = await window.electronAPI.whisper.getModelStatus() if (!modelStatus?.exists) { alert('SenseVoice 模型未下载,请先在设置中下载模型') - setIsBatchTranscribing(false) - setShowBatchProgress(false) + finishTranscribe(0, 0) return } @@ -1319,15 +1334,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(() => { @@ -1474,11 +1486,34 @@ 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 batch-transcribe-btn" - onClick={handleBatchTranscribe} - disabled={isBatchTranscribing || !currentSessionId} - title={isBatchTranscribing ? `批量转写中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total})` : '批量语音转文字'} + 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={() => { + if (isBatchTranscribing) { + setShowBatchProgress(true) + } else { + handleBatchTranscribe() + } + }} + disabled={!currentSessionId} + title={isBatchTranscribing ? `批量转写中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total}),点击查看进度` : '批量语音转文字'} > {isBatchTranscribing ? ( <Loader2 size={18} className="spin" /> @@ -1813,84 +1848,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/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index c94f12f..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[]>([]) @@ -46,14 +48,36 @@ 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 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', @@ -175,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()) @@ -189,17 +231,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 +333,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 +379,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 +903,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 +977,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> )} 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) => { diff --git a/src/stores/batchTranscribeStore.ts b/src/stores/batchTranscribeStore.ts new file mode 100644 index 0000000..b96f085 --- /dev/null +++ b/src/stores/batchTranscribeStore.ts @@ -0,0 +1,65 @@ +import { create } from 'zustand' + +export interface BatchTranscribeState { + /** 是否正在批量转写 */ + isBatchTranscribing: boolean + /** 转写进度 */ + progress: { current: number; total: number } + /** 是否显示进度浮窗 */ + showToast: boolean + /** 是否显示结果弹窗 */ + showResult: boolean + /** 转写结果 */ + result: { success: number; fail: number } + /** 当前转写的会话名 */ + sessionName: string + + // Actions + startTranscribe: (total: number, sessionName: string) => 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 }, + sessionName: '', + + startTranscribe: (total, sessionName) => set({ + isBatchTranscribing: true, + showToast: true, + progress: { current: 0, total }, + showResult: false, + result: { success: 0, fail: 0 }, + sessionName + }), + + 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 }, + sessionName: '' + }) +})) 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; } + } + } + } +} 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 {