优化了html导出

This commit is contained in:
xuncha
2026-02-06 23:09:20 +08:00
parent fe0e2e6592
commit 63ac715792
5 changed files with 488 additions and 255 deletions

View File

@@ -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) => { ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
const onProgress = (progress: ExportProgress) => { const onProgress = (progress: ExportProgress) => {
if (!event.sender.isDestroyed()) { if (!event.sender.isDestroyed()) {

View File

@@ -239,6 +239,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 导出 // 导出
export: { export: {
getExportStats: (sessionIds: string[], options: any) =>
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
exportSessions: (sessionIds: string[], outputDir: string, options: any) => exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options), ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
exportSession: (sessionId: string, outputPath: string, options: any) => exportSession: (sessionId: string, outputPath: string, options: any) =>

View File

@@ -117,10 +117,13 @@ class ChatService {
private voiceWavCache = new Map<string, Buffer>() private voiceWavCache = new Map<string, Buffer>()
private voiceTranscriptCache = new Map<string, string>() private voiceTranscriptCache = new Map<string, string>()
private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>() private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>()
private transcriptCacheLoaded = false
private transcriptCacheDirty = false
private transcriptFlushTimer: ReturnType<typeof setTimeout> | null = null
private mediaDbsCache: string[] | null = null private mediaDbsCache: string[] | null = null
private mediaDbsCacheTime = 0 private mediaDbsCacheTime = 0
private readonly mediaDbsCacheTtl = 300000 // 5分钟 private readonly mediaDbsCacheTtl = 300000 // 5分钟
private readonly voiceCacheMaxEntries = 50 private readonly voiceWavCacheMaxEntries = 50
// 缓存 media.db 的表结构信息 // 缓存 media.db 的表结构信息
private mediaDbSchemaCache = new Map<string, { private mediaDbSchemaCache = new Map<string, {
voiceTable: string voiceTable: string
@@ -3498,6 +3501,8 @@ class ChatService {
): Promise<{ success: boolean; transcript?: string; error?: string }> { ): Promise<{ success: boolean; transcript?: string; error?: string }> {
const startTime = Date.now() const startTime = Date.now()
// 确保磁盘缓存已加载
this.loadTranscriptCacheIfNeeded()
try { try {
let msgCreateTime = createTime let msgCreateTime = createTime
@@ -3625,18 +3630,76 @@ class ChatService {
private cacheVoiceWav(cacheKey: string, wavData: Buffer): void { private cacheVoiceWav(cacheKey: string, wavData: Buffer): void {
this.voiceWavCache.set(cacheKey, wavData) this.voiceWavCache.set(cacheKey, wavData)
if (this.voiceWavCache.size > this.voiceCacheMaxEntries) { if (this.voiceWavCache.size > this.voiceWavCacheMaxEntries) {
const oldestKey = this.voiceWavCache.keys().next().value const oldestKey = this.voiceWavCache.keys().next().value
if (oldestKey) this.voiceWavCache.delete(oldestKey) 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 { private cacheVoiceTranscript(cacheKey: string, transcript: string): void {
this.voiceTranscriptCache.set(cacheKey, transcript) this.voiceTranscriptCache.set(cacheKey, transcript)
if (this.voiceTranscriptCache.size > this.voiceCacheMaxEntries) { this.transcriptCacheDirty = true
const oldestKey = this.voiceTranscriptCache.keys().next().value this.scheduleTranscriptFlush()
if (oldestKey) this.voiceTranscriptCache.delete(oldestKey) }
}
/**
* 检查某个语音消息是否已有缓存的转写结果
*/
hasTranscriptCache(sessionId: string, msgId: string, createTime?: number): boolean {
this.loadTranscriptCacheIfNeeded()
const cacheKey = this.getVoiceCacheKey(sessionId, msgId, createTime)
return this.voiceTranscriptCache.has(cacheKey)
} }
/** /**

View File

@@ -106,6 +106,9 @@ export interface ExportProgress {
total: number total: number
currentSession: string currentSession: string
phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete' phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete'
phaseProgress?: number
phaseTotal?: number
phaseLabel?: string
} }
// 并发控制:限制同时执行的 Promise 数量 // 并发控制:限制同时执行的 Promise 数量
@@ -847,16 +850,30 @@ class ExportService {
} }
private escapeHtml(value: string): string { private escapeHtml(value: string): string {
return value return value.replace(/[&<>"']/g, c => {
.replace(/&/g, '&amp;') switch (c) {
.replace(/</g, '&lt;') case '&': return '&amp;'
.replace(/>/g, '&gt;') case '<': return '&lt;'
.replace(/"/g, '&quot;') case '>': return '&gt;'
.replace(/'/g, '&#39;') case '"': return '&quot;'
case "'": return '&#39;'
default: return c
}
})
} }
private escapeAttribute(value: string): string { private escapeAttribute(value: string): string {
return this.escapeHtml(value).replace(/`/g, '&#96;') return value.replace(/[&<>"'`]/g, c => {
switch (c) {
case '&': return '&amp;'
case '<': return '&lt;'
case '>': return '&gt;'
case '"': return '&quot;'
case "'": return '&#39;'
case '`': return '&#96;'
default: return c
}
})
} }
private getAvatarFallback(name: string): string { private getAvatarFallback(name: string): string {
@@ -997,7 +1014,9 @@ class ExportService {
if (index % 2 === 1) { if (index % 2 === 1) {
const emojiDataUrl = this.getInlineEmojiDataUrl(part) const emojiDataUrl = this.getInlineEmojiDataUrl(part)
if (emojiDataUrl) { 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}]`) return this.escapeHtml(`[${part}]`)
} }
@@ -1135,22 +1154,19 @@ class ExportService {
} }
// 复制文件 // 复制文件
if (fs.existsSync(sourcePath)) { if (!fs.existsSync(sourcePath)) return null
const ext = path.extname(sourcePath) || '.jpg' const ext = path.extname(sourcePath) || '.jpg'
const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}` const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}`
const destPath = path.join(imagesDir, fileName) const destPath = path.join(imagesDir, fileName)
if (!fs.existsSync(destPath)) { if (!fs.existsSync(destPath)) {
fs.copyFileSync(sourcePath, destPath) fs.copyFileSync(sourcePath, destPath)
}
return {
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
kind: 'image'
}
} }
return null return {
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
kind: 'image'
}
} catch (e) { } catch (e) {
return null return null
} }
@@ -1771,9 +1787,10 @@ class ExportService {
fs.mkdirSync(avatarsDir, { recursive: true }) 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) const fileInfo = this.resolveAvatarFile(member.avatarUrl)
if (!fileInfo) continue if (!fileInfo) return
try { try {
let data: Buffer | null = null let data: Buffer | null = null
let mime = fileInfo.mime let mime = fileInfo.mime
@@ -1788,7 +1805,7 @@ class ExportService {
mime = downloaded.mime || mime mime = downloaded.mime || mime
} }
} }
if (!data) continue if (!data) return
// 优先使用内容检测出的 MIME 类型 // 优先使用内容检测出的 MIME 类型
const detectedMime = this.detectMimeType(data) const detectedMime = this.detectMimeType(data)
@@ -1805,15 +1822,19 @@ class ExportService {
const filename = `${sanitizedUsername}${ext}` const filename = `${sanitizedUsername}${ext}`
const avatarPath = path.join(avatarsDir, filename) 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}`) result.set(member.username, `avatars/${filename}`)
} catch { } catch {
continue return
} }
} })
return result return result
} }
@@ -2001,11 +2022,15 @@ class ExportService {
current: 20, current: 20,
total: 100, total: 100,
currentSession: sessionInfo.displayName, currentSession: sessionInfo.displayName,
phase: 'exporting-media' phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: `导出媒体 0/${mediaMessages.length}`
}) })
// 并行导出媒体,并发数跟随导出设置 // 并行导出媒体,并发数跟随导出设置
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
let mediaExported = 0
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
const mediaKey = `${msg.localType}_${msg.localId}` const mediaKey = `${msg.localType}_${msg.localId}`
if (!mediaCache.has(mediaKey)) { if (!mediaCache.has(mediaKey)) {
@@ -2018,6 +2043,18 @@ class ExportService {
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
} }
mediaExported++
if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
onProgress?.({
current: 20,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: mediaExported,
phaseTotal: mediaMessages.length,
phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`
})
}
}) })
} }
@@ -2029,14 +2066,28 @@ class ExportService {
current: 40, current: 40,
total: 100, total: 100,
currentSession: sessionInfo.displayName, currentSession: sessionInfo.displayName,
phase: 'exporting-voice' phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`
}) })
// 并行转写语音,限制 4 个并发(转写比较耗资源) // 并行转写语音,限制 4 个并发(转写比较耗资源)
const VOICE_CONCURRENCY = 4 const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(msg.localId, transcript) voiceTranscriptMap.set(msg.localId, transcript)
voiceTranscribed++
onProgress?.({
current: 40,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: voiceTranscribed,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
})
}) })
} }
@@ -2335,10 +2386,14 @@ class ExportService {
current: 15, current: 15,
total: 100, total: 100,
currentSession: sessionInfo.displayName, currentSession: sessionInfo.displayName,
phase: 'exporting-media' phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: `导出媒体 0/${mediaMessages.length}`
}) })
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
let mediaExported = 0
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
const mediaKey = `${msg.localType}_${msg.localId}` const mediaKey = `${msg.localType}_${msg.localId}`
if (!mediaCache.has(mediaKey)) { if (!mediaCache.has(mediaKey)) {
@@ -2351,6 +2406,18 @@ class ExportService {
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
} }
mediaExported++
if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
onProgress?.({
current: 15,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: mediaExported,
phaseTotal: mediaMessages.length,
phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`
})
}
}) })
} }
@@ -2362,13 +2429,27 @@ class ExportService {
current: 35, current: 35,
total: 100, total: 100,
currentSession: sessionInfo.displayName, currentSession: sessionInfo.displayName,
phase: 'exporting-voice' phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`
}) })
const VOICE_CONCURRENCY = 4 const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(msg.localId, transcript) voiceTranscriptMap.set(msg.localId, transcript)
voiceTranscribed++
onProgress?.({
current: 35,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: voiceTranscribed,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
})
}) })
} }
@@ -2744,10 +2825,14 @@ class ExportService {
current: 35, current: 35,
total: 100, total: 100,
currentSession: sessionInfo.displayName, currentSession: sessionInfo.displayName,
phase: 'exporting-media' phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: `导出媒体 0/${mediaMessages.length}`
}) })
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
let mediaExported = 0
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
const mediaKey = `${msg.localType}_${msg.localId}` const mediaKey = `${msg.localType}_${msg.localId}`
if (!mediaCache.has(mediaKey)) { if (!mediaCache.has(mediaKey)) {
@@ -2760,6 +2845,18 @@ class ExportService {
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
} }
mediaExported++
if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
onProgress?.({
current: 35,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: mediaExported,
phaseTotal: mediaMessages.length,
phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`
})
}
}) })
} }
@@ -2771,13 +2868,27 @@ class ExportService {
current: 50, current: 50,
total: 100, total: 100,
currentSession: sessionInfo.displayName, currentSession: sessionInfo.displayName,
phase: 'exporting-voice' phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`
}) })
const VOICE_CONCURRENCY = 4 const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(msg.localId, transcript) voiceTranscriptMap.set(msg.localId, transcript)
voiceTranscribed++
onProgress?.({
current: 50,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: voiceTranscribed,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
})
}) })
} }
@@ -3074,10 +3185,14 @@ class ExportService {
current: 25, current: 25,
total: 100, total: 100,
currentSession: sessionInfo.displayName, currentSession: sessionInfo.displayName,
phase: 'exporting-media' phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: `导出媒体 0/${mediaMessages.length}`
}) })
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
let mediaExported = 0
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
const mediaKey = `${msg.localType}_${msg.localId}` const mediaKey = `${msg.localType}_${msg.localId}`
if (!mediaCache.has(mediaKey)) { if (!mediaCache.has(mediaKey)) {
@@ -3090,6 +3205,18 @@ class ExportService {
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
} }
mediaExported++
if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
onProgress?.({
current: 25,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: mediaExported,
phaseTotal: mediaMessages.length,
phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`
})
}
}) })
} }
@@ -3100,13 +3227,27 @@ class ExportService {
current: 45, current: 45,
total: 100, total: 100,
currentSession: sessionInfo.displayName, currentSession: sessionInfo.displayName,
phase: 'exporting-voice' phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`
}) })
const VOICE_CONCURRENCY = 4 const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(msg.localId, transcript) voiceTranscriptMap.set(msg.localId, transcript)
voiceTranscribed++
onProgress?.({
current: 45,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: voiceTranscribed,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
})
}) })
} }
@@ -3231,158 +3372,83 @@ class ExportService {
private getVirtualScrollScript(): string { private getVirtualScrollScript(): string {
return ` return `
class VirtualScroller { class ChunkedRenderer {
constructor(container, list, data, renderItem) { constructor(container, data, renderItem) {
this.container = container; this.container = container;
this.list = list;
this.data = data; this.data = data;
this.renderItem = renderItem; this.renderItem = renderItem;
this.batchSize = 100;
this.rendered = 0;
this.loading = false;
this.rowHeight = 80; // Estimated height this.list = document.createElement('div');
this.buffer = 5; this.list.className = 'message-list';
this.heightCache = new Map(); this.container.appendChild(this.list);
this.visibleItems = new Set();
this.spacer = document.createElement('div'); this.sentinel = document.createElement('div');
this.spacer.className = 'virtual-scroll-spacer'; this.sentinel.className = 'load-sentinel';
this.content = document.createElement('div'); this.container.appendChild(this.sentinel);
this.content.className = 'virtual-scroll-content';
this.container.appendChild(this.spacer); this.renderBatch();
this.container.appendChild(this.content);
this.container.addEventListener('scroll', () => this.onScroll()); this.observer = new IntersectionObserver((entries) => {
window.addEventListener('resize', () => this.onScroll()); if (entries[0].isIntersecting && !this.loading) {
this.renderBatch();
}
}, { root: this.container, rootMargin: '600px' });
this.observer.observe(this.sentinel);
}
this.updateTotalHeight(); renderBatch() {
this.onScroll(); 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) { setData(newData) {
this.data = newData; this.data = newData;
this.heightCache.clear(); this.rendered = 0;
this.content.innerHTML = ''; this.list.innerHTML = '';
this.container.scrollTop = 0; this.container.scrollTop = 0;
this.updateTotalHeight();
this.onScroll();
// Show/Hide empty state
if (this.data.length === 0) { 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) { scrollToTime(timestamp) {
const idx = this.data.findIndex(item => item.ts >= timestamp); const idx = this.data.findIndex(item => item.t >= timestamp);
if (idx !== -1) { if (idx === -1) return;
this.scrollToIndex(idx); // 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) { scrollToIndex(index) {
let top = 0; while (this.rendered <= index) {
for(let i=0; i<index; i++) { this.renderBatch();
top += this.heightCache.get(i) || this.rowHeight; }
const el = this.list.children[index];
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
} }
this.container.scrollTop = top;
} }
} }
`; `;
@@ -3447,10 +3513,14 @@ class ExportService {
current: 20, current: 20,
total: 100, total: 100,
currentSession: sessionInfo.displayName, currentSession: sessionInfo.displayName,
phase: 'exporting-media' phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: `导出媒体 0/${mediaMessages.length}`
}) })
const MEDIA_CONCURRENCY = 6 const MEDIA_CONCURRENCY = 6
let mediaExported = 0
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
const mediaKey = `${msg.localType}_${msg.localId}` const mediaKey = `${msg.localType}_${msg.localId}`
if (!mediaCache.has(mediaKey)) { if (!mediaCache.has(mediaKey)) {
@@ -3464,6 +3534,18 @@ class ExportService {
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
} }
mediaExported++
if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
onProgress?.({
current: 20,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: mediaExported,
phaseTotal: mediaMessages.length,
phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`
})
}
}) })
} }
@@ -3478,13 +3560,27 @@ class ExportService {
current: 40, current: 40,
total: 100, total: 100,
currentSession: sessionInfo.displayName, currentSession: sessionInfo.displayName,
phase: 'exporting-voice' phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`
}) })
const VOICE_CONCURRENCY = 4 const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(msg.localId, transcript) voiceTranscriptMap.set(msg.localId, transcript)
voiceTranscribed++
onProgress?.({
current: 40,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: voiceTranscribed,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
})
}) })
} }
@@ -3535,43 +3631,23 @@ class ExportService {
<body> <body>
<div class="page"> <div class="page">
<div class="header"> <div class="header">
<h1 class="title">${this.escapeHtml(sessionInfo.displayName)} 的聊天记录</h1> <h1 class="title">${this.escapeHtml(sessionInfo.displayName)}</h1>
<div class="meta"> <div class="meta">
<span>导出时间:${this.escapeHtml(this.formatTimestamp(exportMeta.chatlab.exportedAt))}</span> <span>${sortedMessages.length} 条消息</span>
<span>消息数量:${sortedMessages.length}</span> <span>${isGroup ? '群聊' : '私聊'}</span>
<span>会话类型:${isGroup ? '群聊' : '私聊'}</span> <span>${this.escapeHtml(this.formatTimestamp(exportMeta.chatlab.exportedAt))}</span>
</div> </div>
<div class="controls"> <div class="controls">
<div class="control"> <input id="searchInput" type="search" placeholder="搜索消息..." />
<label for="searchInput">搜索内容 / 发送者</label> <input id="timeInput" type="datetime-local" />
<input id="searchInput" type="search" placeholder="输入关键词实时过滤" /> <button id="jumpBtn" type="button">跳转</button>
</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>&nbsp;</label>
<button id="jumpBtn" type="button">跳转到时间</button>
</div>
<div class="stats"> <div class="stats">
<span id="resultCount">共 ${sortedMessages.length} 条</span> <span id="resultCount">共 ${sortedMessages.length} 条</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Virtual Scroll Container --> <div id="scrollContainer" class="scroll-container"></div>
<div id="virtualScrollContainer" class="virtual-scroll-container"></div>
</div> </div>
@@ -3584,7 +3660,23 @@ class ExportService {
window.WEFLOW_DATA = [ 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++) { for (let i = 0; i < sortedMessages.length; i++) {
const msg = sortedMessages[i] const msg = sortedMessages[i]
const mediaKey = `${msg.localType}_${msg.localId}` const mediaKey = `${msg.localType}_${msg.localId}`
@@ -3597,10 +3689,8 @@ class ExportService {
: (isGroup : (isGroup
? (senderInfo?.groupNickname || senderInfo?.accountName || msg.senderUsername) ? (senderInfo?.groupNickname || senderInfo?.accountName || msg.senderUsername)
: (sessionInfo.displayName || sessionId)) : (sessionInfo.displayName || sessionId))
const avatarData = avatarMap.get(isSenderMe ? cleanedMyWxid : msg.senderUsername)
const avatarHtml = avatarData const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, senderName)
? `<img src="${this.escapeAttribute(encodeURI(avatarData))}" alt="${this.escapeAttribute(senderName)}" />`
: `<span>${this.escapeHtml(this.getAvatarFallback(senderName))}</span>`
const timeText = this.formatTimestamp(msg.createTime) const timeText = this.formatTimestamp(msg.createTime)
const typeName = this.getMessageTypeName(msg.localType) const typeName = this.getMessageTypeName(msg.localType)
@@ -3634,14 +3724,7 @@ class ExportService {
? `<div class="sender-name">${this.escapeHtml(senderName)}</div>` ? `<div class="sender-name">${this.escapeHtml(senderName)}</div>`
: '' : ''
const timeHtml = `<div class="message-time">${this.escapeHtml(timeText)}</div>` const timeHtml = `<div class="message-time">${this.escapeHtml(timeText)}</div>`
const messageBody = ` const messageBody = `${timeHtml}${senderNameHtml}<div class="message-content">${mediaHtml}${textHtml}</div>`
${timeHtml}
${senderNameHtml}
<div class="message-content">
${mediaHtml}
${textHtml}
</div>
`
// Compact JSON object // Compact JSON object
const itemObj = { const itemObj = {
@@ -3652,8 +3735,15 @@ class ExportService {
b: messageBody // body HTML b: messageBody // body HTML
} }
const jsonStr = JSON.stringify(itemObj) writeBuf.push(JSON.stringify(itemObj))
await writePromise(jsonStr + (i < sortedMessages.length - 1 ? ',\n' : '\n'))
// 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 // Report progress occasionally
if ((i + 1) % 500 === 0) { if ((i + 1) % 500 === 0) {
@@ -3676,10 +3766,9 @@ class ExportService {
const timeInput = document.getElementById('timeInput') const timeInput = document.getElementById('timeInput')
const jumpBtn = document.getElementById('jumpBtn') const jumpBtn = document.getElementById('jumpBtn')
const resultCount = document.getElementById('resultCount') const resultCount = document.getElementById('resultCount')
const themeSelect = document.getElementById('themeSelect')
const imagePreview = document.getElementById('imagePreview') const imagePreview = document.getElementById('imagePreview')
const imagePreviewTarget = document.getElementById('imagePreviewTarget') const imagePreviewTarget = document.getElementById('imagePreviewTarget')
const container = document.getElementById('virtualScrollContainer') const container = document.getElementById('scrollContainer')
let imageZoom = 1 let imageZoom = 1
// Initial Data // Initial Data
@@ -3701,7 +3790,7 @@ class ExportService {
\`; \`;
}; };
const scroller = new VirtualScroller(container, [], currentList, renderItem); const renderer = new ChunkedRenderer(container, currentList, renderItem);
const updateCount = () => { const updateCount = () => {
resultCount.textContent = \`\${currentList.length}\` resultCount.textContent = \`\${currentList.length}\`
@@ -3716,14 +3805,11 @@ class ExportService {
if (!keyword) { if (!keyword) {
currentList = allData; currentList = allData;
} else { } 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 => { currentList = allData.filter(item => {
return item.b.toLowerCase().includes(keyword); return item.b.toLowerCase().includes(keyword);
}); });
} }
scroller.setData(currentList); renderer.setData(currentList);
updateCount(); updateCount();
}, 300); }, 300);
}) })
@@ -3733,21 +3819,7 @@ class ExportService {
const value = timeInput.value const value = timeInput.value
if (!value) return if (!value) return
const target = Math.floor(new Date(value).getTime() / 1000) const target = Math.floor(new Date(value).getTime() / 1000)
// Find in current list renderer.scrollToTime(target);
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)
}) })
// Image Preview (Delegation) // Image Preview (Delegation)
@@ -3788,7 +3860,6 @@ class ExportService {
}) })
updateCount() updateCount()
console.log('WeFlow Export Loaded', allData.length);
</script> </script>
</body> </body>
</html>`); </html>`);
@@ -3811,6 +3882,77 @@ class ExportService {
} }
} }
/**
* 获取导出前的预估统计信息
*/
async getExportStats(
sessionIds: string[],
options: ExportOptions
): Promise<{
totalMessages: number
voiceMessages: number
cachedVoiceCount: number
needTranscribeCount: number
mediaMessages: number
estimatedSeconds: number
sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }>
}> {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) {
return { totalMessages: 0, voiceMessages: 0, cachedVoiceCount: 0, needTranscribeCount: 0, mediaMessages: 0, estimatedSeconds: 0, sessions: [] }
}
const cleanedMyWxid = conn.cleanedWxid
const sessionsStats: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> = []
let totalMessages = 0
let voiceMessages = 0
let cachedVoiceCount = 0
let mediaMessages = 0
for (const sessionId of sessionIds) {
const sessionInfo = await this.getContactInfo(sessionId)
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const msgs = collected.rows
const voiceMsgs = msgs.filter(m => m.localType === 34)
const mediaMsgs = msgs.filter(m => {
const t = m.localType
return (t === 3) || (t === 47) || (t === 43) || (t === 34)
})
// 检查已缓存的转写数量
let cached = 0
for (const msg of voiceMsgs) {
if (chatService.hasTranscriptCache(sessionId, String(msg.localId), msg.createTime)) {
cached++
}
}
totalMessages += msgs.length
voiceMessages += voiceMsgs.length
cachedVoiceCount += cached
mediaMessages += mediaMsgs.length
sessionsStats.push({
sessionId,
displayName: sessionInfo.displayName,
totalCount: msgs.length,
voiceCount: voiceMsgs.length
})
}
const needTranscribeCount = voiceMessages - cachedVoiceCount
// 预估:每条语音转文字约 2 秒
const estimatedSeconds = needTranscribeCount * 2
return {
totalMessages,
voiceMessages,
cachedVoiceCount,
needTranscribeCount,
mediaMessages,
estimatedSeconds,
sessions: sessionsStats
}
}
/** /**
* 批量导出多个会话 * 批量导出多个会话
*/ */
@@ -3850,7 +3992,17 @@ class ExportService {
await parallelLimit(sessionIds, sessionConcurrency, async (sessionId) => { await parallelLimit(sessionIds, sessionConcurrency, async (sessionId) => {
const sessionInfo = await this.getContactInfo(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, current: completedCount,
total: sessionIds.length, total: sessionIds.length,
currentSession: sessionInfo.displayName, currentSession: sessionInfo.displayName,
@@ -3874,15 +4026,15 @@ class ExportService {
let result: { success: boolean; error?: string } let result: { success: boolean; error?: string }
if (options.format === 'json') { 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') { } 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') { } 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') { } 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') { } else if (options.format === 'html') {
result = await this.exportSessionToHtml(sessionId, outputPath, options) result = await this.exportSessionToHtml(sessionId, outputPath, options, sessionProgress)
} else { } else {
result = { success: false, error: `不支持的格式: ${options.format}` } result = { success: false, error: `不支持的格式: ${options.format}` }
} }

View File

@@ -403,6 +403,15 @@ export interface ElectronAPI {
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
} }
export: { 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<{ exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{
success: boolean success: boolean
successCount?: number successCount?: number
@@ -494,7 +503,10 @@ export interface ExportProgress {
current: number current: number
total: number total: number
currentSession: string 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 { export interface WxidInfo {