From 366da8d38e29569c385f679aab70340e3c54502f Mon Sep 17 00:00:00 2001 From: cc <98377878+hicccc77@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:30:38 +0800 Subject: [PATCH 001/162] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=86=85=E5=AD=98?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 6 + electron/preload.ts | 1 + electron/services/keyService.ts | 252 ++++++++++++++++++++++++++++++++ resources/wx_key.dll | Bin 354304 -> 208896 bytes src/pages/SettingsPage.tsx | 76 ++++++---- src/pages/WelcomePage.tsx | 72 +++++---- src/types/electron.d.ts | 1 + 7 files changed, 346 insertions(+), 62 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 91c6b14..f686c4b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1539,6 +1539,12 @@ function registerIpcHandlers() { }, wxid) }) + ipcMain.handle('key:scanImageKeyFromMemory', async (event, userDir: string) => { + return keyService.autoGetImageKeyByMemoryScan(userDir, (message) => { + event.sender.send('key:imageKeyStatus', { message }) + }) + }) + // HTTP API 服务 ipcMain.handle('http:start', async (_, port?: number) => { return httpService.start(port || 5031) diff --git a/electron/preload.ts b/electron/preload.ts index e81a267..dd087bb 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -114,6 +114,7 @@ contextBridge.exposeInMainWorld('electronAPI', { key: { autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'), autoGetImageKey: (manualDir?: string, wxid?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir, wxid), + scanImageKeyFromMemory: (userDir: string) => ipcRenderer.invoke('key:scanImageKeyFromMemory', userDir), onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => { ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('key:dbKeyStatus') diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 3168f1c..0b4a67b 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -731,4 +731,256 @@ export class KeyService { aesKey } } + + // --- 内存扫描备选方案(融合 Dart+Python 优点)--- + // 只扫 RW 可写区域(更快),同时支持 ASCII 和 UTF-16LE 两种密钥格式 + // 验证支持 JPEG/PNG/WEBP/WXGF/GIF 多种格式 + + async autoGetImageKeyByMemoryScan( + userDir: string, + onProgress?: (message: string) => void + ): Promise { + if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' } + + try { + // 1. 查找模板文件获取密文和 XOR 密钥 + onProgress?.('正在查找模板文件...') + const { ciphertext, xorKey } = await this._findTemplateData(userDir) + if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' } + + onProgress?.(`XOR 密钥: 0x${(xorKey ?? 0).toString(16).padStart(2, '0')},正在查找微信进程...`) + + // 2. 找微信 PID + const pid = await this.findWeChatPid() + if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' } + + onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`) + + // 3. 持续轮询内存扫描,最多 60 秒 + const deadline = Date.now() + 60_000 + let scanCount = 0 + while (Date.now() < deadline) { + scanCount++ + onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`) + const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress) + if (aesKey) { + onProgress?.('密钥获取成功') + return { success: true, xorKey: xorKey ?? 0, aesKey } + } + // 等 5 秒再试 + await new Promise(r => setTimeout(r, 5000)) + } + + return { + success: false, + error: '60 秒内未找到 AES 密钥。\n请确保已在微信中打开 2-3 张图片大图后再试。' + } + } catch (e) { + return { success: false, error: `内存扫描失败: ${e}` } + } + } + + private async _findTemplateData(userDir: string): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> { + const { readdirSync, readFileSync, statSync } = await import('fs') + const { join } = await import('path') + const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) + + // 递归收集 *_t.dat 文件 + const collect = (dir: string, results: string[], limit = 32) => { + if (results.length >= limit) return + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (results.length >= limit) break + const full = join(dir, entry.name) + if (entry.isDirectory()) collect(full, results, limit) + else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full) + } + } catch { /* 忽略无权限目录 */ } + } + + const files: string[] = [] + collect(userDir, files) + + // 按修改时间降序 + files.sort((a, b) => { + try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 } + }) + + let ciphertext: Buffer | null = null + const tailCounts: Record = {} + + for (const f of files.slice(0, 32)) { + try { + const data = readFileSync(f) + if (data.length < 8) continue + + // 统计末尾两字节用于 XOR 密钥 + if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) { + const key = `${data[data.length - 2]}_${data[data.length - 1]}` + tailCounts[key] = (tailCounts[key] ?? 0) + 1 + } + + // 提取密文(取第一个有效的) + if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) { + ciphertext = data.subarray(0xF, 0x1F) + } + } catch { /* 忽略 */ } + } + + // 计算 XOR 密钥 + let xorKey: number | null = null + let maxCount = 0 + for (const [key, count] of Object.entries(tailCounts)) { + if (count > maxCount) { maxCount = count; const [x, y] = key.split('_').map(Number); const k = x ^ 0xFF; if (k === (y ^ 0xD9)) xorKey = k } + } + + return { ciphertext, xorKey } + } + + private async _scanMemoryForAesKey( + pid: number, + ciphertext: Buffer, + onProgress?: (msg: string) => void + ): Promise { + if (!this.ensureKernel32()) return null + + // 直接用已加载的 kernel32 实例,用 uintptr 传地址 + const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'size_t', ['void*', 'uintptr', 'void*', 'size_t']) + const ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['void*', 'uintptr', 'void*', 'size_t', this.koffi.out('size_t*')]) + + // RW 保护标志(只扫可写区域,速度更快) + const RW_FLAGS = 0x04 | 0x08 | 0x40 | 0x80 // PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY + const MEM_COMMIT = 0x1000 + const PAGE_NOACCESS = 0x01 + const PAGE_GUARD = 0x100 + const MBI_SIZE = 48 // MEMORY_BASIC_INFORMATION size on x64 + + const hProcess = this.OpenProcess(0x1F0FFF, false, pid) + if (!hProcess) return null + + try { + // 枚举 RW 内存区域 + const regions: Array<[number, number]> = [] + let addr = 0 + const mbi = Buffer.alloc(MBI_SIZE) + + while (addr < 0x7FFFFFFFFFFF) { + const ret = VirtualQueryEx(hProcess, addr, mbi, MBI_SIZE) + if (ret === 0) break + // MEMORY_BASIC_INFORMATION x64 布局: + // 0: BaseAddress (8) + // 8: AllocationBase (8) + // 16: AllocationProtect (4) + 4 padding + // 24: RegionSize (8) + // 32: State (4) + // 36: Protect (4) + // 40: Type (4) + 4 padding = 48 total + const base = Number(mbi.readBigUInt64LE(0)) + const size = Number(mbi.readBigUInt64LE(24)) + const state = mbi.readUInt32LE(32) + const protect = mbi.readUInt32LE(36) + + if (state === MEM_COMMIT && + protect !== PAGE_NOACCESS && + (protect & PAGE_GUARD) === 0 && + (protect & RW_FLAGS) !== 0 && + size <= 50 * 1024 * 1024) { + regions.push([base, size]) + } + const next = base + size + if (next <= addr) break + addr = next + } + + const totalMB = regions.reduce((s, [, sz]) => s + sz, 0) / 1024 / 1024 + onProgress?.(`扫描 ${regions.length} 个 RW 区域 (${totalMB.toFixed(0)} MB)...`) + + const CHUNK = 4 * 1024 * 1024 + const OVERLAP = 65 + + for (let i = 0; i < regions.length; i++) { + const [base, size] = regions[i] + if (i % 20 === 0) { + onProgress?.(`扫描进度 ${i}/${regions.length}...`) + await new Promise(r => setTimeout(r, 1)) // 让出事件循环 + } + + let offset = 0 + let trailing: Buffer | null = null + + while (offset < size) { + const chunkSize = Math.min(CHUNK, size - offset) + const buf = Buffer.alloc(chunkSize) + const bytesReadOut = [0] + const ok = ReadProcessMemory(hProcess, base + offset, buf, chunkSize, bytesReadOut) + if (!ok || bytesReadOut[0] === 0) { offset += chunkSize; trailing = null; continue } + + const data: Buffer = trailing ? Buffer.concat([trailing, buf.subarray(0, bytesReadOut[0])]) : buf.subarray(0, bytesReadOut[0]) + + // 搜索 ASCII 32字节密钥 + const key = this._searchAsciiKey(data, ciphertext) + if (key) { this.CloseHandle(hProcess); return key } + + // 搜索 UTF-16LE 32字节密钥 + const key16 = this._searchUtf16Key(data, ciphertext) + if (key16) { this.CloseHandle(hProcess); return key16 } + + trailing = data.subarray(Math.max(0, data.length - OVERLAP)) + offset += chunkSize + } + } + + return null + } finally { + this.CloseHandle(hProcess) + } + } + + private _searchAsciiKey(data: Buffer, ciphertext: Buffer): string | null { + for (let i = 0; i < data.length - 34; i++) { + if (this._isAlphaNum(data[i])) continue + let valid = true + for (let j = 1; j <= 32; j++) { + if (!this._isAlphaNum(data[i + j])) { valid = false; break } + } + if (!valid) continue + if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue + const keyBytes = data.subarray(i + 1, i + 33) + if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16) + } + return null + } + + private _searchUtf16Key(data: Buffer, ciphertext: Buffer): string | null { + for (let i = 0; i < data.length - 65; i++) { + let valid = true + for (let j = 0; j < 32; j++) { + if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) { valid = false; break } + } + if (!valid) continue + const keyBytes = Buffer.alloc(32) + for (let j = 0; j < 32; j++) keyBytes[j] = data[i + j * 2] + if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16) + } + return null + } + + private _isAlphaNum(b: number): boolean { + return (b >= 0x61 && b <= 0x7A) || (b >= 0x41 && b <= 0x5A) || (b >= 0x30 && b <= 0x39) + } + + private _verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean { + try { + const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes.subarray(0, 16), null) + decipher.setAutoPadding(false) + const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + // 支持 JPEG / PNG / WEBP / WXGF / GIF + if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true + if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true + if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true + if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true + if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true + return false + } catch { return false } + } } \ No newline at end of file diff --git a/resources/wx_key.dll b/resources/wx_key.dll index 30ddb529babc473dcb4992e9ab19f9f8927f6a1a..5edf2989795200841ac9bf9412d88de045b6bd9b 100644 GIT binary patch literal 208896 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 z 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

+jLU3K|A&xn)rrL`=nv*#eD@t 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
470!w(d@<7?ZO%^w(Ucr^6SS0X4j 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 ZR*JN)axc~{(qX(M^vjpUbB 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
5)T}X{Xi@PiyFSTWj#%)wR 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 { if (isFetchingImageKey) return; - if (!dbPath) { - showMessage('请先选择数据库目录', false); - return; - } + if (!dbPath) { showMessage('请先选择数据库目录', false); return; } setIsFetchingImageKey(true); setImageKeyPercent(0) setImageKeyStatus('正在初始化...'); - setImageKeyProgress(0); // 重置进度 + setImageKeyProgress(0); try { const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath; const result = await window.electronAPI.key.autoGetImageKey(accountPath, wxid) if (result.success && result.aesKey) { - if (typeof result.xorKey === 'number') { - setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`) - } + if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`) setImageAesKey(result.aesKey) setImageKeyStatus('已获取图片密钥') showMessage('已自动获取图片密钥', true) - - // Auto-save after fetching keys - // We need to use the values directly because state updates are async const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0 const newAesKey = result.aesKey - await configService.setImageXorKey(newXorKey) await configService.setImageAesKey(newAesKey) - - if (wxid) { - await configService.setWxidConfig(wxid, { - decryptKey: decryptKey, // use current state as it hasn't changed here - imageXorKey: newXorKey, - imageAesKey: newAesKey - }) - } - + if (wxid) await configService.setWxidConfig(wxid, { decryptKey, imageXorKey: newXorKey, imageAesKey: newAesKey }) } else { showMessage(result.error || '自动获取图片密钥失败', false) } @@ -814,6 +797,36 @@ function SettingsPage() { } } + const handleScanImageKeyFromMemory = async () => { + if (isFetchingImageKey) return; + if (!dbPath) { showMessage('请先选择数据库目录', false); return; } + setIsFetchingImageKey(true); + setImageKeyPercent(0) + setImageKeyStatus('正在扫描内存...'); + + try { + const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath; + const result = await window.electronAPI.key.scanImageKeyFromMemory(accountPath) + if (result.success && result.aesKey) { + if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`) + setImageAesKey(result.aesKey) + setImageKeyStatus('内存扫描成功,已获取图片密钥') + showMessage('内存扫描成功,已获取图片密钥', true) + const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0 + const newAesKey = result.aesKey + await configService.setImageXorKey(newXorKey) + await configService.setImageAesKey(newAesKey) + if (wxid) await configService.setWxidConfig(wxid, { decryptKey, imageXorKey: newXorKey, imageAesKey: newAesKey }) + } else { + showMessage(result.error || '内存扫描获取图片密钥失败', false) + } + } catch (e: any) { + showMessage(`内存扫描失败: ${e}`, false) + } finally { + setIsFetchingImageKey(false) + } + } + const handleTestConnection = async () => { @@ -1373,24 +1386,27 @@ function SettingsPage() { scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid })) }} /> - +

+ ⚠️ 快速获取方案基于本地缓存计算,可能因账号信息不匹配而不准确。若图片无法解密,请使用「内存扫描」方案。 +
+
+ + +
{isFetchingImageKey ? (
{imageKeyStatus || '正在启动...'} - {imageKeyPercent !== null && {imageKeyPercent.toFixed(1)}%}
- {imageKeyPercent !== null && ( -
-
-
- )}
) : ( imageKeyStatus &&
{imageKeyStatus}
)} + 内存扫描需要微信正在运行,并在微信中打开 2-3 张图片大图后再点击
diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 0c94d9c..5e61b65 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -309,22 +309,16 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { const handleAutoGetImageKey = async () => { if (isFetchingImageKey) return - if (!dbPath) { - setError('请先选择数据库目录') - return - } + if (!dbPath) { setError('请先选择数据库目录'); return } setIsFetchingImageKey(true) setError('') setImageKeyPercent(0) setImageKeyStatus('正在准备获取图片密钥...') try { - // 拼接完整的账号目录,确保 KeyService 能准确找到模板文件 const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath const result = await window.electronAPI.key.autoGetImageKey(accountPath, wxid) if (result.success && result.aesKey) { - if (typeof result.xorKey === 'number') { - setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`) - } + if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`) setImageAesKey(result.aesKey) setImageKeyStatus('已获取图片密钥') } else { @@ -337,6 +331,30 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { } } + const handleScanImageKeyFromMemory = async () => { + if (isFetchingImageKey) return + if (!dbPath) { setError('请先选择数据库目录'); return } + setIsFetchingImageKey(true) + setError('') + setImageKeyPercent(0) + setImageKeyStatus('正在扫描内存...') + try { + const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath + const result = await window.electronAPI.key.scanImageKeyFromMemory(accountPath) + if (result.success && result.aesKey) { + if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`) + setImageAesKey(result.aesKey) + setImageKeyStatus('内存扫描成功,已获取图片密钥') + } else { + setError(result.error || '内存扫描获取图片密钥失败') + } + } catch (e) { + setError(`内存扫描失败: ${e}`) + } finally { + setIsFetchingImageKey(false) + } + } + const canGoNext = () => { if (currentStep.id === 'intro') return true if (currentStep.id === 'db') return Boolean(dbPath) @@ -747,50 +765,40 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { {currentStep.id === 'image' && (
+
+ ⚠️ 快速获取方案基于本地缓存计算,可能因账号信息不匹配而不准确。若图片无法解密,请使用下方「内存扫描」方案。 +
- setImageXorKey(e.target.value)} - /> + setImageXorKey(e.target.value)} />
- setImageAesKey(e.target.value)} - /> + setImageAesKey(e.target.value)} />
- +
+ + +
{isFetchingImageKey ? (
{imageKeyStatus || '正在启动...'} - {imageKeyPercent !== null && {imageKeyPercent.toFixed(1)}%}
- {imageKeyPercent !== null && ( -
-
-
- )}
) : ( imageKeyStatus &&
{imageKeyStatus}
)} -
请在微信中打开几张图片后再点击获取
+
内存扫描需要微信正在运行,并在微信中打开 2-3 张图片大图后再点击
)}
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index ba9b10b..45116aa 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -67,6 +67,7 @@ export interface ElectronAPI { key: { autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }> autoGetImageKey: (manualDir?: string, wxid?: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }> + scanImageKeyFromMemory: (userDir: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }> onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => () => void onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void } From eace3e9467aa674524298a2a13ebbdf2dede0827 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Mon, 2 Mar 2026 21:36:44 +0800 Subject: [PATCH 002/162] =?UTF-8?q?=E6=9F=A5=E7=9C=8B=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ChatPage.scss | 131 ++++++++++++++++++++++++++++++++++++++++ src/pages/ChatPage.tsx | 83 +++++++++++++++++++++++++ 2 files changed, 214 insertions(+) diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 953341b..0c30aa2 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -4132,4 +4132,135 @@ .session-name { font-weight: 500; } +} + +// 消息信息弹窗 +.message-info-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + padding: 20px; +} + +.message-info-modal { + width: 520px; + max-width: 90vw; + max-height: 80vh; + background: var(--card-bg); + border-radius: 12px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; + overflow: hidden; + + .modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); + + h3 { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } + + .close-btn { + width: 28px; + height: 28px; + border: none; + background: transparent; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + } + + .modal-body { + flex: 1; + overflow-y: auto; + padding: 16px 20px; + } + + .info-section { + margin-bottom: 16px; + + h4 { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + margin: 0 0 8px 0; + } + } + + .info-grid { + display: flex; + flex-direction: column; + gap: 6px; + } + + .info-item { + display: flex; + align-items: baseline; + gap: 8px; + font-size: 13px; + + .label { + color: var(--text-tertiary); + min-width: 80px; + flex-shrink: 0; + } + + .value { + color: var(--text-primary); + word-break: break-all; + + &.code { + font-family: monospace; + font-size: 12px; + background: var(--bg-tertiary); + padding: 2px 6px; + border-radius: 4px; + } + } + } + + .raw-content-container { + background: var(--bg-tertiary); + border-radius: 8px; + padding: 12px; + max-height: 200px; + overflow: auto; + + pre { + margin: 0; + font-size: 12px; + font-family: monospace; + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-all; + } + } + + .select-text { + user-select: text; + } } \ No newline at end of file diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index b2373dd..05fddde 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -324,6 +324,7 @@ function ChatPage(_props: ChatPageProps) { // 消息右键菜单 const [contextMenu, setContextMenu] = useState<{ x: number, y: number, message: Message } | null>(null) + const [showMessageInfo, setShowMessageInfo] = useState(null) const [editingMessage, setEditingMessage] = useState<{ message: Message, content: string } | null>(null) // 多选模式 @@ -2735,11 +2736,93 @@ function ChatPage(_props: ChatPageProps) { 删除消息 +
{ setShowMessageInfo(contextMenu.message); setContextMenu(null) }}> + + 查看消息信息 +
, document.body )} + {/* 消息信息弹窗 */} + {showMessageInfo && createPortal( +
setShowMessageInfo(null)}> +
e.stopPropagation()}> +
+
+ +

消息详细信息

+
+ +
+
+
+

基础字段

+
+
Local ID{showMessageInfo.localId}
+
Server ID{showMessageInfo.serverId}
+
Local Type{showMessageInfo.localType}
+
发送者{showMessageInfo.senderUsername || '-'}
+
创建时间{new Date(showMessageInfo.createTime * 1000).toLocaleString()} ({showMessageInfo.createTime})
+
发送状态{showMessageInfo.isSend === 1 ? '发送' : '接收'}
+
+
+ + {showMessageInfo.imageMd5 && ( +
+

图片信息

+
+
Image MD5{showMessageInfo.imageMd5}
+ {showMessageInfo.imageDatName &&
DAT 文件名{showMessageInfo.imageDatName}
} +
+
+ )} + + {showMessageInfo.videoMd5 && ( +
+

视频信息

+
+
Video MD5{showMessageInfo.videoMd5}
+
+
+ )} + + {showMessageInfo.voiceDurationSeconds != null && ( +
+

语音信息

+
+
时长{showMessageInfo.voiceDurationSeconds}秒
+
+
+ )} + + {(showMessageInfo.emojiMd5 || showMessageInfo.emojiCdnUrl) && ( +
+

表情包信息

+
+ {showMessageInfo.emojiMd5 &&
MD5{showMessageInfo.emojiMd5}
} + {showMessageInfo.emojiCdnUrl &&
CDN URL{showMessageInfo.emojiCdnUrl}
} +
+
+ )} + + {(showMessageInfo.rawContent || showMessageInfo.content) && ( +
+

原始消息内容

+
+
{showMessageInfo.rawContent || showMessageInfo.content}
+
+
+ )} +
+
+
, + document.body + )} + {/* 修改消息弹窗 */} {editingMessage && createPortal(
From 39b38119c1cc1619fff35a3fd373f28cebb02064 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Mon, 2 Mar 2026 22:22:56 +0800 Subject: [PATCH 003/162] =?UTF-8?q?=E5=90=8C=E6=AD=A5ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ChatPage.scss | 159 ++++++++++++++++++++++++---------------- src/pages/ChatPage.tsx | 150 ++++++++++++++++++++++++------------- 2 files changed, 197 insertions(+), 112 deletions(-) diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 0c30aa2..4ccdd2b 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -4137,55 +4137,51 @@ // 消息信息弹窗 .message-info-overlay { position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.6); + inset: 0; + background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(4px); + z-index: 2000; display: flex; align-items: center; justify-content: center; - z-index: 2000; - padding: 20px; } .message-info-modal { - width: 520px; + width: 360px; max-width: 90vw; max-height: 80vh; background: var(--card-bg); + border: 1px solid var(--border-color); border-radius: 12px; - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); display: flex; flex-direction: column; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); overflow: hidden; - .modal-header { + .detail-header { display: flex; align-items: center; justify-content: space-between; - padding: 16px 20px; + padding: 16px; border-bottom: 1px solid var(--border-color); - h3 { - font-size: 16px; + h4 { + font-size: 15px; font-weight: 600; color: var(--text-primary); + margin: 0; } .close-btn { - width: 28px; - height: 28px; + background: none; border: none; - background: transparent; - border-radius: 6px; + padding: 4px; cursor: pointer; + color: var(--text-secondary); + border-radius: 6px; display: flex; align-items: center; justify-content: center; - color: var(--text-tertiary); - transition: all 0.2s; &:hover { background: var(--bg-hover); @@ -4194,56 +4190,98 @@ } } - .modal-body { + .detail-content { flex: 1; overflow-y: auto; - padding: 16px 20px; + padding: 16px; + + &::-webkit-scrollbar { width: 4px; } + &::-webkit-scrollbar-thumb { background: var(--text-tertiary); border-radius: 2px; } } - .info-section { - margin-bottom: 16px; + .detail-section { + margin-bottom: 20px; + &:last-child { margin-bottom: 0; } - h4 { - font-size: 13px; + .section-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; font-weight: 600; color: var(--text-secondary); - margin: 0 0 8px 0; - } - } + margin-bottom: 12px; + letter-spacing: 0.5px; - .info-grid { - display: flex; - flex-direction: column; - gap: 6px; - } + svg { opacity: 0.7; } - .info-item { - display: flex; - align-items: baseline; - gap: 8px; - font-size: 13px; - - .label { - color: var(--text-tertiary); - min-width: 80px; - flex-shrink: 0; - } - - .value { - color: var(--text-primary); - word-break: break-all; - - &.code { - font-family: monospace; - font-size: 12px; - background: var(--bg-tertiary); - padding: 2px 6px; + .copy-btn { + margin-left: auto; + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; border-radius: 4px; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + &:hover { background: var(--bg-secondary); color: var(--text-primary); } } } } - .raw-content-container { + .detail-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; + border-bottom: 1px solid var(--border-color); + font-size: 13px; + + &:last-child { border-bottom: none; } + + svg { color: var(--text-tertiary); flex-shrink: 0; } + + .label { color: var(--text-secondary); flex-shrink: 0; } + + .value { + flex: 1; + text-align: right; + color: var(--text-primary); + word-break: break-all; + user-select: text; + + &.highlight { color: var(--primary); font-weight: 600; } + &.mono { font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; } + } + + .copy-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s, color 0.15s, background 0.15s; + + &:hover { background: var(--bg-secondary); color: var(--text-primary); } + svg { color: inherit; } + } + + &:hover .copy-btn { opacity: 1; } + } + + .raw-content-box { background: var(--bg-tertiary); border-radius: 8px; padding: 12px; @@ -4252,15 +4290,12 @@ pre { margin: 0; - font-size: 12px; - font-family: monospace; - color: var(--text-primary); white-space: pre-wrap; word-break: break-all; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 12px; + color: var(--text-primary); + user-select: text; } } - - .select-text { - user-select: text; - } } \ No newline at end of file diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 05fddde..b795fe4 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -2749,71 +2749,121 @@ function ChatPage(_props: ChatPageProps) { {showMessageInfo && createPortal(
setShowMessageInfo(null)}>
e.stopPropagation()}> -
-
- -

消息详细信息

-
+
+

消息详情

-
-
-

基础字段

-
-
Local ID{showMessageInfo.localId}
-
Server ID{showMessageInfo.serverId}
-
Local Type{showMessageInfo.localType}
-
发送者{showMessageInfo.senderUsername || '-'}
-
创建时间{new Date(showMessageInfo.createTime * 1000).toLocaleString()} ({showMessageInfo.createTime})
-
发送状态{showMessageInfo.isSend === 1 ? '发送' : '接收'}
+
+
+
+ + Local ID + {showMessageInfo.localId} + +
+
+ + Server ID + {showMessageInfo.serverId} +
+
+ 消息类型 + {showMessageInfo.localType} +
+
+ 发送者 + {showMessageInfo.senderUsername || '-'} + {showMessageInfo.senderUsername && ( + + )} +
+
+ + 创建时间 + {new Date(showMessageInfo.createTime * 1000).toLocaleString()} +
+
+ 发送状态 + {showMessageInfo.isSend === 1 ? '发送' : '接收'}
- {showMessageInfo.imageMd5 && ( -
-

图片信息

-
-
Image MD5{showMessageInfo.imageMd5}
- {showMessageInfo.imageDatName &&
DAT 文件名{showMessageInfo.imageDatName}
} -
-
- )} - - {showMessageInfo.videoMd5 && ( -
-

视频信息

-
-
Video MD5{showMessageInfo.videoMd5}
-
-
- )} - - {showMessageInfo.voiceDurationSeconds != null && ( -
-

语音信息

-
-
时长{showMessageInfo.voiceDurationSeconds}秒
+ {(showMessageInfo.imageMd5 || showMessageInfo.videoMd5 || showMessageInfo.voiceDurationSeconds != null) && ( +
+
+ + 媒体信息
+ {showMessageInfo.imageMd5 && ( +
+ Image MD5 + {showMessageInfo.imageMd5} + +
+ )} + {showMessageInfo.imageDatName && ( +
+ DAT 文件 + {showMessageInfo.imageDatName} +
+ )} + {showMessageInfo.videoMd5 && ( +
+ Video MD5 + {showMessageInfo.videoMd5} + +
+ )} + {showMessageInfo.voiceDurationSeconds != null && ( +
+ + 语音时长 + {showMessageInfo.voiceDurationSeconds}秒 +
+ )}
)} {(showMessageInfo.emojiMd5 || showMessageInfo.emojiCdnUrl) && ( -
-

表情包信息

-
- {showMessageInfo.emojiMd5 &&
MD5{showMessageInfo.emojiMd5}
} - {showMessageInfo.emojiCdnUrl &&
CDN URL{showMessageInfo.emojiCdnUrl}
} +
+
+ 表情包信息
+ {showMessageInfo.emojiMd5 && ( +
+ MD5 + {showMessageInfo.emojiMd5} +
+ )} + {showMessageInfo.emojiCdnUrl && ( +
+ CDN URL + {showMessageInfo.emojiCdnUrl} +
+ )}
)} - {(showMessageInfo.rawContent || showMessageInfo.content) && ( -
-

原始消息内容

-
-
{showMessageInfo.rawContent || showMessageInfo.content}
+ {showMessageInfo.localType !== 1 && (showMessageInfo.rawContent || showMessageInfo.content) && ( +
+
+ 原始消息内容 + +
+
+
{showMessageInfo.rawContent || showMessageInfo.content}
)} From 06d6f15e38adc892e0c76c0ec99fc0749438e487 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 14:40:08 +0800 Subject: [PATCH 004/162] feat(export): redesign export board workflow --- src/pages/ExportPage.scss | 1956 ++++++++++++++------------------ src/pages/ExportPage.tsx | 2264 ++++++++++++++++++++----------------- src/services/config.ts | 49 + 3 files changed, 2118 insertions(+), 2151 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 1d7e414..d17679e 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1,1134 +1,909 @@ -.export-page { - display: flex; +.export-board-page { height: calc(100% + 48px); margin: -24px; + padding: 20px; background: var(--bg-primary); + display: flex; + flex-direction: column; + gap: 16px; overflow: hidden; - // 左侧会话选择面板 - .session-panel { - width: 380px; - min-width: 380px; - display: flex; - flex-direction: column; - border-right: 1px solid var(--border-color); - background: var(--card-bg); + .spin { + animation: exportSpin 1s linear infinite; } +} - .panel-header { +.export-top-panel { + display: grid; + grid-template-columns: minmax(260px, 380px) 1fr; + gap: 14px; + flex-shrink: 0; +} + +.current-user-box { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 14px; + display: flex; + align-items: center; + gap: 12px; + + .avatar-wrap { + width: 48px; + height: 48px; + border-radius: 12px; + overflow: hidden; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); display: flex; align-items: center; - justify-content: space-between; - padding: 20px 24px; - border-bottom: 1px solid var(--border-color); - - h2 { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - margin: 0; - } - - .icon-btn { - width: 32px; - height: 32px; - border: none; - background: var(--bg-tertiary); - border-radius: 8px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - color: var(--text-secondary); - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - .spin { - animation: exportSpin 1s linear infinite; - } - } - } - - .search-bar { - display: flex; - align-items: center; - gap: 10px; - margin: 16px 20px; - padding: 10px 14px; - background: var(--bg-secondary); - border-radius: 10px; - border: 1px solid var(--border-color); - transition: border-color 0.2s; - - &:focus-within { - border-color: var(--primary); - } - - svg { - color: var(--text-tertiary); - flex-shrink: 0; - } - - input { - flex: 1; - border: none; - background: none; - outline: none; - font-size: 14px; - color: var(--text-primary); - - &::placeholder { - color: var(--text-tertiary); - } - } - - .clear-btn { - background: none; - border: none; - padding: 4px; - cursor: pointer; - color: var(--text-tertiary); - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - - &:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - } - } - - .select-actions { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 20px 12px; - - .select-all-btn { - background: none; - border: none; - padding: 6px 12px; - font-size: 13px; - color: var(--primary); - cursor: pointer; - border-radius: 6px; - - &:hover { - background: rgba(var(--primary-rgb), 0.1); - } - } - - .selected-count { - font-size: 13px; - color: var(--text-secondary); - padding: 4px 12px; - background: var(--bg-secondary); - border-radius: 12px; - } - } - - .loading-state, - .empty-state { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; justify-content: center; - gap: 12px; - color: var(--text-tertiary); - font-size: 14px; - .spin { - animation: exportSpin 1s linear infinite; - } - } - - .export-session-list { - flex: 1; - overflow-y: auto; - padding: 0 12px 12px; - - &::-webkit-scrollbar { - width: 6px; + img { + width: 100%; + height: 100%; + object-fit: cover; } - &::-webkit-scrollbar-thumb { - background: var(--text-tertiary); - border-radius: 3px; - opacity: 0.3; - } - } - - .export-session-item { - display: flex; - align-items: center; - gap: 12px; - padding: 12px; - border-radius: 10px; - cursor: pointer; - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - } - - &.selected { - background: rgba(var(--primary-rgb), 0.08); - - .check-box { - background: var(--primary); - border-color: var(--primary); - color: #fff; - } - } - - .check-box { - width: 20px; - height: 20px; - border: 2px solid var(--border-color); - border-radius: 6px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - transition: all 0.2s; - } - - .export-avatar { - width: 44px; - height: 44px; - border-radius: 10px; - background: linear-gradient(135deg, var(--primary), var(--primary-hover)); - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - flex-shrink: 0; - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - - span { - color: #fff; - font-size: 16px; - font-weight: 600; - } - } - - .export-session-info { - flex: 1; - min-width: 0; - } - - .export-session-name { - font-size: 14px; - font-weight: 500; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .export-session-summary { - font-size: 12px; - color: var(--text-tertiary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-top: 2px; - } - } - - // 右侧设置面板 - .settings-panel { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - } - - .settings-content { - flex: 1; - overflow-y: auto; - padding: 20px 24px; - - &::-webkit-scrollbar { - width: 6px; - } - - &::-webkit-scrollbar-thumb { - background: var(--text-tertiary); - border-radius: 3px; - } - } - - .setting-section { - margin-bottom: 28px; - - h3 { - font-size: 13px; + span { + color: #fff; + font-size: 18px; font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; - margin: 0 0 14px; } } - .format-options { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 12px; - } - - .format-card { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - padding: 20px 16px; - background: var(--bg-secondary); - border: 2px solid transparent; - border-radius: 12px; - cursor: pointer; - transition: all 0.2s; - text-align: center; - - &:hover { - background: var(--bg-hover); - } - - &.active { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.05); - - svg { - color: var(--primary); - } - } - - svg { - color: var(--text-secondary); - } - - .format-label { - font-size: 14px; - font-weight: 600; - color: var(--text-primary); - } - - .format-desc { - font-size: 11px; - color: var(--text-tertiary); - line-height: 1.4; - } - } - - .time-range-picker-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 16px; - cursor: pointer; - transition: background 0.2s; - background: transparent; - - &:hover { - background: var(--bg-hover); - } - - .time-picker-info { - display: flex; - align-items: center; - gap: 10px; - font-size: 14px; - color: var(--text-primary); - - svg { - color: var(--primary); - } - } - - svg { - color: var(--text-tertiary); - } - } - - .select-field { - position: relative; - } - - .select-trigger { - width: 100%; - padding: 10px 16px; - border: 1px solid var(--border-color); - border-radius: 9999px; - font-size: 14px; - background: var(--bg-primary); - color: var(--text-primary); - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - cursor: pointer; - transition: all 0.2s; - - &:hover { - border-color: var(--text-tertiary); - } - - &.open { - border-color: var(--primary); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); - } - } - - .select-value { - flex: 1; + .user-meta { min-width: 0; - text-align: left; + } + + .user-name { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .select-dropdown { + .user-wxid { + margin-top: 2px; + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.global-export-controls { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 14px; + display: grid; + grid-template-columns: minmax(300px, 1fr) 320px; + gap: 16px; + + .control-label { + font-size: 12px; + color: var(--text-secondary); + font-weight: 600; + letter-spacing: 0.3px; + } + + .path-control { + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + } + + .path-value { + border: 1px dashed var(--border-color); + border-radius: 10px; + padding: 10px 12px; + font-size: 13px; + color: var(--text-primary); + background: var(--bg-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .path-actions { + display: flex; + gap: 8px; + } + + .write-layout-control { + position: relative; + display: flex; + flex-direction: column; + gap: 6px; + } + + .layout-trigger { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 13px; + text-align: left; + cursor: pointer; + + &:hover { + border-color: var(--primary); + } + } + + .layout-dropdown { position: absolute; top: calc(100% + 6px); left: 0; right: 0; - background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary)); + background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 12px; - padding: 6px; box-shadow: var(--shadow-md); + padding: 6px; z-index: 20; max-height: 260px; overflow-y: auto; - backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); } - .select-option { + .layout-option { width: 100%; + border: none; + background: transparent; text-align: left; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; display: flex; flex-direction: column; - gap: 4px; - padding: 10px 12px; - border: none; - border-radius: 10px; - background: transparent; - cursor: pointer; - transition: all 0.15s; - color: var(--text-primary); - font-size: 14px; - - &:hover { - background: var(--bg-tertiary); - } - - &.active { - background: color-mix(in srgb, var(--primary) 12%, transparent); - color: var(--primary); - } - } - - .option-label { - font-weight: 500; - } - - .option-desc { - font-size: 12px; - color: var(--text-tertiary); - } - - .select-option.active .option-desc { - color: var(--primary); - } - - .media-options { - display: flex; - flex-wrap: wrap; - gap: 12px; - margin-top: 12px; - padding-left: 28px; - } - - .folder-select { - display: flex; - align-items: center; - gap: 12px; - padding: 14px 16px; - background: var(--bg-secondary); - border: 1px dashed var(--border-color); - border-radius: 10px; - cursor: pointer; - transition: all 0.2s; - - &:hover { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.02); - } - - svg { - color: var(--primary); - } - - .folder-path { - flex: 1; - font-size: 13px; - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - - .export-path-display { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 16px; - background: var(--bg-secondary); - border-radius: 10px; - font-size: 13px; - color: var(--text-primary); - - svg { - color: var(--primary); - flex-shrink: 0; - } - - span { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - - .path-hint { - font-size: 12px; - color: var(--text-tertiary); - margin: 8px 0 0; - } - - .select-folder-btn { - width: 100%; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 10px 16px; - margin-top: 12px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - font-size: 13px; - font-weight: 500; - color: var(--text-primary); - cursor: pointer; - transition: all 0.2s; + gap: 2px; &:hover { background: var(--bg-hover); - border-color: var(--primary); - color: var(--primary); + } - svg { - color: var(--primary); + &.active { + background: rgba(var(--primary-rgb), 0.12); + color: var(--primary); + } + } + + .layout-option-label { + font-size: 13px; + font-weight: 600; + } + + .layout-option-desc { + font-size: 11px; + color: var(--text-secondary); + line-height: 1.45; + } +} + +.content-card-grid { + display: grid; + grid-template-columns: repeat(5, minmax(150px, 1fr)); + gap: 10px; + flex-shrink: 0; +} + +.content-card { + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--card-bg); + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + + .card-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + color: var(--text-primary); + font-weight: 600; + } + + .card-stats { + display: grid; + grid-template-columns: 1fr; + gap: 4px; + + .stat-item { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + color: var(--text-secondary); + + strong { + color: var(--text-primary); + font-size: 14px; } } + } - &:active { - transform: scale(0.98); + .card-export-btn { + margin-top: auto; + border: none; + border-radius: 8px; + padding: 8px 10px; + background: var(--primary); + color: #fff; + cursor: pointer; + font-size: 13px; + font-weight: 600; + + &:hover { + background: var(--primary-hover); + } + } +} + +.task-center { + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--card-bg); + padding: 12px; + flex-shrink: 0; + + .section-title { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; + } + + .task-empty { + padding: 12px; + background: var(--bg-secondary); + border-radius: 8px; + font-size: 13px; + color: var(--text-secondary); + } + + .task-list { + display: grid; + gap: 8px; + max-height: 190px; + overflow-y: auto; + } + + .task-card { + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 10px; + display: flex; + gap: 10px; + align-items: flex-start; + background: var(--bg-secondary); + + &.running { + border-color: var(--primary); } - svg { - color: var(--text-secondary); - transition: color 0.2s; + &.error { + border-color: rgba(255, 77, 79, 0.45); + } + + &.success { + border-color: rgba(82, 196, 26, 0.4); } } - .export-action { - padding: 20px 24px; - border-top: 1px solid var(--border-color); + .task-main { + flex: 1; + min-width: 0; } - .export-btn { - width: 100%; + .task-title { + font-size: 13px; + color: var(--text-primary); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .task-meta { + margin-top: 2px; + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 11px; + color: var(--text-secondary); + } + + .task-status { + border-radius: 999px; + padding: 2px 8px; + font-weight: 600; + + &.queued { + background: rgba(var(--primary-rgb), 0.14); + color: var(--primary); + } + + &.running { + background: rgba(var(--primary-rgb), 0.2); + color: var(--primary); + } + + &.success { + background: rgba(82, 196, 26, 0.18); + color: #52c41a; + } + + &.error { + background: rgba(255, 77, 79, 0.15); + color: #ff4d4f; + } + } + + .task-progress-bar { + margin-top: 8px; + height: 6px; + background: rgba(0, 0, 0, 0.08); + border-radius: 3px; + overflow: hidden; + } + + .task-progress-fill { + height: 100%; + background: var(--primary); + transition: width 0.2s ease; + } + + .task-progress-text { + margin-top: 4px; + font-size: 11px; + color: var(--text-secondary); + } + + .task-error { + margin-top: 6px; + font-size: 12px; + color: #ff4d4f; + } +} + +.session-table-section { + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--card-bg); + padding: 12px; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; + overflow: hidden; +} + +.table-toolbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; +} + +.table-tabs { + display: flex; + gap: 8px; + + .tab-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + padding: 7px 12px; + border-radius: 999px; + cursor: pointer; + font-size: 13px; + + &.active { + border-color: var(--primary); + color: var(--primary); + background: rgba(var(--primary-rgb), 0.12); + } + } +} + +.toolbar-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.search-input-wrap { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + min-width: 220px; + + input { + border: none; + background: transparent; + color: var(--text-primary); + font-size: 13px; + outline: none; + width: 180px; + } + + .clear-search { + border: none; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + display: flex; + } +} + +.selected-batch-actions { + display: flex; + align-items: center; + gap: 8px; + border: 1px dashed rgba(var(--primary-rgb), 0.45); + background: rgba(var(--primary-rgb), 0.06); + border-radius: 999px; + padding: 6px 10px; + font-size: 12px; + color: var(--text-secondary); +} + +.table-wrap { + overflow: auto; + border: 1px solid var(--border-color); + border-radius: 10px; + min-height: 0; + flex: 1; +} + +.session-table { + width: 100%; + min-width: 1300px; + border-collapse: separate; + border-spacing: 0; + + thead th { + position: sticky; + top: 0; + background: color-mix(in srgb, var(--bg-primary) 75%, var(--bg-secondary)); + z-index: 4; + font-size: 12px; + text-align: left; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); + padding: 10px 10px; + white-space: nowrap; + } + + tbody td { + padding: 10px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent); + font-size: 13px; + color: var(--text-primary); + vertical-align: middle; + white-space: nowrap; + } + + tbody tr:hover { + background: rgba(var(--primary-rgb), 0.03); + } + + .selected-row { + background: rgba(var(--primary-rgb), 0.08); + } + + .sticky-col { + position: sticky; + left: 0; + z-index: 5; + background: inherit; + } + + .sticky-right { + position: sticky; + right: 0; + z-index: 5; + background: inherit; + } +} + +.select-icon-btn { + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 6px; + + &:hover { + background: rgba(var(--primary-rgb), 0.1); + color: var(--primary); + } + + &.checked { + color: var(--primary); + } +} + +.session-cell { + display: flex; + align-items: center; + gap: 10px; + min-width: 230px; + + .session-avatar { + width: 36px; + height: 36px; + border-radius: 8px; + overflow: hidden; + flex-shrink: 0; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); display: flex; align-items: center; justify-content: center; - gap: 10px; - padding: 14px 24px; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: #fff; + font-size: 14px; + font-weight: 600; + } + } + + .session-name { + font-size: 13px; + color: var(--text-primary); + font-weight: 600; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + } + + .session-id { + margin-top: 2px; + font-size: 11px; + color: var(--text-tertiary); + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.row-action-cell { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + + .row-export-btn { + border: none; + border-radius: 8px; + padding: 7px 10px; background: var(--primary); color: #fff; - border: none; - border-radius: 12px; - font-size: 15px; - font-weight: 600; + font-size: 12px; cursor: pointer; - transition: all 0.2s; + display: flex; + align-items: center; + gap: 5px; &:hover:not(:disabled) { background: var(--primary-hover); } &:disabled { - opacity: 0.5; + opacity: 0.75; cursor: not-allowed; } - .spin { - animation: exportSpin 1s linear infinite; + &.running { + background: color-mix(in srgb, var(--primary) 80%, #000); } } - // 导出进度弹窗 - .export-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); + .row-export-time { + font-size: 11px; + color: var(--text-tertiary); + } +} + +.table-state { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 120px; + color: var(--text-secondary); +} + +.export-dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.export-dialog { + width: min(980px, calc(100vw - 40px)); + max-height: calc(100vh - 60px); + overflow: auto; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 16px; +} + +.dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + + h3 { + margin: 0; + color: var(--text-primary); + font-size: 18px; + } +} + +.close-icon-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 8px; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--text-secondary); +} + +.dialog-section { + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 12px; + margin-bottom: 10px; + background: var(--bg-secondary); + + h4 { + margin: 0 0 8px; + font-size: 13px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.4px; + } +} + +.scope-tag-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.scope-tag { + border-radius: 999px; + background: rgba(var(--primary-rgb), 0.15); + color: var(--primary); + padding: 4px 10px; + font-size: 12px; + font-weight: 600; +} + +.scope-count { + font-size: 12px; + color: var(--text-secondary); +} + +.scope-list { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 6px; + max-height: 120px; + overflow: auto; +} + +.scope-item { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 999px; + padding: 4px 9px; + font-size: 12px; + color: var(--text-primary); +} + +.format-grid { + display: grid; + grid-template-columns: repeat(4, minmax(130px, 1fr)); + gap: 8px; +} + +.format-card { + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 10px; + text-align: left; + background: var(--bg-primary); + cursor: pointer; + + .format-label { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + } + + .format-desc { + margin-top: 3px; + font-size: 11px; + color: var(--text-tertiary); + line-height: 1.4; + } + + &.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + } +} + +.switch-row { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 13px; + color: var(--text-primary); +} + +.date-range-row { + margin-top: 10px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + + label { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 12px; + color: var(--text-secondary); + } + + input { + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + padding: 8px; + } +} + +.media-check-grid { + margin-top: 10px; + display: grid; + grid-template-columns: repeat(3, minmax(100px, 1fr)); + gap: 8px; + + label { display: flex; align-items: center; - justify-content: center; - z-index: 1000; + gap: 6px; + font-size: 12px; + color: var(--text-primary); } - .export-progress-modal { - background: var(--card-bg); - padding: 32px 40px; - border-radius: 16px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); - text-align: center; - min-width: 320px; + input[type='checkbox'] { + accent-color: var(--primary); + } +} - .progress-spinner { - margin-bottom: 20px; - color: var(--primary); +.display-name-options { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; +} - .spin { - animation: exportSpin 1s linear infinite; - } - } +.display-name-item { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 8px; + display: flex; + flex-direction: column; + gap: 2px; + background: var(--bg-primary); - h3 { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 8px; - } - - .progress-text { - font-size: 14px; - color: var(--text-secondary); - margin: 0 0 20px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .progress-bar { - height: 6px; - background: var(--bg-secondary); - border-radius: 3px; - overflow: hidden; - margin-bottom: 12px; - - .progress-fill { - height: 100%; - background: var(--primary); - border-radius: 3px; - transition: width 0.3s ease; - } - } - - .progress-count { - font-size: 13px; - color: var(--text-tertiary); - margin: 0; - } + span { + font-size: 12px; + color: var(--text-primary); + font-weight: 600; } - .export-layout-modal { - background: var(--card-bg); - padding: 28px 32px; - border-radius: 16px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); - text-align: center; - width: min(520px, 90vw); - - h3 { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 8px; - } - - .layout-subtitle { - font-size: 14px; - color: var(--text-secondary); - margin: 0 0 20px; - } - - .layout-options { - display: grid; - gap: 12px; - } - - .layout-option-btn { - display: flex; - flex-direction: column; - gap: 6px; - padding: 14px 18px; - border-radius: 12px; - border: 1px solid var(--border-color); - background: var(--bg-secondary); - text-align: left; - cursor: pointer; - transition: all 0.2s; - - &:hover { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.08); - } - - &.primary { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.12); - } - - .layout-title { - font-size: 15px; - font-weight: 600; - color: var(--text-primary); - } - - .layout-desc { - font-size: 12px; - color: var(--text-tertiary); - } - } - - .layout-actions { - margin-top: 18px; - display: flex; - justify-content: center; - } - - .layout-cancel-btn { - padding: 8px 20px; - border-radius: 8px; - border: 1px solid var(--border-color); - background: var(--bg-secondary); - color: var(--text-primary); - cursor: pointer; - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - } - } + small { + color: var(--text-secondary); + font-size: 11px; + line-height: 1.4; } - .export-result-modal { - background: var(--card-bg); - padding: 32px 40px; - border-radius: 16px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); - text-align: center; - min-width: 320px; - - .result-icon { - margin-bottom: 16px; - - &.success { - color: #52c41a; - } - - &.error { - color: #ff4d4f; - } - } - - h3 { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 8px; - } - - .result-text { - font-size: 14px; - color: var(--text-secondary); - margin: 0 0 24px; - - &.error { - color: #ff4d4f; - } - } - - .result-actions { - display: flex; - gap: 12px; - justify-content: center; - - button { - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 10px 20px; - border-radius: 8px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - } - - .open-folder-btn { - background: var(--primary); - color: #fff; - border: none; - - &:hover { - background: var(--primary-hover); - } - } - - .close-btn { - background: var(--bg-secondary); - color: var(--text-primary); - border: 1px solid var(--border-color); - - &:hover { - background: var(--bg-hover); - } - } - } + input { + accent-color: var(--primary); + margin: 0 0 4px; } - .date-picker-modal { - background: var(--card-bg); - padding: 28px 32px; - border-radius: 16px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); - width: 420px; + &.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + } +} - h3 { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 20px; - } +.dialog-actions { + margin-top: 10px; + display: flex; + justify-content: flex-end; + gap: 8px; +} - .quick-select { - display: flex; - gap: 8px; - margin-bottom: 20px; +.primary-btn, +.secondary-btn { + border-radius: 8px; + padding: 7px 12px; + font-size: 12px; + font-weight: 600; + border: 1px solid var(--border-color); + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; +} - .quick-btn { - flex: 1; - padding: 10px 12px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - font-size: 13px; - font-weight: 500; - color: var(--text-primary); - cursor: pointer; - transition: all 0.2s; +.primary-btn { + border-color: var(--primary); + background: var(--primary); + color: #fff; - &:hover { - background: var(--bg-hover); - border-color: var(--primary); - color: var(--primary); - } + &:hover { + background: var(--primary-hover); + } - &:active { - transform: scale(0.98); - } - } - } + &:disabled { + opacity: 0.65; + cursor: not-allowed; + } +} - .date-display { - display: flex; - align-items: center; - gap: 16px; - padding: 20px; - background: var(--bg-secondary); - border-radius: 12px; - margin-bottom: 24px; +.secondary-btn { + background: var(--bg-secondary); + color: var(--text-primary); - .date-display-item { - flex: 1; - display: flex; - flex-direction: column; - gap: 6px; - padding: 8px 12px; - border-radius: 8px; - cursor: pointer; - transition: all 0.2s; - - &:hover { - background: rgba(var(--primary-rgb), 0.05); - } - - &.active { - background: rgba(var(--primary-rgb), 0.1); - border: 1px solid var(--primary); - } - - .date-label { - font-size: 12px; - color: var(--text-tertiary); - font-weight: 500; - } - - .date-value { - font-size: 15px; - color: var(--text-primary); - font-weight: 600; - } - } - - .date-separator { - font-size: 14px; - color: var(--text-tertiary); - padding: 0 4px; - } - } - - .calendar-container { - margin-bottom: 20px; - } - - .calendar-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 16px; - padding: 0 4px; - - .calendar-nav-btn { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - cursor: pointer; - color: var(--text-secondary); - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - border-color: var(--primary); - color: var(--primary); - } - - &:active { - transform: scale(0.95); - } - } - - .calendar-month { - font-size: 15px; - font-weight: 600; - color: var(--text-primary); - - &.clickable { - cursor: pointer; - border-radius: 6px; - padding: 2px 8px; - transition: all 0.15s; - - &:hover { - background: var(--bg-hover); - color: var(--primary); - } - } - } - } - - .calendar-weekdays { - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 4px; - margin-bottom: 8px; - - .calendar-weekday { - text-align: center; - font-size: 12px; - font-weight: 500; - color: var(--text-tertiary); - padding: 8px 0; - } - } - - .calendar-days { - display: grid; - grid-template-columns: repeat(7, 1fr); - grid-template-rows: repeat(6, 40px); - gap: 4px; - - .calendar-day { - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; - color: var(--text-primary); - border-radius: 8px; - cursor: pointer; - transition: all 0.2s; - position: relative; - - &.empty { - cursor: default; - } - - &:not(.empty):hover { - background: var(--bg-hover); - } - - &.in-range { - background: rgba(var(--primary-rgb), 0.08); - } - - &.start, - &.end { - background: var(--primary); - color: #fff; - font-weight: 600; - - &:hover { - background: var(--primary-hover); - } - } - } - } - - .year-month-picker { - padding: 4px 0; - - .year-selector { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; - - .year-label { - font-size: 15px; - font-weight: 600; - color: var(--text-primary); - } - - .calendar-nav-btn { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - cursor: pointer; - color: var(--text-secondary); - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - border-color: var(--primary); - color: var(--primary); - } - } - } - - .month-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 6px; - - .month-btn { - padding: 10px 0; - border: none; - background: transparent; - border-radius: 8px; - cursor: pointer; - font-size: 13px; - color: var(--text-secondary); - transition: all 0.15s; - - &:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - - &.active { - background: var(--primary); - color: #fff; - } - } - } - } - - .date-picker-actions { - display: flex; - gap: 12px; - justify-content: flex-end; - - button { - padding: 10px 20px; - border-radius: 8px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - - &:active { - transform: scale(0.98); - } - } - - .cancel-btn { - background: var(--bg-secondary); - color: var(--text-primary); - border: 1px solid var(--border-color); - - &:hover { - background: var(--bg-hover); - } - } - - .confirm-btn { - background: var(--primary); - color: #fff; - border: none; - - &:hover { - background: var(--primary-hover); - } - } - } + &:hover { + border-color: var(--primary); + color: var(--primary); } } @@ -1142,93 +917,28 @@ } } -// 媒体导出选项卡片样式 -.setting-subtitle { - font-size: 12px; - color: var(--text-tertiary); - margin: 4px 0 12px 0; -} - -.media-options-card { - background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 12px; - overflow: hidden; -} - -.media-switch-row { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 16px; -} - -.media-switch-info { - display: flex; - flex-direction: column; - gap: 2px; -} - -.media-switch-title { - font-size: 14px; - font-weight: 500; - color: var(--text-primary); -} - -.media-switch-desc { - font-size: 11px; - color: var(--text-tertiary); -} - -.media-option-divider { - height: 1px; - background: var(--border-color); - margin-left: 16px; -} - -.media-checkbox-row { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - cursor: pointer; - transition: background 0.2s; - - &:hover:not(.disabled) { - background: var(--bg-hover); +@media (max-width: 1360px) { + .export-top-panel { + grid-template-columns: 1fr; } - &.disabled { - opacity: 0.5; - cursor: not-allowed; + .global-export-controls { + grid-template-columns: 1fr; } - input[type="checkbox"] { - width: 18px; - height: 18px; - accent-color: var(--primary); - cursor: pointer; + .content-card-grid { + grid-template-columns: repeat(2, minmax(160px, 1fr)); + } - &:disabled { - cursor: not-allowed; - } + .format-grid { + grid-template-columns: repeat(2, minmax(120px, 1fr)); + } + + .display-name-options { + grid-template-columns: 1fr; + } + + .media-check-grid { + grid-template-columns: repeat(2, minmax(120px, 1fr)); } } - -.media-checkbox-info { - display: flex; - flex-direction: column; - gap: 2px; -} - -.media-checkbox-title { - font-size: 14px; - color: var(--text-primary); -} - -.media-checkbox-desc { - font-size: 11px; - color: var(--text-tertiary); -} - -// 全局样式已在 main.scss 中定义 \ No newline at end of file diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index fce60b4..6855b60 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,19 +1,38 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } 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 { + CheckSquare, + Download, + ExternalLink, + FolderOpen, + Image as ImageIcon, + Loader2, + MessageSquareText, + Mic, + Search, + Square, + Video, + WandSparkles, + X +} from 'lucide-react' +import type { ChatSession as AppChatSession, ContactInfo } from '../types/models' +import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron' import * as configService from '../services/config' import './ExportPage.scss' -interface ChatSession { - username: string - displayName?: string - avatarUrl?: string - summary: string - lastTimestamp: number -} +type ConversationTab = 'private' | 'group' | 'official' +type TaskStatus = 'queued' | 'running' | 'success' | 'error' +type TaskScope = 'single' | 'multi' | 'content' +type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' + +type SessionLayout = 'shared' | 'per-session' + +type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname' + +type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' interface ExportOptions { - format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' + format: TextExportFormat dateRange: { start: Date; end: Date } | null useAllTime: boolean exportAvatars: boolean @@ -25,61 +44,211 @@ interface ExportOptions { exportVoiceAsText: boolean excelCompactColumns: boolean txtColumns: string[] - displayNamePreference: 'group-nickname' | 'remark' | 'nickname' + displayNamePreference: DisplayNamePreference exportConcurrency: number } -interface ExportResult { - success: boolean - successCount?: number - failCount?: number - error?: string +interface SessionRow extends AppChatSession { + kind: ConversationTab + wechatId?: string } -type SessionLayout = 'shared' | 'per-session' +interface SessionMetrics { + totalMessages?: number + voiceMessages?: number + imageMessages?: number + videoMessages?: number + emojiMessages?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number + firstTimestamp?: number + lastTimestamp?: number +} + +interface TaskProgress { + current: number + total: number + currentName: string + phaseLabel: string + phaseProgress: number + phaseTotal: number +} + +interface ExportTaskPayload { + sessionIds: string[] + outputDir: string + options: ElectronExportOptions + scope: TaskScope + contentType?: ContentType + sessionNames: string[] +} + +interface ExportTask { + id: string + title: string + status: TaskStatus + createdAt: number + startedAt?: number + finishedAt?: number + error?: string + payload: ExportTaskPayload + progress: TaskProgress +} + +interface ExportDialogState { + open: boolean + scope: TaskScope + contentType?: ContentType + sessionIds: string[] + sessionNames: string[] + title: string +} + +interface CurrentUserProfile { + wxid: string + displayName: string + avatarUrl?: string +} + +const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] +const contentTypeLabels: Record = { + text: '聊天文本', + voice: '语音', + image: '图片', + video: '视频', + emoji: '表情包' +} + +const formatOptions: Array<{ value: TextExportFormat; label: string; desc: string }> = [ + { value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' }, + { value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' }, + { value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' }, + { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' }, + { value: 'txt', label: 'TXT', desc: '纯文本,通用格式' }, + { value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' }, + { value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' }, + { value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' } +] + +const displayNameOptions: Array<{ value: DisplayNamePreference; label: string; desc: string }> = [ + { value: 'group-nickname', label: '群昵称优先', desc: '仅群聊有效,私聊显示备注/昵称' }, + { value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示昵称' }, + { value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' } +] + +const writeLayoutOptions: Array<{ value: configService.ExportWriteLayout; label: string; desc: string }> = [ + { + value: 'A', + label: 'A(类型分目录)', + desc: '聊天文本、语音、视频、表情包、图片分别创建文件夹' + }, + { + value: 'B', + label: 'B(文本根目录+媒体按会话)', + desc: '聊天文本在根目录;媒体按类型目录后再按会话分目录' + }, + { + value: 'C', + label: 'C(按会话分目录)', + desc: '每个会话一个目录,目录内包含文本与媒体文件' + } +] + +const createEmptyProgress = (): TaskProgress => ({ + current: 0, + total: 0, + currentName: '', + phaseLabel: '', + phaseProgress: 0, + phaseTotal: 0 +}) + +const formatAbsoluteDate = (timestamp: number): string => { + const d = new Date(timestamp) + const y = d.getFullYear() + const m = `${d.getMonth() + 1}`.padStart(2, '0') + const day = `${d.getDate()}`.padStart(2, '0') + return `${y}-${m}-${day}` +} + +const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => { + if (!timestamp) return '未导出' + const diff = Math.max(0, now - timestamp) + const minute = 60 * 1000 + const hour = 60 * minute + const day = 24 * hour + if (diff < hour) { + const minutes = Math.max(1, Math.floor(diff / minute)) + return `${minutes} 分钟前` + } + if (diff < day) { + const hours = Math.max(1, Math.floor(diff / hour)) + return `${hours} 小时前` + } + return formatAbsoluteDate(timestamp) +} + +const formatDateInputValue = (date: Date): string => { + const y = date.getFullYear() + const m = `${date.getMonth() + 1}`.padStart(2, '0') + const d = `${date.getDate()}`.padStart(2, '0') + return `${y}-${m}-${d}` +} + +const parseDateInput = (value: string, endOfDay: boolean): Date => { + const [year, month, day] = value.split('-').map(v => Number(v)) + const date = new Date(year, month - 1, day) + if (endOfDay) { + date.setHours(23, 59, 59, 999) + } else { + date.setHours(0, 0, 0, 0) + } + return date +} + +const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => { + if (session.username.endsWith('@chatroom')) return 'group' + if (contact?.type === 'official') return 'official' + return 'private' +} + +const getAvatarLetter = (name: string): string => { + if (!name) return '?' + return [...name][0] || '?' +} + +const valueOrDash = (value?: number): string => { + if (value === undefined || value === null) return '--' + return value.toLocaleString() +} + +const timestampOrDash = (timestamp?: number): string => { + if (!timestamp) return '--' + return formatAbsoluteDate(timestamp * 1000) +} + +const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` function ExportPage() { const location = useLocation() - const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] - const [sessions, setSessions] = useState([]) - const [filteredSessions, setFilteredSessions] = useState([]) - const [selectedSessions, setSelectedSessions] = useState>(new Set()) + const [isLoading, setIsLoading] = useState(true) + const [sessions, setSessions] = useState([]) + const [contactMap, setContactMap] = useState>({}) + const [groupMemberCountMap, setGroupMemberCountMap] = useState>({}) + const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') - const [exportFolder, setExportFolder] = useState('') - const [isExporting, setIsExporting] = useState(false) - const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 }) - const [exportResult, setExportResult] = useState(null) - const [showDatePicker, setShowDatePicker] = useState(false) - const [calendarDate, setCalendarDate] = useState(new Date()) - const [selectingStart, setSelectingStart] = useState(true) - const [showYearMonthPicker, setShowYearMonthPicker] = useState(false) - 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('shared') - const exportStartTime = useRef(0) - const [elapsedSeconds, setElapsedSeconds] = useState(0) - const displayNameDropdownRef = useRef(null) - const preselectAppliedRef = useRef(false) - const statsRequestIdRef = useRef(0) + const [activeTab, setActiveTab] = useState('private') + const [selectedSessions, setSelectedSessions] = useState>(new Set()) - 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] : []) + const [currentUser, setCurrentUser] = useState({ wxid: '', displayName: '未识别用户' }) - return rawList - .filter((item): item is string => typeof item === 'string') - .map(item => item.trim()) - .filter(Boolean) - }, [location.state]) + const [exportFolder, setExportFolder] = useState('') + const [writeLayout, setWriteLayout] = useState('A') + const [showWriteLayoutSelect, setShowWriteLayoutSelect] = useState(false) const [options, setOptions] = useState({ format: 'excel', @@ -101,105 +270,170 @@ function ExportPage() { exportConcurrency: 2 }) - const buildDateRangeFromPreset = (preset: string) => { - const now = new Date() - if (preset === 'all') { - return { useAllTime: true, dateRange: { start: now, end: now } } - } - let rangeMs = 0 - if (preset === '7d') rangeMs = 7 * 24 * 60 * 60 * 1000 - if (preset === '30d') rangeMs = 30 * 24 * 60 * 60 * 1000 - if (preset === '90d') rangeMs = 90 * 24 * 60 * 60 * 1000 - if (preset === 'today' || rangeMs === 0) { - const start = new Date(now) - start.setHours(0, 0, 0, 0) - return { useAllTime: false, dateRange: { start, end: now } } - } - const start = new Date(now.getTime() - rangeMs) - start.setHours(0, 0, 0, 0) - return { useAllTime: false, dateRange: { start, end: now } } - } + const [exportDialog, setExportDialog] = useState({ + open: false, + scope: 'single', + sessionIds: [], + sessionNames: [], + title: '' + }) - const loadSessions = useCallback(async () => { - setIsLoading(true) + const [tasks, setTasks] = useState([]) + const [lastExportBySession, setLastExportBySession] = useState>({}) + const [lastExportByContent, setLastExportByContent] = useState>({}) + const [nowTick, setNowTick] = useState(Date.now()) + + const progressUnsubscribeRef = useRef<(() => void) | null>(null) + const runningTaskIdRef = useRef(null) + const tasksRef = useRef([]) + const loadingMetricsRef = useRef>(new Set()) + const preselectAppliedRef = useRef(false) + + useEffect(() => { + tasksRef.current = tasks + }, [tasks]) + + 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]) + + useEffect(() => { + const timer = setInterval(() => setNowTick(Date.now()), 60 * 1000) + return () => clearInterval(timer) + }, []) + + const loadCurrentUser = useCallback(async () => { try { - const result = await window.electronAPI.chat.connect() - if (!result.success) { - console.error('连接失败:', result.error) - setIsLoading(false) - return + const wxid = await configService.getMyWxid() + let displayName = wxid || '未识别用户' + let avatarUrl: string | undefined + + if (wxid) { + const myContact = await window.electronAPI.chat.getContact(wxid) + const bestName = [myContact?.remark, myContact?.nickName, myContact?.alias, wxid].find(Boolean) + if (bestName) displayName = bestName } - const sessionsResult = await window.electronAPI.chat.getSessions() - if (sessionsResult.success && sessionsResult.sessions) { - setSessions(sessionsResult.sessions) - setFilteredSessions(sessionsResult.sessions) + + const avatarResult = await window.electronAPI.chat.getMyAvatarUrl() + if (avatarResult.success && avatarResult.avatarUrl) { + avatarUrl = avatarResult.avatarUrl } - } catch (e) { - console.error('加载会话失败:', e) - } finally { - setIsLoading(false) + + setCurrentUser({ wxid: wxid || '', displayName, avatarUrl }) + } catch (error) { + console.error('加载当前用户信息失败:', error) } }, []) - const loadExportPath = useCallback(async () => { + const loadBaseConfig = useCallback(async () => { try { - const savedPath = await configService.getExportPath() + const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap] = await Promise.all([ + configService.getExportPath(), + configService.getExportDefaultFormat(), + configService.getExportDefaultMedia(), + configService.getExportDefaultVoiceAsText(), + configService.getExportDefaultExcelCompactColumns(), + configService.getExportDefaultTxtColumns(), + configService.getExportDefaultConcurrency(), + configService.getExportWriteLayout(), + configService.getExportLastSessionRunMap(), + configService.getExportLastContentRunMap() + ]) + if (savedPath) { setExportFolder(savedPath) } else { const downloadsPath = await window.electronAPI.app.getDownloadsPath() setExportFolder(downloadsPath) } - } catch (e) { - console.error('加载导出路径失败:', e) + + setWriteLayout(savedWriteLayout) + setLastExportBySession(savedSessionMap) + setLastExportByContent(savedContentMap) + + const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns + setOptions(prev => ({ + ...prev, + format: (savedFormat as TextExportFormat) || prev.format, + exportMedia: savedMedia ?? prev.exportMedia, + exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText, + excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns, + txtColumns, + exportConcurrency: savedConcurrency ?? prev.exportConcurrency + })) + } catch (error) { + console.error('加载导出配置失败:', error) } }, []) - const loadExportDefaults = useCallback(async () => { + const loadSessions = useCallback(async () => { + setIsLoading(true) try { - const [ - savedFormat, - savedRange, - savedMedia, - savedVoiceAsText, - savedExcelCompactColumns, - savedTxtColumns, - savedConcurrency - ] = await Promise.all([ - configService.getExportDefaultFormat(), - configService.getExportDefaultDateRange(), - configService.getExportDefaultMedia(), - configService.getExportDefaultVoiceAsText(), - configService.getExportDefaultExcelCompactColumns(), - configService.getExportDefaultTxtColumns(), - configService.getExportDefaultConcurrency() + const connectResult = await window.electronAPI.chat.connect() + if (!connectResult.success) { + console.error('连接失败:', connectResult.error) + setIsLoading(false) + return + } + + const [sessionsResult, contactsResult, groupChatsResult] = await Promise.all([ + window.electronAPI.chat.getSessions(), + window.electronAPI.chat.getContacts(), + window.electronAPI.groupAnalytics.getGroupChats() ]) - const preset = savedRange || 'today' - const rangeDefaults = buildDateRangeFromPreset(preset) - const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns + const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : [] + const nextContactMap = contacts.reduce>((map, contact) => { + map[contact.username] = contact + return map + }, {}) + setContactMap(nextContactMap) - setOptions((prev) => ({ - ...prev, - format: (savedFormat as ExportOptions['format']) || 'excel', - useAllTime: rangeDefaults.useAllTime, - dateRange: rangeDefaults.dateRange, - exportMedia: savedMedia ?? false, - exportVoiceAsText: savedVoiceAsText ?? false, - excelCompactColumns: savedExcelCompactColumns ?? true, - txtColumns, - exportConcurrency: savedConcurrency ?? 2 - })) - } catch (e) { - console.error('加载导出默认设置失败:', e) + const nextGroupMemberCountMap: Record = {} + if (groupChatsResult.success && groupChatsResult.data) { + for (const group of groupChatsResult.data) { + nextGroupMemberCountMap[group.username] = group.memberCount + } + } + setGroupMemberCountMap(nextGroupMemberCountMap) + + if (sessionsResult.success && sessionsResult.sessions) { + const nextSessions = sessionsResult.sessions + .map((session) => { + const contact = nextContactMap[session.username] + const kind = toKindByContactType(session, contact) + return { + ...session, + kind, + wechatId: contact?.username || session.username, + displayName: session.displayName || contact?.displayName || session.username, + avatarUrl: session.avatarUrl || contact?.avatarUrl + } as SessionRow + }) + .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) + + setSessions(nextSessions) + } + } catch (error) { + console.error('加载会话失败:', error) + } finally { + setIsLoading(false) } }, []) useEffect(() => { + loadCurrentUser() + loadBaseConfig() loadSessions() - loadExportPath() - loadExportDefaults() - }, [loadSessions, loadExportPath, loadExportDefaults]) + }, [loadCurrentUser, loadBaseConfig, loadSessions]) useEffect(() => { preselectAppliedRef.current = false @@ -215,398 +449,757 @@ function ExportPage() { if (matched.length > 0) { setSelectedSessions(new Set(matched)) - setSearchKeyword('') } }, [sessions, preselectSessionIds]) - useEffect(() => { - const handleChange = () => { - setSelectedSessions(new Set()) - setSearchKeyword('') - setExportResult(null) - setSessions([]) - setFilteredSessions([]) - loadSessions() - } - window.addEventListener('wxid-changed', handleChange as EventListener) - return () => window.removeEventListener('wxid-changed', handleChange as EventListener) - }, [loadSessions]) - - useEffect(() => { - 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, - phaseLabel: payload.phaseLabel || '', - phaseProgress: payload.phaseProgress || 0, - phaseTotal: payload.phaseTotal || 0 - }) + const visibleSessions = useMemo(() => { + const keyword = searchKeyword.trim().toLowerCase() + return sessions.filter((session) => { + if (session.kind !== activeTab) return false + if (!keyword) return true + return ( + (session.displayName || '').toLowerCase().includes(keyword) || + session.username.toLowerCase().includes(keyword) + ) }) - return () => { - removeListener?.() + }, [sessions, activeTab, searchKeyword]) + + const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { + const pending = targetSessions.filter(session => !sessionMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) + if (pending.length === 0) return + + for (const session of pending) { + loadingMetricsRef.current.add(session.username) } - }, []) - // 导出计时器 - useEffect(() => { - if (!isExporting) return - const timer = setInterval(() => { - setElapsedSeconds(Math.floor((Date.now() - exportStartTime.current) / 1000)) - }, 1000) - return () => clearInterval(timer) - }, [isExporting]) + const updates: Record = {} - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - const target = event.target as Node - if (showDisplayNameSelect && displayNameDropdownRef.current && !displayNameDropdownRef.current.contains(target)) { - setShowDisplayNameSelect(false) - } - } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showDisplayNameSelect]) + for (const session of pending) { + const metrics: SessionMetrics = {} + try { + const detailResult = await window.electronAPI.chat.getSessionDetail(session.username) + if (detailResult.success && detailResult.detail) { + metrics.totalMessages = detailResult.detail.messageCount + metrics.firstTimestamp = detailResult.detail.firstMessageTime + metrics.lastTimestamp = detailResult.detail.latestMessageTime + } - useEffect(() => { - if (!searchKeyword.trim()) { - setFilteredSessions(sessions) - return - } - const lower = searchKeyword.toLowerCase() - setFilteredSessions(sessions.filter(s => - s.displayName?.toLowerCase().includes(lower) || - s.username.toLowerCase().includes(lower) - )) - }, [searchKeyword, sessions]) - - const toggleSession = (username: string) => { - const newSet = new Set(selectedSessions) - if (newSet.has(username)) { - newSet.delete(username) - } else { - newSet.add(username) - } - setSelectedSessions(newSet) - } - - const toggleSelectAll = () => { - if (selectedSessions.size === filteredSessions.length) { - setSelectedSessions(new Set()) - } else { - setSelectedSessions(new Set(filteredSessions.map(s => s.username))) - } - } - - const getAvatarLetter = (name: string) => { - if (!name) return '?' - return [...name][0] || '?' - } - - const formatDate = (date: Date) => { - return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }) - } - - const handleFormatChange = (format: ExportOptions['format']) => { - setOptions((prev) => { - const next = { ...prev, format } - if (format === 'html') { - return { - ...next, + const exportStats = await window.electronAPI.export.getExportStats([session.username], { + exportVoiceAsText: false, exportMedia: true, exportImages: true, exportVoices: true, exportVideos: true, - exportEmojis: true + exportEmojis: true, + dateRange: null + }) + metrics.voiceMessages = exportStats.voiceMessages + if (metrics.totalMessages === undefined) { + metrics.totalMessages = exportStats.totalMessages + } + + if (session.kind === 'group') { + metrics.groupMemberCount = groupMemberCountMap[session.username] + + const [mediaStatsResult, rankingResult] = await Promise.all([ + window.electronAPI.groupAnalytics.getGroupMediaStats(session.username), + window.electronAPI.groupAnalytics.getGroupMessageRanking(session.username) + ]) + + if (mediaStatsResult.success && mediaStatsResult.data?.typeCounts) { + for (const item of mediaStatsResult.data.typeCounts) { + const n = item.name.toLowerCase() + if (n.includes('图片')) metrics.imageMessages = item.count + if (n.includes('视频')) metrics.videoMessages = item.count + if (n.includes('语音')) metrics.voiceMessages = item.count + if (n.includes('表情')) metrics.emojiMessages = item.count + } + } + + if (rankingResult.success && rankingResult.data) { + metrics.groupActiveSpeakers = rankingResult.data.length + const selfWxid = session.selfWxid || currentUser.wxid + const me = rankingResult.data.find(item => item.member.username === selfWxid) + if (me) { + metrics.groupMyMessages = me.messageCount + } + } + } + } catch (error) { + console.error('加载会话统计失败:', session.username, error) + } finally { + loadingMetricsRef.current.delete(session.username) + } + + updates[session.username] = metrics + } + + if (Object.keys(updates).length > 0) { + setSessionMetrics(prev => ({ ...prev, ...updates })) + } + }, [sessionMetrics, groupMemberCountMap, currentUser.wxid]) + + useEffect(() => { + const targets = visibleSessions.slice(0, 40) + void ensureSessionMetrics(targets) + }, [visibleSessions, ensureSessionMetrics]) + + const selectedCount = selectedSessions.size + + const toggleSelectSession = (sessionId: string) => { + setSelectedSessions(prev => { + const next = new Set(prev) + if (next.has(sessionId)) { + next.delete(sessionId) + } else { + next.add(sessionId) + } + return next + }) + } + + const toggleSelectAllVisible = () => { + const visibleIds = visibleSessions.map(session => session.username) + if (visibleIds.length === 0) return + + setSelectedSessions(prev => { + const next = new Set(prev) + const allSelected = visibleIds.every(id => next.has(id)) + if (allSelected) { + for (const id of visibleIds) { + next.delete(id) + } + } else { + for (const id of visibleIds) { + next.add(id) } } return next }) } - const openExportFolder = async () => { - if (exportFolder) { - await window.electronAPI.shell.openPath(exportFolder) - } - } + const clearSelection = () => setSelectedSessions(new Set()) - const runExport = async (sessionLayout: SessionLayout) => { - if (selectedSessions.size === 0 || !exportFolder) return + const openExportDialog = (payload: Omit) => { + setExportDialog({ open: true, ...payload }) - setIsExporting(true) - 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) - const exportOptions = { - format: options.format, - exportAvatars: options.exportAvatars, - exportMedia: options.exportMedia, - exportImages: options.exportMedia && options.exportImages, - exportVoices: options.exportMedia && options.exportVoices, - exportVideos: options.exportMedia && options.exportVideos, - exportEmojis: options.exportMedia && options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容 - excelCompactColumns: options.excelCompactColumns, - txtColumns: options.txtColumns, - displayNamePreference: options.displayNamePreference, - exportConcurrency: options.exportConcurrency, - sessionLayout, - dateRange: options.useAllTime ? null : options.dateRange ? { - start: Math.floor(options.dateRange.start.getTime() / 1000), - // 将结束日期设置为当天的 23:59:59,确保包含当天的所有记录 - end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000) - } : null - } - - if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html' || options.format === 'weclone') { - const result = await window.electronAPI.export.exportSessions( - sessionList, - exportFolder, - exportOptions - ) - setExportResult(result) + if (payload.scope === 'content' && payload.contentType) { + if (payload.contentType === 'text') { + setOptions(prev => ({ ...prev, exportMedia: false })) } else { - setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式目前暂未实现,请选择其他格式。` }) + setOptions(prev => ({ + ...prev, + exportMedia: true, + exportImages: payload.contentType === 'image', + exportVoices: payload.contentType === 'voice', + exportVideos: payload.contentType === 'video', + exportEmojis: payload.contentType === 'emoji' + })) } - } catch (e) { - console.error('导出过程中发生异常:', e) - setExportResult({ success: false, error: String(e) }) - } finally { - setIsExporting(false) } } - const startExport = async () => { - if (selectedSessions.size === 0 || !exportFolder) return - - // 先获取预估统计 - const requestId = ++statsRequestIdRef.current - setIsLoadingStats(true) - setPreExportStats(null) - 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) - if (statsRequestIdRef.current !== requestId) return - setPreExportStats(stats) - } catch (e) { - console.error('获取导出统计失败:', e) - if (statsRequestIdRef.current !== requestId) return - setPreExportStats(null) - } finally { - if (statsRequestIdRef.current !== requestId) return - setIsLoadingStats(false) - } + const closeExportDialog = () => { + setExportDialog(prev => ({ ...prev, open: false })) } - const confirmExport = () => { - statsRequestIdRef.current++ - setIsLoadingStats(false) - setShowPreExportDialog(false) - setPreExportStats(null) + const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => { + const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared' - if (options.exportMedia && selectedSessions.size > 1) { - setShowMediaLayoutPrompt(true) - return + const base: ElectronExportOptions = { + format: options.format, + exportAvatars: options.exportAvatars, + exportMedia: options.exportMedia, + exportImages: options.exportMedia && options.exportImages, + exportVoices: options.exportMedia && options.exportVoices, + exportVideos: options.exportMedia && options.exportVideos, + exportEmojis: options.exportMedia && options.exportEmojis, + exportVoiceAsText: options.exportVoiceAsText, + excelCompactColumns: options.excelCompactColumns, + txtColumns: options.txtColumns, + displayNamePreference: options.displayNamePreference, + exportConcurrency: options.exportConcurrency, + sessionLayout, + dateRange: options.useAllTime + ? null + : options.dateRange + ? { + start: Math.floor(options.dateRange.start.getTime() / 1000), + end: Math.floor(options.dateRange.end.getTime() / 1000) + } + : null } - const layout: SessionLayout = options.exportMedia ? 'per-session' : 'shared' - runExport(layout) - } - - const getDaysInMonth = (date: Date) => { - const year = date.getFullYear() - const month = date.getMonth() - return new Date(year, month + 1, 0).getDate() - } - - const getFirstDayOfMonth = (date: Date) => { - const year = date.getFullYear() - const month = date.getMonth() - return new Date(year, month, 1).getDay() - } - - const generateCalendar = () => { - const daysInMonth = getDaysInMonth(calendarDate) - const firstDay = getFirstDayOfMonth(calendarDate) - const days: (number | null)[] = [] - - for (let i = 0; i < firstDay; i++) { - days.push(null) - } - - for (let i = 1; i <= daysInMonth; i++) { - days.push(i) - } - - return days - } - - const handleDateSelect = (day: number) => { - const year = calendarDate.getFullYear() - const month = calendarDate.getMonth() - const selectedDate = new Date(year, month, day) - // 设置时间为当天的开始或结束 - selectedDate.setHours(selectingStart ? 0 : 23, selectingStart ? 0 : 59, selectingStart ? 0 : 59, selectingStart ? 0 : 999) - - const now = new Date() - // 如果选择的日期晚于当前时间,限制为当前时间 - if (selectedDate > now) { - selectedDate.setTime(now.getTime()) - } - - if (selectingStart) { - // 选择开始日期 - const currentEnd = options.dateRange?.end || new Date() - // 如果选择的开始日期晚于结束日期,则同时更新结束日期 - if (selectedDate > currentEnd) { - const newEnd = new Date(selectedDate) - newEnd.setHours(23, 59, 59, 999) - // 确保结束日期也不晚于当前时间 - if (newEnd > now) { - newEnd.setTime(now.getTime()) + if (scope === 'content' && contentType) { + if (contentType === 'text') { + return { + ...base, + exportMedia: false, + exportImages: false, + exportVoices: false, + exportVideos: false, + exportEmojis: false } - setOptions({ - ...options, - dateRange: { start: selectedDate, end: newEnd } - }) - } else { - setOptions({ - ...options, - dateRange: options.dateRange ? { ...options.dateRange, start: selectedDate } : { start: selectedDate, end: new Date() } - }) } - setSelectingStart(false) - } else { - // 选择结束日期 - const currentStart = options.dateRange?.start || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) - // 如果选择的结束日期早于开始日期,则同时更新开始日期 - if (selectedDate < currentStart) { - const newStart = new Date(selectedDate) - newStart.setHours(0, 0, 0, 0) - setOptions({ - ...options, - dateRange: { start: newStart, end: selectedDate } - }) - } else { - setOptions({ - ...options, - dateRange: options.dateRange ? { ...options.dateRange, end: selectedDate } : { start: new Date(), end: selectedDate } - }) + + return { + ...base, + exportMedia: true, + exportImages: contentType === 'image', + exportVoices: contentType === 'voice', + exportVideos: contentType === 'video', + exportEmojis: contentType === 'emoji' } - setSelectingStart(true) } + + return base } - const formatOptions = [ - { value: 'chatlab', label: 'ChatLab', icon: FileCode, desc: '标准格式,支持其他软件导入' }, - { value: 'chatlab-jsonl', label: 'ChatLab JSONL', icon: FileCode, desc: '流式格式,适合大量消息' }, - { value: 'json', label: 'JSON', icon: FileJson, desc: '详细格式,包含完整消息信息' }, - { value: 'html', label: 'HTML', icon: FileText, desc: '网页格式,可直接浏览' }, - { value: 'txt', label: 'TXT', icon: Table, desc: '纯文本,通用格式' }, - { value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' }, - { value: 'weclone', label: 'WeClone CSV', icon: Table, desc: 'WeClone 兼容字段格式(CSV)' }, - { value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' } - ] - const displayNameOptions = [ - { - value: 'group-nickname', - label: '群昵称优先', - desc: '仅群聊有效,私聊显示备注/昵称' - }, - { - value: 'remark', - label: '备注优先', - desc: '有备注显示备注,否则显示昵称' - }, - { - value: 'nickname', - label: '微信昵称', - desc: '始终显示微信昵称' + const markSessionExported = useCallback((sessionIds: string[], timestamp: number) => { + setLastExportBySession(prev => { + const next = { ...prev } + for (const id of sessionIds) { + next[id] = timestamp + } + void configService.setExportLastSessionRunMap(next) + return next + }) + }, []) + + const markContentExported = useCallback((sessionIds: string[], contentTypes: ContentType[], timestamp: number) => { + setLastExportByContent(prev => { + const next = { ...prev } + for (const id of sessionIds) { + for (const type of contentTypes) { + next[`${id}::${type}`] = timestamp + } + } + void configService.setExportLastContentRunMap(next) + return next + }) + }, []) + + const inferContentTypesFromOptions = (opts: ElectronExportOptions): ContentType[] => { + const types: ContentType[] = ['text'] + if (opts.exportMedia) { + if (opts.exportVoices) types.push('voice') + if (opts.exportImages) types.push('image') + if (opts.exportVideos) types.push('video') + if (opts.exportEmojis) types.push('emoji') } - ] - const displayNameOption = displayNameOptions.find(option => option.value === options.displayNamePreference) - const displayNameLabel = displayNameOption?.label || '备注优先' + return types + } + + const updateTask = useCallback((taskId: string, updater: (task: ExportTask) => ExportTask) => { + setTasks(prev => prev.map(task => (task.id === taskId ? updater(task) : task))) + }, []) + + const runNextTask = useCallback(async () => { + if (runningTaskIdRef.current) return + + const queue = [...tasksRef.current].reverse() + const next = queue.find(task => task.status === 'queued') + if (!next) return + + runningTaskIdRef.current = next.id + updateTask(next.id, task => ({ ...task, status: 'running', startedAt: Date.now() })) + + progressUnsubscribeRef.current?.() + progressUnsubscribeRef.current = window.electronAPI.export.onProgress((payload: ExportProgress) => { + updateTask(next.id, task => ({ + ...task, + progress: { + current: payload.current, + total: payload.total, + currentName: payload.currentSession, + phaseLabel: payload.phaseLabel || '', + phaseProgress: payload.phaseProgress || 0, + phaseTotal: payload.phaseTotal || 0 + } + })) + }) + + try { + const result = await window.electronAPI.export.exportSessions( + next.payload.sessionIds, + next.payload.outputDir, + next.payload.options + ) + + if (!result.success) { + updateTask(next.id, task => ({ + ...task, + status: 'error', + finishedAt: Date.now(), + error: result.error || '导出失败' + })) + } else { + const doneAt = Date.now() + const contentTypes = next.payload.contentType + ? [next.payload.contentType] + : inferContentTypesFromOptions(next.payload.options) + + markSessionExported(next.payload.sessionIds, doneAt) + markContentExported(next.payload.sessionIds, contentTypes, doneAt) + + updateTask(next.id, task => ({ + ...task, + status: 'success', + finishedAt: doneAt, + progress: { + ...task.progress, + current: task.progress.total || next.payload.sessionIds.length, + total: task.progress.total || next.payload.sessionIds.length, + phaseLabel: '完成', + phaseProgress: 1, + phaseTotal: 1 + } + })) + } + } catch (error) { + updateTask(next.id, task => ({ + ...task, + status: 'error', + finishedAt: Date.now(), + error: String(error) + })) + } finally { + progressUnsubscribeRef.current?.() + progressUnsubscribeRef.current = null + runningTaskIdRef.current = null + void runNextTask() + } + }, [updateTask, markSessionExported, markContentExported]) + + useEffect(() => { + void runNextTask() + }, [tasks, runNextTask]) + + useEffect(() => { + return () => { + progressUnsubscribeRef.current?.() + progressUnsubscribeRef.current = null + } + }, []) + + const createTask = async () => { + if (!exportDialog.open || exportDialog.sessionIds.length === 0 || !exportFolder) return + + const exportOptions = buildExportOptions(exportDialog.scope, exportDialog.contentType) + const title = + exportDialog.scope === 'single' + ? `${exportDialog.sessionNames[0] || '会话'} 导出` + : exportDialog.scope === 'multi' + ? `批量导出(${exportDialog.sessionIds.length} 个会话)` + : `${contentTypeLabels[exportDialog.contentType || 'text']}批量导出` + + const task: ExportTask = { + id: createTaskId(), + title, + status: 'queued', + createdAt: Date.now(), + payload: { + sessionIds: exportDialog.sessionIds, + sessionNames: exportDialog.sessionNames, + outputDir: exportFolder, + options: exportOptions, + scope: exportDialog.scope, + contentType: exportDialog.contentType + }, + progress: createEmptyProgress() + } + + setTasks(prev => [task, ...prev]) + closeExportDialog() + + await configService.setExportDefaultFormat(options.format) + await configService.setExportDefaultMedia(options.exportMedia) + await configService.setExportDefaultVoiceAsText(options.exportVoiceAsText) + await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns) + await configService.setExportDefaultTxtColumns(options.txtColumns) + await configService.setExportDefaultConcurrency(options.exportConcurrency) + } + + const openSingleExport = (session: SessionRow) => { + openExportDialog({ + scope: 'single', + sessionIds: [session.username], + sessionNames: [session.displayName || session.username], + title: `导出会话:${session.displayName || session.username}` + }) + } + + const openBatchExport = () => { + const ids = Array.from(selectedSessions) + if (ids.length === 0) return + const nameMap = new Map(sessions.map(session => [session.username, session.displayName || session.username])) + const names = ids.map(id => nameMap.get(id) || id) + + openExportDialog({ + scope: 'multi', + sessionIds: ids, + sessionNames: names, + title: `批量导出(${ids.length} 个会话)` + }) + } + + const openContentExport = (contentType: ContentType) => { + const ids = sessions + .filter(session => session.kind === 'private' || session.kind === 'group') + .map(session => session.username) + + const names = sessions + .filter(session => session.kind === 'private' || session.kind === 'group') + .map(session => session.displayName || session.username) + + openExportDialog({ + scope: 'content', + contentType, + sessionIds: ids, + sessionNames: names, + title: `${contentTypeLabels[contentType]}批量导出` + }) + } + + const runningSessionIds = useMemo(() => { + const set = new Set() + for (const task of tasks) { + if (task.status !== 'running') continue + for (const id of task.payload.sessionIds) { + set.add(id) + } + } + return set + }, [tasks]) + + const queuedSessionIds = useMemo(() => { + const set = new Set() + for (const task of tasks) { + if (task.status !== 'queued') continue + for (const id of task.payload.sessionIds) { + set.add(id) + } + } + return set + }, [tasks]) + + const contentCards = useMemo(() => { + const scopeSessions = sessions.filter(session => session.kind === 'private' || session.kind === 'group') + const total = scopeSessions.length + + return [ + { type: 'text' as ContentType, icon: MessageSquareText }, + { type: 'voice' as ContentType, icon: Mic }, + { type: 'image' as ContentType, icon: ImageIcon }, + { type: 'video' as ContentType, icon: Video }, + { type: 'emoji' as ContentType, icon: WandSparkles } + ].map(item => { + let exported = 0 + for (const session of scopeSessions) { + if (lastExportByContent[`${session.username}::${item.type}`]) { + exported += 1 + } + } + + return { + ...item, + label: contentTypeLabels[item.type], + total, + exported + } + }) + }, [sessions, lastExportByContent]) + + const activeTabLabel = useMemo(() => { + if (activeTab === 'private') return '私聊' + if (activeTab === 'group') return '群聊' + return '公众号' + }, [activeTab]) + + const renderSessionName = (session: SessionRow) => { + return ( +
+
+ {session.avatarUrl ? : {getAvatarLetter(session.displayName || session.username)}} +
+
+
{session.displayName || session.username}
+
{session.wechatId || session.username}
+
+
+ ) + } + + const renderActionCell = (session: SessionRow) => { + const isRunning = runningSessionIds.has(session.username) + const isQueued = queuedSessionIds.has(session.username) + const recent = formatRecentExportTime(lastExportBySession[session.username], nowTick) + + return ( +
+ + {recent} +
+ ) + } + + const renderTableHeader = () => { + if (activeTab === 'private') { + return ( + + 选择 + 会话名(头像/昵称/微信号) + 总消息 + 语音 + 图片 + 视频 + 表情包 + 共同群聊数 + 最早时间 + 最新时间 + 操作 + + ) + } + + if (activeTab === 'group') { + return ( + + 选择 + 会话名(群头像/群名称/群ID) + 总消息 + 语音 + 图片 + 视频 + 表情包 + 我发的消息数 + 群人数 + 群发言人数 + 群共同好友数 + 最早时间 + 最新时间 + 操作 + + ) + } + + return ( + + 选择 + 会话名(头像/名称/微信号) + 总消息 + 语音 + 图片 + 视频 + 表情包 + 最早时间 + 最新时间 + 操作 + + ) + } + + const renderRow = (session: SessionRow) => { + const metrics = sessionMetrics[session.username] || {} + const checked = selectedSessions.has(session.username) + + return ( + + + + + + {renderSessionName(session)} + {valueOrDash(metrics.totalMessages)} + {valueOrDash(metrics.voiceMessages)} + {valueOrDash(metrics.imageMessages)} + {valueOrDash(metrics.videoMessages)} + {valueOrDash(metrics.emojiMessages)} + + {activeTab === 'private' && ( + <> + {valueOrDash(metrics.privateMutualGroups)} + {timestampOrDash(metrics.firstTimestamp)} + {timestampOrDash(metrics.lastTimestamp)} + + )} + + {activeTab === 'group' && ( + <> + {valueOrDash(metrics.groupMyMessages)} + {valueOrDash(metrics.groupMemberCount)} + {valueOrDash(metrics.groupActiveSpeakers)} + {valueOrDash(metrics.groupMutualFriends)} + {timestampOrDash(metrics.firstTimestamp)} + {timestampOrDash(metrics.lastTimestamp)} + + )} + + {activeTab === 'official' && ( + <> + {timestampOrDash(metrics.firstTimestamp)} + {timestampOrDash(metrics.lastTimestamp)} + + )} + + {renderActionCell(session)} + + ) + } + + const visibleSelectedCount = useMemo(() => { + const visibleSet = new Set(visibleSessions.map(session => session.username)) + let count = 0 + for (const id of selectedSessions) { + if (visibleSet.has(id)) count += 1 + } + return count + }, [visibleSessions, selectedSessions]) + + const writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A(类型分目录)' return ( -
-
-
-

选择会话

- -
- -
- - setSearchKeyword(e.target.value)} - /> - {searchKeyword && ( - - )} -
- -
- - 已选 {selectedSessions.size} 个 -
- - {isLoading ? ( -
- - 加载中... +
+
+
+
+ {currentUser.avatarUrl ? : {getAvatarLetter(currentUser.displayName)}}
- ) : filteredSessions.length === 0 ? ( -
- 暂无会话 +
+
{currentUser.displayName}
+
{currentUser.wxid || 'wxid 未识别'}
- ) : ( -
- {filteredSessions.map(session => ( -
toggleSession(session.username)} +
+ +
+
+ 导出位置 +
{exportFolder || '未设置'}
+
+ + +
+
+ +
+ 写入格式 + + {showWriteLayoutSelect && ( +
+ {writeLayoutOptions.map(option => ( + + ))} +
+ )} +
+
+
+ +
+ {contentCards.map(card => { + const Icon = card.icon + return ( +
+
+
{card.label}
+
+
+
+ 总会话数 + {card.total}
-
- {session.avatarUrl ? ( - - ) : ( - {getAvatarLetter(session.displayName || session.username)} +
+ 已导出会话数 + {card.exported} +
+
+ +
+ ) + })} +
+ +
+
任务中心
+ {tasks.length === 0 ? ( +
暂无任务。点击会话导出或卡片导出后会在这里创建任务。
+ ) : ( +
+ {tasks.map(task => ( +
+
+
{task.title}
+
+ {task.status === 'queued' ? '排队中' : task.status === 'running' ? '进行中' : task.status === 'success' ? '已完成' : '失败'} + {new Date(task.createdAt).toLocaleString('zh-CN')} +
+ {task.status === 'running' && ( + <> +
+
0 ? (task.progress.current / task.progress.total) * 100 : 0}%` }} + /> +
+
+ {task.progress.current} / {task.progress.total || task.payload.sessionIds.length} + {task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''} +
+ )} + {task.status === 'error' &&
{task.error || '任务失败'}
}
-
-
{session.displayName || session.username}
-
{session.summary || '暂无消息'}
+
+
))} @@ -614,591 +1207,206 @@ function ExportPage() { )}
-
-
-

导出设置

-
- -
-
-

导出格式

-
- {formatOptions.map(fmt => ( -
handleFormatChange(fmt.value as ExportOptions['format'])} - > - - {fmt.label} - {fmt.desc} -
- ))} -
+
+
+
+ + +
-
-

时间范围

-

选择要导出的消息时间区间

-
-
-
- 导出全部时间 - 关闭此项以选择特定的起止日期 -
+
+
+ + setSearchKeyword(event.target.value)} + placeholder={`搜索${activeTabLabel}会话...`} + /> + {searchKeyword && ( + + )} +
+ + + + {selectedCount > 0 && ( +
+ 已选中 {selectedCount} 个会话 + + +
+ )} +
+
+ +
+ + {renderTableHeader()} + + {isLoading ? ( + + + + ) : visibleSessions.length === 0 ? ( + + + + ) : ( + visibleSessions.map(renderRow) + )} + +
+
加载中...
+
+
暂无会话
+
+
+
+ + {exportDialog.open && ( +
+
event.stopPropagation()}> +
+

{exportDialog.title}

+ +
+ +
+

导出范围

+
+ {exportDialog.scope === 'single' ? '单会话' : exportDialog.scope === 'multi' ? '多会话' : `按内容批量(${contentTypeLabels[exportDialog.contentType || 'text']})`} + 共 {exportDialog.sessionIds.length} 个会话 +
+
+ {exportDialog.sessionNames.slice(0, 20).map(name => ( + {name} + ))} + {exportDialog.sessionNames.length > 20 && ... 还有 {exportDialog.sessionNames.length - 20} 个} +
+
+ +
+

对话文本导出格式选择

+
+ {formatOptions.map(option => ( + + ))} +
+
+ +
+

时间范围

+
+ 导出全部时间
{!options.useAllTime && options.dateRange && ( - <> -
-
setShowDatePicker(true)}> -
- - {formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)} -
- -
- +
+ + +
)}
-
- {/* 发送者名称显示偏好 */} - {(options.format === 'html' || options.format === 'json' || options.format === 'txt') && ( -
-

发送者名称显示

-

选择导出时优先显示的名称

-
- - {showDisplayNameSelect && ( -
- {displayNameOptions.map(option => ( - - ))} -
- )} -
-
- )} -
-

媒体文件

-

导出图片/语音/视频/表情并在记录内写入相对路径

-
-
-
- 导出媒体文件 - 会创建子文件夹并保存媒体资源 -
+
+

媒体与头像

+
+ 导出媒体文件
-
- - - -
- - - -
- - - -
- - - -
- - -
-
- -
-

头像

-

可选导出头像索引,关闭则不下载头像

-
-
-
- 导出头像 - 用于展示发送者头像,可能会读取或下载头像文件 -
- +
+ + + + + +
-
-
-

导出位置

-
- - {exportFolder || '未设置'} -
- -
-
- -
- -
-
- - {/* 媒体导出布局选择弹窗 */} - {showMediaLayoutPrompt && ( -
setShowMediaLayoutPrompt(false)}> -
e.stopPropagation()}> -

导出文件夹布局

-

检测到同时导出多个会话并包含媒体文件,请选择存放方式:

-
- - -
-
- -
-
-
- )} - - {/* 导出前预估弹窗 */} - {showPreExportDialog && ( -
-
e.stopPropagation()}> -

导出预估

- {isLoadingStats ? ( -
- - 正在统计消息,可直接点击“直接导出”跳过等待 -
- ) : preExportStats ? ( -
-
-
- 会话数 -
{selectedSessions.size}
-
-
- 总消息 -
{preExportStats.totalMessages.toLocaleString()}
-
- {options.exportVoiceAsText && preExportStats.voiceMessages > 0 && ( - <> -
- 语音消息 -
{preExportStats.voiceMessages}
-
-
- 已有缓存 -
{preExportStats.cachedVoiceCount}
-
- - )} -
- {options.exportVoiceAsText && preExportStats.needTranscribeCount > 0 && ( -
- - {' '}需要转写 {preExportStats.needTranscribeCount} 条语音,预计耗时约 {preExportStats.estimatedSeconds > 60 - ? `${Math.round(preExportStats.estimatedSeconds / 60)} 分钟` - : `${preExportStats.estimatedSeconds} 秒` - } -
- )} - {options.exportVoiceAsText && preExportStats.voiceMessages > 0 && preExportStats.needTranscribeCount === 0 && ( -
- - {' '}所有 {preExportStats.voiceMessages} 条语音已有转写缓存,无需重新转写 -
- )} -
- ) : ( -

统计信息获取失败,仍可继续导出

- )} -
- - -
-
-
- )} - - {/* 导出进度弹窗 */} - {isExporting && ( -
-
-
- -
-

正在导出

-

{exportProgress.currentName}

- {exportProgress.phaseLabel && ( -

- {exportProgress.phaseLabel} -

- )} - {exportProgress.phaseTotal > 0 && ( -
-
-
- )} -
-
0 ? (exportProgress.current / exportProgress.total) * 100 : 0}%` }} - /> -
-

- {exportProgress.current} / {exportProgress.total} 个会话 - - {elapsedSeconds > 0 && `已用 ${elapsedSeconds >= 60 ? `${Math.floor(elapsedSeconds / 60)}分${elapsedSeconds % 60}秒` : `${elapsedSeconds}秒`}`} - -

-
-
- )} - - {/* 导出结果弹窗 */} - {exportResult && ( -
-
-
- {exportResult.success ? : } -
-

{exportResult.success ? '导出完成' : '导出失败'}

- {exportResult.success ? ( -

- 成功导出 {exportResult.successCount} 个会话 - {exportResult.failCount ? `,${exportResult.failCount} 个失败` : ''} -

- ) : ( -

{exportResult.error}

- )} -
- {exportResult.success && ( - - )} - -
-
-
- )} - - {/* 日期选择弹窗 */} - {showDatePicker && ( -
{ setShowDatePicker(false); setShowYearMonthPicker(false) }}> -
e.stopPropagation()}> -

选择时间范围

-

- 点击选择开始和结束日期,系统会自动调整确保时间顺序正确 -

-
- - - -
-
-
setSelectingStart(true)} - > - 开始日期 - - {options.dateRange?.start?.toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit' - })} - -
- -
setSelectingStart(false)} - > - 结束日期 - - {options.dateRange?.end?.toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit' - })} - -
-
-
-
- - setShowYearMonthPicker(!showYearMonthPicker)}> - {calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月 - - -
- {showYearMonthPicker ? ( -
-
- - {calendarDate.getFullYear()}年 - -
-
- {['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => ( - - ))} -
-
- ) : ( - <> -
- {['日', '一', '二', '三', '四', '五', '六'].map(day => ( -
{day}
+
+

发送者名称显示

+
+ {displayNameOptions.map(option => ( + ))}
-
- {generateCalendar().map((day, index) => { - if (day === null) { - return
- } - - const currentDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day) - const isStart = options.dateRange?.start?.toDateString() === currentDate.toDateString() - const isEnd = options.dateRange?.end?.toDateString() === currentDate.toDateString() - const isInRange = options.dateRange?.start && options.dateRange?.end && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end - const today = new Date() - today.setHours(0, 0, 0, 0) - const isFuture = currentDate > today - - return ( -
!isFuture && handleDateSelect(day)} - style={{ cursor: isFuture ? 'not-allowed' : 'pointer', opacity: isFuture ? 0.3 : 1 }} - > - {day} -
- ) - })} -
- - )}
-
- - +
diff --git a/src/services/config.ts b/src/services/config.ts index 6b5ddc7..bb96231 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -32,6 +32,9 @@ export const CONFIG_KEYS = { EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns', EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns', EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency', + EXPORT_WRITE_LAYOUT: 'exportWriteLayout', + EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap', + EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap', // 安全 AUTH_ENABLED: 'authEnabled', @@ -386,6 +389,52 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise< await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency) } +export type ExportWriteLayout = 'A' | 'B' | 'C' + +export async function getExportWriteLayout(): Promise { + const value = await config.get(CONFIG_KEYS.EXPORT_WRITE_LAYOUT) + if (value === 'A' || value === 'B' || value === 'C') return value + return 'A' +} + +export async function setExportWriteLayout(layout: ExportWriteLayout): Promise { + await config.set(CONFIG_KEYS.EXPORT_WRITE_LAYOUT, layout) +} + +export async function getExportLastSessionRunMap(): Promise> { + const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SESSION_RUN_MAP) + if (!value || typeof value !== 'object') return {} + const entries = Object.entries(value as Record) + const map: Record = {} + for (const [sessionId, raw] of entries) { + if (typeof raw === 'number' && Number.isFinite(raw)) { + map[sessionId] = raw + } + } + return map +} + +export async function setExportLastSessionRunMap(map: Record): Promise { + await config.set(CONFIG_KEYS.EXPORT_LAST_SESSION_RUN_MAP, map) +} + +export async function getExportLastContentRunMap(): Promise> { + const value = await config.get(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP) + if (!value || typeof value !== 'object') return {} + const entries = Object.entries(value as Record) + const map: Record = {} + for (const [key, raw] of entries) { + if (typeof raw === 'number' && Number.isFinite(raw)) { + map[key] = raw + } + } + return map +} + +export async function setExportLastContentRunMap(map: Record): Promise { + await config.set(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP, map) +} + // === 安全相关 === export async function getAuthEnabled(): Promise { From e686bb624786c3ac70894030bdcf23ef7869df8a Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 14:55:19 +0800 Subject: [PATCH 005/162] feat(export): add batch session stats api for export board --- electron/main.ts | 4 + electron/preload.ts | 1 + electron/services/chatService.ts | 340 +++++++++++++++++++++++++++++++ src/pages/ExportPage.tsx | 100 +++------ src/types/electron.d.ts | 18 ++ 5 files changed, 392 insertions(+), 71 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index f686c4b..af89f08 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -974,6 +974,10 @@ function registerIpcHandlers() { return chatService.getSessionDetail(sessionId) }) + ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[]) => { + return chatService.getExportSessionStats(sessionIds) + }) + ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => { return chatService.getImageData(sessionId, msgId) }) diff --git a/electron/preload.ts b/electron/preload.ts index dd087bb..99aceff 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -151,6 +151,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId), close: () => ipcRenderer.invoke('chat:close'), getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId), + getExportSessionStats: (sessionIds: string[]) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds), getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId), getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index e188de8..171ac0b 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -136,9 +136,25 @@ export interface ContactInfo { type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' } +interface ExportSessionStats { + totalMessages: number + voiceMessages: number + imageMessages: number + videoMessages: number + emojiMessages: number + firstTimestamp?: number + lastTimestamp?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number +} + // 表情包缓存 const emojiCache: Map = new Map() const emojiDownloading: Map> = new Map() +const FRIEND_EXCLUDE_USERNAMES = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) class ChatService { private configService: ConfigService @@ -1210,6 +1226,228 @@ class ChatService { return Number.isFinite(parsed) ? parsed : NaN } + private buildIdentityKeys(raw: string): string[] { + const value = String(raw || '').trim() + if (!value) return [] + const lowerRaw = value.toLowerCase() + const cleaned = this.cleanAccountDirName(value).toLowerCase() + if (cleaned && cleaned !== lowerRaw) { + return [cleaned, lowerRaw] + } + return [lowerRaw] + } + + private extractGroupMemberUsername(member: any): string { + if (!member) return '' + if (typeof member === 'string') return member.trim() + return String( + member.username || + member.userName || + member.user_name || + member.encryptUsername || + member.encryptUserName || + member.encrypt_username || + member.originalName || + '' + ).trim() + } + + private async getFriendIdentitySet(): Promise> { + const identities = new Set() + const contactResult = await wcdbService.execQuery( + 'contact', + null, + 'SELECT username, local_type, quan_pin FROM contact' + ) + if (!contactResult.success || !contactResult.rows) { + return identities + } + + for (const rowAny of contactResult.rows) { + const row = rowAny as Record + const username = String(row.username || '').trim() + if (!username || username.includes('@chatroom') || username.startsWith('gh_')) continue + if (FRIEND_EXCLUDE_USERNAMES.has(username)) continue + + const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) + if (localType !== 1) continue + + for (const key of this.buildIdentityKeys(username)) { + identities.add(key) + } + } + return identities + } + + private async forEachWithConcurrency( + items: T[], + limit: number, + worker: (item: T) => Promise + ): Promise { + if (items.length === 0) return + const concurrency = Math.max(1, Math.min(limit, items.length)) + let index = 0 + + const runners = Array.from({ length: concurrency }, async () => { + while (true) { + const current = index + index += 1 + if (current >= items.length) return + await worker(items[current]) + } + }) + + await Promise.all(runners) + } + + private async collectSessionExportStats( + sessionId: string, + selfIdentitySet: Set + ): Promise { + const stats: ExportSessionStats = { + totalMessages: 0, + voiceMessages: 0, + imageMessages: 0, + videoMessages: 0, + emojiMessages: 0 + } + if (sessionId.endsWith('@chatroom')) { + stats.groupMyMessages = 0 + stats.groupActiveSpeakers = 0 + } + + const senderIdentities = new Set() + const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 500, false, 0, 0) + if (!cursorResult.success || !cursorResult.cursor) { + return stats + } + + const cursor = cursorResult.cursor + try { + while (true) { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success) { + break + } + + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + for (const row of rows) { + stats.totalMessages += 1 + + const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) + if (localType === 34) stats.voiceMessages += 1 + if (localType === 3) stats.imageMessages += 1 + if (localType === 43) stats.videoMessages += 1 + if (localType === 47) stats.emojiMessages += 1 + + const createTime = this.getRowInt( + row, + ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], + 0 + ) + if (createTime > 0) { + if (stats.firstTimestamp === undefined || createTime < stats.firstTimestamp) { + stats.firstTimestamp = createTime + } + if (stats.lastTimestamp === undefined || createTime > stats.lastTimestamp) { + stats.lastTimestamp = createTime + } + } + + if (sessionId.endsWith('@chatroom')) { + const sender = String(this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || '').trim() + const senderKeys = this.buildIdentityKeys(sender) + if (senderKeys.length > 0) { + senderIdentities.add(senderKeys[0]) + if (senderKeys.some((key) => selfIdentitySet.has(key))) { + stats.groupMyMessages = (stats.groupMyMessages || 0) + 1 + } + } else { + const isSend = this.coerceRowNumber(this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])) + if (Number.isFinite(isSend) && isSend === 1) { + stats.groupMyMessages = (stats.groupMyMessages || 0) + 1 + } + } + } + } + + if (!batch.hasMore || rows.length === 0) { + break + } + } + } finally { + await wcdbService.closeMessageCursor(cursor) + } + + if (sessionId.endsWith('@chatroom')) { + stats.groupActiveSpeakers = senderIdentities.size + } + return stats + } + + private async buildGroupRelationStats( + groupSessionIds: string[], + privateSessionIds: string[], + selfIdentitySet: Set + ): Promise<{ + privateMutualGroupMap: Record + groupMutualFriendMap: Record + }> { + const privateMutualGroupMap: Record = {} + const groupMutualFriendMap: Record = {} + if (groupSessionIds.length === 0) { + return { privateMutualGroupMap, groupMutualFriendMap } + } + + const privateIndex = new Map>() + for (const sessionId of privateSessionIds) { + for (const key of this.buildIdentityKeys(sessionId)) { + const set = privateIndex.get(key) || new Set() + set.add(sessionId) + privateIndex.set(key, set) + } + privateMutualGroupMap[sessionId] = 0 + } + + const friendIdentitySet = await this.getFriendIdentitySet() + await this.forEachWithConcurrency(groupSessionIds, 4, async (groupId) => { + const membersResult = await wcdbService.getGroupMembers(groupId) + if (!membersResult.success || !membersResult.members) { + groupMutualFriendMap[groupId] = 0 + return + } + + const touchedPrivateSessions = new Set() + const friendMembers = new Set() + + for (const member of membersResult.members) { + const username = this.extractGroupMemberUsername(member) + const identityKeys = this.buildIdentityKeys(username) + if (identityKeys.length === 0) continue + const canonical = identityKeys[0] + + if (!selfIdentitySet.has(canonical) && friendIdentitySet.has(canonical)) { + friendMembers.add(canonical) + } + + for (const key of identityKeys) { + const linked = privateIndex.get(key) + if (!linked) continue + for (const sessionId of linked) { + touchedPrivateSessions.add(sessionId) + } + } + } + + groupMutualFriendMap[groupId] = friendMembers.size + for (const sessionId of touchedPrivateSessions) { + privateMutualGroupMap[sessionId] = (privateMutualGroupMap[sessionId] || 0) + 1 + } + }) + + return { privateMutualGroupMap, groupMutualFriendMap } + } + /** * HTTP API 复用消息解析逻辑,确保和应用内展示一致。 */ @@ -3407,6 +3645,108 @@ class ChatService { return { success: false, error: String(e) } } } + + async getExportSessionStats(sessionIds: string[]): Promise<{ + success: boolean + data?: Record + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + const normalizedSessionIds = Array.from( + new Set( + (sessionIds || []) + .map((id) => String(id || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedSessionIds.length === 0) { + return { success: true, data: {} } + } + + const myWxid = this.configService.get('myWxid') || '' + const selfIdentitySet = new Set(this.buildIdentityKeys(myWxid)) + + const resultMap: Record = {} + await this.forEachWithConcurrency(normalizedSessionIds, 3, async (sessionId) => { + try { + resultMap[sessionId] = await this.collectSessionExportStats(sessionId, selfIdentitySet) + } catch { + resultMap[sessionId] = { + totalMessages: 0, + voiceMessages: 0, + imageMessages: 0, + videoMessages: 0, + emojiMessages: 0 + } + } + }) + + const groupSessionIds = normalizedSessionIds.filter((id) => id.endsWith('@chatroom')) + const privateSessionIds = normalizedSessionIds.filter((id) => !id.endsWith('@chatroom')) + + for (const privateId of privateSessionIds) { + resultMap[privateId] = { + ...resultMap[privateId], + privateMutualGroups: resultMap[privateId]?.privateMutualGroups ?? 0 + } + } + for (const groupId of groupSessionIds) { + resultMap[groupId] = { + ...resultMap[groupId], + groupMyMessages: resultMap[groupId]?.groupMyMessages ?? 0, + groupActiveSpeakers: resultMap[groupId]?.groupActiveSpeakers ?? 0, + groupMemberCount: resultMap[groupId]?.groupMemberCount ?? 0, + groupMutualFriends: resultMap[groupId]?.groupMutualFriends ?? 0 + } + } + + if (groupSessionIds.length > 0) { + const memberCountsResult = await wcdbService.getGroupMemberCounts(groupSessionIds) + const memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {} + for (const groupId of groupSessionIds) { + resultMap[groupId] = { + ...resultMap[groupId], + groupMemberCount: typeof memberCountMap[groupId] === 'number' ? memberCountMap[groupId] : 0 + } + } + } + + if (groupSessionIds.length > 0) { + try { + const { privateMutualGroupMap, groupMutualFriendMap } = await this.buildGroupRelationStats( + groupSessionIds, + privateSessionIds, + selfIdentitySet + ) + + for (const privateId of privateSessionIds) { + resultMap[privateId] = { + ...resultMap[privateId], + privateMutualGroups: privateMutualGroupMap[privateId] || 0 + } + } + for (const groupId of groupSessionIds) { + resultMap[groupId] = { + ...resultMap[groupId], + groupMutualFriends: groupMutualFriendMap[groupId] || 0 + } + } + } catch { + // 群成员关系统计失败时保留默认值,避免影响主列表展示 + } + } + + return { success: true, data: resultMap } + } catch (e) { + console.error('ChatService: 获取导出会话统计失败:', e) + return { success: false, error: String(e) } + } + } /** * 获取图片数据(解密后的) */ diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 6855b60..52dcc6f 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -237,8 +237,6 @@ function ExportPage() { const [isLoading, setIsLoading] = useState(true) const [sessions, setSessions] = useState([]) - const [contactMap, setContactMap] = useState>({}) - const [groupMemberCountMap, setGroupMemberCountMap] = useState>({}) const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') const [activeTab, setActiveTab] = useState('private') @@ -384,10 +382,9 @@ function ExportPage() { return } - const [sessionsResult, contactsResult, groupChatsResult] = await Promise.all([ + const [sessionsResult, contactsResult] = await Promise.all([ window.electronAPI.chat.getSessions(), - window.electronAPI.chat.getContacts(), - window.electronAPI.groupAnalytics.getGroupChats() + window.electronAPI.chat.getContacts() ]) const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : [] @@ -395,15 +392,6 @@ function ExportPage() { map[contact.username] = contact return map }, {}) - setContactMap(nextContactMap) - - const nextGroupMemberCountMap: Record = {} - if (groupChatsResult.success && groupChatsResult.data) { - for (const group of groupChatsResult.data) { - nextGroupMemberCountMap[group.username] = group.memberCount - } - } - setGroupMemberCountMap(nextGroupMemberCountMap) if (sessionsResult.success && sessionsResult.sessions) { const nextSessions = sessionsResult.sessions @@ -468,76 +456,46 @@ function ExportPage() { const pending = targetSessions.filter(session => !sessionMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) if (pending.length === 0) return + const updates: Record = {} for (const session of pending) { loadingMetricsRef.current.add(session.username) + updates[session.username] = {} } - const updates: Record = {} - - for (const session of pending) { - const metrics: SessionMetrics = {} - try { - const detailResult = await window.electronAPI.chat.getSessionDetail(session.username) - if (detailResult.success && detailResult.detail) { - metrics.totalMessages = detailResult.detail.messageCount - metrics.firstTimestamp = detailResult.detail.firstMessageTime - metrics.lastTimestamp = detailResult.detail.latestMessageTime - } - - const exportStats = await window.electronAPI.export.getExportStats([session.username], { - exportVoiceAsText: false, - exportMedia: true, - exportImages: true, - exportVoices: true, - exportVideos: true, - exportEmojis: true, - dateRange: null - }) - metrics.voiceMessages = exportStats.voiceMessages - if (metrics.totalMessages === undefined) { - metrics.totalMessages = exportStats.totalMessages - } - - if (session.kind === 'group') { - metrics.groupMemberCount = groupMemberCountMap[session.username] - - const [mediaStatsResult, rankingResult] = await Promise.all([ - window.electronAPI.groupAnalytics.getGroupMediaStats(session.username), - window.electronAPI.groupAnalytics.getGroupMessageRanking(session.username) - ]) - - if (mediaStatsResult.success && mediaStatsResult.data?.typeCounts) { - for (const item of mediaStatsResult.data.typeCounts) { - const n = item.name.toLowerCase() - if (n.includes('图片')) metrics.imageMessages = item.count - if (n.includes('视频')) metrics.videoMessages = item.count - if (n.includes('语音')) metrics.voiceMessages = item.count - if (n.includes('表情')) metrics.emojiMessages = item.count - } - } - - if (rankingResult.success && rankingResult.data) { - metrics.groupActiveSpeakers = rankingResult.data.length - const selfWxid = session.selfWxid || currentUser.wxid - const me = rankingResult.data.find(item => item.member.username === selfWxid) - if (me) { - metrics.groupMyMessages = me.messageCount - } + try { + const statsResult = await window.electronAPI.chat.getExportSessionStats(pending.map(session => session.username)) + if (statsResult.success && statsResult.data) { + for (const session of pending) { + const raw = statsResult.data[session.username] + if (!raw) continue + updates[session.username] = { + totalMessages: raw.totalMessages, + voiceMessages: raw.voiceMessages, + imageMessages: raw.imageMessages, + videoMessages: raw.videoMessages, + emojiMessages: raw.emojiMessages, + privateMutualGroups: raw.privateMutualGroups, + groupMemberCount: raw.groupMemberCount, + groupMyMessages: raw.groupMyMessages, + groupActiveSpeakers: raw.groupActiveSpeakers, + groupMutualFriends: raw.groupMutualFriends, + firstTimestamp: raw.firstTimestamp, + lastTimestamp: raw.lastTimestamp } } - } catch (error) { - console.error('加载会话统计失败:', session.username, error) - } finally { + } + } catch (error) { + console.error('加载会话统计失败:', error) + } finally { + for (const session of pending) { loadingMetricsRef.current.delete(session.username) } - - updates[session.username] = metrics } if (Object.keys(updates).length > 0) { setSessionMetrics(prev => ({ ...prev, ...updates })) } - }, [sessionMetrics, groupMemberCountMap, currentUser.wxid]) + }, [sessionMetrics]) useEffect(() => { const targets = visibleSessions.slice(0, 40) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 45116aa..ff6a293 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -124,6 +124,24 @@ export interface ElectronAPI { } error?: string }> + getExportSessionStats: (sessionIds: string[]) => Promise<{ + success: boolean + data?: Record + error?: string + }> getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }> getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }> From 596baad2969d00f185ce18308b53393acf3f8888 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 15:20:08 +0800 Subject: [PATCH 006/162] feat(export): add sns stats card and conversation tab updates --- electron/main.ts | 4 + electron/preload.ts | 1 + electron/services/snsService.ts | 40 ++++ src/components/Sidebar.scss | 70 +++++- src/components/Sidebar.tsx | 63 +++++ src/pages/ExportPage.scss | 74 +----- src/pages/ExportPage.tsx | 412 ++++++++++++++++++++++---------- src/services/config.ts | 14 ++ src/types/electron.d.ts | 1 + 9 files changed, 491 insertions(+), 188 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index af89f08..0a47a22 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1020,6 +1020,10 @@ function registerIpcHandlers() { return snsService.getSnsUsernames() }) + ipcMain.handle('sns:getExportStats', async () => { + return snsService.getExportStats() + }) + ipcMain.handle('sns:debugResource', async (_, url: string) => { return snsService.debugResource(url) }) diff --git a/electron/preload.ts b/electron/preload.ts index 99aceff..49c3126 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -288,6 +288,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), + getExportStats: () => ipcRenderer.invoke('sns:getExportStats'), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload), downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload), diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 835850f..b9f43c2 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -235,6 +235,13 @@ class SnsService { this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string) } + private parseCountValue(row: any): number { + if (!row || typeof row !== 'object') return 0 + const raw = row.total ?? row.count ?? row.cnt ?? Object.values(row)[0] + const num = Number(raw) + return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0 + } + private parseLikesFromXml(xml: string): string[] { if (!xml) return [] const likes: string[] = [] @@ -359,6 +366,39 @@ class SnsService { return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) } } + async getExportStats(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> { + try { + let totalPosts = 0 + const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine') + if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) { + totalPosts = this.parseCountValue(postCountResult.rows[0]) + } + + let totalFriends = 0 + const friendCountPrimary = await wcdbService.execQuery( + 'sns', + null, + "SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''" + ) + if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) { + totalFriends = this.parseCountValue(friendCountPrimary.rows[0]) + } else { + const friendCountFallback = await wcdbService.execQuery( + 'sns', + null, + "SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''" + ) + if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) { + totalFriends = this.parseCountValue(friendCountFallback.rows[0]) + } + } + + return { success: true, data: { totalPosts, totalFriends } } + } catch (e) { + return { success: false, error: String(e) } + } + } + // 安装朋友圈删除拦截 async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { return wcdbService.installSnsBlockDeleteTrigger() diff --git a/src/components/Sidebar.scss b/src/components/Sidebar.scss index d2a1b7f..70781e7 100644 --- a/src/components/Sidebar.scss +++ b/src/components/Sidebar.scss @@ -10,6 +10,16 @@ &.collapsed { width: 64px; + .sidebar-user-card { + margin: 0 8px 8px; + padding: 8px 0; + justify-content: center; + + .user-meta { + display: none; + } + } + .nav-menu, .sidebar-footer { padding: 0 8px; @@ -27,6 +37,64 @@ } } +.sidebar-user-card { + margin: 0 12px 10px; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-secondary); + display: flex; + align-items: center; + gap: 10px; + min-height: 56px; + + .user-avatar { + width: 36px; + height: 36px; + border-radius: 10px; + overflow: hidden; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: #fff; + font-size: 14px; + font-weight: 600; + } + } + + .user-meta { + min-width: 0; + } + + .user-name { + font-size: 13px; + color: var(--text-primary); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .user-wxid { + margin-top: 2px; + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + .nav-menu { flex: 1; display: flex; @@ -130,4 +198,4 @@ background: rgba(209, 158, 187, 0.15); color: #D19EBB; border: 1px solid rgba(209, 158, 187, 0.2); -} \ No newline at end of file +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 0085b6d..2effba3 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -2,19 +2,69 @@ import { useState, useEffect } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react' import { useAppStore } from '../stores/appStore' +import * as configService from '../services/config' import './Sidebar.scss' +interface SidebarUserProfile { + wxid: string + displayName: string + avatarUrl?: string +} + function Sidebar() { const location = useLocation() const [collapsed, setCollapsed] = useState(false) const [authEnabled, setAuthEnabled] = useState(false) + const [userProfile, setUserProfile] = useState({ + wxid: '', + displayName: '未识别用户' + }) const setLocked = useAppStore(state => state.setLocked) useEffect(() => { window.electronAPI.auth.verifyEnabled().then(setAuthEnabled) }, []) + useEffect(() => { + const loadCurrentUser = async () => { + try { + const wxid = await configService.getMyWxid() + let displayName = wxid || '未识别用户' + + if (wxid) { + const myContact = await window.electronAPI.chat.getContact(wxid) + const bestName = [myContact?.remark, myContact?.nickName, myContact?.alias, wxid].find(Boolean) + if (bestName) displayName = bestName + } + + let avatarUrl: string | undefined + const avatarResult = await window.electronAPI.chat.getMyAvatarUrl() + if (avatarResult.success && avatarResult.avatarUrl) { + avatarUrl = avatarResult.avatarUrl + } + + setUserProfile({ + wxid: wxid || '', + displayName, + avatarUrl + }) + } catch (error) { + console.error('加载侧边栏用户信息失败:', error) + } + } + + void loadCurrentUser() + const onWxidChanged = () => { void loadCurrentUser() } + window.addEventListener('wxid-changed', onWxidChanged as EventListener) + return () => window.removeEventListener('wxid-changed', onWxidChanged as EventListener) + }, []) + + const getAvatarLetter = (name: string): string => { + if (!name) return '?' + return [...name][0] || '?' + } + const isActive = (path: string) => { return location.pathname === path || location.pathname.startsWith(`${path}/`) } @@ -106,6 +156,19 @@ function Sidebar() {
+
+
+ {userProfile.avatarUrl ? : {getAvatarLetter(userProfile.displayName)}} +
+
+
{userProfile.displayName}
+
{userProfile.wxid || 'wxid 未识别'}
+
+
+ {authEnabled && ( @@ -1109,16 +1251,25 @@ function ExportPage() {
{card.label}
-
- 总会话数 - {card.total} -
-
- 已导出会话数 - {card.exported} -
+ {card.stats.map((stat) => ( +
+ {stat.label} + {stat.value.toLocaleString()} +
+ ))}
- +
) })} @@ -1147,7 +1298,9 @@ function ExportPage() { />
- {task.progress.current} / {task.progress.total || task.payload.sessionIds.length} + {task.progress.total > 0 + ? `${task.progress.current} / ${task.progress.total}` + : '处理中'} {task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''}
@@ -1168,9 +1321,18 @@ function ExportPage() {
- - - + + + +
@@ -1210,13 +1372,13 @@ function ExportPage() { {isLoading ? ( - +
加载中...
) : visibleSessions.length === 0 ? ( - +
暂无会话
@@ -1239,8 +1401,8 @@ function ExportPage() {

导出范围

- {exportDialog.scope === 'single' ? '单会话' : exportDialog.scope === 'multi' ? '多会话' : `按内容批量(${contentTypeLabels[exportDialog.contentType || 'text']})`} - 共 {exportDialog.sessionIds.length} 个会话 + {scopeLabel} + {scopeCountLabel}
{exportDialog.sessionNames.slice(0, 20).map(name => ( @@ -1253,7 +1415,7 @@ function ExportPage() {

对话文本导出格式选择

- {formatOptions.map(option => ( + {formatCandidateOptions.map(option => ( -
diff --git a/src/services/config.ts b/src/services/config.ts index bb96231..7927939 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -35,6 +35,7 @@ export const CONFIG_KEYS = { EXPORT_WRITE_LAYOUT: 'exportWriteLayout', EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap', EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap', + EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount', // 安全 AUTH_ENABLED: 'authEnabled', @@ -435,6 +436,19 @@ export async function setExportLastContentRunMap(map: Record): P await config.set(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP, map) } +export async function getExportLastSnsPostCount(): Promise { + const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT) + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { + return Math.floor(value) + } + return 0 +} + +export async function setExportLastSnsPostCount(count: number): Promise { + const normalized = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0 + await config.set(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT, normalized) +} + // === 安全相关 === export async function getAuthEnabled(): Promise { diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index ff6a293..dfa82e3 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -539,6 +539,7 @@ export interface ElectronAPI { onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }> getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }> + getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }> checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }> From 0444ca143e4b3e5df0de3662c424f780b5c83107 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 15:53:01 +0800 Subject: [PATCH 007/162] fix(export): correct profile name, sns stats, avatars and sorting --- electron/services/snsService.ts | 77 +++++++++++++++++++++++++++------ src/components/Sidebar.tsx | 33 ++++++++++++-- src/pages/ExportPage.tsx | 63 +++++++++++++++++++++++---- 3 files changed, 148 insertions(+), 25 deletions(-) diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index b9f43c2..9484cdb 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -242,6 +242,43 @@ class SnsService { return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0 } + private pickTimelineUsername(post: any): string { + const raw = post?.username ?? post?.user_name ?? post?.userName ?? '' + if (typeof raw !== 'string') return '' + return raw.trim() + } + + private async getExportStatsFromTimeline(): Promise<{ totalPosts: number; totalFriends: number }> { + const pageSize = 500 + const uniqueUsers = new Set() + let totalPosts = 0 + let offset = 0 + + for (let round = 0; round < 2000; round++) { + const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0) + if (!result.success || !Array.isArray(result.timeline)) { + throw new Error(result.error || '获取朋友圈统计失败') + } + + const rows = result.timeline + if (rows.length === 0) break + + totalPosts += rows.length + for (const row of rows) { + const username = this.pickTimelineUsername(row) + if (username) uniqueUsers.add(username) + } + + if (rows.length < pageSize) break + offset += rows.length + } + + return { + totalPosts, + totalFriends: uniqueUsers.size + } + } + private parseLikesFromXml(xml: string): string[] { if (!xml) return [] const likes: string[] = [] @@ -369,27 +406,41 @@ class SnsService { async getExportStats(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> { try { let totalPosts = 0 + let totalFriends = 0 + const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine') if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) { totalPosts = this.parseCountValue(postCountResult.rows[0]) } - let totalFriends = 0 - const friendCountPrimary = await wcdbService.execQuery( - 'sns', - null, - "SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''" - ) - if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) { - totalFriends = this.parseCountValue(friendCountPrimary.rows[0]) - } else { - const friendCountFallback = await wcdbService.execQuery( + if (totalPosts > 0) { + const friendCountPrimary = await wcdbService.execQuery( 'sns', null, - "SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''" + "SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''" ) - if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) { - totalFriends = this.parseCountValue(friendCountFallback.rows[0]) + if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) { + totalFriends = this.parseCountValue(friendCountPrimary.rows[0]) + } else { + const friendCountFallback = await wcdbService.execQuery( + 'sns', + null, + "SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''" + ) + if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) { + totalFriends = this.parseCountValue(friendCountFallback.rows[0]) + } + } + } + + // 某些环境下 SnsTimeLine 统计查询会返回 0,这里回退到与导出同源的 timeline 接口统计。 + if (totalPosts <= 0 || totalFriends <= 0) { + const timelineStats = await this.getExportStatsFromTimeline() + if (timelineStats.totalPosts > 0) { + totalPosts = timelineStats.totalPosts + } + if (timelineStats.totalFriends > 0) { + totalFriends = timelineStats.totalFriends } } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 2effba3..b1478e1 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -32,10 +32,37 @@ function Sidebar() { const wxid = await configService.getMyWxid() let displayName = wxid || '未识别用户' + const normalizeName = (value?: string | null): string | undefined => { + if (!value) return undefined + const trimmed = value.trim() + if (!trimmed || trimmed.toLowerCase() === 'self') return undefined + return trimmed + } + + let enrichedDisplayName: string | undefined + let fallbackSelfName: string | undefined + if (wxid) { - const myContact = await window.electronAPI.chat.getContact(wxid) - const bestName = [myContact?.remark, myContact?.nickName, myContact?.alias, wxid].find(Boolean) - if (bestName) displayName = bestName + const [myContact, enrichedResult] = await Promise.all([ + window.electronAPI.chat.getContact(wxid), + window.electronAPI.chat.enrichSessionsContactInfo([wxid, 'self']) + ]) + + enrichedDisplayName = normalizeName(enrichedResult.contacts?.[wxid]?.displayName) + fallbackSelfName = normalizeName(enrichedResult.contacts?.self?.displayName) + + const bestName = + normalizeName(myContact?.remark) || + normalizeName(myContact?.nickName) || + normalizeName(myContact?.alias) || + enrichedDisplayName || + fallbackSelfName + + if (bestName) { + displayName = bestName + } else if (fallbackSelfName && fallbackSelfName !== wxid) { + displayName = fallbackSelfName + } } let avatarUrl: string | undefined diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 7f34983..76cc77d 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -177,7 +177,7 @@ const formatAbsoluteDate = (timestamp: number): string => { } const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => { - if (!timestamp) return '未导出' + if (!timestamp) return '' const diff = Math.max(0, now - timestamp) const minute = 60 * 1000 const hour = 60 * minute @@ -290,6 +290,7 @@ function ExportPage() { const progressUnsubscribeRef = useRef<(() => void) | null>(null) const runningTaskIdRef = useRef(null) const tasksRef = useRef([]) + const sessionMetricsRef = useRef>({}) const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) @@ -297,6 +298,10 @@ function ExportPage() { tasksRef.current = tasks }, [tasks]) + useEffect(() => { + sessionMetricsRef.current = sessionMetrics + }, [sessionMetrics]) + const preselectSessionIds = useMemo(() => { const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null const rawList = Array.isArray(state?.preselectSessionIds) @@ -393,7 +398,7 @@ function ExportPage() { }, {}) if (sessionsResult.success && sessionsResult.sessions) { - const nextSessions = sessionsResult.sessions + const baseSessions = sessionsResult.sessions .map((session) => { const contact = nextContactMap[session.username] const kind = toKindByContactType(session, contact) @@ -405,7 +410,29 @@ function ExportPage() { avatarUrl: session.avatarUrl || contact?.avatarUrl } as SessionRow }) - .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) + + const needsEnrichment = baseSessions + .filter(session => !session.avatarUrl || !session.displayName || session.displayName === session.username) + .map(session => session.username) + + let nextSessions = baseSessions + if (needsEnrichment.length > 0) { + try { + const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment) + if (enrichResult.success && enrichResult.contacts) { + nextSessions = baseSessions.map((session) => { + const extra = enrichResult.contacts?.[session.username] + return { + ...session, + displayName: extra?.displayName || session.displayName || session.username, + avatarUrl: extra?.avatarUrl || session.avatarUrl + } + }) + } + } catch (enrichError) { + console.error('导出页补充会话联系人信息失败:', enrichError) + } + } setSessions(nextSessions) } @@ -441,18 +468,31 @@ function ExportPage() { const visibleSessions = useMemo(() => { const keyword = searchKeyword.trim().toLowerCase() - return sessions.filter((session) => { + return sessions + .filter((session) => { if (session.kind !== activeTab) return false if (!keyword) return true return ( (session.displayName || '').toLowerCase().includes(keyword) || session.username.toLowerCase().includes(keyword) ) - }) - }, [sessions, activeTab, searchKeyword]) + }) + .sort((a, b) => { + const totalA = sessionMetrics[a.username]?.totalMessages ?? 0 + const totalB = sessionMetrics[b.username]?.totalMessages ?? 0 + if (totalB !== totalA) { + return totalB - totalA + } + + const latestA = sessionMetrics[a.username]?.lastTimestamp ?? a.lastTimestamp ?? 0 + const latestB = sessionMetrics[b.username]?.lastTimestamp ?? b.lastTimestamp ?? 0 + return latestB - latestA + }) + }, [sessions, activeTab, searchKeyword, sessionMetrics]) const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { - const pending = targetSessions.filter(session => !sessionMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) + const currentMetrics = sessionMetricsRef.current + const pending = targetSessions.filter(session => !currentMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) if (pending.length === 0) return const updates: Record = {} @@ -494,13 +534,18 @@ function ExportPage() { if (Object.keys(updates).length > 0) { setSessionMetrics(prev => ({ ...prev, ...updates })) } - }, [sessionMetrics]) + }, []) useEffect(() => { const targets = visibleSessions.slice(0, 40) void ensureSessionMetrics(targets) }, [visibleSessions, ensureSessionMetrics]) + useEffect(() => { + if (sessions.length === 0) return + void ensureSessionMetrics(sessions) + }, [sessions, ensureSessionMetrics]) + const selectedCount = selectedSessions.size const toggleSelectSession = (sessionId: string) => { @@ -1042,7 +1087,7 @@ function ExportPage() { ) : isQueued ? '排队中' : '导出'} - {recent} + {recent && {recent}}
) } From de7cbdf4943b80660f534b1b374556093be84c14 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 16:03:49 +0800 Subject: [PATCH 008/162] perf(sidebar): show cached user profile before async refresh --- src/components/Sidebar.tsx | 158 ++++++++++++++++++++++++++----------- 1 file changed, 114 insertions(+), 44 deletions(-) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b1478e1..a362ac7 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -12,6 +12,42 @@ interface SidebarUserProfile { avatarUrl?: string } +const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1' + +interface SidebarUserProfileCache extends SidebarUserProfile { + updatedAt: number +} + +const readSidebarUserProfileCache = (): SidebarUserProfile | null => { + try { + const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as SidebarUserProfileCache + if (!parsed || typeof parsed !== 'object') return null + if (!parsed.wxid || !parsed.displayName) return null + return { + wxid: parsed.wxid, + displayName: parsed.displayName, + avatarUrl: parsed.avatarUrl + } + } catch { + return null + } +} + +const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => { + if (!profile.wxid || !profile.displayName) return + try { + const payload: SidebarUserProfileCache = { + ...profile, + updatedAt: Date.now() + } + window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload)) + } catch { + // 忽略本地缓存失败,不影响主流程 + } +} + function Sidebar() { const location = useLocation() const [collapsed, setCollapsed] = useState(false) @@ -28,59 +64,93 @@ function Sidebar() { useEffect(() => { const loadCurrentUser = async () => { + const normalizeName = (value?: string | null): string | undefined => { + if (!value) return undefined + const trimmed = value.trim() + if (!trimmed || trimmed.toLowerCase() === 'self') return undefined + return trimmed + } + + const patchUserProfile = (patch: Partial, expectedWxid?: string) => { + setUserProfile(prev => { + if (expectedWxid && prev.wxid && prev.wxid !== expectedWxid) { + return prev + } + const next: SidebarUserProfile = { + ...prev, + ...patch + } + if (!next.displayName) { + next.displayName = next.wxid || '未识别用户' + } + writeSidebarUserProfileCache(next) + return next + }) + } + try { const wxid = await configService.getMyWxid() - let displayName = wxid || '未识别用户' + const resolvedWxid = wxid || '' + const fallbackDisplayName = resolvedWxid || '未识别用户' - const normalizeName = (value?: string | null): string | undefined => { - if (!value) return undefined - const trimmed = value.trim() - if (!trimmed || trimmed.toLowerCase() === 'self') return undefined - return trimmed - } - - let enrichedDisplayName: string | undefined - let fallbackSelfName: string | undefined - - if (wxid) { - const [myContact, enrichedResult] = await Promise.all([ - window.electronAPI.chat.getContact(wxid), - window.electronAPI.chat.enrichSessionsContactInfo([wxid, 'self']) - ]) - - enrichedDisplayName = normalizeName(enrichedResult.contacts?.[wxid]?.displayName) - fallbackSelfName = normalizeName(enrichedResult.contacts?.self?.displayName) - - const bestName = - normalizeName(myContact?.remark) || - normalizeName(myContact?.nickName) || - normalizeName(myContact?.alias) || - enrichedDisplayName || - fallbackSelfName - - if (bestName) { - displayName = bestName - } else if (fallbackSelfName && fallbackSelfName !== wxid) { - displayName = fallbackSelfName - } - } - - let avatarUrl: string | undefined - const avatarResult = await window.electronAPI.chat.getMyAvatarUrl() - if (avatarResult.success && avatarResult.avatarUrl) { - avatarUrl = avatarResult.avatarUrl - } - - setUserProfile({ - wxid: wxid || '', - displayName, - avatarUrl + // 第一阶段:先把 wxid/名称打上,保证侧边栏第一时间可见。 + patchUserProfile({ + wxid: resolvedWxid, + displayName: fallbackDisplayName }) + + if (!resolvedWxid) return + + // 第二阶段:后台补齐名称(不会阻塞首屏)。 + void (async () => { + try { + const myContact = await window.electronAPI.chat.getContact(resolvedWxid) + const fromContact = + normalizeName(myContact?.remark) || + normalizeName(myContact?.nickName) || + normalizeName(myContact?.alias) + + if (fromContact) { + patchUserProfile({ displayName: fromContact }, resolvedWxid) + return + } + + const enrichedResult = await window.electronAPI.chat.enrichSessionsContactInfo([resolvedWxid, 'self']) + const enrichedDisplayName = normalizeName(enrichedResult.contacts?.[resolvedWxid]?.displayName) + const fallbackSelfName = normalizeName(enrichedResult.contacts?.self?.displayName) + const bestName = enrichedDisplayName || fallbackSelfName + if (bestName) { + patchUserProfile({ displayName: bestName }, resolvedWxid) + } + } catch (nameError) { + console.error('加载侧边栏用户昵称失败:', nameError) + } + })() + + // 第二阶段:后台补齐头像(不会阻塞首屏)。 + void (async () => { + try { + const avatarResult = await window.electronAPI.chat.getMyAvatarUrl() + if (avatarResult.success && avatarResult.avatarUrl) { + patchUserProfile({ avatarUrl: avatarResult.avatarUrl }, resolvedWxid) + } + } catch (avatarError) { + console.error('加载侧边栏用户头像失败:', avatarError) + } + })() } catch (error) { console.error('加载侧边栏用户信息失败:', error) } } + const cachedProfile = readSidebarUserProfileCache() + if (cachedProfile) { + setUserProfile(prev => ({ + ...prev, + ...cachedProfile + })) + } + void loadCurrentUser() const onWxidChanged = () => { void loadCurrentUser() } window.addEventListener('wxid-changed', onWxidChanged as EventListener) From b62c18fd84ab524fa5711f5ba10853c44079cac2 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 16:11:04 +0800 Subject: [PATCH 009/162] perf(export): phase-load sessions and add strong skeleton states --- src/pages/ExportPage.scss | 85 +++++++++++++++++++ src/pages/ExportPage.tsx | 172 ++++++++++++++++++++++++++++---------- 2 files changed, 211 insertions(+), 46 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index e6bfbaf..5f31d01 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -191,6 +191,14 @@ background: var(--primary-hover); } } + + &.skeleton-card { + pointer-events: none; + + .card-stats { + gap: 10px; + } + } } .task-center { @@ -332,6 +340,19 @@ overflow: hidden; } +.table-stage-hint { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(var(--primary-rgb), 0.1); + border: 1px solid rgba(var(--primary-rgb), 0.2); + color: var(--primary); + font-size: 12px; + width: fit-content; +} + .table-toolbar { display: flex; justify-content: space-between; @@ -589,6 +610,61 @@ color: var(--text-secondary); } +.table-skeleton-list { + display: grid; + gap: 8px; + padding: 4px 0; +} + +.table-skeleton-item { + display: grid; + grid-template-columns: 20px 36px minmax(160px, 2fr) repeat(3, minmax(80px, 1fr)); + align-items: center; + gap: 12px; + padding: 10px 8px; + border-radius: 8px; + background: color-mix(in srgb, var(--bg-secondary) 80%, transparent); +} + +.skeleton-shimmer { + position: relative; + overflow: hidden; + border-radius: 8px; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.08) 0%, + rgba(255, 255, 255, 0.35) 50%, + rgba(255, 255, 255, 0.08) 100% + ); + background-size: 220% 100%; + animation: exportSkeletonShimmer 1.2s linear infinite; +} + +.skeleton-dot { + width: 16px; + height: 16px; + border-radius: 6px; +} + +.skeleton-avatar { + width: 36px; + height: 36px; + border-radius: 8px; +} + +.skeleton-line { + display: inline-block; + height: 12px; +} + +.skeleton-line.w-12 { width: 48%; min-width: 42px; } +.skeleton-line.w-20 { width: 22%; min-width: 36px; } +.skeleton-line.w-30 { width: 32%; min-width: 120px; } +.skeleton-line.w-40 { width: 45%; min-width: 80px; } +.skeleton-line.w-60 { width: 62%; min-width: 110px; } +.skeleton-line.w-100 { width: 100%; } +.skeleton-line.h-32 { height: 32px; border-radius: 10px; } + .export-dialog-overlay { position: fixed; inset: 0; @@ -867,6 +943,15 @@ } } +@keyframes exportSkeletonShimmer { + 0% { + background-position: 220% 0; + } + 100% { + background-position: -20% 0; + } +} + @media (max-width: 1360px) { .export-top-panel { grid-template-columns: 1fr; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 76cc77d..c7c2daf 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -239,6 +239,8 @@ function ExportPage() { const location = useLocation() const [isLoading, setIsLoading] = useState(true) + const [isSessionEnriching, setIsSessionEnriching] = useState(false) + const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true) const [sessions, setSessions] = useState([]) const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') @@ -291,6 +293,7 @@ function ExportPage() { const runningTaskIdRef = useRef(null) const tasksRef = useRef([]) const sessionMetricsRef = useRef>({}) + const sessionLoadTokenRef = useRef(0) const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) @@ -363,6 +366,7 @@ function ExportPage() { }, []) const loadSnsStats = useCallback(async () => { + setIsSnsStatsLoading(true) try { const result = await window.electronAPI.sns.getExportStats() if (result.success && result.data) { @@ -373,80 +377,122 @@ function ExportPage() { } } catch (error) { console.error('加载朋友圈导出统计失败:', error) + } finally { + setIsSnsStatsLoading(false) } }, []) const loadSessions = useCallback(async () => { + const loadToken = Date.now() + sessionLoadTokenRef.current = loadToken setIsLoading(true) + setIsSessionEnriching(false) + + const isStale = () => sessionLoadTokenRef.current !== loadToken + try { const connectResult = await window.electronAPI.chat.connect() if (!connectResult.success) { console.error('连接失败:', connectResult.error) - setIsLoading(false) + if (!isStale()) setIsLoading(false) return } - const [sessionsResult, contactsResult] = await Promise.all([ - window.electronAPI.chat.getSessions(), - window.electronAPI.chat.getContacts() - ]) - - const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : [] - const nextContactMap = contacts.reduce>((map, contact) => { - map[contact.username] = contact - return map - }, {}) + const sessionsResult = await window.electronAPI.chat.getSessions() + if (isStale()) return if (sessionsResult.success && sessionsResult.sessions) { const baseSessions = sessionsResult.sessions .map((session) => { - const contact = nextContactMap[session.username] - const kind = toKindByContactType(session, contact) return { ...session, - kind, - wechatId: contact?.username || session.username, - displayName: session.displayName || contact?.displayName || session.username, - avatarUrl: session.avatarUrl || contact?.avatarUrl + kind: toKindByContactType(session), + wechatId: session.username, + displayName: session.displayName || session.username, + avatarUrl: session.avatarUrl } as SessionRow }) + .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) - const needsEnrichment = baseSessions - .filter(session => !session.avatarUrl || !session.displayName || session.displayName === session.username) - .map(session => session.username) + if (isStale()) return + setSessions(baseSessions) + setIsLoading(false) - let nextSessions = baseSessions - if (needsEnrichment.length > 0) { + // 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。 + setIsSessionEnriching(true) + void (async () => { try { - const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment) - if (enrichResult.success && enrichResult.contacts) { - nextSessions = baseSessions.map((session) => { - const extra = enrichResult.contacts?.[session.username] + const contactsResult = await window.electronAPI.chat.getContacts() + if (isStale()) return + + const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : [] + const nextContactMap = contacts.reduce>((map, contact) => { + map[contact.username] = contact + return map + }, {}) + + const needsEnrichment = baseSessions + .filter(session => !session.avatarUrl || !session.displayName || session.displayName === session.username) + .map(session => session.username) + + let extraContactMap: Record = {} + if (needsEnrichment.length > 0) { + const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment) + if (enrichResult.success && enrichResult.contacts) { + extraContactMap = enrichResult.contacts + } + } + + if (isStale()) return + const nextSessions = baseSessions + .map((session) => { + const contact = nextContactMap[session.username] + const extra = extraContactMap[session.username] + const displayName = extra?.displayName || contact?.displayName || session.displayName || session.username + const avatarUrl = extra?.avatarUrl || session.avatarUrl || contact?.avatarUrl return { ...session, - displayName: extra?.displayName || session.displayName || session.username, - avatarUrl: extra?.avatarUrl || session.avatarUrl + kind: toKindByContactType(session, contact), + wechatId: contact?.username || session.wechatId || session.username, + displayName, + avatarUrl } }) - } + .sort((a, b) => { + const aMetric = sessionMetricsRef.current[a.username]?.totalMessages ?? 0 + const bMetric = sessionMetricsRef.current[b.username]?.totalMessages ?? 0 + if (bMetric !== aMetric) return bMetric - aMetric + return (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0) + }) + + setSessions(nextSessions) } catch (enrichError) { console.error('导出页补充会话联系人信息失败:', enrichError) + } finally { + if (!isStale()) setIsSessionEnriching(false) } - } - - setSessions(nextSessions) + })() + } else { + setIsLoading(false) } } catch (error) { console.error('加载会话失败:', error) + if (!isStale()) setIsLoading(false) } finally { - setIsLoading(false) + if (!isStale()) setIsLoading(false) } }, []) useEffect(() => { - loadBaseConfig() - loadSessions() - loadSnsStats() + void loadBaseConfig() + void loadSessions() + + // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 + const timer = window.setTimeout(() => { + void loadSnsStats() + }, 180) + + return () => window.clearTimeout(timer) }, [loadBaseConfig, loadSessions, loadSnsStats]) useEffect(() => { @@ -470,12 +516,12 @@ function ExportPage() { const keyword = searchKeyword.trim().toLowerCase() return sessions .filter((session) => { - if (session.kind !== activeTab) return false - if (!keyword) return true - return ( - (session.displayName || '').toLowerCase().includes(keyword) || - session.username.toLowerCase().includes(keyword) - ) + if (session.kind !== activeTab) return false + if (!keyword) return true + return ( + (session.displayName || '').toLowerCase().includes(keyword) || + session.username.toLowerCase().includes(keyword) + ) }) .sort((a, b) => { const totalA = sessionMetrics[a.username]?.totalMessages ?? 0 @@ -1229,6 +1275,7 @@ function ExportPage() { const formatCandidateOptions = exportDialog.scope === 'sns' ? formatOptions.filter(option => option.value === 'html' || option.value === 'json') : formatOptions + const showInitialSkeleton = isLoading && sessions.length === 0 return (
@@ -1288,7 +1335,22 @@ function ExportPage() {
- {contentCards.map(card => { + {showInitialSkeleton ? Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+ + +
+
+ + +
+
+
+
+ )) : contentCards.map(card => { const Icon = card.icon return (
@@ -1299,7 +1361,7 @@ function ExportPage() { {card.stats.map((stat) => (
{stat.label} - {stat.value.toLocaleString()} + {isSnsStatsLoading && card.type === 'sns' ? '--' : stat.value.toLocaleString()}
))}
@@ -1411,14 +1473,32 @@ function ExportPage() {
+ {(isLoading || isSessionEnriching) && ( +
+ + {isLoading ? '正在加载会话列表…' : '正在补充头像和统计…'} +
+ )} +
{renderTableHeader()} - {isLoading ? ( + {showInitialSkeleton ? ( ) : visibleSessions.length === 0 ? ( From adff7b9e1e5d546c80f95be4acbcfb37be65eb97 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 16:24:12 +0800 Subject: [PATCH 010/162] feat(export): refine task center and loading interactions --- src/pages/ExportPage.scss | 121 +++++++++++++++++++++++++- src/pages/ExportPage.tsx | 173 ++++++++++++++++++++++++-------------- 2 files changed, 229 insertions(+), 65 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 5f31d01..e8a1380 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -41,6 +41,13 @@ gap: 6px; } + .path-inline-row { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + } + .path-value { border: 1px dashed var(--border-color); border-radius: 10px; @@ -51,11 +58,29 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + min-width: 0; + flex: 1; + } + + .path-link { + cursor: pointer; + text-align: left; + + &:hover:not(:disabled) { + border-color: var(--primary); + color: var(--primary); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.65; + } } .path-actions { display: flex; gap: 8px; + flex-shrink: 0; } .write-layout-control { @@ -75,10 +100,15 @@ font-size: 13px; text-align: left; cursor: pointer; + transition: border-color 0.12s ease; &:hover { border-color: var(--primary); } + + &.active { + border-color: var(--primary); + } } .layout-dropdown { @@ -94,8 +124,21 @@ z-index: 3000; max-height: 260px; overflow-y: auto; - opacity: 1; + opacity: 0; + transform: translateY(-4px); + pointer-events: none; + visibility: hidden; + transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s step-end; backdrop-filter: none; + will-change: opacity, transform; + + &.open { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + visibility: visible; + transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s step-start; + } } .layout-option { @@ -201,6 +244,15 @@ } } +.count-loading { + color: var(--text-tertiary); + font-size: 12px; + font-weight: 500; + display: inline-flex; + align-items: baseline; + gap: 1px; +} + .task-center { border: 1px solid var(--border-color); border-radius: 12px; @@ -208,14 +260,51 @@ padding: 12px; flex-shrink: 0; + .task-center-header { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + } + .section-title { font-size: 14px; font-weight: 700; color: var(--text-primary); - margin-bottom: 8px; + margin: 0; + flex-shrink: 0; + } + + .task-summary { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + } + + .task-collapse-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 8px; + padding: 4px 8px; + font-size: 12px; + color: var(--text-secondary); + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + + &:hover { + border-color: var(--primary); + color: var(--primary); + } } .task-empty { + margin-top: 10px; padding: 12px; background: var(--bg-secondary); border-radius: 8px; @@ -224,6 +313,7 @@ } .task-list { + margin-top: 10px; display: grid; gap: 8px; max-height: 190px; @@ -377,6 +467,7 @@ white-space: nowrap; display: inline-flex; align-items: center; + gap: 4px; &.active { border-color: var(--primary); @@ -386,6 +477,14 @@ } } +.animated-ellipsis { + display: inline-block; + width: 0; + overflow: hidden; + vertical-align: bottom; + animation: exportDots 1s steps(4, end) infinite; +} + .toolbar-actions { display: flex; align-items: center; @@ -952,6 +1051,15 @@ } } +@keyframes exportDots { + 0% { + width: 0; + } + 100% { + width: 1.8em; + } +} + @media (max-width: 1360px) { .export-top-panel { grid-template-columns: 1fr; @@ -959,6 +1067,15 @@ .global-export-controls { grid-template-columns: 1fr; + + .path-inline-row { + flex-wrap: wrap; + } + + .path-actions { + width: 100%; + justify-content: flex-end; + } } .content-card-grid { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index c7c2daf..486d31e 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' import { Aperture, + ChevronDown, + ChevronRight, CheckSquare, Download, ExternalLink, @@ -241,6 +243,8 @@ function ExportPage() { const [isLoading, setIsLoading] = useState(true) const [isSessionEnriching, setIsSessionEnriching] = useState(false) const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true) + const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true) + const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false) const [sessions, setSessions] = useState([]) const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') @@ -296,6 +300,7 @@ function ExportPage() { const sessionLoadTokenRef = useRef(0) const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) + const writeLayoutControlRef = useRef(null) useEffect(() => { tasksRef.current = tasks @@ -323,6 +328,7 @@ function ExportPage() { }, []) const loadBaseConfig = useCallback(async () => { + setIsBaseConfigLoading(true) try { const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount] = await Promise.all([ configService.getExportPath(), @@ -362,6 +368,8 @@ function ExportPage() { })) } catch (error) { console.error('加载导出配置失败:', error) + } finally { + setIsBaseConfigLoading(false) } }, []) @@ -499,6 +507,18 @@ function ExportPage() { preselectAppliedRef.current = false }, [location.key, preselectSessionIds]) + useEffect(() => { + if (!showWriteLayoutSelect) return + + const handleOutsideClick = (event: MouseEvent) => { + if (writeLayoutControlRef.current?.contains(event.target as Node)) return + setShowWriteLayoutSelect(false) + } + + document.addEventListener('mousedown', handleOutsideClick) + return () => document.removeEventListener('mousedown', handleOutsideClick) + }, [showWriteLayoutSelect]) + useEffect(() => { if (preselectAppliedRef.current) return if (sessions.length === 0 || preselectSessionIds.length === 0) return @@ -1275,6 +1295,10 @@ function ExportPage() { const formatCandidateOptions = exportDialog.scope === 'sns' ? formatOptions.filter(option => option.value === 'html' || option.value === 'json') : formatOptions + const isTabCountComputing = isLoading || isSessionEnriching + const isSessionCardStatsLoading = isLoading || isBaseConfigLoading + const taskRunningCount = tasks.filter(task => task.status === 'running').length + const taskQueuedCount = tasks.filter(task => task.status === 'queued').length const showInitialSkeleton = isLoading && sessions.length === 0 return ( @@ -1283,75 +1307,76 @@ function ExportPage() {
导出位置 -
{exportFolder || '未设置'}
-
- +
+
+ + +
-
+
写入目录方式 - - {showWriteLayoutSelect && ( -
- {writeLayoutOptions.map(option => ( - - ))} -
- )} +
+ {writeLayoutOptions.map(option => ( + + ))} +
- {showInitialSkeleton ? Array.from({ length: 6 }).map((_, index) => ( -
-
-
-
- - -
-
- - -
-
-
-
- )) : contentCards.map(card => { + {contentCards.map(card => { const Icon = card.icon + const isCardStatsLoading = card.type === 'sns' + ? (isSnsStatsLoading || isBaseConfigLoading) + : isSessionCardStatsLoading return (
@@ -1361,7 +1386,13 @@ function ExportPage() { {card.stats.map((stat) => (
{stat.label} - {isSnsStatsLoading && card.type === 'sns' ? '--' : stat.value.toLocaleString()} + + {isCardStatsLoading ? ( + + 统计中 + + ) : stat.value.toLocaleString()} +
))}
@@ -1382,9 +1413,25 @@ function ExportPage() { })}
-
-
任务中心
- {tasks.length === 0 ? ( +
+
+
任务中心
+
+ 进行中 {taskRunningCount} + 排队 {taskQueuedCount} + 总计 {tasks.length} +
+ +
+ + {isTaskCenterExpanded && (tasks.length === 0 ? (
暂无任务。点击会话导出或卡片导出后会在这里创建任务。
) : (
@@ -1422,23 +1469,23 @@ function ExportPage() {
))}
- )} + ))}
From c6e8bde0781a6ee07838fc0b5c690d2aa759eadd Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 16:32:48 +0800 Subject: [PATCH 011/162] feat(export): prioritize tab counts via lightweight api --- electron/main.ts | 4 ++ electron/preload.ts | 1 + electron/services/chatService.ts | 113 +++++++++++++++++++++++++++++++ src/pages/ExportPage.tsx | 38 +++++++++-- src/types/electron.d.ts | 10 +++ 5 files changed, 162 insertions(+), 4 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 0a47a22..c13c9dc 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -912,6 +912,10 @@ function registerIpcHandlers() { return chatService.getSessions() }) + ipcMain.handle('chat:getExportTabCounts', async () => { + return chatService.getExportTabCounts() + }) + ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => { return chatService.enrichSessionsContactInfo(usernames) }) diff --git a/electron/preload.ts b/electron/preload.ts index 49c3126..c0f76d1 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -130,6 +130,7 @@ contextBridge.exposeInMainWorld('electronAPI', { chat: { connect: () => ipcRenderer.invoke('chat:connect'), getSessions: () => ipcRenderer.invoke('chat:getSessions'), + getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), enrichSessionsContactInfo: (usernames: string[]) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 171ac0b..6cc1e4a 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -151,6 +151,13 @@ interface ExportSessionStats { groupMutualFriends?: number } +interface ExportTabCounts { + private: number + group: number + official: number + former_friend: number +} + // 表情包缓存 const emojiCache: Map = new Map() const emojiDownloading: Map> = new Map() @@ -657,6 +664,112 @@ class ChatService { } } + /** + * 获取导出页会话分类数量(轻量接口,优先用于顶部 Tab 数量展示) + */ + async getExportTabCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error } + } + + const sessionResult = await wcdbService.getSessions() + if (!sessionResult.success || !sessionResult.sessions) { + return { success: false, error: sessionResult.error || '获取会话失败' } + } + + const counts: ExportTabCounts = { + private: 0, + group: 0, + official: 0, + former_friend: 0 + } + + const nonGroupUsernames: string[] = [] + const usernameSet = new Set() + + for (const row of sessionResult.sessions as Record[]) { + const username = + row.username || + row.user_name || + row.userName || + row.usrName || + row.UsrName || + row.talker || + row.talker_id || + row.talkerId || + '' + + if (!this.shouldKeepSession(username)) continue + if (usernameSet.has(username)) continue + usernameSet.add(username) + + if (username.endsWith('@chatroom')) { + counts.group += 1 + } else { + nonGroupUsernames.push(username) + } + } + + if (nonGroupUsernames.length === 0) { + return { success: true, counts } + } + + const contactTypeMap = new Map() + const chunkSize = 400 + + for (let i = 0; i < nonGroupUsernames.length; i += chunkSize) { + const chunk = nonGroupUsernames.slice(i, i + chunkSize) + if (chunk.length === 0) continue + + const usernamesExpr = chunk.map((name) => `'${this.escapeSqlString(name)}'`).join(',') + const contactSql = ` + SELECT username, local_type, quan_pin + FROM contact + WHERE username IN (${usernamesExpr}) + ` + + const contactResult = await wcdbService.execQuery('contact', null, contactSql) + if (!contactResult.success || !contactResult.rows) { + continue + } + + for (const row of contactResult.rows as Record[]) { + const username = String(row.username || '').trim() + if (!username) continue + + if (username.startsWith('gh_')) { + contactTypeMap.set(username, 'official') + continue + } + + const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) + const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim() + if (localType === 0 && quanPin) { + contactTypeMap.set(username, 'former_friend') + } + } + } + + for (const username of nonGroupUsernames) { + const type = contactTypeMap.get(username) + if (type === 'official') { + counts.official += 1 + } else if (type === 'former_friend') { + counts.former_friend += 1 + } else { + counts.private += 1 + } + } + + return { success: true, counts } + } catch (e) { + console.error('ChatService: 获取导出页会话分类数量失败:', e) + return { success: false, error: String(e) } + } + } + /** * 获取通讯录列表 */ diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 486d31e..e8dd8ca 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -242,10 +242,12 @@ function ExportPage() { const [isLoading, setIsLoading] = useState(true) const [isSessionEnriching, setIsSessionEnriching] = useState(false) + const [isTabCountsLoading, setIsTabCountsLoading] = useState(true) const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true) const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true) const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false) const [sessions, setSessions] = useState([]) + const [prefetchedTabCounts, setPrefetchedTabCounts] = useState | null>(null) const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') const [activeTab, setActiveTab] = useState('private') @@ -373,6 +375,20 @@ function ExportPage() { } }, []) + const loadTabCounts = useCallback(async () => { + setIsTabCountsLoading(true) + try { + const result = await window.electronAPI.chat.getExportTabCounts() + if (result.success && result.counts) { + setPrefetchedTabCounts(result.counts) + } + } catch (error) { + console.error('加载导出页会话分类数量失败:', error) + } finally { + setIsTabCountsLoading(false) + } + }, []) + const loadSnsStats = useCallback(async () => { setIsSnsStatsLoading(true) try { @@ -493,7 +509,10 @@ function ExportPage() { useEffect(() => { void loadBaseConfig() - void loadSessions() + void (async () => { + await loadTabCounts() + await loadSessions() + })() // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 const timer = window.setTimeout(() => { @@ -501,7 +520,7 @@ function ExportPage() { }, 180) return () => window.clearTimeout(timer) - }, [loadBaseConfig, loadSessions, loadSnsStats]) + }, [loadTabCounts, loadBaseConfig, loadSessions, loadSnsStats]) useEffect(() => { preselectAppliedRef.current = false @@ -1057,7 +1076,7 @@ function ExportPage() { return set }, [tasks]) - const tabCounts = useMemo(() => { + const sessionTabCounts = useMemo(() => { const counts: Record = { private: 0, group: 0, @@ -1070,6 +1089,16 @@ function ExportPage() { return counts }, [sessions]) + const tabCounts = useMemo(() => { + if (sessions.length > 0) { + return sessionTabCounts + } + if (prefetchedTabCounts) { + return prefetchedTabCounts + } + return sessionTabCounts + }, [sessions.length, sessionTabCounts, prefetchedTabCounts]) + const contentCards = useMemo(() => { const scopeSessions = sessions.filter(session => session.kind === 'private' || session.kind === 'group') const totalSessions = scopeSessions.length @@ -1295,7 +1324,8 @@ function ExportPage() { const formatCandidateOptions = exportDialog.scope === 'sns' ? formatOptions.filter(option => option.value === 'html' || option.value === 'json') : formatOptions - const isTabCountComputing = isLoading || isSessionEnriching + const hasTabCountsSource = prefetchedTabCounts !== null || sessions.length > 0 + const isTabCountComputing = isTabCountsLoading && !hasTabCountsSource const isSessionCardStatsLoading = isLoading || isBaseConfigLoading const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index dfa82e3..4d96cb9 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -74,6 +74,16 @@ export interface ElectronAPI { chat: { connect: () => Promise<{ success: boolean; error?: string }> getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> + getExportTabCounts: () => Promise<{ + success: boolean + counts?: { + private: number + group: number + official: number + former_friend: number + } + error?: string + }> enrichSessionsContactInfo: (usernames: string[]) => Promise<{ success: boolean contacts?: Record From d99c0ff8b208c81f90b4682ae54902cfe1c86a1b Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 16:44:10 +0800 Subject: [PATCH 012/162] perf(export): make write-layout dropdown instant --- src/pages/ExportPage.tsx | 111 +++++++++++++++++++++++---------------- 1 file changed, 67 insertions(+), 44 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index e8dd8ca..f5c2b39 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' import { Aperture, @@ -237,6 +237,60 @@ const timestampOrDash = (timestamp?: number): string => { const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +const WriteLayoutSelector = memo(function WriteLayoutSelector({ + writeLayout, + onChange +}: { + writeLayout: configService.ExportWriteLayout + onChange: (value: configService.ExportWriteLayout) => Promise +}) { + const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef(null) + + useEffect(() => { + if (!isOpen) return + + const handleOutsideClick = (event: MouseEvent) => { + if (containerRef.current?.contains(event.target as Node)) return + setIsOpen(false) + } + + document.addEventListener('mousedown', handleOutsideClick) + return () => document.removeEventListener('mousedown', handleOutsideClick) + }, [isOpen]) + + const writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A(类型分目录)' + + return ( +
+ 写入目录方式 + +
+ {writeLayoutOptions.map(option => ( + + ))} +
+
+ ) +}) + function ExportPage() { const location = useLocation() @@ -255,7 +309,6 @@ function ExportPage() { const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('A') - const [showWriteLayoutSelect, setShowWriteLayoutSelect] = useState(false) const [options, setOptions] = useState({ format: 'excel', @@ -302,7 +355,6 @@ function ExportPage() { const sessionLoadTokenRef = useRef(0) const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) - const writeLayoutControlRef = useRef(null) useEffect(() => { tasksRef.current = tasks @@ -526,18 +578,6 @@ function ExportPage() { preselectAppliedRef.current = false }, [location.key, preselectSessionIds]) - useEffect(() => { - if (!showWriteLayoutSelect) return - - const handleOutsideClick = (event: MouseEvent) => { - if (writeLayoutControlRef.current?.contains(event.target as Node)) return - setShowWriteLayoutSelect(false) - } - - document.addEventListener('mousedown', handleOutsideClick) - return () => document.removeEventListener('mousedown', handleOutsideClick) - }, [showWriteLayoutSelect]) - useEffect(() => { if (preselectAppliedRef.current) return if (sessions.length === 0 || preselectSessionIds.length === 0) return @@ -1306,7 +1346,6 @@ function ExportPage() { return count }, [visibleSessions, selectedSessions]) - const writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A(类型分目录)' const tableColSpan = activeTab === 'group' ? 14 : (activeTab === 'private' || activeTab === 'former_friend' ? 11 : 10) const canCreateTask = exportDialog.scope === 'sns' ? Boolean(exportFolder) @@ -1330,6 +1369,10 @@ function ExportPage() { const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length const showInitialSkeleton = isLoading && sessions.length === 0 + const tableBodyRows = useMemo( + () => visibleSessions.map(renderRow), + [visibleSessions, selectedSessions, sessionMetrics, activeTab, runningSessionIds, queuedSessionIds, nowTick, lastExportBySession] + ) return (
@@ -1371,33 +1414,13 @@ function ExportPage() {
-
- 写入目录方式 - -
- {writeLayoutOptions.map(option => ( - - ))} -
-
+ { + setWriteLayout(value) + await configService.setExportWriteLayout(value) + }} + />
@@ -1585,7 +1608,7 @@ function ExportPage() { ) : ( - visibleSessions.map(renderRow) + tableBodyRows )}
-
加载中...
+
+ {Array.from({ length: 8 }).map((_, rowIndex) => ( +
+ + + + + + +
+ ))} +
From 96aa9d08136f535097acce773eec561989231656 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 16:49:23 +0800 Subject: [PATCH 013/162] feat(export): adjust path actions and compact sns card --- src/pages/ExportPage.scss | 49 +++++++++++++++++++++++++------------- src/pages/ExportPage.tsx | 50 ++++++++++++++++++--------------------- 2 files changed, 55 insertions(+), 44 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index e8a1380..00a5f1e 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -51,36 +51,52 @@ .path-value { border: 1px dashed var(--border-color); border-radius: 10px; - padding: 10px 12px; + background: var(--bg-secondary); + display: flex; + align-items: stretch; + min-width: 0; + flex: 1; + overflow: hidden; + } + + .path-link { + border: none; + background: transparent; font-size: 13px; color: var(--text-primary); - background: var(--bg-secondary); + text-align: left; + padding: 10px 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; flex: 1; - } - - .path-link { cursor: pointer; - text-align: left; - &:hover:not(:disabled) { - border-color: var(--primary); + &:hover { color: var(--primary); } - &:disabled { - cursor: not-allowed; - opacity: 0.65; + &:focus-visible { + outline: none; } } - .path-actions { - display: flex; - gap: 8px; + .path-change-btn { + border: none; + border-left: 1px dashed var(--border-color); + background: transparent; + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + padding: 0 12px; + cursor: pointer; flex-shrink: 0; + + &:hover { + border-color: var(--primary); + color: var(--primary); + } } .write-layout-control { @@ -1072,9 +1088,8 @@ flex-wrap: wrap; } - .path-actions { - width: 100%; - justify-content: flex-end; + .path-inline-row > .secondary-btn { + margin-left: auto; } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index f5c2b39..2d18979 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1174,7 +1174,6 @@ function ExportPage() { label: '朋友圈', stats: [ { label: '朋友圈条数', value: snsStats.totalPosts }, - { label: '好友数', value: snsStats.totalFriends }, { label: '已导出朋友圈条数', value: snsExportedCount } ] } @@ -1373,6 +1372,17 @@ function ExportPage() { () => visibleSessions.map(renderRow), [visibleSessions, selectedSessions, sessionMetrics, activeTab, runningSessionIds, queuedSessionIds, nowTick, lastExportBySession] ) + const chooseExportFolder = useCallback(async () => { + const result = await window.electronAPI.dialog.openFile({ + title: '选择导出目录', + properties: ['openDirectory'] + }) + if (!result.canceled && result.filePaths.length > 0) { + const nextPath = result.filePaths[0] + setExportFolder(nextPath) + await configService.setExportPath(nextPath) + } + }, []) return (
@@ -1381,36 +1391,22 @@ function ExportPage() {
导出位置
- -
- +
+
+
From 22c7048ef69cc7ccb9d60150da5845b7b0121535 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 16:54:02 +0800 Subject: [PATCH 014/162] fix(sidebar): prefer valid nickname over wxid --- electron/services/chatService.ts | 10 +++--- src/components/Sidebar.tsx | 55 +++++++++++++++++++++++--------- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 6cc1e4a..d55ef0f 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -3250,11 +3250,13 @@ class ChatService { if (!connectResult.success) return null const result = await wcdbService.getContact(username) if (!result.success || !result.contact) return null + const contact = result.contact as Record return { - username: result.contact.username || username, - alias: result.contact.alias || '', - remark: result.contact.remark || '', - nickName: result.contact.nickName || '' + username: String(contact.username || contact.user_name || contact.userName || username || ''), + alias: String(contact.alias || contact.Alias || ''), + remark: String(contact.remark || contact.Remark || ''), + // 兼容不同表结构字段,避免 nick_name 丢失导致侧边栏退化到 wxid。 + nickName: String(contact.nickName || contact.nick_name || contact.nickname || contact.NickName || '') } } catch { return null diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a362ac7..8d81da8 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -64,13 +64,6 @@ function Sidebar() { useEffect(() => { const loadCurrentUser = async () => { - const normalizeName = (value?: string | null): string | undefined => { - if (!value) return undefined - const trimmed = value.trim() - if (!trimmed || trimmed.toLowerCase() === 'self') return undefined - return trimmed - } - const patchUserProfile = (patch: Partial, expectedWxid?: string) => { setUserProfile(prev => { if (expectedWxid && prev.wxid && prev.wxid !== expectedWxid) { @@ -91,6 +84,32 @@ function Sidebar() { try { const wxid = await configService.getMyWxid() const resolvedWxid = wxid || '' + const cleanedWxidMatch = resolvedWxid.match(/^(wxid_[^_]+)/i) + const cleanedWxid = cleanedWxidMatch?.[1] || resolvedWxid + const wxidCandidates = new Set([ + resolvedWxid.trim().toLowerCase(), + cleanedWxid.trim().toLowerCase() + ].filter(Boolean)) + + const normalizeName = (value?: string | null): string | undefined => { + if (!value) return undefined + const trimmed = value.trim() + if (!trimmed) return undefined + const lowered = trimmed.toLowerCase() + if (lowered === 'self') return undefined + if (lowered.startsWith('wxid_')) return undefined + if (wxidCandidates.has(lowered)) return undefined + return trimmed + } + + const pickFirstValidName = (...candidates: Array): string | undefined => { + for (const candidate of candidates) { + const normalized = normalizeName(candidate) + if (normalized) return normalized + } + return undefined + } + const fallbackDisplayName = resolvedWxid || '未识别用户' // 第一阶段:先把 wxid/名称打上,保证侧边栏第一时间可见。 @@ -105,20 +124,26 @@ function Sidebar() { void (async () => { try { const myContact = await window.electronAPI.chat.getContact(resolvedWxid) - const fromContact = - normalizeName(myContact?.remark) || - normalizeName(myContact?.nickName) || - normalizeName(myContact?.alias) + const fromContact = pickFirstValidName( + myContact?.remark, + myContact?.nickName, + myContact?.alias + ) if (fromContact) { patchUserProfile({ displayName: fromContact }, resolvedWxid) return } - const enrichedResult = await window.electronAPI.chat.enrichSessionsContactInfo([resolvedWxid, 'self']) - const enrichedDisplayName = normalizeName(enrichedResult.contacts?.[resolvedWxid]?.displayName) - const fallbackSelfName = normalizeName(enrichedResult.contacts?.self?.displayName) - const bestName = enrichedDisplayName || fallbackSelfName + const enrichTargets = Array.from(new Set([resolvedWxid, cleanedWxid, 'self'].filter(Boolean))) + const enrichedResult = await window.electronAPI.chat.enrichSessionsContactInfo(enrichTargets) + const enrichedDisplayName = pickFirstValidName( + enrichedResult.contacts?.[resolvedWxid]?.displayName, + enrichedResult.contacts?.[cleanedWxid]?.displayName, + enrichedResult.contacts?.self?.displayName, + myContact?.alias + ) + const bestName = enrichedDisplayName if (bestName) { patchUserProfile({ displayName: bestName }, resolvedWxid) } From c34f7af6ded66c875c0c45c76fa2832fe7fe9aa8 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 16:56:03 +0800 Subject: [PATCH 015/162] chore(export): shorten card exported labels --- src/pages/ExportPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 2d18979..f2190b0 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1163,7 +1163,7 @@ function ExportPage() { label: contentTypeLabels[item.type], stats: [ { label: '总会话数', value: totalSessions }, - { label: '已导出会话数', value: exported } + { label: '已导出', value: exported } ] } }) @@ -1174,7 +1174,7 @@ function ExportPage() { label: '朋友圈', stats: [ { label: '朋友圈条数', value: snsStats.totalPosts }, - { label: '已导出朋友圈条数', value: snsExportedCount } + { label: '已导出', value: snsExportedCount } ] } From dffd3c91385c0db18a4a700b4af651702998da53 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 17:00:37 +0800 Subject: [PATCH 016/162] fix(export): batch session stats and avoid stale empty cache --- src/pages/ExportPage.tsx | 50 +++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index f2190b0..0fd53ce 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -623,29 +623,41 @@ function ExportPage() { const updates: Record = {} for (const session of pending) { loadingMetricsRef.current.add(session.username) - updates[session.username] = {} } try { - const statsResult = await window.electronAPI.chat.getExportSessionStats(pending.map(session => session.username)) - if (statsResult.success && statsResult.data) { - for (const session of pending) { - const raw = statsResult.data[session.username] - if (!raw) continue - updates[session.username] = { - totalMessages: raw.totalMessages, - voiceMessages: raw.voiceMessages, - imageMessages: raw.imageMessages, - videoMessages: raw.videoMessages, - emojiMessages: raw.emojiMessages, - privateMutualGroups: raw.privateMutualGroups, - groupMemberCount: raw.groupMemberCount, - groupMyMessages: raw.groupMyMessages, - groupActiveSpeakers: raw.groupActiveSpeakers, - groupMutualFriends: raw.groupMutualFriends, - firstTimestamp: raw.firstTimestamp, - lastTimestamp: raw.lastTimestamp + const batchSize = 80 + for (let i = 0; i < pending.length; i += batchSize) { + const chunk = pending.slice(i, i + batchSize) + const ids = chunk.map(session => session.username) + + try { + const statsResult = await window.electronAPI.chat.getExportSessionStats(ids) + if (!statsResult.success || !statsResult.data) { + console.error('加载会话统计失败:', statsResult.error || '未知错误') + continue } + + for (const session of chunk) { + const raw = statsResult.data[session.username] + // 成功响应但无明细时按 0 回填,避免该行反复重试导致滚动抖动。 + updates[session.username] = { + totalMessages: raw?.totalMessages ?? 0, + voiceMessages: raw?.voiceMessages ?? 0, + imageMessages: raw?.imageMessages ?? 0, + videoMessages: raw?.videoMessages ?? 0, + emojiMessages: raw?.emojiMessages ?? 0, + privateMutualGroups: raw?.privateMutualGroups, + groupMemberCount: raw?.groupMemberCount, + groupMyMessages: raw?.groupMyMessages, + groupActiveSpeakers: raw?.groupActiveSpeakers, + groupMutualFriends: raw?.groupMutualFriends, + firstTimestamp: raw?.firstTimestamp, + lastTimestamp: raw?.lastTimestamp + } + } + } catch (error) { + console.error('加载会话统计分批失败:', error) } } } catch (error) { From d12c111684a123980985c79c0cc241abe80c5bc3 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 17:07:32 +0800 Subject: [PATCH 017/162] perf(export): virtualize session table and prioritize metrics loading --- src/pages/ExportPage.scss | 12 +++- src/pages/ExportPage.tsx | 114 ++++++++++++++++++++++++-------------- 2 files changed, 81 insertions(+), 45 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 00a5f1e..c576959 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -549,14 +549,19 @@ } .table-wrap { - overflow: auto; + overflow: hidden; border: 1px solid var(--border-color); border-radius: 10px; min-height: 0; flex: 1; } -.session-table { +.table-virtuoso { + height: 100%; +} + +.session-table, +.table-wrap table { width: 100%; min-width: 1300px; border-collapse: separate; @@ -588,7 +593,8 @@ background: rgba(var(--primary-rgb), 0.03); } - .selected-row { + .selected-row, + tbody tr:has(.select-icon-btn.checked) { background: rgba(var(--primary-rgb), 0.08); } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 0fd53ce..c5338bf 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,5 +1,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' +import { TableVirtuoso } from 'react-virtuoso' import { Aperture, ChevronDown, @@ -236,6 +237,9 @@ const timestampOrDash = (timestamp?: number): string => { } const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +const METRICS_VIEWPORT_PREFETCH = 140 +const METRICS_BACKGROUND_BATCH = 60 +const METRICS_BACKGROUND_INTERVAL_MS = 180 const WriteLayoutSelector = memo(function WriteLayoutSelector({ writeLayout, @@ -355,6 +359,7 @@ function ExportPage() { const sessionLoadTokenRef = useRef(0) const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) + const visibleSessionsRef = useRef([]) useEffect(() => { tasksRef.current = tasks @@ -615,6 +620,10 @@ function ExportPage() { }) }, [sessions, activeTab, searchKeyword, sessionMetrics]) + useEffect(() => { + visibleSessionsRef.current = visibleSessions + }, [visibleSessions]) + const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { const currentMetrics = sessionMetricsRef.current const pending = targetSessions.filter(session => !currentMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) @@ -674,13 +683,44 @@ function ExportPage() { }, []) useEffect(() => { - const targets = visibleSessions.slice(0, 40) + const keyword = searchKeyword.trim().toLowerCase() + const targets = sessions + .filter((session) => { + if (session.kind !== activeTab) return false + if (!keyword) return true + return ( + (session.displayName || '').toLowerCase().includes(keyword) || + session.username.toLowerCase().includes(keyword) + ) + }) + .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) + .slice(0, METRICS_VIEWPORT_PREFETCH) void ensureSessionMetrics(targets) - }, [visibleSessions, ensureSessionMetrics]) + }, [sessions, activeTab, searchKeyword, ensureSessionMetrics]) + + const handleTableRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { + const current = visibleSessionsRef.current + if (current.length === 0) return + const start = Math.max(0, range.startIndex - METRICS_VIEWPORT_PREFETCH) + const end = Math.min(current.length - 1, range.endIndex + METRICS_VIEWPORT_PREFETCH) + if (end < start) return + void ensureSessionMetrics(current.slice(start, end + 1)) + }, [ensureSessionMetrics]) useEffect(() => { if (sessions.length === 0) return - void ensureSessionMetrics(sessions) + let cursor = 0 + const timer = window.setInterval(() => { + if (cursor >= sessions.length) { + window.clearInterval(timer) + return + } + const chunk = sessions.slice(cursor, cursor + METRICS_BACKGROUND_BATCH) + cursor += METRICS_BACKGROUND_BATCH + void ensureSessionMetrics(chunk) + }, METRICS_BACKGROUND_INTERVAL_MS) + + return () => window.clearInterval(timer) }, [sessions, ensureSessionMetrics]) const selectedCount = selectedSessions.size @@ -1294,12 +1334,12 @@ function ExportPage() { ) } - const renderRow = (session: SessionRow) => { + const renderRowCells = (session: SessionRow) => { const metrics = sessionMetrics[session.username] || {} const checked = selectedSessions.has(session.username) return ( - + <>
From bf9b5ba5935e5325ac84fb4d1f03bcc9ca5e6a0c Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 17:27:10 +0800 Subject: [PATCH 018/162] perf(export): prioritize totals and keep table visible --- electron/main.ts | 4 + electron/preload.ts | 1 + electron/services/chatService.ts | 42 ++++++ src/pages/ExportPage.scss | 4 +- src/pages/ExportPage.tsx | 219 +++++++++++++++++++++++-------- src/types/electron.d.ts | 5 + 6 files changed, 216 insertions(+), 59 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index c13c9dc..14bf1e6 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -916,6 +916,10 @@ function registerIpcHandlers() { return chatService.getExportTabCounts() }) + ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[]) => { + return chatService.getSessionMessageCounts(sessionIds) + }) + ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => { return chatService.enrichSessionsContactInfo(usernames) }) diff --git a/electron/preload.ts b/electron/preload.ts index c0f76d1..43a478f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -131,6 +131,7 @@ contextBridge.exposeInMainWorld('electronAPI', { connect: () => ipcRenderer.invoke('chat:connect'), getSessions: () => ipcRenderer.invoke('chat:getSessions'), getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), + getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds), enrichSessionsContactInfo: (usernames: string[]) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index d55ef0f..67984e9 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -770,6 +770,48 @@ class ChatService { } } + /** + * 批量获取会话消息总数(轻量接口,用于列表优先排序) + */ + async getSessionMessageCounts(sessionIds: string[]): Promise<{ + success: boolean + counts?: Record + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + const normalizedSessionIds = Array.from( + new Set( + (sessionIds || []) + .map((id) => String(id || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedSessionIds.length === 0) { + return { success: true, counts: {} } + } + + const counts: Record = {} + await this.forEachWithConcurrency(normalizedSessionIds, 8, async (sessionId) => { + try { + const result = await wcdbService.getMessageCount(sessionId) + counts[sessionId] = result.success && typeof result.count === 'number' ? result.count : 0 + } catch { + counts[sessionId] = 0 + } + }) + + return { success: true, counts } + } catch (e) { + console.error('ChatService: 批量获取会话消息总数失败:', e) + return { success: false, error: String(e) } + } + } + /** * 获取通讯录列表 */ diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index c576959..ae7129b 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -439,6 +439,7 @@ border-radius: 12px; background: var(--card-bg); padding: 12px; + flex: 1; min-height: 0; display: flex; flex-direction: column; @@ -552,7 +553,8 @@ overflow: hidden; border: 1px solid var(--border-color); border-radius: 10px; - min-height: 0; + min-height: 320px; + height: 100%; flex: 1; } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index c5338bf..671c8e2 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -237,9 +237,29 @@ const timestampOrDash = (timestamp?: number): string => { } const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -const METRICS_VIEWPORT_PREFETCH = 140 -const METRICS_BACKGROUND_BATCH = 60 -const METRICS_BACKGROUND_INTERVAL_MS = 180 +const MESSAGE_COUNT_VIEWPORT_PREFETCH = 220 +const MESSAGE_COUNT_BACKGROUND_BATCH = 180 +const MESSAGE_COUNT_BACKGROUND_INTERVAL_MS = 100 +const METRICS_VIEWPORT_PREFETCH = 90 +const METRICS_BACKGROUND_BATCH = 40 +const METRICS_BACKGROUND_INTERVAL_MS = 220 +const CONTACT_ENRICH_TIMEOUT_MS = 7000 + +const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { + let timer: ReturnType | null = null + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs) + }) + ]) + } finally { + if (timer) { + clearTimeout(timer) + } + } +} const WriteLayoutSelector = memo(function WriteLayoutSelector({ writeLayout, @@ -306,6 +326,7 @@ function ExportPage() { const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false) const [sessions, setSessions] = useState([]) const [prefetchedTabCounts, setPrefetchedTabCounts] = useState | null>(null) + const [sessionMessageCounts, setSessionMessageCounts] = useState>({}) const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') const [activeTab, setActiveTab] = useState('private') @@ -355,8 +376,10 @@ function ExportPage() { const progressUnsubscribeRef = useRef<(() => void) | null>(null) const runningTaskIdRef = useRef(null) const tasksRef = useRef([]) + const sessionMessageCountsRef = useRef>({}) const sessionMetricsRef = useRef>({}) const sessionLoadTokenRef = useRef(0) + const loadingMessageCountsRef = useRef>(new Set()) const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) const visibleSessionsRef = useRef([]) @@ -365,6 +388,10 @@ function ExportPage() { tasksRef.current = tasks }, [tasks]) + useEffect(() => { + sessionMessageCountsRef.current = sessionMessageCounts + }, [sessionMessageCounts]) + useEffect(() => { sessionMetricsRef.current = sessionMetrics }, [sessionMetrics]) @@ -468,6 +495,12 @@ function ExportPage() { sessionLoadTokenRef.current = loadToken setIsLoading(true) setIsSessionEnriching(false) + loadingMessageCountsRef.current.clear() + loadingMetricsRef.current.clear() + sessionMessageCountsRef.current = {} + sessionMetricsRef.current = {} + setSessionMessageCounts({}) + setSessionMetrics({}) const isStale = () => sessionLoadTokenRef.current !== loadToken @@ -503,10 +536,10 @@ function ExportPage() { setIsSessionEnriching(true) void (async () => { try { - const contactsResult = await window.electronAPI.chat.getContacts() + const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) if (isStale()) return - const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : [] + const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] const nextContactMap = contacts.reduce>((map, contact) => { map[contact.username] = contact return map @@ -518,8 +551,11 @@ function ExportPage() { let extraContactMap: Record = {} if (needsEnrichment.length > 0) { - const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment) - if (enrichResult.success && enrichResult.contacts) { + const enrichResult = await withTimeout( + window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment), + CONTACT_ENRICH_TIMEOUT_MS + ) + if (enrichResult?.success && enrichResult.contacts) { extraContactMap = enrichResult.contacts } } @@ -539,12 +575,7 @@ function ExportPage() { avatarUrl } }) - .sort((a, b) => { - const aMetric = sessionMetricsRef.current[a.username]?.totalMessages ?? 0 - const bMetric = sessionMetricsRef.current[b.username]?.totalMessages ?? 0 - if (bMetric !== aMetric) return bMetric - aMetric - return (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0) - }) + .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) setSessions(nextSessions) } catch (enrichError) { @@ -566,10 +597,8 @@ function ExportPage() { useEffect(() => { void loadBaseConfig() - void (async () => { - await loadTabCounts() - await loadSessions() - })() + void loadTabCounts() + void loadSessions() // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 const timer = window.setTimeout(() => { @@ -608,23 +637,74 @@ function ExportPage() { ) }) .sort((a, b) => { - const totalA = sessionMetrics[a.username]?.totalMessages ?? 0 - const totalB = sessionMetrics[b.username]?.totalMessages ?? 0 - if (totalB !== totalA) { + const totalA = sessionMessageCounts[a.username] + const totalB = sessionMessageCounts[b.username] + const hasTotalA = typeof totalA === 'number' + const hasTotalB = typeof totalB === 'number' + + if (hasTotalA && hasTotalB && totalB !== totalA) { return totalB - totalA } + if (hasTotalA !== hasTotalB) { + return hasTotalA ? -1 : 1 + } const latestA = sessionMetrics[a.username]?.lastTimestamp ?? a.lastTimestamp ?? 0 const latestB = sessionMetrics[b.username]?.lastTimestamp ?? b.lastTimestamp ?? 0 return latestB - latestA }) - }, [sessions, activeTab, searchKeyword, sessionMetrics]) + }, [sessions, activeTab, searchKeyword, sessionMessageCounts, sessionMetrics]) useEffect(() => { visibleSessionsRef.current = visibleSessions }, [visibleSessions]) + const ensureSessionMessageCounts = useCallback(async (targetSessions: SessionRow[]) => { + const loadTokenAtStart = sessionLoadTokenRef.current + const currentCounts = sessionMessageCountsRef.current + const pending = targetSessions.filter( + session => currentCounts[session.username] === undefined && !loadingMessageCountsRef.current.has(session.username) + ) + if (pending.length === 0) return + + const updates: Record = {} + for (const session of pending) { + loadingMessageCountsRef.current.add(session.username) + } + + try { + const batchSize = 220 + for (let i = 0; i < pending.length; i += batchSize) { + if (loadTokenAtStart !== sessionLoadTokenRef.current) return + const chunk = pending.slice(i, i + batchSize) + const ids = chunk.map(session => session.username) + + try { + const result = await window.electronAPI.chat.getSessionMessageCounts(ids) + for (const session of chunk) { + const value = result.success && result.counts ? result.counts[session.username] : undefined + updates[session.username] = typeof value === 'number' ? value : 0 + } + } catch (error) { + console.error('加载会话总消息数失败:', error) + for (const session of chunk) { + updates[session.username] = 0 + } + } + } + } finally { + for (const session of pending) { + loadingMessageCountsRef.current.delete(session.username) + } + } + + if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(updates).length > 0) { + setSessionMessageCounts(prev => ({ ...prev, ...updates })) + } + }, []) + const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { + const loadTokenAtStart = sessionLoadTokenRef.current const currentMetrics = sessionMetricsRef.current const pending = targetSessions.filter(session => !currentMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) if (pending.length === 0) return @@ -637,6 +717,7 @@ function ExportPage() { try { const batchSize = 80 for (let i = 0; i < pending.length; i += batchSize) { + if (loadTokenAtStart !== sessionLoadTokenRef.current) return const chunk = pending.slice(i, i + batchSize) const ids = chunk.map(session => session.username) @@ -677,35 +758,48 @@ function ExportPage() { } } - if (Object.keys(updates).length > 0) { + if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(updates).length > 0) { setSessionMetrics(prev => ({ ...prev, ...updates })) } }, []) useEffect(() => { - const keyword = searchKeyword.trim().toLowerCase() - const targets = sessions - .filter((session) => { - if (session.kind !== activeTab) return false - if (!keyword) return true - return ( - (session.displayName || '').toLowerCase().includes(keyword) || - session.username.toLowerCase().includes(keyword) - ) - }) - .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) - .slice(0, METRICS_VIEWPORT_PREFETCH) + const targets = visibleSessions.slice(0, MESSAGE_COUNT_VIEWPORT_PREFETCH) + void ensureSessionMessageCounts(targets) + }, [visibleSessions, ensureSessionMessageCounts]) + + useEffect(() => { + const targets = visibleSessions.slice(0, METRICS_VIEWPORT_PREFETCH) void ensureSessionMetrics(targets) - }, [sessions, activeTab, searchKeyword, ensureSessionMetrics]) + }, [visibleSessions, ensureSessionMetrics]) const handleTableRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { const current = visibleSessionsRef.current if (current.length === 0) return - const start = Math.max(0, range.startIndex - METRICS_VIEWPORT_PREFETCH) - const end = Math.min(current.length - 1, range.endIndex + METRICS_VIEWPORT_PREFETCH) + const prefetch = Math.max(MESSAGE_COUNT_VIEWPORT_PREFETCH, METRICS_VIEWPORT_PREFETCH) + const start = Math.max(0, range.startIndex - prefetch) + const end = Math.min(current.length - 1, range.endIndex + prefetch) if (end < start) return - void ensureSessionMetrics(current.slice(start, end + 1)) - }, [ensureSessionMetrics]) + const rangeSessions = current.slice(start, end + 1) + void ensureSessionMessageCounts(rangeSessions) + void ensureSessionMetrics(rangeSessions) + }, [ensureSessionMessageCounts, ensureSessionMetrics]) + + useEffect(() => { + if (sessions.length === 0) return + let cursor = 0 + const timer = window.setInterval(() => { + if (cursor >= sessions.length) { + window.clearInterval(timer) + return + } + const chunk = sessions.slice(cursor, cursor + MESSAGE_COUNT_BACKGROUND_BATCH) + cursor += MESSAGE_COUNT_BACKGROUND_BATCH + void ensureSessionMessageCounts(chunk) + }, MESSAGE_COUNT_BACKGROUND_INTERVAL_MS) + + return () => window.clearInterval(timer) + }, [sessions, ensureSessionMessageCounts]) useEffect(() => { if (sessions.length === 0) return @@ -1335,7 +1429,8 @@ function ExportPage() { } const renderRowCells = (session: SessionRow) => { - const metrics = sessionMetrics[session.username] || {} + const metrics = sessionMetrics[session.username] + const totalMessages = sessionMessageCounts[session.username] const checked = selectedSessions.has(session.username) return ( @@ -1351,35 +1446,43 @@ function ExportPage() { {renderSessionName(session)} - {valueOrDash(metrics.totalMessages)} - {valueOrDash(metrics.voiceMessages)} - {valueOrDash(metrics.imageMessages)} - {valueOrDash(metrics.videoMessages)} - {valueOrDash(metrics.emojiMessages)} + + {typeof totalMessages === 'number' + ? totalMessages.toLocaleString() + : ( + + 统计中 + + )} + + {valueOrDash(metrics?.voiceMessages)} + {valueOrDash(metrics?.imageMessages)} + {valueOrDash(metrics?.videoMessages)} + {valueOrDash(metrics?.emojiMessages)} {(activeTab === 'private' || activeTab === 'former_friend') && ( <> - {valueOrDash(metrics.privateMutualGroups)} - {timestampOrDash(metrics.firstTimestamp)} - {timestampOrDash(metrics.lastTimestamp)} + {valueOrDash(metrics?.privateMutualGroups)} + {timestampOrDash(metrics?.firstTimestamp)} + {timestampOrDash(metrics?.lastTimestamp)} )} {activeTab === 'group' && ( <> - {valueOrDash(metrics.groupMyMessages)} - {valueOrDash(metrics.groupMemberCount)} - {valueOrDash(metrics.groupActiveSpeakers)} - {valueOrDash(metrics.groupMutualFriends)} - {timestampOrDash(metrics.firstTimestamp)} - {timestampOrDash(metrics.lastTimestamp)} + {valueOrDash(metrics?.groupMyMessages)} + {valueOrDash(metrics?.groupMemberCount)} + {valueOrDash(metrics?.groupActiveSpeakers)} + {valueOrDash(metrics?.groupMutualFriends)} + {timestampOrDash(metrics?.firstTimestamp)} + {timestampOrDash(metrics?.lastTimestamp)} )} {activeTab === 'official' && ( <> - {timestampOrDash(metrics.firstTimestamp)} - {timestampOrDash(metrics.lastTimestamp)} + {timestampOrDash(metrics?.firstTimestamp)} + {timestampOrDash(metrics?.lastTimestamp)} )} @@ -1616,10 +1719,10 @@ function ExportPage() {
- {(isLoading || isSessionEnriching) && ( + {!showInitialSkeleton && (isLoading || isSessionEnriching) && (
- {isLoading ? '正在加载会话列表…' : '正在补充头像和统计…'} + {isLoading ? '正在刷新会话列表…' : '正在补充头像和统计…'}
)} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 4d96cb9..471ac70 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -84,6 +84,11 @@ export interface ElectronAPI { } error?: string }> + getSessionMessageCounts: (sessionIds: string[]) => Promise<{ + success: boolean + counts?: Record + error?: string + }> enrichSessionsContactInfo: (usernames: string[]) => Promise<{ success: boolean contacts?: Record From 7604ff2ae414cba286922718311ef0b4c765262b Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 17:51:28 +0800 Subject: [PATCH 019/162] perf(export): cache counts and speed sns/session stats --- electron/main.ts | 4 + electron/preload.ts | 1 + electron/services/chatService.ts | 38 ++++++- electron/services/snsService.ts | 100 +++++++++++++---- src/pages/ExportPage.tsx | 183 ++++++++++++++++++++++++------- src/services/config.ts | 100 +++++++++++++++++ src/types/electron.d.ts | 1 + 7 files changed, 365 insertions(+), 62 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 14bf1e6..4638662 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1032,6 +1032,10 @@ function registerIpcHandlers() { return snsService.getExportStats() }) + ipcMain.handle('sns:getExportStatsFast', async () => { + return snsService.getExportStatsFast() + }) + ipcMain.handle('sns:debugResource', async (_, url: string) => { return snsService.debugResource(url) }) diff --git a/electron/preload.ts b/electron/preload.ts index 43a478f..a26c46b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -290,6 +290,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), + getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'), getExportStats: () => ipcRenderer.invoke('sns:getExportStats'), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 67984e9..67de6e0 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -196,6 +196,9 @@ class ChatService { // 缓存会话表信息,避免每次查询 private sessionTablesCache = new Map>() private readonly sessionTablesCacheTtl = 300000 // 5分钟 + private sessionMessageCountCache = new Map() + private sessionMessageCountCacheScope = '' + private readonly sessionMessageCountCacheTtlMs = 10 * 60 * 1000 constructor() { this.configService = new ConfigService() @@ -795,13 +798,35 @@ class ChatService { return { success: true, counts: {} } } + this.refreshSessionMessageCountCacheScope() const counts: Record = {} - await this.forEachWithConcurrency(normalizedSessionIds, 8, async (sessionId) => { + const now = Date.now() + const pendingSessionIds: string[] = [] + + for (const sessionId of normalizedSessionIds) { + const cached = this.sessionMessageCountCache.get(sessionId) + if (cached && now - cached.updatedAt <= this.sessionMessageCountCacheTtlMs) { + counts[sessionId] = cached.count + } else { + pendingSessionIds.push(sessionId) + } + } + + await this.forEachWithConcurrency(pendingSessionIds, 16, async (sessionId) => { try { const result = await wcdbService.getMessageCount(sessionId) - counts[sessionId] = result.success && typeof result.count === 'number' ? result.count : 0 + const nextCount = result.success && typeof result.count === 'number' ? result.count : 0 + counts[sessionId] = nextCount + this.sessionMessageCountCache.set(sessionId, { + count: nextCount, + updatedAt: Date.now() + }) } catch { counts[sessionId] = 0 + this.sessionMessageCountCache.set(sessionId, { + count: 0, + updatedAt: Date.now() + }) } }) @@ -1455,6 +1480,15 @@ class ChatService { await Promise.all(runners) } + private refreshSessionMessageCountCacheScope(): void { + const dbPath = String(this.configService.get('dbPath') || '') + const myWxid = String(this.configService.get('myWxid') || '') + const scope = `${dbPath}::${myWxid}` + if (scope === this.sessionMessageCountCacheScope) return + this.sessionMessageCountCacheScope = scope + this.sessionMessageCountCache.clear() + } + private async collectSessionExportStats( sessionId: string, selfIdentitySet: Set diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 9484cdb..369a003 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -229,6 +229,10 @@ class SnsService { private configService: ConfigService private contactCache: ContactCacheService private imageCache = new Map() + private exportStatsCache: { totalPosts: number; totalFriends: number; updatedAt: number } | null = null + private readonly exportStatsCacheTtlMs = 5 * 60 * 1000 + private lastTimelineFallbackAt = 0 + private readonly timelineFallbackCooldownMs = 3 * 60 * 1000 constructor() { this.configService = new ConfigService() @@ -403,38 +407,66 @@ class SnsService { return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) } } - async getExportStats(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> { - try { - let totalPosts = 0 - let totalFriends = 0 + private async getExportStatsFromTableCount(): Promise<{ totalPosts: number; totalFriends: number }> { + let totalPosts = 0 + let totalFriends = 0 - const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine') - if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) { - totalPosts = this.parseCountValue(postCountResult.rows[0]) - } + const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine') + if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) { + totalPosts = this.parseCountValue(postCountResult.rows[0]) + } - if (totalPosts > 0) { - const friendCountPrimary = await wcdbService.execQuery( + if (totalPosts > 0) { + const friendCountPrimary = await wcdbService.execQuery( + 'sns', + null, + "SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''" + ) + if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) { + totalFriends = this.parseCountValue(friendCountPrimary.rows[0]) + } else { + const friendCountFallback = await wcdbService.execQuery( 'sns', null, - "SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''" + "SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''" ) - if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) { - totalFriends = this.parseCountValue(friendCountPrimary.rows[0]) - } else { - const friendCountFallback = await wcdbService.execQuery( - 'sns', - null, - "SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''" - ) - if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) { - totalFriends = this.parseCountValue(friendCountFallback.rows[0]) + if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) { + totalFriends = this.parseCountValue(friendCountFallback.rows[0]) + } + } + } + + return { totalPosts, totalFriends } + } + + async getExportStats(options?: { + allowTimelineFallback?: boolean + preferCache?: boolean + }): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> { + const allowTimelineFallback = options?.allowTimelineFallback ?? true + const preferCache = options?.preferCache ?? false + const now = Date.now() + + try { + if (preferCache && this.exportStatsCache && now - this.exportStatsCache.updatedAt <= this.exportStatsCacheTtlMs) { + return { + success: true, + data: { + totalPosts: this.exportStatsCache.totalPosts, + totalFriends: this.exportStatsCache.totalFriends } } } - // 某些环境下 SnsTimeLine 统计查询会返回 0,这里回退到与导出同源的 timeline 接口统计。 - if (totalPosts <= 0 || totalFriends <= 0) { + let { totalPosts, totalFriends } = await this.getExportStatsFromTableCount() + + // 某些环境下 SnsTimeLine 统计查询会返回 0,这里在允许时回退到与导出同源的 timeline 接口统计。 + if ( + allowTimelineFallback && + (totalPosts <= 0 || totalFriends <= 0) && + now - this.lastTimelineFallbackAt >= this.timelineFallbackCooldownMs + ) { + this.lastTimelineFallbackAt = now const timelineStats = await this.getExportStatsFromTimeline() if (timelineStats.totalPosts > 0) { totalPosts = timelineStats.totalPosts @@ -444,12 +476,34 @@ class SnsService { } } + this.exportStatsCache = { + totalPosts, + totalFriends, + updatedAt: Date.now() + } + return { success: true, data: { totalPosts, totalFriends } } } catch (e) { + if (this.exportStatsCache) { + return { + success: true, + data: { + totalPosts: this.exportStatsCache.totalPosts, + totalFriends: this.exportStatsCache.totalFriends + } + } + } return { success: false, error: String(e) } } } + async getExportStatsFast(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> { + return this.getExportStats({ + allowTimelineFallback: false, + preferCache: true + }) + } + // 安装朋友圈删除拦截 async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { return wcdbService.installSnsBlockDeleteTrigger() diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 671c8e2..415a93a 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -237,13 +237,15 @@ const timestampOrDash = (timestamp?: number): string => { } const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -const MESSAGE_COUNT_VIEWPORT_PREFETCH = 220 -const MESSAGE_COUNT_BACKGROUND_BATCH = 180 -const MESSAGE_COUNT_BACKGROUND_INTERVAL_MS = 100 +const MESSAGE_COUNT_VIEWPORT_PREFETCH = 120 +const MESSAGE_COUNT_BACKGROUND_BATCH = 90 +const MESSAGE_COUNT_BACKGROUND_INTERVAL_MS = 90 const METRICS_VIEWPORT_PREFETCH = 90 const METRICS_BACKGROUND_BATCH = 40 const METRICS_BACKGROUND_INTERVAL_MS = 220 const CONTACT_ENRICH_TIMEOUT_MS = 7000 +const EXPORT_SESSION_COUNT_CACHE_STALE_MS = 48 * 60 * 60 * 1000 +const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null @@ -371,11 +373,13 @@ function ExportPage() { totalPosts: 0, totalFriends: 0 }) + const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false) const [nowTick, setNowTick] = useState(Date.now()) const progressUnsubscribeRef = useRef<(() => void) | null>(null) const runningTaskIdRef = useRef(null) const tasksRef = useRef([]) + const hasSeededSnsStatsRef = useRef(false) const sessionMessageCountsRef = useRef>({}) const sessionMetricsRef = useRef>({}) const sessionLoadTokenRef = useRef(0) @@ -383,11 +387,18 @@ function ExportPage() { const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) const visibleSessionsRef = useRef([]) + const exportCacheScopeRef = useRef('default') + const exportCacheScopeReadyRef = useRef(false) + const persistSessionCountTimerRef = useRef(null) useEffect(() => { tasksRef.current = tasks }, [tasks]) + useEffect(() => { + hasSeededSnsStatsRef.current = hasSeededSnsStats + }, [hasSeededSnsStats]) + useEffect(() => { sessionMessageCountsRef.current = sessionMessageCounts }, [sessionMessageCounts]) @@ -396,6 +407,30 @@ function ExportPage() { sessionMetricsRef.current = sessionMetrics }, [sessionMetrics]) + useEffect(() => { + if (persistSessionCountTimerRef.current) { + window.clearTimeout(persistSessionCountTimerRef.current) + persistSessionCountTimerRef.current = null + } + + if (isBaseConfigLoading || !exportCacheScopeReadyRef.current) return + + const countSize = Object.keys(sessionMessageCounts).length + if (countSize === 0) return + + persistSessionCountTimerRef.current = window.setTimeout(() => { + void configService.setExportSessionMessageCountCache(exportCacheScopeRef.current, sessionMessageCounts) + persistSessionCountTimerRef.current = null + }, 900) + + return () => { + if (persistSessionCountTimerRef.current) { + window.clearTimeout(persistSessionCountTimerRef.current) + persistSessionCountTimerRef.current = null + } + } + }, [sessionMessageCounts, isBaseConfigLoading]) + const preselectSessionIds = useMemo(() => { const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null const rawList = Array.isArray(state?.preselectSessionIds) @@ -416,7 +451,7 @@ function ExportPage() { const loadBaseConfig = useCallback(async () => { setIsBaseConfigLoading(true) try { - const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount] = await Promise.all([ + const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount, myWxid, dbPath] = await Promise.all([ configService.getExportPath(), configService.getExportDefaultFormat(), configService.getExportDefaultMedia(), @@ -427,7 +462,17 @@ function ExportPage() { configService.getExportWriteLayout(), configService.getExportLastSessionRunMap(), configService.getExportLastContentRunMap(), - configService.getExportLastSnsPostCount() + configService.getExportLastSnsPostCount(), + configService.getMyWxid(), + configService.getDbPath() + ]) + const exportCacheScope = `${dbPath || ''}::${myWxid || ''}` || 'default' + exportCacheScopeRef.current = exportCacheScope + exportCacheScopeReadyRef.current = true + + const [cachedSessionCountMap, cachedSnsStats] = await Promise.all([ + configService.getExportSessionMessageCountCache(exportCacheScope), + configService.getExportSnsStatsCache(exportCacheScope) ]) if (savedPath) { @@ -442,6 +487,19 @@ function ExportPage() { setLastExportByContent(savedContentMap) setLastSnsExportPostCount(savedSnsPostCount) + if (cachedSessionCountMap && Date.now() - cachedSessionCountMap.updatedAt <= EXPORT_SESSION_COUNT_CACHE_STALE_MS) { + setSessionMessageCounts(cachedSessionCountMap.counts || {}) + } + + if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) { + setSnsStats({ + totalPosts: cachedSnsStats.totalPosts || 0, + totalFriends: cachedSnsStats.totalFriends || 0 + }) + hasSeededSnsStatsRef.current = true + setHasSeededSnsStats(true) + } + const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns setOptions(prev => ({ ...prev, @@ -473,20 +531,52 @@ function ExportPage() { } }, []) - const loadSnsStats = useCallback(async () => { - setIsSnsStatsLoading(true) + const loadSnsStats = useCallback(async (options?: { full?: boolean; silent?: boolean }) => { + if (!options?.silent) { + setIsSnsStatsLoading(true) + } + + const applyStats = async (next: { totalPosts: number; totalFriends: number } | null) => { + if (!next) return + const normalized = { + totalPosts: Number.isFinite(next.totalPosts) ? Math.max(0, Math.floor(next.totalPosts)) : 0, + totalFriends: Number.isFinite(next.totalFriends) ? Math.max(0, Math.floor(next.totalFriends)) : 0 + } + setSnsStats(normalized) + hasSeededSnsStatsRef.current = true + setHasSeededSnsStats(true) + if (exportCacheScopeReadyRef.current) { + await configService.setExportSnsStatsCache(exportCacheScopeRef.current, normalized) + } + } + try { - const result = await window.electronAPI.sns.getExportStats() - if (result.success && result.data) { - setSnsStats({ - totalPosts: result.data.totalPosts || 0, - totalFriends: result.data.totalFriends || 0 - }) + const fastResult = await withTimeout(window.electronAPI.sns.getExportStatsFast(), 2200) + if (fastResult?.success && fastResult.data) { + const fastStats = { + totalPosts: fastResult.data.totalPosts || 0, + totalFriends: fastResult.data.totalFriends || 0 + } + if (fastStats.totalPosts > 0 || hasSeededSnsStatsRef.current) { + await applyStats(fastStats) + } + } + + if (options?.full) { + const result = await withTimeout(window.electronAPI.sns.getExportStats(), 9000) + if (result?.success && result.data) { + await applyStats({ + totalPosts: result.data.totalPosts || 0, + totalFriends: result.data.totalFriends || 0 + }) + } } } catch (error) { console.error('加载朋友圈导出统计失败:', error) } finally { - setIsSnsStatsLoading(false) + if (!options?.silent) { + setIsSnsStatsLoading(false) + } } }, []) @@ -497,9 +587,7 @@ function ExportPage() { setIsSessionEnriching(false) loadingMessageCountsRef.current.clear() loadingMetricsRef.current.clear() - sessionMessageCountsRef.current = {} sessionMetricsRef.current = {} - setSessionMessageCounts({}) setSessionMetrics({}) const isStale = () => sessionLoadTokenRef.current !== loadToken @@ -530,6 +618,16 @@ function ExportPage() { if (isStale()) return setSessions(baseSessions) + setSessionMessageCounts(prev => { + const next: Record = {} + for (const session of baseSessions) { + const count = prev[session.username] + if (typeof count === 'number') { + next[session.username] = count + } + } + return next + }) setIsLoading(false) // 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。 @@ -602,8 +700,8 @@ function ExportPage() { // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 const timer = window.setTimeout(() => { - void loadSnsStats() - }, 180) + void loadSnsStats({ full: true }) + }, 120) return () => window.clearTimeout(timer) }, [loadTabCounts, loadBaseConfig, loadSessions, loadSnsStats]) @@ -666,41 +764,43 @@ function ExportPage() { session => currentCounts[session.username] === undefined && !loadingMessageCountsRef.current.has(session.username) ) if (pending.length === 0) return - - const updates: Record = {} for (const session of pending) { loadingMessageCountsRef.current.add(session.username) } try { - const batchSize = 220 + const batchSize = pending.length > 100 ? 48 : 28 for (let i = 0; i < pending.length; i += batchSize) { if (loadTokenAtStart !== sessionLoadTokenRef.current) return const chunk = pending.slice(i, i + batchSize) const ids = chunk.map(session => session.username) + const chunkUpdates: Record = {} try { - const result = await window.electronAPI.chat.getSessionMessageCounts(ids) + const result = await withTimeout(window.electronAPI.chat.getSessionMessageCounts(ids), 10000) + if (!result) { + continue + } for (const session of chunk) { - const value = result.success && result.counts ? result.counts[session.username] : undefined - updates[session.username] = typeof value === 'number' ? value : 0 + const value = result?.success && result.counts ? result.counts[session.username] : undefined + chunkUpdates[session.username] = typeof value === 'number' ? value : 0 } } catch (error) { console.error('加载会话总消息数失败:', error) for (const session of chunk) { - updates[session.username] = 0 + chunkUpdates[session.username] = 0 } } + + if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(chunkUpdates).length > 0) { + setSessionMessageCounts(prev => ({ ...prev, ...chunkUpdates })) + } } } finally { for (const session of pending) { loadingMessageCountsRef.current.delete(session.username) } } - - if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(updates).length > 0) { - setSessionMessageCounts(prev => ({ ...prev, ...updates })) - } }, []) const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { @@ -787,35 +887,43 @@ function ExportPage() { useEffect(() => { if (sessions.length === 0) return + const prioritySessions = [ + ...sessions.filter(session => session.kind === activeTab), + ...sessions.filter(session => session.kind !== activeTab) + ] let cursor = 0 const timer = window.setInterval(() => { - if (cursor >= sessions.length) { + if (cursor >= prioritySessions.length) { window.clearInterval(timer) return } - const chunk = sessions.slice(cursor, cursor + MESSAGE_COUNT_BACKGROUND_BATCH) + const chunk = prioritySessions.slice(cursor, cursor + MESSAGE_COUNT_BACKGROUND_BATCH) cursor += MESSAGE_COUNT_BACKGROUND_BATCH void ensureSessionMessageCounts(chunk) }, MESSAGE_COUNT_BACKGROUND_INTERVAL_MS) return () => window.clearInterval(timer) - }, [sessions, ensureSessionMessageCounts]) + }, [sessions, activeTab, ensureSessionMessageCounts]) useEffect(() => { if (sessions.length === 0) return + const prioritySessions = [ + ...sessions.filter(session => session.kind === activeTab), + ...sessions.filter(session => session.kind !== activeTab) + ] let cursor = 0 const timer = window.setInterval(() => { - if (cursor >= sessions.length) { + if (cursor >= prioritySessions.length) { window.clearInterval(timer) return } - const chunk = sessions.slice(cursor, cursor + METRICS_BACKGROUND_BATCH) + const chunk = prioritySessions.slice(cursor, cursor + METRICS_BACKGROUND_BATCH) cursor += METRICS_BACKGROUND_BATCH void ensureSessionMetrics(chunk) }, METRICS_BACKGROUND_INTERVAL_MS) return () => window.clearInterval(timer) - }, [sessions, ensureSessionMetrics]) + }, [sessions, activeTab, ensureSessionMetrics]) const selectedCount = selectedSessions.size @@ -1059,7 +1167,7 @@ function ExportPage() { const mergedExportedCount = Math.max(lastSnsExportPostCount, exportedPosts) setLastSnsExportPostCount(mergedExportedCount) await configService.setExportLastSnsPostCount(mergedExportedCount) - await loadSnsStats() + await loadSnsStats({ full: true }) updateTask(next.id, task => ({ ...task, @@ -1519,6 +1627,7 @@ function ExportPage() { const hasTabCountsSource = prefetchedTabCounts !== null || sessions.length > 0 const isTabCountComputing = isTabCountsLoading && !hasTabCountsSource const isSessionCardStatsLoading = isLoading || isBaseConfigLoading + const isSnsCardStatsLoading = !hasSeededSnsStats const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length const showInitialSkeleton = isLoading && sessions.length === 0 @@ -1574,7 +1683,7 @@ function ExportPage() { {contentCards.map(card => { const Icon = card.icon const isCardStatsLoading = card.type === 'sns' - ? (isSnsStatsLoading || isBaseConfigLoading) + ? isSnsCardStatsLoading : isSessionCardStatsLoading return (
diff --git a/src/services/config.ts b/src/services/config.ts index 7927939..53969ef 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -36,6 +36,8 @@ export const CONFIG_KEYS = { EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap', EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap', EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount', + EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap', + EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap', // 安全 AUTH_ENABLED: 'authEnabled', @@ -449,6 +451,104 @@ export async function setExportLastSnsPostCount(count: number): Promise { await config.set(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT, normalized) } +export interface ExportSessionMessageCountCacheItem { + updatedAt: number + counts: Record +} + +export interface ExportSnsStatsCacheItem { + updatedAt: number + totalPosts: number + totalFriends: number +} + +export async function getExportSessionMessageCountCache(scopeKey: string): Promise { + if (!scopeKey) return null + const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP) + if (!value || typeof value !== 'object') return null + const rawMap = value as Record + const rawItem = rawMap[scopeKey] + if (!rawItem || typeof rawItem !== 'object') return null + + const rawUpdatedAt = (rawItem as Record).updatedAt + const rawCounts = (rawItem as Record).counts + if (!rawCounts || typeof rawCounts !== 'object') return null + + const counts: Record = {} + for (const [sessionId, countRaw] of Object.entries(rawCounts as Record)) { + if (typeof countRaw === 'number' && Number.isFinite(countRaw) && countRaw >= 0) { + counts[sessionId] = Math.floor(countRaw) + } + } + + return { + updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0, + counts + } +} + +export async function setExportSessionMessageCountCache(scopeKey: string, counts: Record): Promise { + if (!scopeKey) return + const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP) + const map = current && typeof current === 'object' + ? { ...(current as Record) } + : {} + + const normalized: Record = {} + for (const [sessionId, countRaw] of Object.entries(counts || {})) { + if (typeof countRaw === 'number' && Number.isFinite(countRaw) && countRaw >= 0) { + normalized[sessionId] = Math.floor(countRaw) + } + } + + map[scopeKey] = { + updatedAt: Date.now(), + counts: normalized + } + await config.set(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP, map) +} + +export async function getExportSnsStatsCache(scopeKey: string): Promise { + if (!scopeKey) return null + const value = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP) + if (!value || typeof value !== 'object') return null + const rawMap = value as Record + const rawItem = rawMap[scopeKey] + if (!rawItem || typeof rawItem !== 'object') return null + + const raw = rawItem as Record + const totalPosts = typeof raw.totalPosts === 'number' && Number.isFinite(raw.totalPosts) && raw.totalPosts >= 0 + ? Math.floor(raw.totalPosts) + : 0 + const totalFriends = typeof raw.totalFriends === 'number' && Number.isFinite(raw.totalFriends) && raw.totalFriends >= 0 + ? Math.floor(raw.totalFriends) + : 0 + const updatedAt = typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) + ? raw.updatedAt + : 0 + + return { updatedAt, totalPosts, totalFriends } +} + +export async function setExportSnsStatsCache( + scopeKey: string, + stats: { totalPosts: number; totalFriends: number } +): Promise { + if (!scopeKey) return + const current = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP) + const map = current && typeof current === 'object' + ? { ...(current as Record) } + : {} + + map[scopeKey] = { + updatedAt: Date.now(), + totalPosts: Number.isFinite(stats.totalPosts) ? Math.max(0, Math.floor(stats.totalPosts)) : 0, + totalFriends: Number.isFinite(stats.totalFriends) ? Math.max(0, Math.floor(stats.totalFriends)) : 0 + } + + await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map) +} + // === 安全相关 === export async function getAuthEnabled(): Promise { diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 471ac70..e638331 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -554,6 +554,7 @@ export interface ElectronAPI { onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }> getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }> + getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }> From a8eb0057e392cc8201ac88fbf3a759c51608fc53 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 17:55:49 +0800 Subject: [PATCH 020/162] perf(export): keep page alive across route switches --- src/App.scss | 13 +++++++++++++ src/App.tsx | 16 +++++++++++++++- src/pages/ExportPage.tsx | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/App.scss b/src/App.scss index 3c137bd..5cffbbd 100644 --- a/src/App.scss +++ b/src/App.scss @@ -69,6 +69,19 @@ flex: 1; overflow: auto; padding: 24px; + position: relative; +} + +.export-keepalive-page { + height: 100%; + + &.hidden { + display: none; + } +} + +.export-route-anchor { + display: none; } @keyframes appFadeIn { diff --git a/src/App.tsx b/src/App.tsx index c999a80..adc32cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,7 +61,9 @@ function App() { const isVideoPlayerWindow = location.pathname === '/video-player-window' const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') const isNotificationWindow = location.pathname === '/notification-window' + const isExportRoute = location.pathname === '/export' const [themeHydrated, setThemeHydrated] = useState(false) + const [hasVisitedExport, setHasVisitedExport] = useState(isExportRoute) // 锁定状态 // const [isLocked, setIsLocked] = useState(false) // Moved to store @@ -99,6 +101,12 @@ function App() { } }, [isOnboardingWindow]) + useEffect(() => { + if (isExportRoute) { + setHasVisitedExport(true) + } + }, [isExportRoute]) + // 应用主题 useEffect(() => { const mq = window.matchMedia('(prefers-color-scheme: dark)') @@ -454,6 +462,12 @@ function App() {
+ {hasVisitedExport && ( +
+ +
+ )} + } /> } /> @@ -468,7 +482,7 @@ function App() { } /> } /> - } /> +
{/* 折叠群 header */} @@ -2093,7 +2160,7 @@ function ChatPage(_props: ChatPageProps) { )} {/* ... (previous content) ... */} - {isLoadingSessions ? ( + {shouldShowSessionsSkeleton ? (
{[1, 2, 3, 4, 5].map(i => (
@@ -2311,11 +2378,11 @@ function ChatPage(_props: ChatPageProps) {
-
- {isLoadingMessages && !hasInitialMessages && ( +
+ {isLoadingMessages && (!hasInitialMessages || isSessionSwitching) && (
- 加载消息中... + {isSessionSwitching ? '切换会话中...' : '加载消息中...'}
)}
void setSessions: (sessions: ChatSession[]) => void setFilteredSessions: (sessions: ChatSession[]) => void - setCurrentSession: (sessionId: string | null) => void + setCurrentSession: (sessionId: string | null, options?: { preserveMessages?: boolean }) => void setLoadingSessions: (loading: boolean) => void setMessages: (messages: Message[]) => void appendMessages: (messages: Message[], prepend?: boolean) => void @@ -69,12 +69,12 @@ export const useChatStore = create((set, get) => ({ setSessions: (sessions) => set({ sessions, filteredSessions: sessions }), setFilteredSessions: (sessions) => set({ filteredSessions: sessions }), - setCurrentSession: (sessionId) => set({ + setCurrentSession: (sessionId, options) => set((state) => ({ currentSessionId: sessionId, - messages: [], + messages: options?.preserveMessages ? state.messages : [], hasMoreMessages: true, hasMoreLater: false - }), + })), setLoadingSessions: (loading) => set({ isLoadingSessions: loading }), diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index e638331..8f8f6f1 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -74,6 +74,11 @@ export interface ElectronAPI { chat: { connect: () => Promise<{ success: boolean; error?: string }> getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> + getSessionStatuses: (usernames: string[]) => Promise<{ + success: boolean + map?: Record + error?: string + }> getExportTabCounts: () => Promise<{ success: boolean counts?: { From a5ae22d2a5cd11fb3496f166453c6bc7351f02ff Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 18:41:06 +0800 Subject: [PATCH 024/162] perf(chat): split session detail into fast and extra loading --- electron/main.ts | 8 + electron/preload.ts | 2 + electron/services/chatService.ts | 254 ++++++++++++++++++++++++------- src/pages/ChatPage.scss | 8 + src/pages/ChatPage.tsx | 146 +++++++++++++----- src/types/electron.d.ts | 22 +++ 6 files changed, 344 insertions(+), 96 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index a1ac68f..e73a715 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -986,6 +986,14 @@ function registerIpcHandlers() { return chatService.getSessionDetail(sessionId) }) + ipcMain.handle('chat:getSessionDetailFast', async (_, sessionId: string) => { + return chatService.getSessionDetailFast(sessionId) + }) + + ipcMain.handle('chat:getSessionDetailExtra', async (_, sessionId: string) => { + return chatService.getSessionDetailExtra(sessionId) + }) + ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[]) => { return chatService.getExportSessionStats(sessionIds) }) diff --git a/electron/preload.ts b/electron/preload.ts index 7a3c0af..999486f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -154,6 +154,8 @@ contextBridge.exposeInMainWorld('electronAPI', { getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId), close: () => ipcRenderer.invoke('chat:close'), getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId), + getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId), + getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId), getExportSessionStats: (sessionIds: string[]) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds), getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId), getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index d5db221..0e655d3 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -159,6 +159,24 @@ interface ExportTabCounts { former_friend: number } +interface SessionDetailFast { + wxid: string + displayName: string + remark?: string + nickName?: string + alias?: string + avatarUrl?: string + messageCount: number +} + +interface SessionDetailExtra { + firstMessageTime?: number + latestMessageTime?: number + messageTables: { dbName: string; tableName: string; count: number }[] +} + +type SessionDetail = SessionDetailFast & SessionDetailExtra + // 表情包缓存 const emojiCache: Map = new Map() const emojiDownloading: Map> = new Map() @@ -201,6 +219,10 @@ class ChatService { private sessionMessageCountHintCache = new Map() private sessionMessageCountCacheScope = '' private readonly sessionMessageCountCacheTtlMs = 10 * 60 * 1000 + private sessionDetailFastCache = new Map() + private sessionDetailExtraCache = new Map() + private readonly sessionDetailFastCacheTtlMs = 60 * 1000 + private readonly sessionDetailExtraCacheTtlMs = 5 * 60 * 1000 private sessionStatusCache = new Map() private readonly sessionStatusCacheTtlMs = 10 * 60 * 1000 @@ -1565,6 +1587,8 @@ class ChatService { this.sessionMessageCountCacheScope = scope this.sessionMessageCountCache.clear() this.sessionMessageCountHintCache.clear() + this.sessionDetailFastCache.clear() + this.sessionDetailExtraCache.clear() this.sessionStatusCache.clear() } @@ -3819,20 +3843,9 @@ class ChatService { /** * 获取会话详情信息 */ - async getSessionDetail(sessionId: string): Promise<{ + async getSessionDetailFast(sessionId: string): Promise<{ success: boolean - detail?: { - wxid: string - displayName: string - remark?: string - nickName?: string - alias?: string - avatarUrl?: string - messageCount: number - firstMessageTime?: number - latestMessageTime?: number - messageTables: { dbName: string; tableName: string; count: number }[] - } + detail?: SessionDetailFast error?: string }> { try { @@ -3840,53 +3853,152 @@ class ChatService { if (!connectResult.success) { return { success: false, error: connectResult.error || '数据库未连接' } } + this.refreshSessionMessageCountCacheScope() - let displayName = sessionId + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) { + return { success: false, error: '会话ID不能为空' } + } + + const now = Date.now() + const cachedDetail = this.sessionDetailFastCache.get(normalizedSessionId) + if (cachedDetail && now - cachedDetail.updatedAt <= this.sessionDetailFastCacheTtlMs) { + return { success: true, detail: cachedDetail.detail } + } + + let displayName = normalizedSessionId let remark: string | undefined let nickName: string | undefined let alias: string | undefined let avatarUrl: string | undefined - - const contactResult = await wcdbService.getContact(sessionId) - if (contactResult.success && contactResult.contact) { - remark = contactResult.contact.remark || undefined - nickName = contactResult.contact.nickName || undefined - alias = contactResult.contact.alias || undefined - displayName = remark || nickName || alias || sessionId - } - const avatarResult = await wcdbService.getAvatarUrls([sessionId]) - if (avatarResult.success && avatarResult.map) { - avatarUrl = avatarResult.map[sessionId] + const cachedContact = this.avatarCache.get(normalizedSessionId) + if (cachedContact) { + displayName = cachedContact.displayName || normalizedSessionId + avatarUrl = cachedContact.avatarUrl } - const countResult = await wcdbService.getMessageCount(sessionId) - const totalMessageCount = countResult.success && countResult.count ? countResult.count : 0 + const [contactResult, avatarResult] = await Promise.allSettled([ + wcdbService.getContact(normalizedSessionId), + avatarUrl ? Promise.resolve({ success: true, map: { [normalizedSessionId]: avatarUrl } }) : wcdbService.getAvatarUrls([normalizedSessionId]) + ]) - let firstMessageTime: number | undefined - let latestMessageTime: number | undefined + if (contactResult.status === 'fulfilled' && contactResult.value.success && contactResult.value.contact) { + remark = contactResult.value.contact.remark || undefined + nickName = contactResult.value.contact.nickName || undefined + alias = contactResult.value.contact.alias || undefined + displayName = remark || nickName || alias || displayName + } - const earliestCursor = await wcdbService.openMessageCursor(sessionId, 1, true, 0, 0) - if (earliestCursor.success && earliestCursor.cursor) { - const batch = await wcdbService.fetchMessageBatch(earliestCursor.cursor) - if (batch.success && batch.rows && batch.rows.length > 0) { - firstMessageTime = parseInt(batch.rows[0].create_time || '0', 10) || undefined + if (avatarResult.status === 'fulfilled' && avatarResult.value.success && avatarResult.value.map) { + avatarUrl = avatarResult.value.map[normalizedSessionId] + } + + let messageCount: number | undefined + const cachedCount = this.sessionMessageCountCache.get(normalizedSessionId) + if (cachedCount && now - cachedCount.updatedAt <= this.sessionMessageCountCacheTtlMs) { + messageCount = cachedCount.count + } else { + const hintCount = this.sessionMessageCountHintCache.get(normalizedSessionId) + if (typeof hintCount === 'number' && Number.isFinite(hintCount) && hintCount >= 0) { + messageCount = Math.floor(hintCount) + this.sessionMessageCountCache.set(normalizedSessionId, { + count: messageCount, + updatedAt: now + }) } - await wcdbService.closeMessageCursor(earliestCursor.cursor) } - const latestCursor = await wcdbService.openMessageCursor(sessionId, 1, false, 0, 0) - if (latestCursor.success && latestCursor.cursor) { - const batch = await wcdbService.fetchMessageBatch(latestCursor.cursor) - if (batch.success && batch.rows && batch.rows.length > 0) { - latestMessageTime = parseInt(batch.rows[0].create_time || '0', 10) || undefined - } - await wcdbService.closeMessageCursor(latestCursor.cursor) + if (!Number.isFinite(messageCount)) { + const countResult = await wcdbService.getMessageCount(normalizedSessionId) + messageCount = countResult.success && Number.isFinite(countResult.count) + ? Math.max(0, Math.floor(countResult.count || 0)) + : 0 + this.sessionMessageCountCache.set(normalizedSessionId, { + count: messageCount, + updatedAt: Date.now() + }) } + const detail: SessionDetailFast = { + wxid: normalizedSessionId, + displayName, + remark, + nickName, + alias, + avatarUrl, + messageCount: Math.max(0, Math.floor(messageCount || 0)) + } + + this.sessionDetailFastCache.set(normalizedSessionId, { + detail, + updatedAt: Date.now() + }) + + return { success: true, detail } + } catch (e) { + console.error('ChatService: 获取会话详情快速信息失败:', e) + return { success: false, error: String(e) } + } + } + + private async getBoundaryMessageTime(sessionId: string, ascending: boolean): Promise { + const cursorResult = await wcdbService.openMessageCursor(sessionId, 1, ascending, 0, 0) + if (!cursorResult.success || !cursorResult.cursor) { + return undefined + } + + const cursor = cursorResult.cursor + try { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success || !batch.rows || batch.rows.length === 0) { + return undefined + } + const ts = parseInt(batch.rows[0].create_time || '0', 10) + return Number.isFinite(ts) && ts > 0 ? ts : undefined + } finally { + await wcdbService.closeMessageCursor(cursor) + } + } + + async getSessionDetailExtra(sessionId: string): Promise<{ + success: boolean + detail?: SessionDetailExtra + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + this.refreshSessionMessageCountCacheScope() + + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) { + return { success: false, error: '会话ID不能为空' } + } + + const now = Date.now() + const cachedDetail = this.sessionDetailExtraCache.get(normalizedSessionId) + if (cachedDetail && now - cachedDetail.updatedAt <= this.sessionDetailExtraCacheTtlMs) { + return { success: true, detail: cachedDetail.detail } + } + + const [firstMessageTimeResult, latestMessageTimeResult, tableStatsResult] = await Promise.allSettled([ + this.getBoundaryMessageTime(normalizedSessionId, true), + this.getBoundaryMessageTime(normalizedSessionId, false), + wcdbService.getMessageTableStats(normalizedSessionId) + ]) + + const firstMessageTime = firstMessageTimeResult.status === 'fulfilled' + ? firstMessageTimeResult.value + : undefined + const latestMessageTime = latestMessageTimeResult.status === 'fulfilled' + ? latestMessageTimeResult.value + : undefined + const messageTables: { dbName: string; tableName: string; count: number }[] = [] - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (tableStats.success && tableStats.tables) { - for (const row of tableStats.tables) { + if (tableStatsResult.status === 'fulfilled' && tableStatsResult.value.success && tableStatsResult.value.tables) { + for (const row of tableStatsResult.value.tables) { messageTables.push({ dbName: basename(row.db_path || ''), tableName: row.table_name || '', @@ -3895,21 +4007,49 @@ class ChatService { } } + const detail: SessionDetailExtra = { + firstMessageTime, + latestMessageTime, + messageTables + } + + this.sessionDetailExtraCache.set(normalizedSessionId, { + detail, + updatedAt: Date.now() + }) + return { success: true, - detail: { - wxid: sessionId, - displayName, - remark, - nickName, - alias, - avatarUrl, - messageCount: totalMessageCount, - firstMessageTime, - latestMessageTime, - messageTables - } + detail } + } catch (e) { + console.error('ChatService: 获取会话详情补充统计失败:', e) + return { success: false, error: String(e) } + } + } + + async getSessionDetail(sessionId: string): Promise<{ + success: boolean + detail?: SessionDetail + error?: string + }> { + try { + const fastResult = await this.getSessionDetailFast(sessionId) + if (!fastResult.success || !fastResult.detail) { + return { success: false, error: fastResult.error || '获取会话详情失败' } + } + + const extraResult = await this.getSessionDetailExtra(sessionId) + const detail: SessionDetail = { + ...fastResult.detail, + firstMessageTime: extraResult.success ? extraResult.detail?.firstMessageTime : undefined, + latestMessageTime: extraResult.success ? extraResult.detail?.latestMessageTime : undefined, + messageTables: extraResult.success && extraResult.detail?.messageTables + ? extraResult.detail.messageTables + : [] + } + + return { success: true, detail } } catch (e) { console.error('ChatService: 获取会话详情失败:', e) return { success: false, error: String(e) } diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 01bb85e..d54b2c4 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2766,6 +2766,14 @@ gap: 8px; } + .detail-table-placeholder { + padding: 10px 12px; + background: var(--bg-secondary); + border-radius: 8px; + font-size: 12px; + color: var(--text-secondary); + } + .table-item { display: flex; align-items: center; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index cc351f6..6e4f282 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -312,6 +312,7 @@ function ChatPage(_props: ChatPageProps) { const [showDetailPanel, setShowDetailPanel] = useState(false) const [sessionDetail, setSessionDetail] = useState(null) const [isLoadingDetail, setIsLoadingDetail] = useState(false) + const [isLoadingDetailExtra, setIsLoadingDetailExtra] = useState(false) const [copiedField, setCopiedField] = useState(null) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState([]) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) @@ -386,6 +387,7 @@ function ChatPage(_props: ChatPageProps) { const searchKeywordRef = useRef('') const preloadImageKeysRef = useRef>(new Set()) const lastPreloadSessionRef = useRef(null) + const detailRequestSeqRef = useRef(0) // 加载当前用户头像 const loadMyAvatar = useCallback(async () => { @@ -401,25 +403,91 @@ function ChatPage(_props: ChatPageProps) { // 加载会话详情 const loadSessionDetail = useCallback(async (sessionId: string) => { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return + + const requestSeq = ++detailRequestSeqRef.current + const mappedSession = sessionMapRef.current.get(normalizedSessionId) || sessionsRef.current.find((s) => s.username === normalizedSessionId) + const hintedCount = typeof mappedSession?.messageCountHint === 'number' && Number.isFinite(mappedSession.messageCountHint) && mappedSession.messageCountHint >= 0 + ? Math.floor(mappedSession.messageCountHint) + : undefined + + setSessionDetail((prev) => { + const sameSession = prev?.wxid === normalizedSessionId + return { + wxid: normalizedSessionId, + displayName: mappedSession?.displayName || prev?.displayName || normalizedSessionId, + remark: sameSession ? prev?.remark : undefined, + nickName: sameSession ? prev?.nickName : undefined, + alias: sameSession ? prev?.alias : undefined, + avatarUrl: mappedSession?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined), + messageCount: hintedCount ?? (sameSession ? prev.messageCount : Number.NaN), + firstMessageTime: sameSession ? prev?.firstMessageTime : undefined, + latestMessageTime: sameSession ? prev?.latestMessageTime : undefined, + messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : [] + } + }) setIsLoadingDetail(true) + setIsLoadingDetailExtra(true) + try { - const result = await window.electronAPI.chat.getSessionDetail(sessionId) + const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId) + if (requestSeq !== detailRequestSeqRef.current) return if (result.success && result.detail) { - setSessionDetail(result.detail) + setSessionDetail((prev) => ({ + wxid: normalizedSessionId, + displayName: result.detail!.displayName || prev?.displayName || normalizedSessionId, + remark: result.detail!.remark, + nickName: result.detail!.nickName, + alias: result.detail!.alias, + avatarUrl: result.detail!.avatarUrl || prev?.avatarUrl, + messageCount: Number.isFinite(result.detail!.messageCount) ? result.detail!.messageCount : prev?.messageCount ?? Number.NaN, + firstMessageTime: prev?.firstMessageTime, + latestMessageTime: prev?.latestMessageTime, + messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : [] + })) } } catch (e) { console.error('加载会话详情失败:', e) } finally { - setIsLoadingDetail(false) + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingDetail(false) + } + } + + try { + const result = await window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId) + if (requestSeq !== detailRequestSeqRef.current) return + if (result.success && result.detail) { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + firstMessageTime: result.detail!.firstMessageTime, + latestMessageTime: result.detail!.latestMessageTime, + messageTables: Array.isArray(result.detail!.messageTables) ? result.detail!.messageTables : [] + } + }) + } + } catch (e) { + console.error('加载会话详情补充统计失败:', e) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingDetailExtra(false) + } } }, []) // 切换详情面板 const toggleDetailPanel = useCallback(() => { - if (!showDetailPanel && currentSessionId) { - loadSessionDetail(currentSessionId) + if (showDetailPanel) { + setShowDetailPanel(false) + return + } + setShowDetailPanel(true) + if (currentSessionId) { + void loadSessionDetail(currentSessionId) } - setShowDetailPanel(!showDetailPanel) }, [showDetailPanel, currentSessionId, loadSessionDetail]) // 复制字段值到剪贴板 @@ -1107,7 +1175,7 @@ function ChatPage(_props: ChatPageProps) { // 重置详情面板 setSessionDetail(null) if (showDetailPanel) { - loadSessionDetail(session.username) + void loadSessionDetail(session.username) } } @@ -2475,7 +2543,7 @@ function ChatPage(_props: ChatPageProps) {
- {isLoadingDetail ? ( + {isLoadingDetail && !sessionDetail ? (
加载中... @@ -2530,39 +2598,35 @@ function ChatPage(_props: ChatPageProps) { {Number.isFinite(sessionDetail.messageCount) ? sessionDetail.messageCount.toLocaleString() - : '—'} + : (isLoadingDetail ? '统计中...' : '—')} + +
+
+ + 首条消息 + + {Number.isFinite(sessionDetail.firstMessageTime) + ? new Date((sessionDetail.firstMessageTime as number) * 1000).toLocaleDateString('zh-CN') + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ + 最新消息 + + {Number.isFinite(sessionDetail.latestMessageTime) + ? new Date((sessionDetail.latestMessageTime as number) * 1000).toLocaleDateString('zh-CN') + : (isLoadingDetailExtra ? '统计中...' : '—')}
- {sessionDetail.firstMessageTime && ( -
- - 首条消息 - - {Number.isFinite(sessionDetail.firstMessageTime) - ? new Date(sessionDetail.firstMessageTime * 1000).toLocaleDateString('zh-CN') - : '—'} - -
- )} - {sessionDetail.latestMessageTime && ( -
- - 最新消息 - - {Number.isFinite(sessionDetail.latestMessageTime) - ? new Date(sessionDetail.latestMessageTime * 1000).toLocaleDateString('zh-CN') - : '—'} - -
- )}
- {Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 && ( -
-
- - 数据库分布 -
+
+
+ + 数据库分布 +
+ {Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 ? (
{sessionDetail.messageTables.map((t, i) => (
@@ -2571,8 +2635,12 @@ function ChatPage(_props: ChatPageProps) {
))}
-
- )} + ) : ( +
+ {isLoadingDetailExtra ? '统计中...' : '暂无统计数据'} +
+ )} +
) : (
暂无详情
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 8f8f6f1..88bc819 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -144,6 +144,28 @@ export interface ElectronAPI { } error?: string }> + getSessionDetailFast: (sessionId: string) => Promise<{ + success: boolean + detail?: { + wxid: string + displayName: string + remark?: string + nickName?: string + alias?: string + avatarUrl?: string + messageCount: number + } + error?: string + }> + getSessionDetailExtra: (sessionId: string) => Promise<{ + success: boolean + detail?: { + firstMessageTime?: number + latestMessageTime?: number + messageTables: { dbName: string; tableName: string; count: number }[] + } + error?: string + }> getExportSessionStats: (sessionIds: string[]) => Promise<{ success: boolean data?: Record Date: Sun, 1 Mar 2026 18:45:04 +0800 Subject: [PATCH 025/162] feat(chat): show export-table metrics in session detail sidebar --- src/pages/ChatPage.tsx | 177 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 160 insertions(+), 17 deletions(-) diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 6e4f282..1536157 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -142,6 +142,15 @@ function cleanMessageContent(content: string): string { return content.trim() } +function formatYmdDateFromSeconds(timestamp?: number): string { + if (!timestamp || !Number.isFinite(timestamp)) return '—' + const d = new Date(timestamp * 1000) + const y = d.getFullYear() + const m = `${d.getMonth() + 1}`.padStart(2, '0') + const day = `${d.getDate()}`.padStart(2, '0') + return `${y}-${m}-${day}` +} + interface ChatPageProps { // 保留接口以备将来扩展 } @@ -155,6 +164,15 @@ interface SessionDetail { alias?: string avatarUrl?: string messageCount: number + voiceMessages?: number + imageMessages?: number + videoMessages?: number + emojiMessages?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number firstMessageTime?: number latestMessageTime?: number messageTables: { dbName: string; tableName: string; count: number }[] @@ -422,6 +440,15 @@ function ChatPage(_props: ChatPageProps) { alias: sameSession ? prev?.alias : undefined, avatarUrl: mappedSession?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined), messageCount: hintedCount ?? (sameSession ? prev.messageCount : Number.NaN), + voiceMessages: sameSession ? prev?.voiceMessages : undefined, + imageMessages: sameSession ? prev?.imageMessages : undefined, + videoMessages: sameSession ? prev?.videoMessages : undefined, + emojiMessages: sameSession ? prev?.emojiMessages : undefined, + privateMutualGroups: sameSession ? prev?.privateMutualGroups : undefined, + groupMemberCount: sameSession ? prev?.groupMemberCount : undefined, + groupMyMessages: sameSession ? prev?.groupMyMessages : undefined, + groupActiveSpeakers: sameSession ? prev?.groupActiveSpeakers : undefined, + groupMutualFriends: sameSession ? prev?.groupMutualFriends : undefined, firstMessageTime: sameSession ? prev?.firstMessageTime : undefined, latestMessageTime: sameSession ? prev?.latestMessageTime : undefined, messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : [] @@ -442,6 +469,15 @@ function ChatPage(_props: ChatPageProps) { alias: result.detail!.alias, avatarUrl: result.detail!.avatarUrl || prev?.avatarUrl, messageCount: Number.isFinite(result.detail!.messageCount) ? result.detail!.messageCount : prev?.messageCount ?? Number.NaN, + voiceMessages: prev?.voiceMessages, + imageMessages: prev?.imageMessages, + videoMessages: prev?.videoMessages, + emojiMessages: prev?.emojiMessages, + privateMutualGroups: prev?.privateMutualGroups, + groupMemberCount: prev?.groupMemberCount, + groupMyMessages: prev?.groupMyMessages, + groupActiveSpeakers: prev?.groupActiveSpeakers, + groupMutualFriends: prev?.groupMutualFriends, firstMessageTime: prev?.firstMessageTime, latestMessageTime: prev?.latestMessageTime, messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : [] @@ -456,19 +492,49 @@ function ChatPage(_props: ChatPageProps) { } try { - const result = await window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId) + const [extraResultSettled, statsResultSettled] = await Promise.allSettled([ + window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), + window.electronAPI.chat.getExportSessionStats([normalizedSessionId]) + ]) + if (requestSeq !== detailRequestSeqRef.current) return - if (result.success && result.detail) { - setSessionDetail((prev) => { - if (!prev || prev.wxid !== normalizedSessionId) return prev - return { - ...prev, - firstMessageTime: result.detail!.firstMessageTime, - latestMessageTime: result.detail!.latestMessageTime, - messageTables: Array.isArray(result.detail!.messageTables) ? result.detail!.messageTables : [] + + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + + let next = { ...prev } + if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success && extraResultSettled.value.detail) { + next = { + ...next, + firstMessageTime: extraResultSettled.value.detail.firstMessageTime, + latestMessageTime: extraResultSettled.value.detail.latestMessageTime, + messageTables: Array.isArray(extraResultSettled.value.detail.messageTables) ? extraResultSettled.value.detail.messageTables : [] } - }) - } + } + + if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success && statsResultSettled.value.data) { + const metric = statsResultSettled.value.data[normalizedSessionId] + if (metric) { + next = { + ...next, + messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : next.messageCount, + voiceMessages: metric.voiceMessages, + imageMessages: metric.imageMessages, + videoMessages: metric.videoMessages, + emojiMessages: metric.emojiMessages, + privateMutualGroups: metric.privateMutualGroups, + groupMemberCount: metric.groupMemberCount, + groupMyMessages: metric.groupMyMessages, + groupActiveSpeakers: metric.groupActiveSpeakers, + groupMutualFriends: metric.groupMutualFriends, + firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : next.firstMessageTime, + latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : next.latestMessageTime + } + } + } + + return next + }) } catch (e) { console.error('加载会话详情补充统计失败:', e) } finally { @@ -2591,22 +2657,99 @@ function ChatPage(_props: ChatPageProps) {
- 消息统计 + 消息统计(导出口径)
消息总数 {Number.isFinite(sessionDetail.messageCount) ? sessionDetail.messageCount.toLocaleString() - : (isLoadingDetail ? '统计中...' : '—')} + : ((isLoadingDetail || isLoadingDetailExtra) ? '统计中...' : '—')}
+
+ 语音 + + {Number.isFinite(sessionDetail.voiceMessages) + ? (sessionDetail.voiceMessages as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 图片 + + {Number.isFinite(sessionDetail.imageMessages) + ? (sessionDetail.imageMessages as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 视频 + + {Number.isFinite(sessionDetail.videoMessages) + ? (sessionDetail.videoMessages as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 表情包 + + {Number.isFinite(sessionDetail.emojiMessages) + ? (sessionDetail.emojiMessages as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+ {sessionDetail.wxid.includes('@chatroom') ? ( + <> +
+ 我发的消息数 + + {Number.isFinite(sessionDetail.groupMyMessages) + ? (sessionDetail.groupMyMessages as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 群人数 + + {Number.isFinite(sessionDetail.groupMemberCount) + ? (sessionDetail.groupMemberCount as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 群发言人数 + + {Number.isFinite(sessionDetail.groupActiveSpeakers) + ? (sessionDetail.groupActiveSpeakers as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 群共同好友数 + + {Number.isFinite(sessionDetail.groupMutualFriends) + ? (sessionDetail.groupMutualFriends as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+ + ) : ( +
+ 共同群聊数 + + {Number.isFinite(sessionDetail.privateMutualGroups) + ? (sessionDetail.privateMutualGroups as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+ )}
首条消息 - {Number.isFinite(sessionDetail.firstMessageTime) - ? new Date((sessionDetail.firstMessageTime as number) * 1000).toLocaleDateString('zh-CN') + {sessionDetail.firstMessageTime + ? formatYmdDateFromSeconds(sessionDetail.firstMessageTime) : (isLoadingDetailExtra ? '统计中...' : '—')}
@@ -2614,8 +2757,8 @@ function ChatPage(_props: ChatPageProps) { 最新消息 - {Number.isFinite(sessionDetail.latestMessageTime) - ? new Date((sessionDetail.latestMessageTime as number) * 1000).toLocaleDateString('zh-CN') + {sessionDetail.latestMessageTime + ? formatYmdDateFromSeconds(sessionDetail.latestMessageTime) : (isLoadingDetailExtra ? '统计中...' : '—')}
From a87d4198687d45a0c3839bd6eb6dc13521f9d81e Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 18:48:03 +0800 Subject: [PATCH 026/162] fix(chat): collapse detail panel when switching sessions --- src/pages/ChatPage.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 1536157..b9b4def 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1238,11 +1238,9 @@ function ChatPage(_props: ChatPageProps) { setJumpStartTime(0) setJumpEndTime(0) void loadMessages(session.username, 0, 0, 0) - // 重置详情面板 + // 切换会话后回到正常聊天窗口:收起详情侧栏,详情需手动再次展开 + setShowDetailPanel(false) setSessionDetail(null) - if (showDetailPanel) { - void loadSessionDetail(session.username) - } } // 搜索过滤 From ac61ee183306448bf73b20c77f309625cbf35043 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 18:55:39 +0800 Subject: [PATCH 027/162] perf(chat): add local session list and preview cache hydration --- src/pages/ChatPage.tsx | 247 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 244 insertions(+), 3 deletions(-) diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index b9b4def..a8c2f36 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -142,6 +142,35 @@ function cleanMessageContent(content: string): string { return content.trim() } +const CHAT_SESSION_LIST_CACHE_TTL_MS = 24 * 60 * 60 * 1000 +const CHAT_SESSION_PREVIEW_CACHE_TTL_MS = 24 * 60 * 60 * 1000 +const CHAT_SESSION_PREVIEW_LIMIT_PER_SESSION = 30 +const CHAT_SESSION_PREVIEW_MAX_SESSIONS = 18 + +function buildChatSessionListCacheKey(scope: string): string { + return `weflow.chat.sessions.v1::${scope || 'default'}` +} + +function buildChatSessionPreviewCacheKey(scope: string): string { + return `weflow.chat.preview.v1::${scope || 'default'}` +} + +function normalizeChatCacheScope(dbPath: unknown, wxid: unknown): string { + const db = String(dbPath || '').trim() + const id = String(wxid || '').trim() + if (!db && !id) return 'default' + return `${db}::${id}` +} + +function safeParseJson(raw: string | null): T | null { + if (!raw) return null + try { + return JSON.parse(raw) as T + } catch { + return null + } +} + function formatYmdDateFromSeconds(timestamp?: number): string { if (!timestamp || !Number.isFinite(timestamp)) return '—' const d = new Date(timestamp * 1000) @@ -178,6 +207,21 @@ interface SessionDetail { messageTables: { dbName: string; tableName: string; count: number }[] } +interface SessionListCachePayload { + updatedAt: number + sessions: ChatSession[] +} + +interface SessionPreviewCacheEntry { + updatedAt: number + messages: Message[] +} + +interface SessionPreviewCachePayload { + updatedAt: number + entries: Record +} + // 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts // 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts import { avatarLoadQueue } from '../utils/AvatarLoadQueue' @@ -406,6 +450,10 @@ function ChatPage(_props: ChatPageProps) { const preloadImageKeysRef = useRef>(new Set()) const lastPreloadSessionRef = useRef(null) const detailRequestSeqRef = useRef(0) + const chatCacheScopeRef = useRef('default') + const previewCacheRef = useRef>({}) + const previewPersistTimerRef = useRef(null) + const sessionListPersistTimerRef = useRef(null) // 加载当前用户头像 const loadMyAvatar = useCallback(async () => { @@ -419,6 +467,150 @@ function ChatPage(_props: ChatPageProps) { } }, []) + const resolveChatCacheScope = useCallback(async (): Promise => { + try { + const [dbPath, myWxid] = await Promise.all([ + window.electronAPI.config.get('dbPath'), + window.electronAPI.config.get('myWxid') + ]) + const scope = normalizeChatCacheScope(dbPath, myWxid) + chatCacheScopeRef.current = scope + return scope + } catch { + chatCacheScopeRef.current = 'default' + return 'default' + } + }, []) + + const loadPreviewCacheFromStorage = useCallback((scope: string): Record => { + try { + const cacheKey = buildChatSessionPreviewCacheKey(scope) + const payload = safeParseJson(window.localStorage.getItem(cacheKey)) + if (!payload || typeof payload.updatedAt !== 'number' || !payload.entries) { + return {} + } + if (Date.now() - payload.updatedAt > CHAT_SESSION_PREVIEW_CACHE_TTL_MS) { + return {} + } + return payload.entries + } catch { + return {} + } + }, []) + + const persistPreviewCacheToStorage = useCallback((scope: string, entries: Record) => { + try { + const cacheKey = buildChatSessionPreviewCacheKey(scope) + const payload: SessionPreviewCachePayload = { + updatedAt: Date.now(), + entries + } + window.localStorage.setItem(cacheKey, JSON.stringify(payload)) + } catch { + // ignore cache write failures + } + }, []) + + const persistSessionPreviewCache = useCallback((sessionId: string, previewMessages: Message[]) => { + const id = String(sessionId || '').trim() + if (!id || !Array.isArray(previewMessages) || previewMessages.length === 0) return + + const trimmed = previewMessages.slice(-CHAT_SESSION_PREVIEW_LIMIT_PER_SESSION) + const currentEntries = { ...previewCacheRef.current } + currentEntries[id] = { + updatedAt: Date.now(), + messages: trimmed + } + + const sortedIds = Object.entries(currentEntries) + .sort((a, b) => (b[1]?.updatedAt || 0) - (a[1]?.updatedAt || 0)) + .map(([entryId]) => entryId) + + const keptIds = new Set(sortedIds.slice(0, CHAT_SESSION_PREVIEW_MAX_SESSIONS)) + const compactEntries: Record = {} + for (const [entryId, entry] of Object.entries(currentEntries)) { + if (keptIds.has(entryId)) { + compactEntries[entryId] = entry + } + } + + previewCacheRef.current = compactEntries + if (previewPersistTimerRef.current !== null) { + window.clearTimeout(previewPersistTimerRef.current) + } + previewPersistTimerRef.current = window.setTimeout(() => { + persistPreviewCacheToStorage(chatCacheScopeRef.current, previewCacheRef.current) + previewPersistTimerRef.current = null + }, 220) + }, [persistPreviewCacheToStorage]) + + const hydrateSessionPreview = useCallback(async (sessionId: string) => { + const id = String(sessionId || '').trim() + if (!id) return + + const localEntry = previewCacheRef.current[id] + if ( + localEntry && + Array.isArray(localEntry.messages) && + localEntry.messages.length > 0 && + Date.now() - localEntry.updatedAt <= CHAT_SESSION_PREVIEW_CACHE_TTL_MS + ) { + setMessages(localEntry.messages.slice()) + setHasInitialMessages(true) + return + } + + try { + const result = await window.electronAPI.chat.getCachedMessages(id) + if (!result.success || !Array.isArray(result.messages) || result.messages.length === 0) { + return + } + if (currentSessionRef.current !== id && pendingSessionLoadRef.current !== id) return + setMessages(result.messages) + setHasInitialMessages(true) + persistSessionPreviewCache(id, result.messages) + } catch { + // ignore preview cache errors + } + }, [persistSessionPreviewCache, setMessages]) + + const hydrateSessionListCache = useCallback((scope: string): boolean => { + try { + const cacheKey = buildChatSessionListCacheKey(scope) + const payload = safeParseJson(window.localStorage.getItem(cacheKey)) + if (!payload || typeof payload.updatedAt !== 'number' || !Array.isArray(payload.sessions)) { + previewCacheRef.current = loadPreviewCacheFromStorage(scope) + return false + } + previewCacheRef.current = loadPreviewCacheFromStorage(scope) + if (Date.now() - payload.updatedAt > CHAT_SESSION_LIST_CACHE_TTL_MS) { + return false + } + if (!Array.isArray(sessionsRef.current) || sessionsRef.current.length === 0) { + setSessions(payload.sessions) + sessionsRef.current = payload.sessions + return payload.sessions.length > 0 + } + return false + } catch { + previewCacheRef.current = loadPreviewCacheFromStorage(scope) + return false + } + }, [loadPreviewCacheFromStorage, setSessions]) + + const persistSessionListCache = useCallback((scope: string, nextSessions: ChatSession[]) => { + try { + const cacheKey = buildChatSessionListCacheKey(scope) + const payload: SessionListCachePayload = { + updatedAt: Date.now(), + sessions: nextSessions + } + window.localStorage.setItem(cacheKey, JSON.stringify(payload)) + } catch { + // ignore cache write failures + } + }, []) + // 加载会话详情 const loadSessionDetail = useCallback(async (sessionId: string) => { const normalizedSessionId = String(sessionId || '').trim() @@ -580,11 +772,12 @@ function ChatPage(_props: ChatPageProps) { setConnecting(true) setConnectionError(null) try { + const scopePromise = resolveChatCacheScope() const result = await window.electronAPI.chat.connect() if (result.success) { setConnected(true) const wxidPromise = window.electronAPI.config.get('myWxid') - await Promise.all([loadSessions(), loadMyAvatar()]) + await Promise.all([scopePromise, loadSessions(), loadMyAvatar()]) // 获取 myWxid 用于匹配个人头像 const wxid = await wxidPromise if (wxid) setMyWxid(wxid as string) @@ -596,7 +789,7 @@ function ChatPage(_props: ChatPageProps) { } finally { setConnecting(false) } - }, [loadMyAvatar]) + }, [loadMyAvatar, resolveChatCacheScope]) const handleAccountChanged = useCallback(async () => { senderAvatarCache.clear() @@ -616,9 +809,13 @@ function ChatPage(_props: ChatPageProps) { setConnecting(false) setHasMoreMessages(true) setHasMoreLater(false) + const scope = await resolveChatCacheScope() + hydrateSessionListCache(scope) await connect() }, [ connect, + resolveChatCacheScope, + hydrateSessionListCache, setConnected, setConnecting, setConnectionError, @@ -632,6 +829,19 @@ function ChatPage(_props: ChatPageProps) { setSessions ]) + useEffect(() => { + let cancelled = false + void (async () => { + const scope = await resolveChatCacheScope() + if (cancelled) return + hydrateSessionListCache(scope) + })() + + return () => { + cancelled = true + } + }, [resolveChatCacheScope, hydrateSessionListCache]) + // 同步 currentSessionId 到 ref useEffect(() => { currentSessionRef.current = currentSessionId @@ -684,6 +894,7 @@ function ChatPage(_props: ChatPageProps) { setLoadingSessions(true) } try { + const scope = await resolveChatCacheScope() const result = await window.electronAPI.chat.getSessions() if (result.success && result.sessions) { // 确保 sessions 是数组 @@ -695,12 +906,15 @@ function ChatPage(_props: ChatPageProps) { setSessions(nextSessions) sessionsRef.current = nextSessions + persistSessionListCache(scope, nextSessions) void hydrateSessionStatuses(nextSessions) // 立即启动联系人信息加载,不再延迟 500ms void enrichSessionsContactInfo(nextSessions) } else { console.error('mergeSessions returned non-array:', nextSessions) setSessions(sessionsArray) + sessionsRef.current = sessionsArray + persistSessionListCache(scope, sessionsArray) void hydrateSessionStatuses(sessionsArray) void enrichSessionsContactInfo(sessionsArray) } @@ -1085,6 +1299,7 @@ function ChatPage(_props: ChatPageProps) { if (result.success && result.messages) { if (offset === 0) { setMessages(result.messages) + persistSessionPreviewCache(sessionId, result.messages) if (result.messages.length === 0) { setNoMessageTable(true) setHasMoreMessages(false) @@ -1233,10 +1448,12 @@ function ChatPage(_props: ChatPageProps) { if (session.username === currentSessionId) return pendingSessionLoadRef.current = session.username setIsSessionSwitching(true) - setCurrentSession(session.username, { preserveMessages: true }) + setCurrentSession(session.username, { preserveMessages: false }) + void hydrateSessionPreview(session.username) setCurrentOffset(0) setJumpStartTime(0) setJumpEndTime(0) + setNoMessageTable(false) void loadMessages(session.username, 0, 0, 0) // 切换会话后回到正常聊天窗口:收起详情侧栏,详情需手动再次展开 setShowDetailPanel(false) @@ -1374,6 +1591,14 @@ function ChatPage(_props: ChatPageProps) { // 组件卸载时清理 return () => { avatarLoadQueue.clear() + if (previewPersistTimerRef.current !== null) { + window.clearTimeout(previewPersistTimerRef.current) + previewPersistTimerRef.current = null + } + if (sessionListPersistTimerRef.current !== null) { + window.clearTimeout(sessionListPersistTimerRef.current) + sessionListPersistTimerRef.current = null + } if (contactUpdateTimerRef.current) { clearTimeout(contactUpdateTimerRef.current) } @@ -1522,6 +1747,22 @@ function ChatPage(_props: ChatPageProps) { searchKeywordRef.current = searchKeyword }, [searchKeyword]) + useEffect(() => { + if (!currentSessionId || !Array.isArray(messages) || messages.length === 0) return + persistSessionPreviewCache(currentSessionId, messages) + }, [currentSessionId, messages, persistSessionPreviewCache]) + + useEffect(() => { + if (!Array.isArray(sessions) || sessions.length === 0) return + if (sessionListPersistTimerRef.current !== null) { + window.clearTimeout(sessionListPersistTimerRef.current) + } + sessionListPersistTimerRef.current = window.setTimeout(() => { + persistSessionListCache(chatCacheScopeRef.current, sessions) + sessionListPersistTimerRef.current = null + }, 260) + }, [sessions, persistSessionListCache]) + // 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口 useEffect(() => { if (!Array.isArray(sessions)) { From 794a306f897f1ef9b9fc053a5d3a2ced5cff1569 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 19:03:15 +0800 Subject: [PATCH 028/162] perf(contacts): speed up directory loading and smooth list rendering --- electron/services/chatService.ts | 114 ++++++----- src/pages/ContactsPage.scss | 25 ++- src/pages/ContactsPage.tsx | 341 +++++++++++++++++++++++-------- 3 files changed, 335 insertions(+), 145 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 0e655d3..00958cf 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -684,40 +684,52 @@ class ChatService { if (!headImageDbPath) return result - // 使用 wcdbService.execQuery 查询加密的 head_image.db - for (const username of usernames) { - try { - const escapedUsername = username.replace(/'/g, "''") - const queryResult = await wcdbService.execQuery( - 'media', - headImageDbPath, - `SELECT image_buffer FROM head_image WHERE username = '${escapedUsername}' LIMIT 1` - ) + const normalizedUsernames = Array.from( + new Set( + usernames + .map((username) => String(username || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedUsernames.length === 0) return result - if (queryResult.success && queryResult.rows && queryResult.rows.length > 0) { - const row = queryResult.rows[0] as any - if (row?.image_buffer) { - let base64Data: string - if (typeof row.image_buffer === 'string') { - // WCDB 返回的 BLOB 是十六进制字符串,需要转换为 base64 - if (row.image_buffer.toLowerCase().startsWith('ffd8')) { - const buffer = Buffer.from(row.image_buffer, 'hex') - base64Data = buffer.toString('base64') - } else { - base64Data = row.image_buffer - } - } else if (Buffer.isBuffer(row.image_buffer)) { - base64Data = row.image_buffer.toString('base64') - } else if (Array.isArray(row.image_buffer)) { - base64Data = Buffer.from(row.image_buffer).toString('base64') - } else { - continue - } - result[username] = `data:image/jpeg;base64,${base64Data}` + const batchSize = 320 + for (let i = 0; i < normalizedUsernames.length; i += batchSize) { + const batch = normalizedUsernames.slice(i, i + batchSize) + if (batch.length === 0) continue + const usernamesExpr = batch.map((name) => `'${this.escapeSqlString(name)}'`).join(',') + const queryResult = await wcdbService.execQuery( + 'media', + headImageDbPath, + `SELECT username, image_buffer FROM head_image WHERE username IN (${usernamesExpr})` + ) + + if (!queryResult.success || !queryResult.rows || queryResult.rows.length === 0) { + continue + } + + for (const row of queryResult.rows as any[]) { + const username = String(row?.username || '').trim() + if (!username || !row?.image_buffer) continue + + let base64Data: string | null = null + if (typeof row.image_buffer === 'string') { + // WCDB 返回的 BLOB 可能是十六进制字符串,需要转换为 base64 + if (row.image_buffer.toLowerCase().startsWith('ffd8')) { + const buffer = Buffer.from(row.image_buffer, 'hex') + base64Data = buffer.toString('base64') + } else { + base64Data = row.image_buffer } + } else if (Buffer.isBuffer(row.image_buffer)) { + base64Data = row.image_buffer.toString('base64') + } else if (Array.isArray(row.image_buffer)) { + base64Data = Buffer.from(row.image_buffer).toString('base64') + } + + if (base64Data) { + result[username] = `data:image/jpeg;base64,${base64Data}` } - } catch { - // 静默处理单个用户的错误 } } } catch (e) { @@ -949,11 +961,17 @@ class ChatService { // 使用execQuery直接查询加密的contact.db // kind='contact', path=null表示使用已打开的contact.db const contactQuery = ` - SELECT username, remark, nick_name, alias, local_type, flag, quan_pin + SELECT username, remark, nick_name, alias, local_type, quan_pin FROM contact + WHERE username IS NOT NULL + AND username != '' + AND ( + username LIKE '%@chatroom' + OR username LIKE 'gh_%' + OR local_type = 1 + OR (local_type = 0 AND COALESCE(quan_pin, '') != '') + ) ` - - const contactResult = await wcdbService.execQuery('contact', null, contactQuery) if (!contactResult.success || !contactResult.rows) { @@ -963,21 +981,6 @@ class ChatService { const rows = contactResult.rows as Record[] - - // 调试:显示前5条数据样本 - - rows.slice(0, 5).forEach((row, idx) => { - - }) - - // 调试:统计local_type分布 - const localTypeStats = new Map() - rows.forEach(row => { - const lt = row.local_type || 0 - localTypeStats.set(lt, (localTypeStats.get(lt) || 0) + 1) - }) - - // 获取会话表的最后联系时间用于排序 const lastContactTimeMap = new Map() const sessionResult = await wcdbService.getSessions() @@ -993,25 +996,24 @@ class ChatService { // 转换为ContactInfo const contacts: (ContactInfo & { lastContactTime: number })[] = [] + const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) for (const row of rows) { - const username = row.username || '' + const username = String(row.username || '').trim() if (!username) continue - const excludeNames = ['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'] let type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' = 'other' const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) - const flag = Number(row.flag ?? 0) - const quanPin = this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '' + const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim() - if (username.includes('@chatroom')) { + if (username.endsWith('@chatroom')) { type = 'group' } else if (username.startsWith('gh_')) { type = 'official' - } else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 1 && !excludeNames.includes(username)) { + } else if (localType === 1 && !excludeNames.has(username)) { type = 'friend' - } else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 0 && quanPin) { + } else if (localType === 0 && quanPin) { type = 'former_friend' } else { continue diff --git a/src/pages/ContactsPage.scss b/src/pages/ContactsPage.scss index f7986ff..6bb4844 100644 --- a/src/pages/ContactsPage.scss +++ b/src/pages/ContactsPage.scss @@ -177,6 +177,12 @@ padding: 0 20px 12px; font-size: 13px; color: var(--text-secondary); + + .avatar-enrich-progress { + margin-left: 10px; + color: var(--text-tertiary); + font-size: 12px; + } } .selection-toolbar { @@ -217,6 +223,7 @@ flex: 1; overflow-y: auto; padding: 0 12px 12px; + position: relative; &::-webkit-scrollbar { width: 6px; @@ -229,15 +236,31 @@ } } + .contacts-list-virtual { + position: relative; + min-height: 100%; + } + + .contact-row { + position: absolute; + left: 0; + right: 0; + height: 76px; + padding-bottom: 4px; + will-change: transform; + } + .contact-item { display: flex; align-items: center; gap: 12px; padding: 12px; + height: 72px; + box-sizing: border-box; border-radius: 10px; transition: all 0.2s; cursor: pointer; - margin-bottom: 4px; + margin-bottom: 0; &:hover { background: var(--bg-hover); diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 1f74576..570b40c 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react' import { useNavigate } from 'react-router-dom' import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react' import { useChatStore } from '../stores/chatStore' @@ -13,12 +13,22 @@ interface ContactInfo { type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' } +interface ContactEnrichInfo { + displayName?: string + avatarUrl?: string +} + +const AVATAR_ENRICH_BATCH_SIZE = 80 +const SEARCH_DEBOUNCE_MS = 120 +const VIRTUAL_ROW_HEIGHT = 76 +const VIRTUAL_OVERSCAN = 10 + function ContactsPage() { const [contacts, setContacts] = useState([]) - const [filteredContacts, setFilteredContacts] = useState([]) const [selectedUsernames, setSelectedUsernames] = useState>(new Set()) const [isLoading, setIsLoading] = useState(true) const [searchKeyword, setSearchKeyword] = useState('') + const [debouncedSearchKeyword, setDebouncedSearchKeyword] = useState('') const [contactTypes, setContactTypes] = useState({ friends: true, groups: false, @@ -39,79 +49,193 @@ function ContactsPage() { const [isExporting, setIsExporting] = useState(false) const [showFormatSelect, setShowFormatSelect] = useState(false) const formatDropdownRef = useRef(null) + const listRef = useRef(null) + const loadVersionRef = useRef(0) + const [avatarEnrichProgress, setAvatarEnrichProgress] = useState({ + loaded: 0, + total: 0, + running: false + }) + const [scrollTop, setScrollTop] = useState(0) + const [listViewportHeight, setListViewportHeight] = useState(480) + + const applyEnrichedContacts = useCallback((enrichedMap: Record) => { + if (!enrichedMap || Object.keys(enrichedMap).length === 0) return + + setContacts(prev => { + let changed = false + const next = prev.map(contact => { + const enriched = enrichedMap[contact.username] + if (!enriched) return contact + const displayName = enriched.displayName || contact.displayName + const avatarUrl = enriched.avatarUrl || contact.avatarUrl + if (displayName === contact.displayName && avatarUrl === contact.avatarUrl) { + return contact + } + changed = true + return { + ...contact, + displayName, + avatarUrl + } + }) + return changed ? next : prev + }) + + setSelectedContact(prev => { + if (!prev) return prev + const enriched = enrichedMap[prev.username] + if (!enriched) return prev + const displayName = enriched.displayName || prev.displayName + const avatarUrl = enriched.avatarUrl || prev.avatarUrl + if (displayName === prev.displayName && avatarUrl === prev.avatarUrl) { + return prev + } + return { + ...prev, + displayName, + avatarUrl + } + }) + }, []) + + const enrichContactsInBackground = useCallback(async (sourceContacts: ContactInfo[], loadVersion: number) => { + const usernames = sourceContacts.map(contact => contact.username).filter(Boolean) + const total = usernames.length + setAvatarEnrichProgress({ + loaded: 0, + total, + running: total > 0 + }) + if (total === 0) return + + for (let i = 0; i < total; i += AVATAR_ENRICH_BATCH_SIZE) { + if (loadVersionRef.current !== loadVersion) return + const batch = usernames.slice(i, i + AVATAR_ENRICH_BATCH_SIZE) + if (batch.length === 0) continue + + try { + const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(batch) + if (loadVersionRef.current !== loadVersion) return + if (avatarResult.success && avatarResult.contacts) { + applyEnrichedContacts(avatarResult.contacts) + } + } catch (e) { + console.error('分批补全头像失败:', e) + } + + const loaded = Math.min(i + batch.length, total) + setAvatarEnrichProgress({ + loaded, + total, + running: loaded < total + }) + + await new Promise(resolve => setTimeout(resolve, 0)) + } + }, [applyEnrichedContacts]) // 加载通讯录 const loadContacts = useCallback(async () => { + const loadVersion = loadVersionRef.current + 1 + loadVersionRef.current = loadVersion setIsLoading(true) + setAvatarEnrichProgress({ + loaded: 0, + total: 0, + running: false + }) try { - const result = await window.electronAPI.chat.connect() - if (!result.success) { - console.error('连接失败:', result.error) - setIsLoading(false) - return - } const contactsResult = await window.electronAPI.chat.getContacts() - + + if (loadVersionRef.current !== loadVersion) return if (contactsResult.success && contactsResult.contacts) { - - - - // 获取头像URL - const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username) - if (usernames.length > 0) { - const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) - if (avatarResult.success && avatarResult.contacts) { - contactsResult.contacts.forEach((contact: ContactInfo) => { - const enriched = avatarResult.contacts?.[contact.username] - if (enriched?.avatarUrl) { - contact.avatarUrl = enriched.avatarUrl - } - }) - } - } - setContacts(contactsResult.contacts) - setFilteredContacts(contactsResult.contacts) setSelectedUsernames(new Set()) + setSelectedContact(prev => { + if (!prev) return prev + return contactsResult.contacts!.find(contact => contact.username === prev.username) || null + }) + setIsLoading(false) + void enrichContactsInBackground(contactsResult.contacts, loadVersion) + return } } catch (e) { console.error('加载通讯录失败:', e) } finally { - setIsLoading(false) + if (loadVersionRef.current === loadVersion) { + setIsLoading(false) + } } - }, []) + }, [enrichContactsInBackground]) useEffect(() => { loadContacts() }, [loadContacts]) - // 搜索和类型过滤 useEffect(() => { - let filtered = contacts + return () => { + loadVersionRef.current += 1 + } + }, []) - // 类型过滤 - filtered = filtered.filter(c => { - if (c.type === 'friend' && !contactTypes.friends) return false - if (c.type === 'group' && !contactTypes.groups) return false - if (c.type === 'official' && !contactTypes.officials) return false - if (c.type === 'former_friend' && !contactTypes.deletedFriends) return false + useEffect(() => { + const timer = window.setTimeout(() => { + setDebouncedSearchKeyword(searchKeyword.trim().toLowerCase()) + }, SEARCH_DEBOUNCE_MS) + return () => window.clearTimeout(timer) + }, [searchKeyword]) + + const filteredContacts = useMemo(() => { + let filtered = contacts.filter(contact => { + if (contact.type === 'friend' && !contactTypes.friends) return false + if (contact.type === 'group' && !contactTypes.groups) return false + if (contact.type === 'official' && !contactTypes.officials) return false + if (contact.type === 'former_friend' && !contactTypes.deletedFriends) return false return true }) - // 关键词过滤 - if (searchKeyword.trim()) { - const lower = searchKeyword.toLowerCase() - filtered = filtered.filter(c => - c.displayName?.toLowerCase().includes(lower) || - c.remark?.toLowerCase().includes(lower) || - c.username.toLowerCase().includes(lower) + if (debouncedSearchKeyword) { + filtered = filtered.filter(contact => + contact.displayName?.toLowerCase().includes(debouncedSearchKeyword) || + contact.remark?.toLowerCase().includes(debouncedSearchKeyword) || + contact.username.toLowerCase().includes(debouncedSearchKeyword) ) } - setFilteredContacts(filtered) - }, [searchKeyword, contacts, contactTypes]) + return filtered + }, [contacts, contactTypes, debouncedSearchKeyword]) - // 点击外部关闭下拉菜单 + useEffect(() => { + if (!listRef.current) return + listRef.current.scrollTop = 0 + setScrollTop(0) + }, [debouncedSearchKeyword, contactTypes]) + + useEffect(() => { + const node = listRef.current + if (!node) return + + const updateViewportHeight = () => { + setListViewportHeight(Math.max(node.clientHeight, VIRTUAL_ROW_HEIGHT)) + } + updateViewportHeight() + + const observer = new ResizeObserver(() => updateViewportHeight()) + observer.observe(node) + return () => observer.disconnect() + }, [filteredContacts.length, isLoading]) + + useEffect(() => { + const maxScroll = Math.max(0, filteredContacts.length * VIRTUAL_ROW_HEIGHT - listViewportHeight) + if (scrollTop <= maxScroll) return + setScrollTop(maxScroll) + if (listRef.current) { + listRef.current.scrollTop = maxScroll + } + }, [filteredContacts.length, listViewportHeight, scrollTop]) + + // 搜索和类型过滤 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node @@ -123,11 +247,35 @@ function ContactsPage() { return () => document.removeEventListener('mousedown', handleClickOutside) }, [showFormatSelect]) - const selectedInFilteredCount = filteredContacts.reduce((count, contact) => { - return selectedUsernames.has(contact.username) ? count + 1 : count - }, 0) + const selectedInFilteredCount = useMemo(() => { + return filteredContacts.reduce((count, contact) => { + return selectedUsernames.has(contact.username) ? count + 1 : count + }, 0) + }, [filteredContacts, selectedUsernames]) const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length + const { startIndex, endIndex } = useMemo(() => { + if (filteredContacts.length === 0) { + return { startIndex: 0, endIndex: 0 } + } + const baseStart = Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) + const visibleCount = Math.ceil(listViewportHeight / VIRTUAL_ROW_HEIGHT) + const nextStart = Math.max(0, baseStart - VIRTUAL_OVERSCAN) + const nextEnd = Math.min(filteredContacts.length, nextStart + visibleCount + VIRTUAL_OVERSCAN * 2) + return { + startIndex: nextStart, + endIndex: nextEnd + } + }, [filteredContacts.length, listViewportHeight, scrollTop]) + + const visibleContacts = useMemo(() => { + return filteredContacts.slice(startIndex, endIndex) + }, [filteredContacts, startIndex, endIndex]) + + const onContactsListScroll = useCallback((event: UIEvent) => { + setScrollTop(event.currentTarget.scrollTop) + }, []) + const toggleContactSelected = (username: string, checked: boolean) => { setSelectedUsernames(prev => { const next = new Set(prev) @@ -297,7 +445,12 @@ function ContactsPage() {
- 共 {filteredContacts.length} 个联系人 + 共 {filteredContacts.length} / {contacts.length} 个联系人 + {avatarEnrichProgress.running && ( + + 头像补全中 {avatarEnrichProgress.loaded}/{avatarEnrichProgress.total} + + )}
{exportMode && ( @@ -315,61 +468,73 @@ function ContactsPage() {
)} - {isLoading ? ( + {isLoading && contacts.length === 0 ? (
- 加载中... + 联系人加载中...
) : filteredContacts.length === 0 ? (
暂无联系人
) : ( -
- {filteredContacts.map(contact => { +
+
+ {visibleContacts.map((contact, idx) => { + const absoluteIndex = startIndex + idx + const top = absoluteIndex * VIRTUAL_ROW_HEIGHT const isChecked = selectedUsernames.has(contact.username) const isActive = !exportMode && selectedContact?.username === contact.username return (
{ - if (exportMode) { - toggleContactSelected(contact.username, !isChecked) - } else { - setSelectedContact(isActive ? null : contact) - } - }} + className="contact-row" + style={{ transform: `translateY(${top}px)` }} > - {exportMode && ( - - )} -
- {contact.avatarUrl ? ( - - ) : ( - {getAvatarLetter(contact.displayName)} +
{ + if (exportMode) { + toggleContactSelected(contact.username, !isChecked) + } else { + setSelectedContact(isActive ? null : contact) + } + }} + > + {exportMode && ( + )} -
-
-
{contact.displayName}
- {contact.remark && contact.remark !== contact.displayName && ( -
备注: {contact.remark}
- )} -
-
- {getContactTypeIcon(contact.type)} - {getContactTypeName(contact.type)} +
+ {contact.avatarUrl ? ( + + ) : ( + {getAvatarLetter(contact.displayName)} + )} +
+
+
{contact.displayName}
+ {contact.remark && contact.remark !== contact.displayName && ( +
备注: {contact.remark}
+ )} +
+
+ {getContactTypeIcon(contact.type)} + {getContactTypeName(contact.type)} +
) - })} + })} +
)}
From da7d3544363e921b5fb833db5cef8e95a9f34b53 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 10:23:36 +0800 Subject: [PATCH 029/162] feat(counts): unify contacts and export tab counters --- electron/main.ts | 4 + electron/preload.ts | 1 + electron/services/chatService.ts | 136 ++++++++++----------------- src/pages/ContactsPage.scss | 11 +++ src/pages/ContactsPage.tsx | 24 ++++- src/pages/ExportPage.tsx | 57 +++-------- src/stores/contactTypeCountsStore.ts | 115 ++++++++++++++++++++++ src/types/electron.d.ts | 10 ++ 8 files changed, 222 insertions(+), 136 deletions(-) create mode 100644 src/stores/contactTypeCountsStore.ts diff --git a/electron/main.ts b/electron/main.ts index e73a715..5985639 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -920,6 +920,10 @@ function registerIpcHandlers() { return chatService.getExportTabCounts() }) + ipcMain.handle('chat:getContactTypeCounts', async () => { + return chatService.getContactTypeCounts() + }) + ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[]) => { return chatService.getSessionMessageCounts(sessionIds) }) diff --git a/electron/preload.ts b/electron/preload.ts index 999486f..8723db5 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -132,6 +132,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getSessions: () => ipcRenderer.invoke('chat:getSessions'), getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames), getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), + getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'), getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds), enrichSessionsContactInfo: (usernames: string[]) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 00958cf..788234e 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -762,111 +762,73 @@ class ChatService { } /** - * 获取导出页会话分类数量(轻量接口,优先用于顶部 Tab 数量展示) + * 获取联系人类型数量(好友、群聊、公众号、曾经的好友) */ - async getExportTabCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> { + async getContactTypeCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> { try { const connectResult = await this.ensureConnected() if (!connectResult.success) { return { success: false, error: connectResult.error } } - const sessionResult = await wcdbService.getSessions() - if (!sessionResult.success || !sessionResult.sessions) { - return { success: false, error: sessionResult.error || '获取会话失败' } + const excludeExpr = Array.from(FRIEND_EXCLUDE_USERNAMES) + .map((username) => `'${this.escapeSqlString(username)}'`) + .join(',') + + const countsSql = ` + SELECT + SUM(CASE WHEN username LIKE '%@chatroom' THEN 1 ELSE 0 END) AS group_count, + SUM(CASE WHEN username LIKE 'gh_%' THEN 1 ELSE 0 END) AS official_count, + SUM( + CASE + WHEN username NOT LIKE '%@chatroom' + AND username NOT LIKE 'gh_%' + AND local_type = 1 + AND username NOT IN (${excludeExpr}) + THEN 1 ELSE 0 + END + ) AS private_count, + SUM( + CASE + WHEN username NOT LIKE '%@chatroom' + AND username NOT LIKE 'gh_%' + AND local_type = 0 + AND COALESCE(quan_pin, '') != '' + THEN 1 ELSE 0 + END + ) AS former_friend_count + FROM contact + WHERE username IS NOT NULL + AND username != '' + ` + + const result = await wcdbService.execQuery('contact', null, countsSql) + if (!result.success || !result.rows || result.rows.length === 0) { + return { success: false, error: result.error || '获取联系人类型数量失败' } } + const row = result.rows[0] as Record const counts: ExportTabCounts = { - private: 0, - group: 0, - official: 0, - former_friend: 0 - } - - const nonGroupUsernames: string[] = [] - const usernameSet = new Set() - - for (const row of sessionResult.sessions as Record[]) { - const username = - row.username || - row.user_name || - row.userName || - row.usrName || - row.UsrName || - row.talker || - row.talker_id || - row.talkerId || - '' - - if (!this.shouldKeepSession(username)) continue - if (usernameSet.has(username)) continue - usernameSet.add(username) - - if (username.endsWith('@chatroom')) { - counts.group += 1 - } else { - nonGroupUsernames.push(username) - } - } - - if (nonGroupUsernames.length === 0) { - return { success: true, counts } - } - - const contactTypeMap = new Map() - const chunkSize = 400 - - for (let i = 0; i < nonGroupUsernames.length; i += chunkSize) { - const chunk = nonGroupUsernames.slice(i, i + chunkSize) - if (chunk.length === 0) continue - - const usernamesExpr = chunk.map((name) => `'${this.escapeSqlString(name)}'`).join(',') - const contactSql = ` - SELECT username, local_type, quan_pin - FROM contact - WHERE username IN (${usernamesExpr}) - ` - - const contactResult = await wcdbService.execQuery('contact', null, contactSql) - if (!contactResult.success || !contactResult.rows) { - continue - } - - for (const row of contactResult.rows as Record[]) { - const username = String(row.username || '').trim() - if (!username) continue - - if (username.startsWith('gh_')) { - contactTypeMap.set(username, 'official') - continue - } - - const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) - const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim() - if (localType === 0 && quanPin) { - contactTypeMap.set(username, 'former_friend') - } - } - } - - for (const username of nonGroupUsernames) { - const type = contactTypeMap.get(username) - if (type === 'official') { - counts.official += 1 - } else if (type === 'former_friend') { - counts.former_friend += 1 - } else { - counts.private += 1 - } + private: this.getRowInt(row, ['private_count', 'privateCount'], 0), + group: this.getRowInt(row, ['group_count', 'groupCount'], 0), + official: this.getRowInt(row, ['official_count', 'officialCount'], 0), + former_friend: this.getRowInt(row, ['former_friend_count', 'formerFriendCount'], 0) } return { success: true, counts } } catch (e) { - console.error('ChatService: 获取导出页会话分类数量失败:', e) + console.error('ChatService: 获取联系人类型数量失败:', e) return { success: false, error: String(e) } } } + /** + * 获取导出页会话分类数量(轻量接口,优先用于顶部 Tab 数量展示) + */ + async getExportTabCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> { + return this.getContactTypeCounts() + } + /** * 批量获取会话消息总数(轻量接口,用于列表优先排序) */ diff --git a/src/pages/ContactsPage.scss b/src/pages/ContactsPage.scss index 6bb4844..541f428 100644 --- a/src/pages/ContactsPage.scss +++ b/src/pages/ContactsPage.scss @@ -148,6 +148,17 @@ svg { opacity: 0.7; transition: transform 0.2s; + flex-shrink: 0; + } + + .chip-label { + min-width: 0; + } + + .chip-count { + margin-left: auto; + text-align: right; + font-variant-numeric: tabular-nums; } &:hover { diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 570b40c..77271c0 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from import { useNavigate } from 'react-router-dom' import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react' import { useChatStore } from '../stores/chatStore' +import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore' import './ContactsPage.scss' interface ContactInfo { @@ -58,6 +59,8 @@ function ContactsPage() { }) const [scrollTop, setScrollTop] = useState(0) const [listViewportHeight, setListViewportHeight] = useState(480) + const sharedTabCounts = useContactTypeCountsStore(state => state.tabCounts) + const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts) const applyEnrichedContacts = useCallback((enrichedMap: Record) => { if (!enrichedMap || Object.keys(enrichedMap).length === 0) return @@ -151,6 +154,7 @@ function ContactsPage() { if (loadVersionRef.current !== loadVersion) return if (contactsResult.success && contactsResult.contacts) { setContacts(contactsResult.contacts) + syncContactTypeCounts(contactsResult.contacts) setSelectedUsernames(new Set()) setSelectedContact(prev => { if (!prev) return prev @@ -167,7 +171,7 @@ function ContactsPage() { setIsLoading(false) } } - }, [enrichContactsInBackground]) + }, [enrichContactsInBackground, syncContactTypeCounts]) useEffect(() => { loadContacts() @@ -206,6 +210,8 @@ function ContactsPage() { return filtered }, [contacts, contactTypes, debouncedSearchKeyword]) + const contactTypeCounts = useMemo(() => toContactTypeCardCounts(sharedTabCounts), [sharedTabCounts]) + useEffect(() => { if (!listRef.current) return listRef.current.scrollTop = 0 @@ -428,19 +434,27 @@ function ContactsPage() {
diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 9b704c8..1812efd 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -22,6 +22,7 @@ import { import type { ChatSession as AppChatSession, ContactInfo } from '../types/models' import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron' import * as configService from '../services/config' +import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' import './ExportPage.scss' type ConversationTab = 'private' | 'group' | 'official' | 'former_friend' @@ -321,12 +322,10 @@ function ExportPage() { const [isLoading, setIsLoading] = useState(true) const [isSessionEnriching, setIsSessionEnriching] = useState(false) - const [isTabCountsLoading, setIsTabCountsLoading] = useState(true) const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true) const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true) const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false) const [sessions, setSessions] = useState([]) - const [prefetchedTabCounts, setPrefetchedTabCounts] = useState | null>(null) const [sessionMessageCounts, setSessionMessageCounts] = useState>({}) const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') @@ -374,6 +373,11 @@ function ExportPage() { }) const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false) const [nowTick, setNowTick] = useState(Date.now()) + const tabCounts = useContactTypeCountsStore(state => state.tabCounts) + const isSharedTabCountsLoading = useContactTypeCountsStore(state => state.isLoading) + const isSharedTabCountsReady = useContactTypeCountsStore(state => state.isReady) + const ensureSharedTabCountsLoaded = useContactTypeCountsStore(state => state.ensureLoaded) + const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts) const progressUnsubscribeRef = useRef<(() => void) | null>(null) const runningTaskIdRef = useRef(null) @@ -516,20 +520,6 @@ function ExportPage() { } }, []) - const loadTabCounts = useCallback(async () => { - setIsTabCountsLoading(true) - try { - const result = await window.electronAPI.chat.getExportTabCounts() - if (result.success && result.counts) { - setPrefetchedTabCounts(result.counts) - } - } catch (error) { - console.error('加载导出页会话分类数量失败:', error) - } finally { - setIsTabCountsLoading(false) - } - }, []) - const loadSnsStats = useCallback(async (options?: { full?: boolean; silent?: boolean }) => { if (!options?.silent) { setIsSnsStatsLoading(true) @@ -641,6 +631,9 @@ function ExportPage() { if (isStale()) return const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] + if (contacts.length > 0) { + syncContactTypeCounts(contacts) + } const nextContactMap = contacts.reduce>((map, contact) => { map[contact.username] = contact return map @@ -694,11 +687,11 @@ function ExportPage() { } finally { if (!isStale()) setIsLoading(false) } - }, []) + }, [syncContactTypeCounts]) useEffect(() => { void loadBaseConfig() - void loadTabCounts() + void ensureSharedTabCountsLoaded() void loadSessions() // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 @@ -707,7 +700,7 @@ function ExportPage() { }, 120) return () => window.clearTimeout(timer) - }, [loadTabCounts, loadBaseConfig, loadSessions, loadSnsStats]) + }, [ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats]) useEffect(() => { preselectAppliedRef.current = false @@ -1363,29 +1356,6 @@ function ExportPage() { return set }, [tasks]) - const sessionTabCounts = useMemo(() => { - const counts: Record = { - private: 0, - group: 0, - official: 0, - former_friend: 0 - } - for (const session of sessions) { - counts[session.kind] += 1 - } - return counts - }, [sessions]) - - const tabCounts = useMemo(() => { - if (sessions.length > 0) { - return sessionTabCounts - } - if (prefetchedTabCounts) { - return prefetchedTabCounts - } - return sessionTabCounts - }, [sessions.length, sessionTabCounts, prefetchedTabCounts]) - const contentCards = useMemo(() => { const scopeSessions = sessions.filter(session => session.kind === 'private' || session.kind === 'group') const totalSessions = scopeSessions.length @@ -1617,8 +1587,7 @@ function ExportPage() { const formatCandidateOptions = exportDialog.scope === 'sns' ? formatOptions.filter(option => option.value === 'html' || option.value === 'json') : formatOptions - const hasTabCountsSource = prefetchedTabCounts !== null || sessions.length > 0 - const isTabCountComputing = isTabCountsLoading && !hasTabCountsSource + const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady const isSessionCardStatsLoading = isLoading || isBaseConfigLoading const isSnsCardStatsLoading = !hasSeededSnsStats const taskRunningCount = tasks.filter(task => task.status === 'running').length diff --git a/src/stores/contactTypeCountsStore.ts b/src/stores/contactTypeCountsStore.ts new file mode 100644 index 0000000..d3252fc --- /dev/null +++ b/src/stores/contactTypeCountsStore.ts @@ -0,0 +1,115 @@ +import { create } from 'zustand' +import type { ContactInfo } from '../types/models' + +export interface ContactTypeTabCounts { + private: number + group: number + official: number + former_friend: number +} + +export interface ContactTypeCardCounts { + friends: number + groups: number + officials: number + deletedFriends: number +} + +const emptyTabCounts: ContactTypeTabCounts = { + private: 0, + group: 0, + official: 0, + former_friend: 0 +} + +let inflightPromise: Promise | null = null + +const normalizeCounts = (counts?: Partial | null): ContactTypeTabCounts => { + return { + private: Number.isFinite(counts?.private) ? Math.max(0, Math.floor(Number(counts?.private))) : 0, + group: Number.isFinite(counts?.group) ? Math.max(0, Math.floor(Number(counts?.group))) : 0, + official: Number.isFinite(counts?.official) ? Math.max(0, Math.floor(Number(counts?.official))) : 0, + former_friend: Number.isFinite(counts?.former_friend) ? Math.max(0, Math.floor(Number(counts?.former_friend))) : 0 + } +} + +export const toContactTypeTabCountsFromContacts = (contacts: ContactInfo[]): ContactTypeTabCounts => { + const next = { ...emptyTabCounts } + for (const contact of contacts || []) { + if (contact.type === 'friend') next.private += 1 + if (contact.type === 'group') next.group += 1 + if (contact.type === 'official') next.official += 1 + if (contact.type === 'former_friend') next.former_friend += 1 + } + return next +} + +export const toContactTypeCardCounts = (counts: ContactTypeTabCounts): ContactTypeCardCounts => { + return { + friends: counts.private, + groups: counts.group, + officials: counts.official, + deletedFriends: counts.former_friend + } +} + +interface ContactTypeCountsState { + tabCounts: ContactTypeTabCounts + isLoading: boolean + isReady: boolean + updatedAt: number + setTabCounts: (counts: ContactTypeTabCounts) => void + syncFromContacts: (contacts: ContactInfo[]) => void + ensureLoaded: (options?: { force?: boolean }) => Promise +} + +export const useContactTypeCountsStore = create((set, get) => ({ + tabCounts: { ...emptyTabCounts }, + isLoading: false, + isReady: false, + updatedAt: 0, + setTabCounts: (counts) => { + const normalized = normalizeCounts(counts) + set({ + tabCounts: normalized, + isReady: true, + updatedAt: Date.now() + }) + }, + syncFromContacts: (contacts) => { + const fromContacts = toContactTypeTabCountsFromContacts(contacts || []) + get().setTabCounts(fromContacts) + }, + ensureLoaded: async (options) => { + if (!options?.force && get().isReady) { + return get().tabCounts + } + if (inflightPromise) { + return inflightPromise + } + + set({ isLoading: true }) + inflightPromise = (async () => { + try { + const result = await window.electronAPI.chat.getContactTypeCounts() + if (result?.success && result.counts) { + const normalized = normalizeCounts(result.counts) + set({ + tabCounts: normalized, + isReady: true, + updatedAt: Date.now() + }) + return normalized + } + } catch (error) { + console.error('加载联系人类型计数失败:', error) + } + return get().tabCounts + })().finally(() => { + inflightPromise = null + set({ isLoading: false }) + }) + + return inflightPromise + } +})) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 88bc819..f7a1e57 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -89,6 +89,16 @@ export interface ElectronAPI { } error?: string }> + getContactTypeCounts: () => Promise<{ + success: boolean + counts?: { + private: number + group: number + official: number + former_friend: number + } + error?: string + }> getSessionMessageCounts: (sessionIds: string[]) => Promise<{ success: boolean counts?: Record From abdb4f62def10c407e783f72bcb7c9d2e001c67b Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 10:37:56 +0800 Subject: [PATCH 030/162] fix(export): pause hidden export background loading to unblock contacts --- src/pages/ExportPage.tsx | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 1812efd..02774f1 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -319,6 +319,7 @@ const WriteLayoutSelector = memo(function WriteLayoutSelector({ function ExportPage() { const location = useLocation() + const isExportRoute = location.pathname === '/export' const [isLoading, setIsLoading] = useState(true) const [isSessionEnriching, setIsSessionEnriching] = useState(false) @@ -690,6 +691,7 @@ function ExportPage() { }, [syncContactTypeCounts]) useEffect(() => { + if (!isExportRoute) return void loadBaseConfig() void ensureSharedTabCountsLoaded() void loadSessions() @@ -700,7 +702,16 @@ function ExportPage() { }, 120) return () => window.clearTimeout(timer) - }, [ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats]) + }, [isExportRoute, ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats]) + + useEffect(() => { + if (isExportRoute) return + // 导出页隐藏时停止后台统计请求,避免与通讯录页面查询抢占。 + sessionLoadTokenRef.current = Date.now() + loadingMessageCountsRef.current.clear() + loadingMetricsRef.current.clear() + setIsSessionEnriching(false) + }, [isExportRoute]) useEffect(() => { preselectAppliedRef.current = false @@ -754,6 +765,7 @@ function ExportPage() { }, [visibleSessions]) const ensureSessionMessageCounts = useCallback(async (targetSessions: SessionRow[]) => { + if (!isExportRoute) return const loadTokenAtStart = sessionLoadTokenRef.current const currentCounts = sessionMessageCountsRef.current const pending = targetSessions.filter( @@ -797,9 +809,10 @@ function ExportPage() { loadingMessageCountsRef.current.delete(session.username) } } - }, []) + }, [isExportRoute]) const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { + if (!isExportRoute) return const loadTokenAtStart = sessionLoadTokenRef.current const currentMetrics = sessionMetricsRef.current const pending = targetSessions.filter(session => !currentMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) @@ -857,14 +870,16 @@ function ExportPage() { if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(updates).length > 0) { setSessionMetrics(prev => ({ ...prev, ...updates })) } - }, []) + }, [isExportRoute]) useEffect(() => { + if (!isExportRoute) return const targets = visibleSessions.slice(0, MESSAGE_COUNT_VIEWPORT_PREFETCH) void ensureSessionMessageCounts(targets) - }, [visibleSessions, ensureSessionMessageCounts]) + }, [isExportRoute, visibleSessions, ensureSessionMessageCounts]) useEffect(() => { + if (!isExportRoute) return if (sessions.length === 0) return const activeTabTargets = sessions .filter(session => session.kind === activeTab) @@ -872,14 +887,16 @@ function ExportPage() { .slice(0, MESSAGE_COUNT_ACTIVE_TAB_WARMUP_LIMIT) if (activeTabTargets.length === 0) return void ensureSessionMessageCounts(activeTabTargets) - }, [sessions, activeTab, ensureSessionMessageCounts]) + }, [isExportRoute, sessions, activeTab, ensureSessionMessageCounts]) useEffect(() => { + if (!isExportRoute) return const targets = visibleSessions.slice(0, METRICS_VIEWPORT_PREFETCH) void ensureSessionMetrics(targets) - }, [visibleSessions, ensureSessionMetrics]) + }, [isExportRoute, visibleSessions, ensureSessionMetrics]) const handleTableRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { + if (!isExportRoute) return const current = visibleSessionsRef.current if (current.length === 0) return const prefetch = Math.max(MESSAGE_COUNT_VIEWPORT_PREFETCH, METRICS_VIEWPORT_PREFETCH) @@ -889,9 +906,10 @@ function ExportPage() { const rangeSessions = current.slice(start, end + 1) void ensureSessionMessageCounts(rangeSessions) void ensureSessionMetrics(rangeSessions) - }, [ensureSessionMessageCounts, ensureSessionMetrics]) + }, [isExportRoute, ensureSessionMessageCounts, ensureSessionMetrics]) useEffect(() => { + if (!isExportRoute) return if (sessions.length === 0) return const prioritySessions = [ ...sessions.filter(session => session.kind === activeTab), @@ -909,7 +927,7 @@ function ExportPage() { }, METRICS_BACKGROUND_INTERVAL_MS) return () => window.clearInterval(timer) - }, [sessions, activeTab, ensureSessionMetrics]) + }, [isExportRoute, sessions, activeTab, ensureSessionMetrics]) const selectedCount = selectedSessions.size From 9cb41e01e241d93ebf77e26493b0eea89c6c8b44 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 10:51:28 +0800 Subject: [PATCH 031/162] fix(contacts): persist list cache and add load timeout diagnostics --- src/pages/ContactsPage.scss | 102 +++++++++++++ src/pages/ContactsPage.tsx | 275 +++++++++++++++++++++++++++++++++++- src/pages/ExportPage.tsx | 16 ++- src/services/config.ts | 101 +++++++++++++ 4 files changed, 481 insertions(+), 13 deletions(-) diff --git a/src/pages/ContactsPage.scss b/src/pages/ContactsPage.scss index 541f428..bd6fc98 100644 --- a/src/pages/ContactsPage.scss +++ b/src/pages/ContactsPage.scss @@ -189,6 +189,16 @@ font-size: 13px; color: var(--text-secondary); + .contacts-cache-meta { + margin-left: 10px; + color: var(--text-tertiary); + font-size: 12px; + + &.syncing { + color: var(--primary); + } + } + .avatar-enrich-progress { margin-left: 10px; color: var(--text-tertiary); @@ -230,6 +240,98 @@ } } + .load-issue-state { + flex: 1; + padding: 14px 14px 18px; + overflow-y: auto; + } + + .issue-card { + border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color)); + background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--card-bg)); + border-radius: 12px; + padding: 14px; + color: var(--text-primary); + + .issue-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary)); + margin-bottom: 8px; + } + + .issue-message { + margin: 0 0 8px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + + .issue-reason { + margin: 0; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + + .issue-hints { + margin: 10px 0 0; + padding-left: 18px; + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.6; + } + + .issue-actions { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .issue-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 8px; + padding: 7px 10px; + font-size: 12px; + color: var(--text-secondary); + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + color: var(--text-primary); + border-color: var(--text-tertiary); + background: var(--bg-hover); + } + + &.primary { + background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary)); + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + color: var(--primary); + } + } + + .issue-diagnostics { + margin-top: 12px; + border-radius: 8px; + background: var(--bg-primary); + border: 1px dashed var(--border-color); + padding: 10px; + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; + } + } + .contacts-list { flex: 1; overflow-y: auto; diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 77271c0..6a87372 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -1,8 +1,9 @@ import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react' import { useNavigate } from 'react-router-dom' -import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react' +import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList } from 'lucide-react' import { useChatStore } from '../stores/chatStore' import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore' +import * as configService from '../services/config' import './ContactsPage.scss' interface ContactInfo { @@ -23,6 +24,26 @@ const AVATAR_ENRICH_BATCH_SIZE = 80 const SEARCH_DEBOUNCE_MS = 120 const VIRTUAL_ROW_HEIGHT = 76 const VIRTUAL_OVERSCAN = 10 +const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000 + +interface ContactsLoadSession { + requestId: string + startedAt: number + attempt: number + timeoutMs: number +} + +interface ContactsLoadIssue { + kind: 'timeout' | 'error' + title: string + message: string + reason: string + errorDetail?: string + occurredAt: number + elapsedMs: number +} + +type ContactsDataSource = 'cache' | 'network' | null function ContactsPage() { const [contacts, setContacts] = useState([]) @@ -61,6 +82,53 @@ function ContactsPage() { const [listViewportHeight, setListViewportHeight] = useState(480) const sharedTabCounts = useContactTypeCountsStore(state => state.tabCounts) const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts) + const loadAttemptRef = useRef(0) + const loadTimeoutTimerRef = useRef(null) + const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) + const [loadSession, setLoadSession] = useState(null) + const [loadIssue, setLoadIssue] = useState(null) + const [showDiagnostics, setShowDiagnostics] = useState(false) + const [diagnosticTick, setDiagnosticTick] = useState(Date.now()) + const [contactsDataSource, setContactsDataSource] = useState(null) + const [contactsUpdatedAt, setContactsUpdatedAt] = useState(null) + const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) + const contactsCacheScopeRef = useRef('default') + + const ensureContactsCacheScope = useCallback(async () => { + if (contactsCacheScopeRef.current !== 'default') { + return contactsCacheScopeRef.current + } + const [dbPath, myWxid] = await Promise.all([ + configService.getDbPath(), + configService.getMyWxid() + ]) + const scopeKey = dbPath || myWxid + ? `${dbPath || ''}::${myWxid || ''}` + : 'default' + contactsCacheScopeRef.current = scopeKey + return scopeKey + }, []) + + useEffect(() => { + let cancelled = false + void (async () => { + try { + const value = await configService.getContactsLoadTimeoutMs() + if (!cancelled) { + setContactsLoadTimeoutMs(value) + } + } catch (error) { + console.error('读取通讯录超时配置失败:', error) + } + })() + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs + }, [contactsLoadTimeoutMs]) const applyEnrichedContacts = useCallback((enrichedMap: Record) => { if (!enrichedMap || Object.keys(enrichedMap).length === 0) return @@ -139,9 +207,40 @@ function ContactsPage() { }, [applyEnrichedContacts]) // 加载通讯录 - const loadContacts = useCallback(async () => { + const loadContacts = useCallback(async (options?: { scopeKey?: string }) => { + const scopeKey = options?.scopeKey || await ensureContactsCacheScope() const loadVersion = loadVersionRef.current + 1 loadVersionRef.current = loadVersion + loadAttemptRef.current += 1 + const startedAt = Date.now() + const timeoutMs = contactsLoadTimeoutMsRef.current + const requestId = `contacts-${startedAt}-${loadAttemptRef.current}` + setLoadSession({ + requestId, + startedAt, + attempt: loadAttemptRef.current, + timeoutMs + }) + setLoadIssue(null) + setShowDiagnostics(false) + if (loadTimeoutTimerRef.current) { + window.clearTimeout(loadTimeoutTimerRef.current) + loadTimeoutTimerRef.current = null + } + const timeoutTimerId = window.setTimeout(() => { + if (loadVersionRef.current !== loadVersion) return + const elapsedMs = Date.now() - startedAt + setLoadIssue({ + kind: 'timeout', + title: '通讯录加载超时', + message: `等待超过 ${timeoutMs}ms,联系人列表仍未返回。`, + reason: 'chat.getContacts 长时间未返回,可能是数据库查询繁忙或连接异常。', + occurredAt: Date.now(), + elapsedMs + }) + }, timeoutMs) + loadTimeoutTimerRef.current = timeoutTimerId + setIsLoading(true) setAvatarEnrichProgress({ loaded: 0, @@ -153,6 +252,10 @@ function ContactsPage() { if (loadVersionRef.current !== loadVersion) return if (contactsResult.success && contactsResult.contacts) { + if (loadTimeoutTimerRef.current === timeoutTimerId) { + window.clearTimeout(loadTimeoutTimerRef.current) + loadTimeoutTimerRef.current = null + } setContacts(contactsResult.contacts) syncContactTypeCounts(contactsResult.contacts) setSelectedUsernames(new Set()) @@ -160,29 +263,108 @@ function ContactsPage() { if (!prev) return prev return contactsResult.contacts!.find(contact => contact.username === prev.username) || null }) + const now = Date.now() + setContactsDataSource('network') + setContactsUpdatedAt(now) + setLoadIssue(null) setIsLoading(false) + void configService.setContactsListCache( + scopeKey, + contactsResult.contacts.map(contact => ({ + username: contact.username, + displayName: contact.displayName, + remark: contact.remark, + nickname: contact.nickname, + type: contact.type + })) + ).catch((error) => { + console.error('写入通讯录缓存失败:', error) + }) void enrichContactsInBackground(contactsResult.contacts, loadVersion) return } + const elapsedMs = Date.now() - startedAt + setLoadIssue({ + kind: 'error', + title: '通讯录加载失败', + message: '联系人接口返回失败,未拿到联系人列表。', + reason: 'chat.getContacts 返回 success=false。', + errorDetail: contactsResult.error || '未知错误', + occurredAt: Date.now(), + elapsedMs + }) } catch (e) { console.error('加载通讯录失败:', e) + const elapsedMs = Date.now() - startedAt + setLoadIssue({ + kind: 'error', + title: '通讯录加载失败', + message: '联系人请求执行异常。', + reason: '调用 chat.getContacts 发生异常。', + errorDetail: String(e), + occurredAt: Date.now(), + elapsedMs + }) } finally { + if (loadTimeoutTimerRef.current === timeoutTimerId) { + window.clearTimeout(loadTimeoutTimerRef.current) + loadTimeoutTimerRef.current = null + } if (loadVersionRef.current === loadVersion) { setIsLoading(false) } } - }, [enrichContactsInBackground, syncContactTypeCounts]) + }, [ensureContactsCacheScope, enrichContactsInBackground, syncContactTypeCounts]) useEffect(() => { - loadContacts() - }, [loadContacts]) + let cancelled = false + void (async () => { + const scopeKey = await ensureContactsCacheScope() + if (cancelled) return + try { + const cacheItem = await configService.getContactsListCache(scopeKey) + if (!cancelled && cacheItem && Array.isArray(cacheItem.contacts) && cacheItem.contacts.length > 0) { + const cachedContacts: ContactInfo[] = cacheItem.contacts.map(contact => ({ + ...contact, + avatarUrl: undefined + })) + setContacts(cachedContacts) + syncContactTypeCounts(cachedContacts) + setContactsDataSource('cache') + setContactsUpdatedAt(cacheItem.updatedAt || null) + setIsLoading(false) + } + } catch (error) { + console.error('读取通讯录缓存失败:', error) + } + if (!cancelled) { + void loadContacts({ scopeKey }) + } + })() + return () => { + cancelled = true + } + }, [ensureContactsCacheScope, loadContacts, syncContactTypeCounts]) useEffect(() => { return () => { + if (loadTimeoutTimerRef.current) { + window.clearTimeout(loadTimeoutTimerRef.current) + loadTimeoutTimerRef.current = null + } loadVersionRef.current += 1 } }, []) + useEffect(() => { + if (!loadIssue || contacts.length > 0) return + if (!(isLoading && loadIssue.kind === 'timeout')) return + const timer = window.setInterval(() => { + setDiagnosticTick(Date.now()) + }, 500) + return () => window.clearInterval(timer) + }, [contacts.length, isLoading, loadIssue]) + useEffect(() => { const timer = window.setTimeout(() => { setDebouncedSearchKeyword(searchKeyword.trim().toLowerCase()) @@ -282,6 +464,45 @@ function ContactsPage() { setScrollTop(event.currentTarget.scrollTop) }, []) + const issueElapsedMs = useMemo(() => { + if (!loadIssue) return 0 + if (isLoading && loadSession) { + return Math.max(loadIssue.elapsedMs, diagnosticTick - loadSession.startedAt) + } + return loadIssue.elapsedMs + }, [diagnosticTick, isLoading, loadIssue, loadSession]) + + const diagnosticsText = useMemo(() => { + if (!loadIssue || !loadSession) return '' + return [ + `请求ID: ${loadSession.requestId}`, + `请求序号: 第 ${loadSession.attempt} 次`, + `阈值配置: ${loadSession.timeoutMs}ms`, + `当前状态: ${loadIssue.kind === 'timeout' ? '超时等待中' : '请求失败'}`, + `累计耗时: ${(issueElapsedMs / 1000).toFixed(1)}s`, + `发生时间: ${new Date(loadIssue.occurredAt).toLocaleString()}`, + `阶段: chat.getContacts`, + `原因: ${loadIssue.reason}`, + `错误详情: ${loadIssue.errorDetail || '无'}` + ].join('\n') + }, [issueElapsedMs, loadIssue, loadSession]) + + const copyDiagnostics = useCallback(async () => { + if (!diagnosticsText) return + try { + await navigator.clipboard.writeText(diagnosticsText) + alert('诊断信息已复制') + } catch (error) { + console.error('复制诊断信息失败:', error) + alert('复制失败,请手动复制诊断信息') + } + }, [diagnosticsText]) + + const contactsUpdatedAtLabel = useMemo(() => { + if (!contactsUpdatedAt) return '' + return new Date(contactsUpdatedAt).toLocaleString() + }, [contactsUpdatedAt]) + const toggleContactSelected = (username: string, checked: boolean) => { setSelectedUsernames(prev => { const next = new Set(prev) @@ -410,7 +631,7 @@ function ContactsPage() { > -
@@ -460,6 +681,14 @@ function ContactsPage() {
共 {filteredContacts.length} / {contacts.length} 个联系人 + {contactsUpdatedAt && ( + + {contactsDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {contactsUpdatedAtLabel} + + )} + {isLoading && contacts.length > 0 && ( + 后台同步中... + )} {avatarEnrichProgress.running && ( 头像补全中 {avatarEnrichProgress.loaded}/{avatarEnrichProgress.total} @@ -482,7 +711,39 @@ function ContactsPage() {
)} - {isLoading && contacts.length === 0 ? ( + {contacts.length === 0 && loadIssue ? ( +
+
+
+ + {loadIssue.title} +
+

{loadIssue.message}

+

{loadIssue.reason}

+
    +
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • +
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • +
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • +
+
+ + + +
+ {showDiagnostics && ( +
{diagnosticsText}
+ )} +
+
+ ) : isLoading && contacts.length === 0 ? (
联系人加载中... diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 02774f1..7f4861d 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -222,6 +222,10 @@ const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): Co return 'private' } +const isContentScopeSession = (session: SessionRow): boolean => ( + session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend' +) + const getAvatarLetter = (name: string): string => { if (!name) return '?' return [...name][0] || '?' @@ -1327,11 +1331,11 @@ function ExportPage() { const openContentExport = (contentType: ContentType) => { const ids = sessions - .filter(session => session.kind === 'private' || session.kind === 'group') + .filter(isContentScopeSession) .map(session => session.username) const names = sessions - .filter(session => session.kind === 'private' || session.kind === 'group') + .filter(isContentScopeSession) .map(session => session.displayName || session.username) openExportDialog({ @@ -1375,8 +1379,8 @@ function ExportPage() { }, [tasks]) const contentCards = useMemo(() => { - const scopeSessions = sessions.filter(session => session.kind === 'private' || session.kind === 'group') - const totalSessions = scopeSessions.length + const scopeSessions = sessions.filter(isContentScopeSession) + const totalSessions = tabCounts.private + tabCounts.group + tabCounts.former_friend const snsExportedCount = Math.min(lastSnsExportPostCount, snsStats.totalPosts) const sessionCards = [ @@ -1414,7 +1418,7 @@ function ExportPage() { } return [...sessionCards, snsCard] - }, [sessions, lastExportByContent, snsStats, lastSnsExportPostCount]) + }, [sessions, tabCounts, lastExportByContent, snsStats, lastSnsExportPostCount]) const activeTabLabel = useMemo(() => { if (activeTab === 'private') return '私聊' @@ -1606,7 +1610,7 @@ function ExportPage() { ? formatOptions.filter(option => option.value === 'html' || option.value === 'json') : formatOptions const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady - const isSessionCardStatsLoading = isLoading || isBaseConfigLoading + const isSessionCardStatsLoading = isBaseConfigLoading || (isSharedTabCountsLoading && !isSharedTabCountsReady) const isSnsCardStatsLoading = !hasSeededSnsStats const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length diff --git a/src/services/config.ts b/src/services/config.ts index 53969ef..3ea4652 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -38,6 +38,8 @@ export const CONFIG_KEYS = { EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount', EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap', EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap', + CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs', + CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap', // 安全 AUTH_ENABLED: 'authEnabled', @@ -462,6 +464,19 @@ export interface ExportSnsStatsCacheItem { totalFriends: number } +export interface ContactsListCacheContact { + username: string + displayName: string + remark?: string + nickname?: string + type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' +} + +export interface ContactsListCacheItem { + updatedAt: number + contacts: ContactsListCacheContact[] +} + export async function getExportSessionMessageCountCache(scopeKey: string): Promise { if (!scopeKey) return null const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP) @@ -549,6 +564,92 @@ export async function setExportSnsStatsCache( await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map) } +// 获取通讯录加载超时阈值(毫秒) +export async function getContactsLoadTimeoutMs(): Promise { + const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS) + if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) { + return Math.floor(value) + } + return 3000 +} + +// 设置通讯录加载超时阈值(毫秒) +export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise { + const normalized = Number.isFinite(timeoutMs) + ? Math.min(60000, Math.max(1000, Math.floor(timeoutMs))) + : 3000 + await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized) +} + +export async function getContactsListCache(scopeKey: string): Promise { + if (!scopeKey) return null + const value = await config.get(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP) + if (!value || typeof value !== 'object') return null + const rawMap = value as Record + const rawItem = rawMap[scopeKey] + if (!rawItem || typeof rawItem !== 'object') return null + + const rawUpdatedAt = (rawItem as Record).updatedAt + const rawContacts = (rawItem as Record).contacts + if (!Array.isArray(rawContacts)) return null + + const contacts: ContactsListCacheContact[] = [] + for (const raw of rawContacts) { + if (!raw || typeof raw !== 'object') continue + const item = raw as Record + const username = typeof item.username === 'string' ? item.username.trim() : '' + if (!username) continue + const displayName = typeof item.displayName === 'string' ? item.displayName : username + const type = typeof item.type === 'string' ? item.type : 'other' + contacts.push({ + username, + displayName, + remark: typeof item.remark === 'string' ? item.remark : undefined, + nickname: typeof item.nickname === 'string' ? item.nickname : undefined, + type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other') + ? type + : 'other' + }) + } + + return { + updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0, + contacts + } +} + +export async function setContactsListCache(scopeKey: string, contacts: ContactsListCacheContact[]): Promise { + if (!scopeKey) return + const current = await config.get(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP) + const map = current && typeof current === 'object' + ? { ...(current as Record) } + : {} + + const normalized: ContactsListCacheContact[] = [] + for (const contact of contacts || []) { + const username = String(contact?.username || '').trim() + if (!username) continue + const displayName = String(contact?.displayName || username) + const type = contact?.type || 'other' + if (type !== 'friend' && type !== 'group' && type !== 'official' && type !== 'former_friend' && type !== 'other') { + continue + } + normalized.push({ + username, + displayName, + remark: contact?.remark ? String(contact.remark) : undefined, + nickname: contact?.nickname ? String(contact.nickname) : undefined, + type + }) + } + + map[scopeKey] = { + updatedAt: Date.now(), + contacts: normalized + } + await config.set(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP, map) +} + // === 安全相关 === export async function getAuthEnabled(): Promise { From 01a221831f5364964b023e97f0f28a54345bf400 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 11:06:42 +0800 Subject: [PATCH 032/162] feat(export): move task center into top control row --- src/pages/ExportPage.scss | 102 ++++++------ src/pages/ExportPage.tsx | 315 ++++++++++++++++++++++---------------- 2 files changed, 242 insertions(+), 175 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index ae7129b..8a69ee6 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -24,8 +24,9 @@ border-radius: 14px; padding: 14px; display: grid; - grid-template-columns: minmax(300px, 1fr) 320px; + grid-template-columns: minmax(320px, 1.45fr) minmax(220px, 0.8fr) minmax(260px, 1fr); gap: 16px; + align-items: end; .control-label { font-size: 12px; @@ -189,6 +190,54 @@ color: var(--text-secondary); line-height: 1.45; } + + .task-center-control { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + } + + .task-center-inline { + min-height: 40px; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-secondary); + padding: 0 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .task-summary { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + min-width: 0; + } + + .task-collapse-btn { + border: 1px solid var(--border-color); + background: var(--bg-primary); + border-radius: 8px; + padding: 4px 8px; + font-size: 12px; + color: var(--text-secondary); + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + flex-shrink: 0; + + &:hover { + border-color: var(--primary); + color: var(--primary); + } + } } .content-card-grid { @@ -276,51 +325,7 @@ padding: 12px; flex-shrink: 0; - .task-center-header { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; - } - - .section-title { - font-size: 14px; - font-weight: 700; - color: var(--text-primary); - margin: 0; - flex-shrink: 0; - } - - .task-summary { - margin-left: auto; - display: inline-flex; - align-items: center; - gap: 10px; - font-size: 12px; - color: var(--text-secondary); - white-space: nowrap; - } - - .task-collapse-btn { - border: 1px solid var(--border-color); - background: var(--bg-secondary); - border-radius: 8px; - padding: 4px 8px; - font-size: 12px; - color: var(--text-secondary); - display: inline-flex; - align-items: center; - gap: 4px; - cursor: pointer; - - &:hover { - border-color: var(--primary); - color: var(--primary); - } - } - .task-empty { - margin-top: 10px; padding: 12px; background: var(--bg-secondary); border-radius: 8px; @@ -329,7 +334,6 @@ } .task-list { - margin-top: 10px; display: grid; gap: 8px; max-height: 190px; @@ -1099,6 +1103,12 @@ .path-inline-row > .secondary-btn { margin-left: auto; } + + .task-center-inline { + flex-wrap: wrap; + min-height: auto; + padding: 8px 10px; + } } .content-card-grid { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 7f4861d..f6177ed 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -242,11 +242,13 @@ const timestampOrDash = (timestamp?: number): string => { } const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -const MESSAGE_COUNT_VIEWPORT_PREFETCH = 180 -const MESSAGE_COUNT_ACTIVE_TAB_WARMUP_LIMIT = 960 -const METRICS_VIEWPORT_PREFETCH = 90 -const METRICS_BACKGROUND_BATCH = 40 -const METRICS_BACKGROUND_INTERVAL_MS = 220 +const MESSAGE_COUNT_VIEWPORT_PREFETCH = 90 +const MESSAGE_COUNT_ACTIVE_TAB_WARMUP_LIMIT = 240 +const MESSAGE_COUNT_REQUEST_BATCH = 120 +const METRICS_VIEWPORT_PREFETCH = 60 +const METRICS_REQUEST_BATCH = 24 +const METRICS_BACKGROUND_BATCH = 20 +const METRICS_BACKGROUND_INTERVAL_MS = 500 const CONTACT_ENRICH_TIMEOUT_MS = 7000 const EXPORT_SESSION_COUNT_CACHE_STALE_MS = 48 * 60 * 60 * 1000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 @@ -393,6 +395,11 @@ function ExportPage() { const sessionLoadTokenRef = useRef(0) const loadingMessageCountsRef = useRef>(new Set()) const loadingMetricsRef = useRef>(new Set()) + const pendingMessageCountsRef = useRef>(new Set()) + const pendingMetricsRef = useRef>(new Set()) + const messageCountPumpRunningRef = useRef(false) + const metricsPumpRunningRef = useRef(false) + const isExportRouteRef = useRef(isExportRoute) const preselectAppliedRef = useRef(false) const visibleSessionsRef = useRef([]) const exportCacheScopeRef = useRef('default') @@ -415,6 +422,10 @@ function ExportPage() { sessionMetricsRef.current = sessionMetrics }, [sessionMetrics]) + useEffect(() => { + isExportRouteRef.current = isExportRoute + }, [isExportRoute]) + useEffect(() => { if (persistSessionCountTimerRef.current) { window.clearTimeout(persistSessionCountTimerRef.current) @@ -452,9 +463,10 @@ function ExportPage() { }, [location.state]) useEffect(() => { + if (!isExportRoute) return const timer = setInterval(() => setNowTick(Date.now()), 60 * 1000) return () => clearInterval(timer) - }, []) + }, [isExportRoute]) const loadBaseConfig = useCallback(async () => { setIsBaseConfigLoading(true) @@ -581,6 +593,8 @@ function ExportPage() { setIsSessionEnriching(false) loadingMessageCountsRef.current.clear() loadingMetricsRef.current.clear() + pendingMessageCountsRef.current.clear() + pendingMetricsRef.current.clear() sessionMetricsRef.current = {} setSessionMetrics({}) @@ -632,6 +646,7 @@ function ExportPage() { setIsSessionEnriching(true) void (async () => { try { + if (isStale()) return const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) if (isStale()) return @@ -650,6 +665,7 @@ function ExportPage() { let extraContactMap: Record = {} if (needsEnrichment.length > 0) { + if (isStale()) return const enrichResult = await withTimeout( window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment), CONTACT_ENRICH_TIMEOUT_MS @@ -714,6 +730,8 @@ function ExportPage() { sessionLoadTokenRef.current = Date.now() loadingMessageCountsRef.current.clear() loadingMetricsRef.current.clear() + pendingMessageCountsRef.current.clear() + pendingMetricsRef.current.clear() setIsSessionEnriching(false) }, [isExportRoute]) @@ -769,38 +787,50 @@ function ExportPage() { }, [visibleSessions]) const ensureSessionMessageCounts = useCallback(async (targetSessions: SessionRow[]) => { - if (!isExportRoute) return - const loadTokenAtStart = sessionLoadTokenRef.current + if (!isExportRouteRef.current) return const currentCounts = sessionMessageCountsRef.current - const pending = targetSessions.filter( - session => currentCounts[session.username] === undefined && !loadingMessageCountsRef.current.has(session.username) - ) - if (pending.length === 0) return - for (const session of pending) { - loadingMessageCountsRef.current.add(session.username) + for (const session of targetSessions) { + if (currentCounts[session.username] !== undefined) continue + if (loadingMessageCountsRef.current.has(session.username)) continue + pendingMessageCountsRef.current.add(session.username) } + if (pendingMessageCountsRef.current.size === 0 || messageCountPumpRunningRef.current) return + + messageCountPumpRunningRef.current = true + const loadTokenAtStart = sessionLoadTokenRef.current try { - const batchSize = pending.length > 260 ? 260 : pending.length - for (let i = 0; i < pending.length; i += batchSize) { - if (loadTokenAtStart !== sessionLoadTokenRef.current) return - const chunk = pending.slice(i, i + batchSize) - const ids = chunk.map(session => session.username) + while (isExportRouteRef.current && loadTokenAtStart === sessionLoadTokenRef.current) { + const ids = Array.from(pendingMessageCountsRef.current).slice(0, MESSAGE_COUNT_REQUEST_BATCH) + if (ids.length === 0) break + + for (const id of ids) { + pendingMessageCountsRef.current.delete(id) + loadingMessageCountsRef.current.add(id) + } + const chunkUpdates: Record = {} try { const result = await withTimeout(window.electronAPI.chat.getSessionMessageCounts(ids), 10000) if (!result) { - continue - } - for (const session of chunk) { - const value = result?.success && result.counts ? result.counts[session.username] : undefined - chunkUpdates[session.username] = typeof value === 'number' ? value : 0 + for (const id of ids) { + chunkUpdates[id] = 0 + } + } else { + for (const id of ids) { + const value = result?.success && result.counts ? result.counts[id] : undefined + chunkUpdates[id] = typeof value === 'number' ? value : 0 + } } } catch (error) { console.error('加载会话总消息数失败:', error) - for (const session of chunk) { - chunkUpdates[session.username] = 0 + for (const id of ids) { + chunkUpdates[id] = 0 + } + } finally { + for (const id of ids) { + loadingMessageCountsRef.current.delete(id) } } @@ -809,72 +839,95 @@ function ExportPage() { } } } finally { - for (const session of pending) { - loadingMessageCountsRef.current.delete(session.username) - } + messageCountPumpRunningRef.current = false } - }, [isExportRoute]) + }, []) const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { - if (!isExportRoute) return - const loadTokenAtStart = sessionLoadTokenRef.current + if (!isExportRouteRef.current) return const currentMetrics = sessionMetricsRef.current - const pending = targetSessions.filter(session => !currentMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) - if (pending.length === 0) return - - const updates: Record = {} - for (const session of pending) { - loadingMetricsRef.current.add(session.username) + for (const session of targetSessions) { + if (currentMetrics[session.username]) continue + if (loadingMetricsRef.current.has(session.username)) continue + pendingMetricsRef.current.add(session.username) } + if (pendingMetricsRef.current.size === 0 || metricsPumpRunningRef.current) return + + metricsPumpRunningRef.current = true + const loadTokenAtStart = sessionLoadTokenRef.current try { - const batchSize = 80 - for (let i = 0; i < pending.length; i += batchSize) { - if (loadTokenAtStart !== sessionLoadTokenRef.current) return - const chunk = pending.slice(i, i + batchSize) - const ids = chunk.map(session => session.username) + while (isExportRouteRef.current && loadTokenAtStart === sessionLoadTokenRef.current) { + const ids = Array.from(pendingMetricsRef.current).slice(0, METRICS_REQUEST_BATCH) + if (ids.length === 0) break + + for (const id of ids) { + pendingMetricsRef.current.delete(id) + loadingMetricsRef.current.add(id) + } + + const updates: Record = {} try { const statsResult = await window.electronAPI.chat.getExportSessionStats(ids) if (!statsResult.success || !statsResult.data) { console.error('加载会话统计失败:', statsResult.error || '未知错误') - continue - } - - for (const session of chunk) { - const raw = statsResult.data[session.username] - // 成功响应但无明细时按 0 回填,避免该行反复重试导致滚动抖动。 - updates[session.username] = { - totalMessages: raw?.totalMessages ?? 0, - voiceMessages: raw?.voiceMessages ?? 0, - imageMessages: raw?.imageMessages ?? 0, - videoMessages: raw?.videoMessages ?? 0, - emojiMessages: raw?.emojiMessages ?? 0, - privateMutualGroups: raw?.privateMutualGroups, - groupMemberCount: raw?.groupMemberCount, - groupMyMessages: raw?.groupMyMessages, - groupActiveSpeakers: raw?.groupActiveSpeakers, - groupMutualFriends: raw?.groupMutualFriends, - firstTimestamp: raw?.firstTimestamp, - lastTimestamp: raw?.lastTimestamp + for (const id of ids) { + updates[id] = { + totalMessages: 0, + voiceMessages: 0, + imageMessages: 0, + videoMessages: 0, + emojiMessages: 0 + } + } + } else { + for (const id of ids) { + const raw = statsResult.data[id] + // 成功响应但无明细时按 0 回填,避免该行反复重试导致滚动抖动。 + updates[id] = { + totalMessages: raw?.totalMessages ?? 0, + voiceMessages: raw?.voiceMessages ?? 0, + imageMessages: raw?.imageMessages ?? 0, + videoMessages: raw?.videoMessages ?? 0, + emojiMessages: raw?.emojiMessages ?? 0, + privateMutualGroups: raw?.privateMutualGroups, + groupMemberCount: raw?.groupMemberCount, + groupMyMessages: raw?.groupMyMessages, + groupActiveSpeakers: raw?.groupActiveSpeakers, + groupMutualFriends: raw?.groupMutualFriends, + firstTimestamp: raw?.firstTimestamp, + lastTimestamp: raw?.lastTimestamp + } } } } catch (error) { console.error('加载会话统计分批失败:', error) + for (const id of ids) { + updates[id] = { + totalMessages: 0, + voiceMessages: 0, + imageMessages: 0, + videoMessages: 0, + emojiMessages: 0 + } + } + } finally { + for (const id of ids) { + loadingMetricsRef.current.delete(id) + } + } + + if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(updates).length > 0) { + setSessionMetrics(prev => ({ ...prev, ...updates })) } } } catch (error) { console.error('加载会话统计失败:', error) } finally { - for (const session of pending) { - loadingMetricsRef.current.delete(session.username) - } + metricsPumpRunningRef.current = false } - - if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(updates).length > 0) { - setSessionMetrics(prev => ({ ...prev, ...updates })) - } - }, [isExportRoute]) + }, []) useEffect(() => { if (!isExportRoute) return @@ -1660,9 +1713,72 @@ function ExportPage() { await configService.setExportWriteLayout(value) }} /> + +
+ 任务中心 +
+
+ 进行中 {taskRunningCount} + 排队 {taskQueuedCount} + 总计 {tasks.length} +
+ +
+
+ {isTaskCenterExpanded && ( +
+ {tasks.length === 0 ? ( +
暂无任务。点击会话导出或卡片导出后会在这里创建任务。
+ ) : ( +
+ {tasks.map(task => ( +
+
+
{task.title}
+
+ {task.status === 'queued' ? '排队中' : task.status === 'running' ? '进行中' : task.status === 'success' ? '已完成' : '失败'} + {new Date(task.createdAt).toLocaleString('zh-CN')} +
+ {task.status === 'running' && ( + <> +
+
0 ? (task.progress.current / task.progress.total) * 100 : 0}%` }} + /> +
+
+ {task.progress.total > 0 + ? `${task.progress.current} / ${task.progress.total}` + : '处理中'} + {task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''} +
+ + )} + {task.status === 'error' &&
{task.error || '任务失败'}
} +
+
+ +
+
+ ))} +
+ )} +
+ )} +
{contentCards.map(card => { const Icon = card.icon @@ -1705,65 +1821,6 @@ function ExportPage() { })}
-
-
-
任务中心
-
- 进行中 {taskRunningCount} - 排队 {taskQueuedCount} - 总计 {tasks.length} -
- -
- - {isTaskCenterExpanded && (tasks.length === 0 ? ( -
暂无任务。点击会话导出或卡片导出后会在这里创建任务。
- ) : ( -
- {tasks.map(task => ( -
-
-
{task.title}
-
- {task.status === 'queued' ? '排队中' : task.status === 'running' ? '进行中' : task.status === 'success' ? '已完成' : '失败'} - {new Date(task.createdAt).toLocaleString('zh-CN')} -
- {task.status === 'running' && ( - <> -
-
0 ? (task.progress.current / task.progress.total) * 100 : 0}%` }} - /> -
-
- {task.progress.total > 0 - ? `${task.progress.current} / ${task.progress.total}` - : '处理中'} - {task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''} -
- - )} - {task.status === 'error' &&
{task.error || '任务失败'}
} -
-
- -
-
- ))} -
- ))} -
-
From b3700c3a4c1703515be72da8fac60fdae2ff5189 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 11:12:09 +0800 Subject: [PATCH 033/162] refactor(export): remove session stats columns and background counting --- src/pages/ExportPage.tsx | 430 +-------------------------------------- 1 file changed, 6 insertions(+), 424 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index f6177ed..6991981 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -59,21 +59,6 @@ interface SessionRow extends AppChatSession { wechatId?: string } -interface SessionMetrics { - totalMessages?: number - voiceMessages?: number - imageMessages?: number - videoMessages?: number - emojiMessages?: number - privateMutualGroups?: number - groupMemberCount?: number - groupMyMessages?: number - groupActiveSpeakers?: number - groupMutualFriends?: number - firstTimestamp?: number - lastTimestamp?: number -} - interface TaskProgress { current: number total: number @@ -231,26 +216,8 @@ const getAvatarLetter = (name: string): string => { return [...name][0] || '?' } -const valueOrDash = (value?: number): string => { - if (value === undefined || value === null) return '--' - return value.toLocaleString() -} - -const timestampOrDash = (timestamp?: number): string => { - if (!timestamp) return '--' - return formatAbsoluteDate(timestamp * 1000) -} - const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -const MESSAGE_COUNT_VIEWPORT_PREFETCH = 90 -const MESSAGE_COUNT_ACTIVE_TAB_WARMUP_LIMIT = 240 -const MESSAGE_COUNT_REQUEST_BATCH = 120 -const METRICS_VIEWPORT_PREFETCH = 60 -const METRICS_REQUEST_BATCH = 24 -const METRICS_BACKGROUND_BATCH = 20 -const METRICS_BACKGROUND_INTERVAL_MS = 500 const CONTACT_ENRICH_TIMEOUT_MS = 7000 -const EXPORT_SESSION_COUNT_CACHE_STALE_MS = 48 * 60 * 60 * 1000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { @@ -333,8 +300,6 @@ function ExportPage() { const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true) const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false) const [sessions, setSessions] = useState([]) - const [sessionMessageCounts, setSessionMessageCounts] = useState>({}) - const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') const [activeTab, setActiveTab] = useState('private') const [selectedSessions, setSelectedSessions] = useState>(new Set()) @@ -390,21 +355,10 @@ function ExportPage() { const runningTaskIdRef = useRef(null) const tasksRef = useRef([]) const hasSeededSnsStatsRef = useRef(false) - const sessionMessageCountsRef = useRef>({}) - const sessionMetricsRef = useRef>({}) const sessionLoadTokenRef = useRef(0) - const loadingMessageCountsRef = useRef>(new Set()) - const loadingMetricsRef = useRef>(new Set()) - const pendingMessageCountsRef = useRef>(new Set()) - const pendingMetricsRef = useRef>(new Set()) - const messageCountPumpRunningRef = useRef(false) - const metricsPumpRunningRef = useRef(false) - const isExportRouteRef = useRef(isExportRoute) const preselectAppliedRef = useRef(false) - const visibleSessionsRef = useRef([]) const exportCacheScopeRef = useRef('default') const exportCacheScopeReadyRef = useRef(false) - const persistSessionCountTimerRef = useRef(null) useEffect(() => { tasksRef.current = tasks @@ -414,42 +368,6 @@ function ExportPage() { hasSeededSnsStatsRef.current = hasSeededSnsStats }, [hasSeededSnsStats]) - useEffect(() => { - sessionMessageCountsRef.current = sessionMessageCounts - }, [sessionMessageCounts]) - - useEffect(() => { - sessionMetricsRef.current = sessionMetrics - }, [sessionMetrics]) - - useEffect(() => { - isExportRouteRef.current = isExportRoute - }, [isExportRoute]) - - useEffect(() => { - if (persistSessionCountTimerRef.current) { - window.clearTimeout(persistSessionCountTimerRef.current) - persistSessionCountTimerRef.current = null - } - - if (isBaseConfigLoading || !exportCacheScopeReadyRef.current) return - - const countSize = Object.keys(sessionMessageCounts).length - if (countSize === 0) return - - persistSessionCountTimerRef.current = window.setTimeout(() => { - void configService.setExportSessionMessageCountCache(exportCacheScopeRef.current, sessionMessageCounts) - persistSessionCountTimerRef.current = null - }, 900) - - return () => { - if (persistSessionCountTimerRef.current) { - window.clearTimeout(persistSessionCountTimerRef.current) - persistSessionCountTimerRef.current = null - } - } - }, [sessionMessageCounts, isBaseConfigLoading]) - const preselectSessionIds = useMemo(() => { const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null const rawList = Array.isArray(state?.preselectSessionIds) @@ -490,10 +408,7 @@ function ExportPage() { exportCacheScopeRef.current = exportCacheScope exportCacheScopeReadyRef.current = true - const [cachedSessionCountMap, cachedSnsStats] = await Promise.all([ - configService.getExportSessionMessageCountCache(exportCacheScope), - configService.getExportSnsStatsCache(exportCacheScope) - ]) + const cachedSnsStats = await configService.getExportSnsStatsCache(exportCacheScope) if (savedPath) { setExportFolder(savedPath) @@ -507,10 +422,6 @@ function ExportPage() { setLastExportByContent(savedContentMap) setLastSnsExportPostCount(savedSnsPostCount) - if (cachedSessionCountMap && Date.now() - cachedSessionCountMap.updatedAt <= EXPORT_SESSION_COUNT_CACHE_STALE_MS) { - setSessionMessageCounts(cachedSessionCountMap.counts || {}) - } - if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) { setSnsStats({ totalPosts: cachedSnsStats.totalPosts || 0, @@ -591,12 +502,6 @@ function ExportPage() { sessionLoadTokenRef.current = loadToken setIsLoading(true) setIsSessionEnriching(false) - loadingMessageCountsRef.current.clear() - loadingMetricsRef.current.clear() - pendingMessageCountsRef.current.clear() - pendingMetricsRef.current.clear() - sessionMetricsRef.current = {} - setSessionMetrics({}) const isStale = () => sessionLoadTokenRef.current !== loadToken @@ -626,20 +531,6 @@ function ExportPage() { if (isStale()) return setSessions(baseSessions) - setSessionMessageCounts(prev => { - const next: Record = {} - for (const session of baseSessions) { - const count = prev[session.username] - if (typeof count === 'number') { - next[session.username] = count - continue - } - if (typeof session.messageCountHint === 'number' && Number.isFinite(session.messageCountHint) && session.messageCountHint >= 0) { - next[session.username] = Math.floor(session.messageCountHint) - } - } - return next - }) setIsLoading(false) // 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。 @@ -726,12 +617,8 @@ function ExportPage() { useEffect(() => { if (isExportRoute) return - // 导出页隐藏时停止后台统计请求,避免与通讯录页面查询抢占。 + // 导出页隐藏时停止后台联系人补齐请求,避免与通讯录页面查询抢占。 sessionLoadTokenRef.current = Date.now() - loadingMessageCountsRef.current.clear() - loadingMetricsRef.current.clear() - pendingMessageCountsRef.current.clear() - pendingMetricsRef.current.clear() setIsSessionEnriching(false) }, [isExportRoute]) @@ -764,227 +651,11 @@ function ExportPage() { ) }) .sort((a, b) => { - const totalA = sessionMessageCounts[a.username] - const totalB = sessionMessageCounts[b.username] - const hasTotalA = typeof totalA === 'number' - const hasTotalB = typeof totalB === 'number' - - if (hasTotalA && hasTotalB && totalB !== totalA) { - return totalB - totalA - } - if (hasTotalA !== hasTotalB) { - return hasTotalA ? -1 : 1 - } - - const latestA = sessionMetrics[a.username]?.lastTimestamp ?? a.lastTimestamp ?? 0 - const latestB = sessionMetrics[b.username]?.lastTimestamp ?? b.lastTimestamp ?? 0 + const latestA = a.sortTimestamp || a.lastTimestamp || 0 + const latestB = b.sortTimestamp || b.lastTimestamp || 0 return latestB - latestA }) - }, [sessions, activeTab, searchKeyword, sessionMessageCounts, sessionMetrics]) - - useEffect(() => { - visibleSessionsRef.current = visibleSessions - }, [visibleSessions]) - - const ensureSessionMessageCounts = useCallback(async (targetSessions: SessionRow[]) => { - if (!isExportRouteRef.current) return - const currentCounts = sessionMessageCountsRef.current - for (const session of targetSessions) { - if (currentCounts[session.username] !== undefined) continue - if (loadingMessageCountsRef.current.has(session.username)) continue - pendingMessageCountsRef.current.add(session.username) - } - if (pendingMessageCountsRef.current.size === 0 || messageCountPumpRunningRef.current) return - - messageCountPumpRunningRef.current = true - const loadTokenAtStart = sessionLoadTokenRef.current - - try { - while (isExportRouteRef.current && loadTokenAtStart === sessionLoadTokenRef.current) { - const ids = Array.from(pendingMessageCountsRef.current).slice(0, MESSAGE_COUNT_REQUEST_BATCH) - if (ids.length === 0) break - - for (const id of ids) { - pendingMessageCountsRef.current.delete(id) - loadingMessageCountsRef.current.add(id) - } - - const chunkUpdates: Record = {} - - try { - const result = await withTimeout(window.electronAPI.chat.getSessionMessageCounts(ids), 10000) - if (!result) { - for (const id of ids) { - chunkUpdates[id] = 0 - } - } else { - for (const id of ids) { - const value = result?.success && result.counts ? result.counts[id] : undefined - chunkUpdates[id] = typeof value === 'number' ? value : 0 - } - } - } catch (error) { - console.error('加载会话总消息数失败:', error) - for (const id of ids) { - chunkUpdates[id] = 0 - } - } finally { - for (const id of ids) { - loadingMessageCountsRef.current.delete(id) - } - } - - if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(chunkUpdates).length > 0) { - setSessionMessageCounts(prev => ({ ...prev, ...chunkUpdates })) - } - } - } finally { - messageCountPumpRunningRef.current = false - } - }, []) - - const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { - if (!isExportRouteRef.current) return - const currentMetrics = sessionMetricsRef.current - for (const session of targetSessions) { - if (currentMetrics[session.username]) continue - if (loadingMetricsRef.current.has(session.username)) continue - pendingMetricsRef.current.add(session.username) - } - if (pendingMetricsRef.current.size === 0 || metricsPumpRunningRef.current) return - - metricsPumpRunningRef.current = true - const loadTokenAtStart = sessionLoadTokenRef.current - - try { - while (isExportRouteRef.current && loadTokenAtStart === sessionLoadTokenRef.current) { - const ids = Array.from(pendingMetricsRef.current).slice(0, METRICS_REQUEST_BATCH) - if (ids.length === 0) break - - for (const id of ids) { - pendingMetricsRef.current.delete(id) - loadingMetricsRef.current.add(id) - } - - const updates: Record = {} - - try { - const statsResult = await window.electronAPI.chat.getExportSessionStats(ids) - if (!statsResult.success || !statsResult.data) { - console.error('加载会话统计失败:', statsResult.error || '未知错误') - for (const id of ids) { - updates[id] = { - totalMessages: 0, - voiceMessages: 0, - imageMessages: 0, - videoMessages: 0, - emojiMessages: 0 - } - } - } else { - for (const id of ids) { - const raw = statsResult.data[id] - // 成功响应但无明细时按 0 回填,避免该行反复重试导致滚动抖动。 - updates[id] = { - totalMessages: raw?.totalMessages ?? 0, - voiceMessages: raw?.voiceMessages ?? 0, - imageMessages: raw?.imageMessages ?? 0, - videoMessages: raw?.videoMessages ?? 0, - emojiMessages: raw?.emojiMessages ?? 0, - privateMutualGroups: raw?.privateMutualGroups, - groupMemberCount: raw?.groupMemberCount, - groupMyMessages: raw?.groupMyMessages, - groupActiveSpeakers: raw?.groupActiveSpeakers, - groupMutualFriends: raw?.groupMutualFriends, - firstTimestamp: raw?.firstTimestamp, - lastTimestamp: raw?.lastTimestamp - } - } - } - } catch (error) { - console.error('加载会话统计分批失败:', error) - for (const id of ids) { - updates[id] = { - totalMessages: 0, - voiceMessages: 0, - imageMessages: 0, - videoMessages: 0, - emojiMessages: 0 - } - } - } finally { - for (const id of ids) { - loadingMetricsRef.current.delete(id) - } - } - - if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(updates).length > 0) { - setSessionMetrics(prev => ({ ...prev, ...updates })) - } - } - } catch (error) { - console.error('加载会话统计失败:', error) - } finally { - metricsPumpRunningRef.current = false - } - }, []) - - useEffect(() => { - if (!isExportRoute) return - const targets = visibleSessions.slice(0, MESSAGE_COUNT_VIEWPORT_PREFETCH) - void ensureSessionMessageCounts(targets) - }, [isExportRoute, visibleSessions, ensureSessionMessageCounts]) - - useEffect(() => { - if (!isExportRoute) return - if (sessions.length === 0) return - const activeTabTargets = sessions - .filter(session => session.kind === activeTab) - .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) - .slice(0, MESSAGE_COUNT_ACTIVE_TAB_WARMUP_LIMIT) - if (activeTabTargets.length === 0) return - void ensureSessionMessageCounts(activeTabTargets) - }, [isExportRoute, sessions, activeTab, ensureSessionMessageCounts]) - - useEffect(() => { - if (!isExportRoute) return - const targets = visibleSessions.slice(0, METRICS_VIEWPORT_PREFETCH) - void ensureSessionMetrics(targets) - }, [isExportRoute, visibleSessions, ensureSessionMetrics]) - - const handleTableRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { - if (!isExportRoute) return - const current = visibleSessionsRef.current - if (current.length === 0) return - const prefetch = Math.max(MESSAGE_COUNT_VIEWPORT_PREFETCH, METRICS_VIEWPORT_PREFETCH) - const start = Math.max(0, range.startIndex - prefetch) - const end = Math.min(current.length - 1, range.endIndex + prefetch) - if (end < start) return - const rangeSessions = current.slice(start, end + 1) - void ensureSessionMessageCounts(rangeSessions) - void ensureSessionMetrics(rangeSessions) - }, [isExportRoute, ensureSessionMessageCounts, ensureSessionMetrics]) - - useEffect(() => { - if (!isExportRoute) return - if (sessions.length === 0) return - const prioritySessions = [ - ...sessions.filter(session => session.kind === activeTab), - ...sessions.filter(session => session.kind !== activeTab) - ] - let cursor = 0 - const timer = window.setInterval(() => { - if (cursor >= prioritySessions.length) { - window.clearInterval(timer) - return - } - const chunk = prioritySessions.slice(cursor, cursor + METRICS_BACKGROUND_BATCH) - cursor += METRICS_BACKGROUND_BATCH - void ensureSessionMetrics(chunk) - }, METRICS_BACKGROUND_INTERVAL_MS) - - return () => window.clearInterval(timer) - }, [isExportRoute, sessions, activeTab, ensureSessionMetrics]) + }, [sessions, activeTab, searchKeyword]) const selectedCount = selectedSessions.size @@ -1519,64 +1190,16 @@ function ExportPage() { } const renderTableHeader = () => { - if (activeTab === 'private' || activeTab === 'former_friend') { - return ( - - 选择 - 会话名(头像/昵称/微信号) - 总消息 - 语音 - 图片 - 视频 - 表情包 - 共同群聊数 - 最早时间 - 最新时间 - 操作 - - ) - } - - if (activeTab === 'group') { - return ( - - 选择 - 会话名(群头像/群名称/群ID) - 总消息 - 语音 - 图片 - 视频 - 表情包 - 我发的消息数 - 群人数 - 群发言人数 - 群共同好友数 - 最早时间 - 最新时间 - 操作 - - ) - } - return ( 选择 会话名(头像/名称/微信号) - 总消息 - 语音 - 图片 - 视频 - 表情包 - 最早时间 - 最新时间 操作 ) } const renderRowCells = (session: SessionRow) => { - const metrics = sessionMetrics[session.username] - const totalMessages = sessionMessageCounts[session.username] const checked = selectedSessions.has(session.username) return ( @@ -1592,46 +1215,6 @@ function ExportPage() { {renderSessionName(session)} - - {typeof totalMessages === 'number' - ? totalMessages.toLocaleString() - : ( - - 统计中 - - )} - - {valueOrDash(metrics?.voiceMessages)} - {valueOrDash(metrics?.imageMessages)} - {valueOrDash(metrics?.videoMessages)} - {valueOrDash(metrics?.emojiMessages)} - - {(activeTab === 'private' || activeTab === 'former_friend') && ( - <> - {valueOrDash(metrics?.privateMutualGroups)} - {timestampOrDash(metrics?.firstTimestamp)} - {timestampOrDash(metrics?.lastTimestamp)} - - )} - - {activeTab === 'group' && ( - <> - {valueOrDash(metrics?.groupMyMessages)} - {valueOrDash(metrics?.groupMemberCount)} - {valueOrDash(metrics?.groupActiveSpeakers)} - {valueOrDash(metrics?.groupMutualFriends)} - {timestampOrDash(metrics?.firstTimestamp)} - {timestampOrDash(metrics?.lastTimestamp)} - - )} - - {activeTab === 'official' && ( - <> - {timestampOrDash(metrics?.firstTimestamp)} - {timestampOrDash(metrics?.lastTimestamp)} - - )} - {renderActionCell(session)} ) @@ -1872,7 +1455,7 @@ function ExportPage() { {!showInitialSkeleton && (isLoading || isSessionEnriching) && (
- {isLoading ? '导出板块数据加载中…' : '正在补充头像和统计…'} + {isLoading ? '导出板块数据加载中…' : '正在补充头像…'}
)} @@ -1898,7 +1481,6 @@ function ExportPage() { data={visibleSessions} fixedHeaderContent={renderTableHeader} computeItemKey={(_, session) => session.username} - rangeChanged={handleTableRangeChanged} itemContent={(_, session) => renderRowCells(session)} overscan={420} /> From faeda030e9407be7b7b034bd1e5f205a5985fa26 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 11:15:24 +0800 Subject: [PATCH 034/162] feat(contacts): persist avatar cache with incremental refresh --- src/pages/ContactsPage.tsx | 176 ++++++++++++++++++++++++++++++++++--- src/services/config.ts | 100 +++++++++++++++++++++ 2 files changed, 265 insertions(+), 11 deletions(-) diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 6a87372..2d489f9 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -25,6 +25,7 @@ const SEARCH_DEBOUNCE_MS = 120 const VIRTUAL_ROW_HEIGHT = 76 const VIRTUAL_OVERSCAN = 10 const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000 +const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 interface ContactsLoadSession { requestId: string @@ -91,8 +92,10 @@ function ContactsPage() { const [diagnosticTick, setDiagnosticTick] = useState(Date.now()) const [contactsDataSource, setContactsDataSource] = useState(null) const [contactsUpdatedAt, setContactsUpdatedAt] = useState(null) + const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState(null) const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) const contactsCacheScopeRef = useRef('default') + const contactsAvatarCacheRef = useRef>({}) const ensureContactsCacheScope = useCallback(async () => { if (contactsCacheScopeRef.current !== 'default') { @@ -130,6 +133,85 @@ function ContactsPage() { contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs }, [contactsLoadTimeoutMs]) + const mergeAvatarCacheIntoContacts = useCallback((sourceContacts: ContactInfo[]): ContactInfo[] => { + const avatarCache = contactsAvatarCacheRef.current + if (!sourceContacts.length || Object.keys(avatarCache).length === 0) { + return sourceContacts + } + let changed = false + const merged = sourceContacts.map((contact) => { + const cachedAvatar = avatarCache[contact.username]?.avatarUrl + if (!cachedAvatar || contact.avatarUrl) { + return contact + } + changed = true + return { + ...contact, + avatarUrl: cachedAvatar + } + }) + return changed ? merged : sourceContacts + }, []) + + const upsertAvatarCacheFromContacts = useCallback(( + scopeKey: string, + sourceContacts: ContactInfo[], + options?: { prune?: boolean; markCheckedUsernames?: string[] } + ) => { + if (!scopeKey) return + const nextCache = { ...contactsAvatarCacheRef.current } + const now = Date.now() + const markCheckedSet = new Set((options?.markCheckedUsernames || []).filter(Boolean)) + const usernamesInSource = new Set() + let changed = false + + for (const contact of sourceContacts) { + const username = String(contact.username || '').trim() + if (!username) continue + usernamesInSource.add(username) + const prev = nextCache[username] + const avatarUrl = String(contact.avatarUrl || '').trim() + if (!avatarUrl) continue + const updatedAt = !prev || prev.avatarUrl !== avatarUrl ? now : prev.updatedAt + const checkedAt = markCheckedSet.has(username) ? now : (prev?.checkedAt || now) + if (!prev || prev.avatarUrl !== avatarUrl || prev.updatedAt !== updatedAt || prev.checkedAt !== checkedAt) { + nextCache[username] = { + avatarUrl, + updatedAt, + checkedAt + } + changed = true + } + } + + for (const username of markCheckedSet) { + const prev = nextCache[username] + if (!prev) continue + if (prev.checkedAt !== now) { + nextCache[username] = { + ...prev, + checkedAt: now + } + changed = true + } + } + + if (options?.prune) { + for (const username of Object.keys(nextCache)) { + if (usernamesInSource.has(username)) continue + delete nextCache[username] + changed = true + } + } + + if (!changed) return + contactsAvatarCacheRef.current = nextCache + setAvatarCacheUpdatedAt(now) + void configService.setContactsAvatarCache(scopeKey, nextCache).catch((error) => { + console.error('写入通讯录头像缓存失败:', error) + }) + }, []) + const applyEnrichedContacts = useCallback((enrichedMap: Record) => { if (!enrichedMap || Object.keys(enrichedMap).length === 0) return @@ -170,8 +252,34 @@ function ContactsPage() { }) }, []) - const enrichContactsInBackground = useCallback(async (sourceContacts: ContactInfo[], loadVersion: number) => { - const usernames = sourceContacts.map(contact => contact.username).filter(Boolean) + const enrichContactsInBackground = useCallback(async ( + sourceContacts: ContactInfo[], + loadVersion: number, + scopeKey: string + ) => { + const sourceByUsername = new Map() + for (const contact of sourceContacts) { + if (!contact.username) continue + sourceByUsername.set(contact.username, contact) + } + const now = Date.now() + const usernames = sourceContacts + .map(contact => contact.username) + .filter(Boolean) + .filter((username) => { + const currentContact = sourceByUsername.get(username) + if (!currentContact) return false + const cacheEntry = contactsAvatarCacheRef.current[username] + if (!cacheEntry || !cacheEntry.avatarUrl) { + return !currentContact.avatarUrl + } + if (currentContact.avatarUrl && currentContact.avatarUrl !== cacheEntry.avatarUrl) { + return true + } + const checkedAt = cacheEntry.checkedAt || 0 + return now - checkedAt >= AVATAR_RECHECK_INTERVAL_MS + }) + const total = usernames.length setAvatarEnrichProgress({ loaded: 0, @@ -190,7 +298,22 @@ function ContactsPage() { if (loadVersionRef.current !== loadVersion) return if (avatarResult.success && avatarResult.contacts) { applyEnrichedContacts(avatarResult.contacts) + for (const [username, enriched] of Object.entries(avatarResult.contacts)) { + const prev = sourceByUsername.get(username) + if (!prev) continue + sourceByUsername.set(username, { + ...prev, + displayName: enriched.displayName || prev.displayName, + avatarUrl: enriched.avatarUrl || prev.avatarUrl + }) + } } + const batchContacts = batch + .map(username => sourceByUsername.get(username)) + .filter((contact): contact is ContactInfo => Boolean(contact)) + upsertAvatarCacheFromContacts(scopeKey, batchContacts, { + markCheckedUsernames: batch + }) } catch (e) { console.error('分批补全头像失败:', e) } @@ -204,7 +327,7 @@ function ContactsPage() { await new Promise(resolve => setTimeout(resolve, 0)) } - }, [applyEnrichedContacts]) + }, [applyEnrichedContacts, upsertAvatarCacheFromContacts]) // 加载通讯录 const loadContacts = useCallback(async (options?: { scopeKey?: string }) => { @@ -256,21 +379,23 @@ function ContactsPage() { window.clearTimeout(loadTimeoutTimerRef.current) loadTimeoutTimerRef.current = null } - setContacts(contactsResult.contacts) - syncContactTypeCounts(contactsResult.contacts) + const contactsWithAvatarCache = mergeAvatarCacheIntoContacts(contactsResult.contacts) + setContacts(contactsWithAvatarCache) + syncContactTypeCounts(contactsWithAvatarCache) setSelectedUsernames(new Set()) setSelectedContact(prev => { if (!prev) return prev - return contactsResult.contacts!.find(contact => contact.username === prev.username) || null + return contactsWithAvatarCache.find(contact => contact.username === prev.username) || null }) const now = Date.now() setContactsDataSource('network') setContactsUpdatedAt(now) setLoadIssue(null) setIsLoading(false) + upsertAvatarCacheFromContacts(scopeKey, contactsWithAvatarCache, { prune: true }) void configService.setContactsListCache( scopeKey, - contactsResult.contacts.map(contact => ({ + contactsWithAvatarCache.map(contact => ({ username: contact.username, displayName: contact.displayName, remark: contact.remark, @@ -280,7 +405,7 @@ function ContactsPage() { ).catch((error) => { console.error('写入通讯录缓存失败:', error) }) - void enrichContactsInBackground(contactsResult.contacts, loadVersion) + void enrichContactsInBackground(contactsWithAvatarCache, loadVersion, scopeKey) return } const elapsedMs = Date.now() - startedAt @@ -314,7 +439,13 @@ function ContactsPage() { setIsLoading(false) } } - }, [ensureContactsCacheScope, enrichContactsInBackground, syncContactTypeCounts]) + }, [ + ensureContactsCacheScope, + enrichContactsInBackground, + mergeAvatarCacheIntoContacts, + syncContactTypeCounts, + upsertAvatarCacheFromContacts + ]) useEffect(() => { let cancelled = false @@ -322,11 +453,17 @@ function ContactsPage() { const scopeKey = await ensureContactsCacheScope() if (cancelled) return try { - const cacheItem = await configService.getContactsListCache(scopeKey) + const [cacheItem, avatarCacheItem] = await Promise.all([ + configService.getContactsListCache(scopeKey), + configService.getContactsAvatarCache(scopeKey) + ]) + const avatarCacheMap = avatarCacheItem?.avatars || {} + contactsAvatarCacheRef.current = avatarCacheMap + setAvatarCacheUpdatedAt(avatarCacheItem?.updatedAt || null) if (!cancelled && cacheItem && Array.isArray(cacheItem.contacts) && cacheItem.contacts.length > 0) { const cachedContacts: ContactInfo[] = cacheItem.contacts.map(contact => ({ ...contact, - avatarUrl: undefined + avatarUrl: avatarCacheMap[contact.username]?.avatarUrl })) setContacts(cachedContacts) syncContactTypeCounts(cachedContacts) @@ -503,6 +640,17 @@ function ContactsPage() { return new Date(contactsUpdatedAt).toLocaleString() }, [contactsUpdatedAt]) + const avatarCachedCount = useMemo(() => { + return contacts.reduce((count, contact) => ( + contact.avatarUrl ? count + 1 : count + ), 0) + }, [contacts]) + + const avatarCacheUpdatedAtLabel = useMemo(() => { + if (!avatarCacheUpdatedAt) return '' + return new Date(avatarCacheUpdatedAt).toLocaleString() + }, [avatarCacheUpdatedAt]) + const toggleContactSelected = (username: string, checked: boolean) => { setSelectedUsernames(prev => { const next = new Set(prev) @@ -686,6 +834,12 @@ function ContactsPage() { {contactsDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {contactsUpdatedAtLabel} )} + {contacts.length > 0 && ( + + 头像缓存 {avatarCachedCount}/{contacts.length} + {avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''} + + )} {isLoading && contacts.length > 0 && ( 后台同步中... )} diff --git a/src/services/config.ts b/src/services/config.ts index 3ea4652..b34f71a 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -40,6 +40,7 @@ export const CONFIG_KEYS = { EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap', CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs', CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap', + CONTACTS_AVATAR_CACHE_MAP: 'contactsAvatarCacheMap', // 安全 AUTH_ENABLED: 'authEnabled', @@ -477,6 +478,17 @@ export interface ContactsListCacheItem { contacts: ContactsListCacheContact[] } +export interface ContactsAvatarCacheEntry { + avatarUrl: string + updatedAt: number + checkedAt: number +} + +export interface ContactsAvatarCacheItem { + updatedAt: number + avatars: Record +} + export async function getExportSessionMessageCountCache(scopeKey: string): Promise { if (!scopeKey) return null const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP) @@ -650,6 +662,94 @@ export async function setContactsListCache(scopeKey: string, contacts: ContactsL await config.set(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP, map) } +export async function getContactsAvatarCache(scopeKey: string): Promise { + if (!scopeKey) return null + const value = await config.get(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP) + if (!value || typeof value !== 'object') return null + const rawMap = value as Record + const rawItem = rawMap[scopeKey] + if (!rawItem || typeof rawItem !== 'object') return null + + const rawUpdatedAt = (rawItem as Record).updatedAt + const rawAvatars = (rawItem as Record).avatars + if (!rawAvatars || typeof rawAvatars !== 'object') return null + + const avatars: Record = {} + for (const [rawUsername, rawEntry] of Object.entries(rawAvatars as Record)) { + const username = rawUsername.trim() + if (!username) continue + + if (typeof rawEntry === 'string') { + const avatarUrl = rawEntry.trim() + if (!avatarUrl) continue + avatars[username] = { + avatarUrl, + updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0, + checkedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0 + } + continue + } + + if (!rawEntry || typeof rawEntry !== 'object') continue + const entry = rawEntry as Record + const avatarUrl = typeof entry.avatarUrl === 'string' ? entry.avatarUrl.trim() : '' + if (!avatarUrl) continue + const updatedAt = typeof entry.updatedAt === 'number' && Number.isFinite(entry.updatedAt) + ? entry.updatedAt + : 0 + const checkedAt = typeof entry.checkedAt === 'number' && Number.isFinite(entry.checkedAt) + ? entry.checkedAt + : updatedAt + + avatars[username] = { + avatarUrl, + updatedAt, + checkedAt + } + } + + return { + updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0, + avatars + } +} + +export async function setContactsAvatarCache( + scopeKey: string, + avatars: Record +): Promise { + if (!scopeKey) return + const current = await config.get(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP) + const map = current && typeof current === 'object' + ? { ...(current as Record) } + : {} + + const normalized: Record = {} + for (const [rawUsername, rawEntry] of Object.entries(avatars || {})) { + const username = String(rawUsername || '').trim() + if (!username || !rawEntry || typeof rawEntry !== 'object') continue + const avatarUrl = String(rawEntry.avatarUrl || '').trim() + if (!avatarUrl) continue + const updatedAt = Number.isFinite(rawEntry.updatedAt) + ? Math.max(0, Math.floor(rawEntry.updatedAt)) + : Date.now() + const checkedAt = Number.isFinite(rawEntry.checkedAt) + ? Math.max(0, Math.floor(rawEntry.checkedAt)) + : updatedAt + normalized[username] = { + avatarUrl, + updatedAt, + checkedAt + } + } + + map[scopeKey] = { + updatedAt: Date.now(), + avatars: normalized + } + await config.set(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP, map) +} + // === 安全相关 === export async function getAuthEnabled(): Promise { From 0a1f55f6a6409791d54a551f9babc97559d2ec0e Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 11:21:53 +0800 Subject: [PATCH 035/162] feat(export): reuse contacts cache for session names and avatars --- src/pages/ExportPage.scss | 16 +++ src/pages/ExportPage.tsx | 245 ++++++++++++++++++++++++++++++++------ 2 files changed, 224 insertions(+), 37 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 8a69ee6..73a2fbc 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -472,6 +472,22 @@ flex-wrap: wrap; } +.table-cache-meta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + font-size: 12px; + + .meta-item { + color: var(--text-tertiary); + } + + .meta-item.syncing { + color: var(--primary); + } +} + .table-tabs { display: flex; gap: 8px; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 6991981..e32ecc1 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -219,6 +219,8 @@ const getAvatarLetter = (name: string): string => { const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const CONTACT_ENRICH_TIMEOUT_MS = 7000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 +const EXPORT_AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 +type SessionDataSource = 'cache' | 'network' | null const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null @@ -236,6 +238,39 @@ const withTimeout = async (promise: Promise, timeoutMs: number): Promise< } } +const toContactMapFromCaches = ( + contacts: configService.ContactsListCacheContact[], + avatarEntries: Record +): Record => { + const map: Record = {} + for (const contact of contacts || []) { + if (!contact?.username) continue + map[contact.username] = { + ...contact, + avatarUrl: avatarEntries[contact.username]?.avatarUrl + } + } + return map +} + +const toSessionRowsWithContacts = ( + sessions: AppChatSession[], + contactMap: Record +): SessionRow[] => { + return sessions + .map((session) => { + const contact = contactMap[session.username] + return { + ...session, + kind: toKindByContactType(session, contact), + wechatId: contact?.username || session.username, + displayName: contact?.displayName || session.displayName || session.username, + avatarUrl: contact?.avatarUrl || session.avatarUrl + } as SessionRow + }) + .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) +} + const WriteLayoutSelector = memo(function WriteLayoutSelector({ writeLayout, onChange @@ -300,6 +335,9 @@ function ExportPage() { const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true) const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false) const [sessions, setSessions] = useState([]) + const [sessionDataSource, setSessionDataSource] = useState(null) + const [sessionContactsUpdatedAt, setSessionContactsUpdatedAt] = useState(null) + const [sessionAvatarUpdatedAt, setSessionAvatarUpdatedAt] = useState(null) const [searchKeyword, setSearchKeyword] = useState('') const [activeTab, setActiveTab] = useState('private') const [selectedSessions, setSelectedSessions] = useState>(new Set()) @@ -360,6 +398,22 @@ function ExportPage() { const exportCacheScopeRef = useRef('default') const exportCacheScopeReadyRef = useRef(false) + const ensureExportCacheScope = useCallback(async (): Promise => { + if (exportCacheScopeReadyRef.current) { + return exportCacheScopeRef.current + } + const [myWxid, dbPath] = await Promise.all([ + configService.getMyWxid(), + configService.getDbPath() + ]) + const scopeKey = dbPath || myWxid + ? `${dbPath || ''}::${myWxid || ''}` + : 'default' + exportCacheScopeRef.current = scopeKey + exportCacheScopeReadyRef.current = true + return scopeKey + }, []) + useEffect(() => { tasksRef.current = tasks }, [tasks]) @@ -389,7 +443,7 @@ function ExportPage() { const loadBaseConfig = useCallback(async () => { setIsBaseConfigLoading(true) try { - const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount, myWxid, dbPath] = await Promise.all([ + const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount, exportCacheScope] = await Promise.all([ configService.getExportPath(), configService.getExportDefaultFormat(), configService.getExportDefaultMedia(), @@ -401,12 +455,8 @@ function ExportPage() { configService.getExportLastSessionRunMap(), configService.getExportLastContentRunMap(), configService.getExportLastSnsPostCount(), - configService.getMyWxid(), - configService.getDbPath() + ensureExportCacheScope() ]) - const exportCacheScope = `${dbPath || ''}::${myWxid || ''}` || 'default' - exportCacheScopeRef.current = exportCacheScope - exportCacheScopeReadyRef.current = true const cachedSnsStats = await configService.getExportSnsStatsCache(exportCacheScope) @@ -446,7 +496,7 @@ function ExportPage() { } finally { setIsBaseConfigLoading(false) } - }, []) + }, [ensureExportCacheScope]) const loadSnsStats = useCallback(async (options?: { full?: boolean; silent?: boolean }) => { if (!options?.silent) { @@ -506,6 +556,24 @@ function ExportPage() { const isStale = () => sessionLoadTokenRef.current !== loadToken try { + const scopeKey = await ensureExportCacheScope() + if (isStale()) return + + const [cachedContactsItem, cachedAvatarItem] = await Promise.all([ + configService.getContactsListCache(scopeKey), + configService.getContactsAvatarCache(scopeKey) + ]) + if (isStale()) return + + const cachedContacts = cachedContactsItem?.contacts || [] + const cachedAvatarEntries = cachedAvatarItem?.avatars || {} + const cachedContactMap = toContactMapFromCaches(cachedContacts, cachedAvatarEntries) + if (cachedContacts.length > 0) { + syncContactTypeCounts(Object.values(cachedContactMap)) + } + setSessionContactsUpdatedAt(cachedContactsItem?.updatedAt || null) + setSessionAvatarUpdatedAt(cachedAvatarItem?.updatedAt || null) + const connectResult = await window.electronAPI.chat.connect() if (!connectResult.success) { console.error('连接失败:', connectResult.error) @@ -517,42 +585,54 @@ function ExportPage() { if (isStale()) return if (sessionsResult.success && sessionsResult.sessions) { - const baseSessions = sessionsResult.sessions - .map((session) => { - return { - ...session, - kind: toKindByContactType(session), - wechatId: session.username, - displayName: session.displayName || session.username, - avatarUrl: session.avatarUrl - } as SessionRow - }) - .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) + const rawSessions = sessionsResult.sessions + const baseSessions = toSessionRowsWithContacts(rawSessions, cachedContactMap) if (isStale()) return setSessions(baseSessions) + setSessionDataSource(cachedContacts.length > 0 ? 'cache' : 'network') + if (cachedContacts.length === 0) { + setSessionContactsUpdatedAt(Date.now()) + } setIsLoading(false) // 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。 setIsSessionEnriching(true) void (async () => { try { - if (isStale()) return - const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) - if (isStale()) return + let contactMap = { ...cachedContactMap } + let avatarEntries = { ...cachedAvatarEntries } + let hasFreshNetworkData = false - const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] - if (contacts.length > 0) { - syncContactTypeCounts(contacts) + if (isStale()) return + if (cachedContacts.length === 0) { + const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) + if (isStale()) return + + const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] + if (contacts.length > 0) { + hasFreshNetworkData = true + syncContactTypeCounts(contacts) + const nextContactMap = contacts.reduce>((map, contact) => { + map[contact.username] = contact + return map + }, {}) + contactMap = nextContactMap + setSessionContactsUpdatedAt(Date.now()) + } } - const nextContactMap = contacts.reduce>((map, contact) => { - map[contact.username] = contact - return map - }, {}) + const now = Date.now() const needsEnrichment = baseSessions - .filter(session => !session.avatarUrl || !session.displayName || session.displayName === session.username) - .map(session => session.username) + .filter((session) => { + const contact = contactMap[session.username] + const avatarEntry = avatarEntries[session.username] + const displayName = contact?.displayName || session.displayName || session.username + const avatarUrl = contact?.avatarUrl || session.avatarUrl || avatarEntry?.avatarUrl + const shouldRecheckAvatar = !avatarEntry || (now - (avatarEntry.checkedAt || 0) >= EXPORT_AVATAR_RECHECK_INTERVAL_MS) + return !avatarUrl || displayName === session.username || shouldRecheckAvatar + }) + .map((session) => session.username) let extraContactMap: Record = {} if (needsEnrichment.length > 0) { @@ -563,27 +643,87 @@ function ExportPage() { ) if (enrichResult?.success && enrichResult.contacts) { extraContactMap = enrichResult.contacts + hasFreshNetworkData = true + } + } + + const persistAt = Date.now() + for (const contact of Object.values(contactMap)) { + const avatarUrl = String(contact.avatarUrl || '').trim() + if (!avatarUrl) continue + const prev = avatarEntries[contact.username] + avatarEntries[contact.username] = { + avatarUrl, + updatedAt: prev?.avatarUrl === avatarUrl ? prev.updatedAt : persistAt, + checkedAt: prev?.checkedAt || persistAt + } + } + + for (const username of needsEnrichment) { + const extra = extraContactMap[username] + const prev = avatarEntries[username] + if (extra?.avatarUrl) { + avatarEntries[username] = { + avatarUrl: extra.avatarUrl, + updatedAt: !prev || prev.avatarUrl !== extra.avatarUrl ? persistAt : prev.updatedAt, + checkedAt: persistAt + } + } else if (prev) { + avatarEntries[username] = { + ...prev, + checkedAt: persistAt + } + } + + if (!extra) continue + const current = contactMap[username] + if (!current) continue + const nextDisplayName = extra.displayName || current.displayName + const nextAvatarUrl = extra.avatarUrl || current.avatarUrl + if (nextDisplayName !== current.displayName || nextAvatarUrl !== current.avatarUrl) { + contactMap[username] = { + ...current, + displayName: nextDisplayName, + avatarUrl: nextAvatarUrl + } } } if (isStale()) return - const nextSessions = baseSessions + const nextSessions = toSessionRowsWithContacts(rawSessions, contactMap) .map((session) => { - const contact = nextContactMap[session.username] const extra = extraContactMap[session.username] - const displayName = extra?.displayName || contact?.displayName || session.displayName || session.username - const avatarUrl = extra?.avatarUrl || session.avatarUrl || contact?.avatarUrl + const displayName = extra?.displayName || session.displayName || session.username + const avatarUrl = extra?.avatarUrl || session.avatarUrl + if (displayName === session.displayName && avatarUrl === session.avatarUrl) { + return session + } return { ...session, - kind: toKindByContactType(session, contact), - wechatId: contact?.username || session.wechatId || session.username, displayName, avatarUrl } }) .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) + const contactsCachePayload = Object.values(contactMap).map((contact) => ({ + username: contact.username, + displayName: contact.displayName || contact.username, + remark: contact.remark, + nickname: contact.nickname, + type: contact.type + })) + setSessions(nextSessions) + if (contactsCachePayload.length > 0) { + await configService.setContactsListCache(scopeKey, contactsCachePayload) + setSessionContactsUpdatedAt(persistAt) + } + await configService.setContactsAvatarCache(scopeKey, avatarEntries) + setSessionAvatarUpdatedAt(persistAt) + if (hasFreshNetworkData) { + setSessionDataSource('network') + } } catch (enrichError) { console.error('导出页补充会话联系人信息失败:', enrichError) } finally { @@ -599,7 +739,7 @@ function ExportPage() { } finally { if (!isStale()) setIsLoading(false) } - }, [syncContactTypeCounts]) + }, [ensureExportCacheScope, syncContactTypeCounts]) useEffect(() => { if (!isExportRoute) return @@ -1151,6 +1291,20 @@ function ExportPage() { return '公众号' }, [activeTab]) + const sessionContactsUpdatedAtLabel = useMemo(() => { + if (!sessionContactsUpdatedAt) return '' + return new Date(sessionContactsUpdatedAt).toLocaleString() + }, [sessionContactsUpdatedAt]) + + const sessionAvatarUpdatedAtLabel = useMemo(() => { + if (!sessionAvatarUpdatedAt) return '' + return new Date(sessionAvatarUpdatedAt).toLocaleString() + }, [sessionAvatarUpdatedAt]) + + const sessionAvatarCachedCount = useMemo(() => { + return sessions.reduce((count, session) => (session.avatarUrl ? count + 1 : count), 0) + }, [sessions]) + const renderSessionName = (session: SessionRow) => { return (
@@ -1452,6 +1606,23 @@ function ExportPage() {
+
+ {sessionContactsUpdatedAt && ( + + {sessionDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {sessionContactsUpdatedAtLabel} + + )} + {sessions.length > 0 && ( + + 头像缓存 {sessionAvatarCachedCount}/{sessions.length} + {sessionAvatarUpdatedAtLabel ? ` · 更新于 ${sessionAvatarUpdatedAtLabel}` : ''} + + )} + {(isLoading || isSessionEnriching) && sessions.length > 0 && ( + 后台同步中... + )} +
+ {!showInitialSkeleton && (isLoading || isSessionEnriching) && (
From d18a8714292b25031265d5b76584c9d261fa09c3 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 11:31:07 +0800 Subject: [PATCH 036/162] fix(export): restore dialog scroll and adaptive format grid --- src/pages/ExportPage.scss | 66 +++++++- src/pages/ExportPage.tsx | 312 +++++++++++++++++++++++--------------- 2 files changed, 252 insertions(+), 126 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 73a2fbc..49efd28 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -655,6 +655,11 @@ &.checked { color: var(--primary); } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } } .session-cell { @@ -736,6 +741,12 @@ &.running { background: color-mix(in srgb, var(--primary) 80%, #000); } + + &.no-session { + background: var(--bg-secondary); + color: var(--text-tertiary); + border: 1px dashed var(--border-color); + } } .row-export-time { @@ -816,17 +827,29 @@ display: flex; align-items: center; justify-content: center; + padding: 16px; z-index: 1000; } .export-dialog { - width: min(980px, calc(100vw - 40px)); - max-height: calc(100vh - 60px); - overflow: auto; + width: min(1080px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 14px; - padding: 16px; + padding: 14px 14px 12px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.dialog-body { + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 10px; + padding-right: 2px; } .dialog-header { @@ -859,7 +882,6 @@ border: 1px solid var(--border-color); border-radius: 10px; padding: 12px; - margin-bottom: 10px; background: var(--bg-secondary); h4 { @@ -912,17 +934,23 @@ .format-grid { display: grid; - grid-template-columns: repeat(4, minmax(130px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; } .format-card { + width: 100%; + min-height: 82px; border: 1px solid var(--border-color); border-radius: 10px; padding: 10px; text-align: left; background: var(--bg-primary); cursor: pointer; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; .format-label { font-size: 13px; @@ -1033,9 +1061,13 @@ .dialog-actions { margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border-color); display: flex; justify-content: flex-end; gap: 8px; + flex-shrink: 0; + background: var(--card-bg); } .primary-btn, @@ -1132,7 +1164,7 @@ } .format-grid { - grid-template-columns: repeat(2, minmax(120px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); } .display-name-options { @@ -1143,3 +1175,23 @@ grid-template-columns: repeat(2, minmax(120px, 1fr)); } } + +@media (max-width: 720px) { + .export-dialog-overlay { + padding: 10px; + } + + .export-dialog { + width: calc(100vw - 20px); + max-height: calc(100vh - 20px); + padding: 12px 10px 10px; + } + + .format-grid { + grid-template-columns: 1fr; + } + + .date-range-row { + grid-template-columns: 1fr; + } +} diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index e32ecc1..d7cdf25 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -57,6 +57,7 @@ interface ExportOptions { interface SessionRow extends AppChatSession { kind: ConversationTab wechatId?: string + hasSession: boolean } interface TaskProgress { @@ -207,6 +208,13 @@ const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): Co return 'private' } +const toKindByContact = (contact: ContactInfo): ConversationTab => { + if (contact.type === 'group') return 'group' + if (contact.type === 'official') return 'official' + if (contact.type === 'former_friend') return 'former_friend' + return 'private' +} + const isContentScopeSession = (session: SessionRow): boolean => ( session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend' ) @@ -257,6 +265,50 @@ const toSessionRowsWithContacts = ( sessions: AppChatSession[], contactMap: Record ): SessionRow[] => { + const sessionMap = new Map() + for (const session of sessions || []) { + sessionMap.set(session.username, session) + } + + const contacts = Object.values(contactMap) + .filter((contact) => ( + contact.type === 'friend' || + contact.type === 'group' || + contact.type === 'official' || + contact.type === 'former_friend' + )) + + if (contacts.length > 0) { + return contacts + .map((contact) => { + const session = sessionMap.get(contact.username) + const latestTs = session?.sortTimestamp || session?.lastTimestamp || 0 + return { + ...(session || { + username: contact.username, + type: 0, + unreadCount: 0, + summary: '', + sortTimestamp: latestTs, + lastTimestamp: latestTs, + lastMsgType: 0 + }), + username: contact.username, + kind: toKindByContact(contact), + wechatId: contact.username, + displayName: contact.displayName || session?.displayName || contact.username, + avatarUrl: contact.avatarUrl || session?.avatarUrl, + hasSession: Boolean(session) + } as SessionRow + }) + .sort((a, b) => { + const latestA = a.sortTimestamp || a.lastTimestamp || 0 + const latestB = b.sortTimestamp || b.lastTimestamp || 0 + if (latestA !== latestB) return latestB - latestA + return (a.displayName || a.username).localeCompare(b.displayName || b.username, 'zh-Hans-CN') + }) + } + return sessions .map((session) => { const contact = contactMap[session.username] @@ -265,7 +317,8 @@ const toSessionRowsWithContacts = ( kind: toKindByContactType(session, contact), wechatId: contact?.username || session.username, displayName: contact?.displayName || session.displayName || session.username, - avatarUrl: contact?.avatarUrl || session.avatarUrl + avatarUrl: contact?.avatarUrl || session.avatarUrl, + hasSession: true } as SessionRow }) .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) @@ -570,6 +623,9 @@ function ExportPage() { const cachedContactMap = toContactMapFromCaches(cachedContacts, cachedAvatarEntries) if (cachedContacts.length > 0) { syncContactTypeCounts(Object.values(cachedContactMap)) + setSessions(toSessionRowsWithContacts([], cachedContactMap)) + setSessionDataSource('cache') + setIsLoading(false) } setSessionContactsUpdatedAt(cachedContactsItem?.updatedAt || null) setSessionAvatarUpdatedAt(cachedAvatarItem?.updatedAt || null) @@ -800,6 +856,8 @@ function ExportPage() { const selectedCount = selectedSessions.size const toggleSelectSession = (sessionId: string) => { + const target = sessions.find(session => session.username === sessionId) + if (!target?.hasSession) return setSelectedSessions(prev => { const next = new Set(prev) if (next.has(sessionId)) { @@ -812,7 +870,7 @@ function ExportPage() { } const toggleSelectAllVisible = () => { - const visibleIds = visibleSessions.map(session => session.username) + const visibleIds = visibleSessions.filter(session => session.hasSession).map(session => session.username) if (visibleIds.length === 0) return setSelectedSessions(prev => { @@ -1171,6 +1229,7 @@ function ExportPage() { } const openSingleExport = (session: SessionRow) => { + if (!session.hasSession) return openExportDialog({ scope: 'single', sessionIds: [session.username], @@ -1180,7 +1239,8 @@ function ExportPage() { } const openBatchExport = () => { - const ids = Array.from(selectedSessions) + const selectable = new Set(sessions.filter(session => session.hasSession).map(session => session.username)) + const ids = Array.from(selectedSessions).filter(id => selectable.has(id)) if (ids.length === 0) return const nameMap = new Map(sessions.map(session => [session.username, session.displayName || session.username])) const names = ids.map(id => nameMap.get(id) || id) @@ -1195,11 +1255,11 @@ function ExportPage() { const openContentExport = (contentType: ContentType) => { const ids = sessions - .filter(isContentScopeSession) + .filter(session => session.hasSession && isContentScopeSession(session)) .map(session => session.username) const names = sessions - .filter(isContentScopeSession) + .filter(session => session.hasSession && isContentScopeSession(session)) .map(session => session.displayName || session.username) openExportDialog({ @@ -1320,6 +1380,16 @@ function ExportPage() { } const renderActionCell = (session: SessionRow) => { + if (!session.hasSession) { + return ( +
+ +
+ ) + } + const isRunning = runningSessionIds.has(session.username) const isQueued = queuedSessionIds.has(session.username) const recent = formatRecentExportTime(lastExportBySession[session.username], nowTick) @@ -1347,22 +1417,24 @@ function ExportPage() { return ( 选择 - 会话名(头像/名称/微信号) + 联系人(头像/名称/微信号) 操作 ) } const renderRowCells = (session: SessionRow) => { - const checked = selectedSessions.has(session.username) + const selectable = session.hasSession + const checked = selectable && selectedSessions.has(session.username) return ( <> @@ -1661,134 +1733,136 @@ function ExportPage() { {exportDialog.open && (
-
event.stopPropagation()}> +
event.stopPropagation()}>

{exportDialog.title}

-
-

导出范围

-
- {scopeLabel} - {scopeCountLabel} -
-
- {exportDialog.sessionNames.slice(0, 20).map(name => ( - {name} - ))} - {exportDialog.sessionNames.length > 20 && ... 还有 {exportDialog.sessionNames.length - 20} 个} -
-
- -
-

对话文本导出格式选择

-
- {formatCandidateOptions.map(option => ( - - ))} -
-
- -
-

时间范围

-
- 导出全部时间 - +
+
+

导出范围

+
+ {scopeLabel} + {scopeCountLabel} +
+
+ {exportDialog.sessionNames.slice(0, 20).map(name => ( + {name} + ))} + {exportDialog.sessionNames.length > 20 && ... 还有 {exportDialog.sessionNames.length - 20} 个} +
- {!options.useAllTime && options.dateRange && ( -
-
) From d1ef159e87d4552f18c579618f490104beb8ccce Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 11:57:04 +0800 Subject: [PATCH 038/162] fix(export): stabilize contact cache fallback and batched avatar enrich --- src/pages/ExportPage.tsx | 136 ++++++++++++++++++++++++++++----------- 1 file changed, 100 insertions(+), 36 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 57209cf..b98ac48 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -228,6 +228,7 @@ const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString( const CONTACT_ENRICH_TIMEOUT_MS = 7000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 const EXPORT_AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 +const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80 type SessionDataSource = 'cache' | 'network' | null const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { @@ -482,25 +483,74 @@ function ExportPage() { 'default' ].filter(Boolean))) + type CacheCandidate = { + scopeKey: string + contactsItem: configService.ContactsListCacheItem | null + avatarItem: configService.ContactsAvatarCacheItem | null + contactsCount: number + avatarCount: number + contactsUpdatedAt: number + avatarUpdatedAt: number + } + + const candidatesWithData: CacheCandidate[] = [] for (const candidate of candidates) { const [contactsItem, avatarItem] = await Promise.all([ configService.getContactsListCache(candidate), configService.getContactsAvatarCache(candidate) ]) - const hasContacts = Boolean(contactsItem?.contacts?.length) - const hasAvatars = Boolean(avatarItem && Object.keys(avatarItem.avatars || {}).length > 0) - if (!hasContacts && !hasAvatars) continue - return { - resolvedScopeKey: candidate, + const contactsCount = contactsItem?.contacts?.length || 0 + const avatarCount = avatarItem ? Object.keys(avatarItem.avatars || {}).length : 0 + if (contactsCount === 0 && avatarCount === 0) continue + candidatesWithData.push({ + scopeKey: candidate, contactsItem, - avatarItem + avatarItem, + contactsCount, + avatarCount, + contactsUpdatedAt: contactsItem?.updatedAt || 0, + avatarUpdatedAt: avatarItem?.updatedAt || 0 + }) + } + + if (candidatesWithData.length === 0) { + return { + resolvedContactsScopeKey: primaryScopeKey, + resolvedAvatarScopeKeys: [] as string[], + contactsItem: null as configService.ContactsListCacheItem | null, + avatarItem: null as configService.ContactsAvatarCacheItem | null } } + const bestContactsCandidate = candidatesWithData + .filter(item => item.contactsCount > 0) + .sort((a, b) => { + if (b.contactsCount !== a.contactsCount) return b.contactsCount - a.contactsCount + if (b.contactsUpdatedAt !== a.contactsUpdatedAt) return b.contactsUpdatedAt - a.contactsUpdatedAt + return b.avatarCount - a.avatarCount + })[0] + + const avatarCandidates = candidatesWithData + .filter(item => item.avatarCount > 0) + .sort((a, b) => a.avatarUpdatedAt - b.avatarUpdatedAt) + const mergedAvatarEntries: Record = {} + for (const candidate of avatarCandidates) { + Object.assign(mergedAvatarEntries, candidate.avatarItem?.avatars || {}) + } + const mergedAvatarUpdatedAt = avatarCandidates.reduce((max, candidate) => ( + candidate.avatarUpdatedAt > max ? candidate.avatarUpdatedAt : max + ), 0) + return { - resolvedScopeKey: primaryScopeKey, - contactsItem: null as configService.ContactsListCacheItem | null, - avatarItem: null as configService.ContactsAvatarCacheItem | null + resolvedContactsScopeKey: bestContactsCandidate?.scopeKey || primaryScopeKey, + resolvedAvatarScopeKeys: avatarCandidates.map(candidate => candidate.scopeKey), + contactsItem: bestContactsCandidate?.contactsItem || null, + avatarItem: Object.keys(mergedAvatarEntries).length > 0 + ? { + updatedAt: mergedAvatarUpdatedAt, + avatars: mergedAvatarEntries + } + : null } }, []) @@ -650,7 +700,8 @@ function ExportPage() { if (isStale()) return const { - resolvedScopeKey, + resolvedContactsScopeKey, + resolvedAvatarScopeKeys, contactsItem: cachedContactsItem, avatarItem: cachedAvatarItem } = await loadContactsCachesWithScopeFallback(scopeKey) @@ -668,12 +719,12 @@ function ExportPage() { setSessionContactsUpdatedAt(cachedContactsItem?.updatedAt || null) setSessionAvatarUpdatedAt(cachedAvatarItem?.updatedAt || null) - if (resolvedScopeKey !== scopeKey && cachedContacts.length > 0) { + if (resolvedContactsScopeKey !== scopeKey && cachedContacts.length > 0) { void configService.setContactsListCache(scopeKey, cachedContacts).catch((error) => { console.error('回填主 scope 通讯录缓存失败:', error) }) } - if (resolvedScopeKey !== scopeKey && Object.keys(cachedAvatarEntries).length > 0) { + if (!resolvedAvatarScopeKeys.includes(scopeKey) && Object.keys(cachedAvatarEntries).length > 0) { void configService.setContactsAvatarCache(scopeKey, cachedAvatarEntries).catch((error) => { console.error('回填主 scope 头像缓存失败:', error) }) @@ -710,21 +761,19 @@ function ExportPage() { let hasFreshNetworkData = false if (isStale()) return - if (cachedContacts.length === 0) { - const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) - if (isStale()) return + const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) + if (isStale()) return - const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] - if (contacts.length > 0) { - hasFreshNetworkData = true - syncContactTypeCounts(contacts) - const nextContactMap = contacts.reduce>((map, contact) => { - map[contact.username] = contact - return map - }, {}) - contactMap = nextContactMap - setSessionContactsUpdatedAt(Date.now()) - } + const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] + if (contacts.length > 0) { + hasFreshNetworkData = true + syncContactTypeCounts(contacts) + const nextContactMap = contacts.reduce>((map, contact) => { + map[contact.username] = contact + return map + }, {}) + contactMap = nextContactMap + setSessionContactsUpdatedAt(Date.now()) } const now = Date.now() @@ -741,14 +790,27 @@ function ExportPage() { let extraContactMap: Record = {} if (needsEnrichment.length > 0) { - if (isStale()) return - const enrichResult = await withTimeout( - window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment), - CONTACT_ENRICH_TIMEOUT_MS - ) - if (enrichResult?.success && enrichResult.contacts) { - extraContactMap = enrichResult.contacts - hasFreshNetworkData = true + for (let i = 0; i < needsEnrichment.length; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) { + if (isStale()) return + const batch = needsEnrichment.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE) + if (batch.length === 0) continue + try { + const enrichResult = await withTimeout( + window.electronAPI.chat.enrichSessionsContactInfo(batch), + CONTACT_ENRICH_TIMEOUT_MS + ) + if (isStale()) return + if (enrichResult?.success && enrichResult.contacts) { + extraContactMap = { + ...extraContactMap, + ...enrichResult.contacts + } + hasFreshNetworkData = true + } + } catch (batchError) { + console.error('导出页分批补充会话联系人信息失败:', batchError) + } + await new Promise(resolve => setTimeout(resolve, 0)) } } @@ -824,8 +886,10 @@ function ExportPage() { await configService.setContactsListCache(scopeKey, contactsCachePayload) setSessionContactsUpdatedAt(persistAt) } - await configService.setContactsAvatarCache(scopeKey, avatarEntries) - setSessionAvatarUpdatedAt(persistAt) + if (Object.keys(avatarEntries).length > 0) { + await configService.setContactsAvatarCache(scopeKey, avatarEntries) + setSessionAvatarUpdatedAt(persistAt) + } if (hasFreshNetworkData) { setSessionDataSource('network') } From dabc6a2d0a7e71c2bedf18e9d6f0a505ab84f563 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 12:07:28 +0800 Subject: [PATCH 039/162] fix(export): align avatar loading pipeline with contacts --- src/pages/ExportPage.tsx | 330 +++++++++++++++++++++------------------ 1 file changed, 179 insertions(+), 151 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index b98ac48..1c4a27e 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -262,6 +262,91 @@ const toContactMapFromCaches = ( return map } +const mergeAvatarCacheIntoContacts = ( + sourceContacts: ContactInfo[], + avatarEntries: Record +): ContactInfo[] => { + if (!sourceContacts.length || Object.keys(avatarEntries).length === 0) { + return sourceContacts + } + + let changed = false + const merged = sourceContacts.map((contact) => { + const cachedAvatar = avatarEntries[contact.username]?.avatarUrl + if (!cachedAvatar || contact.avatarUrl) { + return contact + } + changed = true + return { + ...contact, + avatarUrl: cachedAvatar + } + }) + + return changed ? merged : sourceContacts +} + +const upsertAvatarCacheFromContacts = ( + avatarEntries: Record, + sourceContacts: ContactInfo[], + options?: { prune?: boolean; markCheckedUsernames?: string[]; now?: number } +): { + avatarEntries: Record + changed: boolean + updatedAt: number | null +} => { + const nextCache = { ...avatarEntries } + const now = options?.now || Date.now() + const markCheckedSet = new Set((options?.markCheckedUsernames || []).filter(Boolean)) + const usernamesInSource = new Set() + let changed = false + + for (const contact of sourceContacts) { + const username = String(contact.username || '').trim() + if (!username) continue + usernamesInSource.add(username) + const prev = nextCache[username] + const avatarUrl = String(contact.avatarUrl || '').trim() + if (!avatarUrl) continue + const updatedAt = !prev || prev.avatarUrl !== avatarUrl ? now : prev.updatedAt + const checkedAt = markCheckedSet.has(username) ? now : (prev?.checkedAt || now) + if (!prev || prev.avatarUrl !== avatarUrl || prev.updatedAt !== updatedAt || prev.checkedAt !== checkedAt) { + nextCache[username] = { + avatarUrl, + updatedAt, + checkedAt + } + changed = true + } + } + + for (const username of markCheckedSet) { + const prev = nextCache[username] + if (!prev) continue + if (prev.checkedAt !== now) { + nextCache[username] = { + ...prev, + checkedAt: now + } + changed = true + } + } + + if (options?.prune) { + for (const username of Object.keys(nextCache)) { + if (usernamesInSource.has(username)) continue + delete nextCache[username] + changed = true + } + } + + return { + avatarEntries: nextCache, + changed, + updatedAt: changed ? now : null + } +} + const toSessionRowsWithContacts = ( sessions: AppChatSession[], contactMap: Record @@ -468,89 +553,14 @@ function ExportPage() { return scopeKey }, []) - const loadContactsCachesWithScopeFallback = useCallback(async (primaryScopeKey: string) => { - const [myWxid, dbPath] = await Promise.all([ - configService.getMyWxid(), - configService.getDbPath() + const loadContactsCaches = useCallback(async (scopeKey: string) => { + const [contactsItem, avatarItem] = await Promise.all([ + configService.getContactsListCache(scopeKey), + configService.getContactsAvatarCache(scopeKey) ]) - const candidates = Array.from(new Set([ - primaryScopeKey, - dbPath || '', - myWxid || '', - dbPath && myWxid ? `${dbPath}::${myWxid}` : '', - dbPath ? `${dbPath}::` : '', - myWxid ? `::${myWxid}` : '', - 'default' - ].filter(Boolean))) - - type CacheCandidate = { - scopeKey: string - contactsItem: configService.ContactsListCacheItem | null - avatarItem: configService.ContactsAvatarCacheItem | null - contactsCount: number - avatarCount: number - contactsUpdatedAt: number - avatarUpdatedAt: number - } - - const candidatesWithData: CacheCandidate[] = [] - for (const candidate of candidates) { - const [contactsItem, avatarItem] = await Promise.all([ - configService.getContactsListCache(candidate), - configService.getContactsAvatarCache(candidate) - ]) - const contactsCount = contactsItem?.contacts?.length || 0 - const avatarCount = avatarItem ? Object.keys(avatarItem.avatars || {}).length : 0 - if (contactsCount === 0 && avatarCount === 0) continue - candidatesWithData.push({ - scopeKey: candidate, - contactsItem, - avatarItem, - contactsCount, - avatarCount, - contactsUpdatedAt: contactsItem?.updatedAt || 0, - avatarUpdatedAt: avatarItem?.updatedAt || 0 - }) - } - - if (candidatesWithData.length === 0) { - return { - resolvedContactsScopeKey: primaryScopeKey, - resolvedAvatarScopeKeys: [] as string[], - contactsItem: null as configService.ContactsListCacheItem | null, - avatarItem: null as configService.ContactsAvatarCacheItem | null - } - } - - const bestContactsCandidate = candidatesWithData - .filter(item => item.contactsCount > 0) - .sort((a, b) => { - if (b.contactsCount !== a.contactsCount) return b.contactsCount - a.contactsCount - if (b.contactsUpdatedAt !== a.contactsUpdatedAt) return b.contactsUpdatedAt - a.contactsUpdatedAt - return b.avatarCount - a.avatarCount - })[0] - - const avatarCandidates = candidatesWithData - .filter(item => item.avatarCount > 0) - .sort((a, b) => a.avatarUpdatedAt - b.avatarUpdatedAt) - const mergedAvatarEntries: Record = {} - for (const candidate of avatarCandidates) { - Object.assign(mergedAvatarEntries, candidate.avatarItem?.avatars || {}) - } - const mergedAvatarUpdatedAt = avatarCandidates.reduce((max, candidate) => ( - candidate.avatarUpdatedAt > max ? candidate.avatarUpdatedAt : max - ), 0) - return { - resolvedContactsScopeKey: bestContactsCandidate?.scopeKey || primaryScopeKey, - resolvedAvatarScopeKeys: avatarCandidates.map(candidate => candidate.scopeKey), - contactsItem: bestContactsCandidate?.contactsItem || null, - avatarItem: Object.keys(mergedAvatarEntries).length > 0 - ? { - updatedAt: mergedAvatarUpdatedAt, - avatars: mergedAvatarEntries - } - : null + contactsItem, + avatarItem } }, []) @@ -700,11 +710,9 @@ function ExportPage() { if (isStale()) return const { - resolvedContactsScopeKey, - resolvedAvatarScopeKeys, contactsItem: cachedContactsItem, avatarItem: cachedAvatarItem - } = await loadContactsCachesWithScopeFallback(scopeKey) + } = await loadContactsCaches(scopeKey) if (isStale()) return const cachedContacts = cachedContactsItem?.contacts || [] @@ -719,17 +727,6 @@ function ExportPage() { setSessionContactsUpdatedAt(cachedContactsItem?.updatedAt || null) setSessionAvatarUpdatedAt(cachedAvatarItem?.updatedAt || null) - if (resolvedContactsScopeKey !== scopeKey && cachedContacts.length > 0) { - void configService.setContactsListCache(scopeKey, cachedContacts).catch((error) => { - console.error('回填主 scope 通讯录缓存失败:', error) - }) - } - if (!resolvedAvatarScopeKeys.includes(scopeKey) && Object.keys(cachedAvatarEntries).length > 0) { - void configService.setContactsAvatarCache(scopeKey, cachedAvatarEntries).catch((error) => { - console.error('回填主 scope 头像缓存失败:', error) - }) - } - const connectResult = await window.electronAPI.chat.connect() if (!connectResult.success) { console.error('连接失败:', connectResult.error) @@ -759,34 +756,71 @@ function ExportPage() { let contactMap = { ...cachedContactMap } let avatarEntries = { ...cachedAvatarEntries } let hasFreshNetworkData = false + let hasNetworkContactsSnapshot = false if (isStale()) return const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) if (isStale()) return - const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] - if (contacts.length > 0) { + const contactsFromNetwork: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] + if (contactsFromNetwork.length > 0) { hasFreshNetworkData = true - syncContactTypeCounts(contacts) - const nextContactMap = contacts.reduce>((map, contact) => { + hasNetworkContactsSnapshot = true + const contactsWithCachedAvatar = mergeAvatarCacheIntoContacts(contactsFromNetwork, avatarEntries) + const nextContactMap = contactsWithCachedAvatar.reduce>((map, contact) => { map[contact.username] = contact return map }, {}) + for (const [username, cachedContact] of Object.entries(cachedContactMap)) { + if (!nextContactMap[username]) { + nextContactMap[username] = cachedContact + } + } contactMap = nextContactMap - setSessionContactsUpdatedAt(Date.now()) + syncContactTypeCounts(Object.values(contactMap)) + const refreshAt = Date.now() + setSessionContactsUpdatedAt(refreshAt) + + const upsertResult = upsertAvatarCacheFromContacts(avatarEntries, Object.values(contactMap), { + prune: true, + now: refreshAt + }) + avatarEntries = upsertResult.avatarEntries + if (upsertResult.updatedAt) { + setSessionAvatarUpdatedAt(upsertResult.updatedAt) + } } + const sourceContacts = Object.values(contactMap) + const sourceByUsername = new Map() + for (const contact of sourceContacts) { + if (!contact?.username) continue + sourceByUsername.set(contact.username, contact) + } const now = Date.now() - const needsEnrichment = baseSessions - .filter((session) => { - const contact = contactMap[session.username] - const avatarEntry = avatarEntries[session.username] - const displayName = contact?.displayName || session.displayName || session.username - const avatarUrl = contact?.avatarUrl || session.avatarUrl || avatarEntry?.avatarUrl - const shouldRecheckAvatar = !avatarEntry || (now - (avatarEntry.checkedAt || 0) >= EXPORT_AVATAR_RECHECK_INTERVAL_MS) - return !avatarUrl || displayName === session.username || shouldRecheckAvatar + const rawSessionMap = rawSessions.reduce>((map, session) => { + map[session.username] = session + return map + }, {}) + const candidateUsernames = sourceContacts.length > 0 + ? sourceContacts.map(contact => contact.username) + : baseSessions.map(session => session.username) + const needsEnrichment = candidateUsernames + .filter(Boolean) + .filter((username) => { + const currentContact = sourceByUsername.get(username) + const cacheEntry = avatarEntries[username] + const session = rawSessionMap[username] + const currentAvatarUrl = currentContact?.avatarUrl || session?.avatarUrl + if (!cacheEntry || !cacheEntry.avatarUrl) { + return !currentAvatarUrl + } + if (currentAvatarUrl && currentAvatarUrl !== cacheEntry.avatarUrl) { + return true + } + const checkedAt = cacheEntry.checkedAt || 0 + return now - checkedAt >= EXPORT_AVATAR_RECHECK_INTERVAL_MS }) - .map((session) => session.username) let extraContactMap: Record = {} if (needsEnrichment.length > 0) { @@ -806,62 +840,55 @@ function ExportPage() { ...enrichResult.contacts } hasFreshNetworkData = true + for (const [username, enriched] of Object.entries(enrichResult.contacts)) { + const current = sourceByUsername.get(username) + if (!current) continue + sourceByUsername.set(username, { + ...current, + displayName: enriched.displayName || current.displayName, + avatarUrl: enriched.avatarUrl || current.avatarUrl + }) + } } } catch (batchError) { console.error('导出页分批补充会话联系人信息失败:', batchError) } + + const batchContacts = batch + .map(username => sourceByUsername.get(username)) + .filter((contact): contact is ContactInfo => Boolean(contact)) + const upsertResult = upsertAvatarCacheFromContacts(avatarEntries, batchContacts, { + markCheckedUsernames: batch + }) + avatarEntries = upsertResult.avatarEntries + if (upsertResult.updatedAt) { + setSessionAvatarUpdatedAt(upsertResult.updatedAt) + } await new Promise(resolve => setTimeout(resolve, 0)) } } - const persistAt = Date.now() - for (const contact of Object.values(contactMap)) { - const avatarUrl = String(contact.avatarUrl || '').trim() - if (!avatarUrl) continue - const prev = avatarEntries[contact.username] - avatarEntries[contact.username] = { - avatarUrl, - updatedAt: prev?.avatarUrl === avatarUrl ? prev.updatedAt : persistAt, - checkedAt: prev?.checkedAt || persistAt - } - } - - for (const username of needsEnrichment) { - const extra = extraContactMap[username] - const prev = avatarEntries[username] - if (extra?.avatarUrl) { - avatarEntries[username] = { - avatarUrl: extra.avatarUrl, - updatedAt: !prev || prev.avatarUrl !== extra.avatarUrl ? persistAt : prev.updatedAt, - checkedAt: persistAt - } - } else if (prev) { - avatarEntries[username] = { - ...prev, - checkedAt: persistAt - } - } - - if (!extra) continue - const current = contactMap[username] - if (!current) continue - const nextDisplayName = extra.displayName || current.displayName - const nextAvatarUrl = extra.avatarUrl || current.avatarUrl - if (nextDisplayName !== current.displayName || nextAvatarUrl !== current.avatarUrl) { - contactMap[username] = { - ...current, - displayName: nextDisplayName, - avatarUrl: nextAvatarUrl - } + const contactsForPersist = Array.from(sourceByUsername.values()) + if (hasNetworkContactsSnapshot && contactsForPersist.length > 0) { + const upsertResult = upsertAvatarCacheFromContacts(avatarEntries, contactsForPersist, { + prune: true + }) + avatarEntries = upsertResult.avatarEntries + if (upsertResult.updatedAt) { + setSessionAvatarUpdatedAt(upsertResult.updatedAt) } } + contactMap = contactsForPersist.reduce>((map, contact) => { + map[contact.username] = contact + return map + }, contactMap) if (isStale()) return const nextSessions = toSessionRowsWithContacts(rawSessions, contactMap) .map((session) => { const extra = extraContactMap[session.username] const displayName = extra?.displayName || session.displayName || session.username - const avatarUrl = extra?.avatarUrl || session.avatarUrl + const avatarUrl = extra?.avatarUrl || session.avatarUrl || avatarEntries[session.username]?.avatarUrl if (displayName === session.displayName && avatarUrl === session.avatarUrl) { return session } @@ -881,8 +908,9 @@ function ExportPage() { type: contact.type })) + const persistAt = Date.now() setSessions(nextSessions) - if (contactsCachePayload.length > 0) { + if (hasNetworkContactsSnapshot && contactsCachePayload.length > 0) { await configService.setContactsListCache(scopeKey, contactsCachePayload) setSessionContactsUpdatedAt(persistAt) } @@ -908,7 +936,7 @@ function ExportPage() { } finally { if (!isStale()) setIsLoading(false) } - }, [ensureExportCacheScope, loadContactsCachesWithScopeFallback, syncContactTypeCounts]) + }, [ensureExportCacheScope, loadContactsCaches, syncContactTypeCounts]) useEffect(() => { if (!isExportRoute) return From af7639aa731dd65b6dd599bb49b47d562ade1e08 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 12:28:40 +0800 Subject: [PATCH 040/162] feat(export): optimize dialog defaults and option cards --- src/pages/ExportPage.scss | 28 +- src/pages/ExportPage.tsx | 720 ++++++++++++++++++++++++++++++++++---- 2 files changed, 662 insertions(+), 86 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 49efd28..a33df95 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -935,15 +935,15 @@ .format-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 8px; + gap: 6px; } .format-card { width: 100%; - min-height: 82px; + min-height: 0; border: 1px solid var(--border-color); border-radius: 10px; - padding: 10px; + padding: 8px 10px; text-align: left; background: var(--bg-primary); cursor: pointer; @@ -956,13 +956,14 @@ font-size: 13px; font-weight: 600; color: var(--text-primary); + line-height: 1.35; } .format-desc { - margin-top: 3px; + margin-top: 1px; font-size: 11px; color: var(--text-tertiary); - line-height: 1.4; + line-height: 1.35; } &.active { @@ -1031,10 +1032,22 @@ border: 1px solid var(--border-color); border-radius: 8px; padding: 8px; + width: 100%; display: flex; flex-direction: column; gap: 2px; background: var(--bg-primary); + text-align: left; + cursor: pointer; + color: inherit; + font: inherit; + appearance: none; + -webkit-appearance: none; + + &:focus-visible { + outline: 2px solid rgba(var(--primary-rgb), 0.35); + outline-offset: 1px; + } span { font-size: 12px; @@ -1048,11 +1061,6 @@ line-height: 1.4; } - input { - accent-color: var(--primary); - margin: 0 0 4px; - } - &.active { border-color: var(--primary); background: rgba(var(--primary-rgb), 0.08); diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 1c4a27e..b87c021 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from 'react' import { useLocation } from 'react-router-dom' import { TableVirtuoso } from 'react-virtuoso' import { @@ -11,8 +11,11 @@ import { FolderOpen, Image as ImageIcon, Loader2, + AlertTriangle, + ClipboardList, MessageSquareText, Mic, + RefreshCw, Search, Square, Video, @@ -224,12 +227,48 @@ const getAvatarLetter = (name: string): string => { return [...name][0] || '?' } +const matchesContactTab = (contact: ContactInfo, tab: ConversationTab): boolean => { + if (tab === 'private') return contact.type === 'friend' + if (tab === 'group') return contact.type === 'group' + if (tab === 'official') return contact.type === 'official' + return contact.type === 'former_friend' +} + +const getContactTypeName = (type: ContactInfo['type']): string => { + if (type === 'friend') return '好友' + if (type === 'group') return '群聊' + if (type === 'official') return '公众号' + if (type === 'former_friend') return '曾经的好友' + return '其他' +} + const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const CONTACT_ENRICH_TIMEOUT_MS = 7000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 const EXPORT_AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80 +const CONTACTS_LIST_VIRTUAL_ROW_HEIGHT = 76 +const CONTACTS_LIST_VIRTUAL_OVERSCAN = 10 +const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000 type SessionDataSource = 'cache' | 'network' | null +type ContactsDataSource = 'cache' | 'network' | null + +interface ContactsLoadSession { + requestId: string + startedAt: number + attempt: number + timeoutMs: number +} + +interface ContactsLoadIssue { + kind: 'timeout' | 'error' + title: string + message: string + reason: string + errorDetail?: string + occurredAt: number + elapsedMs: number +} const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null @@ -480,6 +519,23 @@ function ExportPage() { const [searchKeyword, setSearchKeyword] = useState('') const [activeTab, setActiveTab] = useState('private') const [selectedSessions, setSelectedSessions] = useState>(new Set()) + const [contactsList, setContactsList] = useState([]) + const [isContactsListLoading, setIsContactsListLoading] = useState(true) + const [contactsDataSource, setContactsDataSource] = useState(null) + const [contactsUpdatedAt, setContactsUpdatedAt] = useState(null) + const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState(null) + const [contactsListScrollTop, setContactsListScrollTop] = useState(0) + const [contactsListViewportHeight, setContactsListViewportHeight] = useState(480) + const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) + const [contactsLoadSession, setContactsLoadSession] = useState(null) + const [contactsLoadIssue, setContactsLoadIssue] = useState(null) + const [showContactsDiagnostics, setShowContactsDiagnostics] = useState(false) + const [contactsDiagnosticTick, setContactsDiagnosticTick] = useState(Date.now()) + const [contactsAvatarEnrichProgress, setContactsAvatarEnrichProgress] = useState({ + loaded: 0, + total: 0, + running: false + }) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('A') @@ -536,6 +592,12 @@ function ExportPage() { const preselectAppliedRef = useRef(false) const exportCacheScopeRef = useRef('default') const exportCacheScopeReadyRef = useRef(false) + const contactsLoadVersionRef = useRef(0) + const contactsLoadAttemptRef = useRef(0) + const contactsLoadTimeoutTimerRef = useRef(null) + const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) + const contactsAvatarCacheRef = useRef>({}) + const contactsListRef = useRef(null) const ensureExportCacheScope = useCallback(async (): Promise => { if (exportCacheScopeReadyRef.current) { @@ -564,6 +626,331 @@ function ExportPage() { } }, []) + useEffect(() => { + let cancelled = false + void (async () => { + try { + const value = await configService.getContactsLoadTimeoutMs() + if (!cancelled) { + setContactsLoadTimeoutMs(value) + } + } catch (error) { + console.error('读取通讯录超时配置失败:', error) + } + })() + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs + }, [contactsLoadTimeoutMs]) + + const applyEnrichedContactsToList = useCallback((enrichedMap: Record) => { + if (!enrichedMap || Object.keys(enrichedMap).length === 0) return + setContactsList(prev => { + let changed = false + const next = prev.map(contact => { + const enriched = enrichedMap[contact.username] + if (!enriched) return contact + const displayName = enriched.displayName || contact.displayName + const avatarUrl = enriched.avatarUrl || contact.avatarUrl + if (displayName === contact.displayName && avatarUrl === contact.avatarUrl) { + return contact + } + changed = true + return { + ...contact, + displayName, + avatarUrl + } + }) + return changed ? next : prev + }) + }, []) + + const enrichContactsListInBackground = useCallback(async ( + sourceContacts: ContactInfo[], + loadVersion: number, + scopeKey: string + ) => { + const sourceByUsername = new Map() + for (const contact of sourceContacts) { + if (!contact.username) continue + sourceByUsername.set(contact.username, contact) + } + + const now = Date.now() + const usernames = sourceContacts + .map(contact => contact.username) + .filter(Boolean) + .filter((username) => { + const currentContact = sourceByUsername.get(username) + if (!currentContact) return false + const cacheEntry = contactsAvatarCacheRef.current[username] + if (!cacheEntry || !cacheEntry.avatarUrl) { + return !currentContact.avatarUrl + } + if (currentContact.avatarUrl && currentContact.avatarUrl !== cacheEntry.avatarUrl) { + return true + } + const checkedAt = cacheEntry.checkedAt || 0 + return now - checkedAt >= EXPORT_AVATAR_RECHECK_INTERVAL_MS + }) + + const total = usernames.length + setContactsAvatarEnrichProgress({ + loaded: 0, + total, + running: total > 0 + }) + if (total === 0) return + + for (let i = 0; i < total; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) { + if (contactsLoadVersionRef.current !== loadVersion) return + const batch = usernames.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE) + if (batch.length === 0) continue + + try { + const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(batch) + if (contactsLoadVersionRef.current !== loadVersion) return + if (avatarResult.success && avatarResult.contacts) { + applyEnrichedContactsToList(avatarResult.contacts) + for (const [username, enriched] of Object.entries(avatarResult.contacts)) { + const prev = sourceByUsername.get(username) + if (!prev) continue + sourceByUsername.set(username, { + ...prev, + displayName: enriched.displayName || prev.displayName, + avatarUrl: enriched.avatarUrl || prev.avatarUrl + }) + } + } + + const batchContacts = batch + .map(username => sourceByUsername.get(username)) + .filter((contact): contact is ContactInfo => Boolean(contact)) + const upsertResult = upsertAvatarCacheFromContacts( + contactsAvatarCacheRef.current, + batchContacts, + { markCheckedUsernames: batch } + ) + contactsAvatarCacheRef.current = upsertResult.avatarEntries + if (upsertResult.updatedAt) { + setAvatarCacheUpdatedAt(upsertResult.updatedAt) + } + } catch (error) { + console.error('导出页分批补全头像失败:', error) + } + + const loaded = Math.min(i + batch.length, total) + setContactsAvatarEnrichProgress({ + loaded, + total, + running: loaded < total + }) + await new Promise(resolve => setTimeout(resolve, 0)) + } + + void configService.setContactsAvatarCache(scopeKey, contactsAvatarCacheRef.current).catch((error) => { + console.error('写入导出页头像缓存失败:', error) + }) + }, [applyEnrichedContactsToList]) + + const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => { + const scopeKey = options?.scopeKey || await ensureExportCacheScope() + const loadVersion = contactsLoadVersionRef.current + 1 + contactsLoadVersionRef.current = loadVersion + contactsLoadAttemptRef.current += 1 + const startedAt = Date.now() + const timeoutMs = contactsLoadTimeoutMsRef.current + const requestId = `export-contacts-${startedAt}-${contactsLoadAttemptRef.current}` + setContactsLoadSession({ + requestId, + startedAt, + attempt: contactsLoadAttemptRef.current, + timeoutMs + }) + setContactsLoadIssue(null) + setShowContactsDiagnostics(false) + if (contactsLoadTimeoutTimerRef.current) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + const timeoutTimerId = window.setTimeout(() => { + if (contactsLoadVersionRef.current !== loadVersion) return + const elapsedMs = Date.now() - startedAt + setContactsLoadIssue({ + kind: 'timeout', + title: '联系人列表加载超时', + message: `等待超过 ${timeoutMs}ms,联系人列表仍未返回。`, + reason: 'chat.getContacts 长时间未返回,可能是数据库查询繁忙或连接异常。', + occurredAt: Date.now(), + elapsedMs + }) + }, timeoutMs) + contactsLoadTimeoutTimerRef.current = timeoutTimerId + + setIsContactsListLoading(true) + setContactsAvatarEnrichProgress({ + loaded: 0, + total: 0, + running: false + }) + + try { + const contactsResult = await window.electronAPI.chat.getContacts() + if (contactsLoadVersionRef.current !== loadVersion) return + + if (contactsResult.success && contactsResult.contacts) { + if (contactsLoadTimeoutTimerRef.current === timeoutTimerId) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + const contactsWithAvatarCache = mergeAvatarCacheIntoContacts( + contactsResult.contacts, + contactsAvatarCacheRef.current + ) + setContactsList(contactsWithAvatarCache) + syncContactTypeCounts(contactsWithAvatarCache) + setContactsDataSource('network') + setContactsUpdatedAt(Date.now()) + setContactsLoadIssue(null) + setIsContactsListLoading(false) + + const upsertResult = upsertAvatarCacheFromContacts( + contactsAvatarCacheRef.current, + contactsWithAvatarCache, + { prune: true } + ) + contactsAvatarCacheRef.current = upsertResult.avatarEntries + if (upsertResult.updatedAt) { + setAvatarCacheUpdatedAt(upsertResult.updatedAt) + } + + void configService.setContactsAvatarCache(scopeKey, contactsAvatarCacheRef.current).catch((error) => { + console.error('写入导出页头像缓存失败:', error) + }) + void configService.setContactsListCache( + scopeKey, + contactsWithAvatarCache.map(contact => ({ + username: contact.username, + displayName: contact.displayName, + remark: contact.remark, + nickname: contact.nickname, + type: contact.type + })) + ).catch((error) => { + console.error('写入导出页通讯录缓存失败:', error) + }) + void enrichContactsListInBackground(contactsWithAvatarCache, loadVersion, scopeKey) + return + } + + const elapsedMs = Date.now() - startedAt + setContactsLoadIssue({ + kind: 'error', + title: '联系人列表加载失败', + message: '联系人接口返回失败,未拿到联系人列表。', + reason: 'chat.getContacts 返回 success=false。', + errorDetail: contactsResult.error || '未知错误', + occurredAt: Date.now(), + elapsedMs + }) + } catch (error) { + console.error('加载导出页联系人失败:', error) + const elapsedMs = Date.now() - startedAt + setContactsLoadIssue({ + kind: 'error', + title: '联系人列表加载失败', + message: '联系人请求执行异常。', + reason: '调用 chat.getContacts 发生异常。', + errorDetail: String(error), + occurredAt: Date.now(), + elapsedMs + }) + } finally { + if (contactsLoadTimeoutTimerRef.current === timeoutTimerId) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + if (contactsLoadVersionRef.current === loadVersion) { + setIsContactsListLoading(false) + } + } + }, [ensureExportCacheScope, enrichContactsListInBackground, syncContactTypeCounts]) + + useEffect(() => { + if (!isExportRoute) return + let cancelled = false + void (async () => { + const scopeKey = await ensureExportCacheScope() + if (cancelled) return + try { + const [cacheItem, avatarCacheItem] = await Promise.all([ + configService.getContactsListCache(scopeKey), + configService.getContactsAvatarCache(scopeKey) + ]) + const avatarCacheMap = avatarCacheItem?.avatars || {} + contactsAvatarCacheRef.current = avatarCacheMap + setAvatarCacheUpdatedAt(avatarCacheItem?.updatedAt || null) + if (!cancelled && cacheItem && Array.isArray(cacheItem.contacts) && cacheItem.contacts.length > 0) { + const cachedContacts: ContactInfo[] = cacheItem.contacts.map(contact => ({ + ...contact, + avatarUrl: avatarCacheMap[contact.username]?.avatarUrl + })) + setContactsList(cachedContacts) + syncContactTypeCounts(cachedContacts) + setContactsDataSource('cache') + setContactsUpdatedAt(cacheItem.updatedAt || null) + setIsContactsListLoading(false) + } + } catch (error) { + console.error('读取导出页联系人缓存失败:', error) + } + + if (!cancelled) { + void loadContactsList({ scopeKey }) + } + })() + return () => { + cancelled = true + } + }, [isExportRoute, ensureExportCacheScope, loadContactsList, syncContactTypeCounts]) + + useEffect(() => { + if (isExportRoute) return + contactsLoadVersionRef.current += 1 + setContactsAvatarEnrichProgress({ + loaded: 0, + total: 0, + running: false + }) + }, [isExportRoute]) + + useEffect(() => { + if (contactsLoadTimeoutTimerRef.current) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + return () => { + if (contactsLoadTimeoutTimerRef.current) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + } + }, []) + + useEffect(() => { + if (!contactsLoadIssue || contactsList.length > 0) return + if (!(isContactsListLoading && contactsLoadIssue.kind === 'timeout')) return + const timer = window.setInterval(() => { + setContactsDiagnosticTick(Date.now()) + }, 500) + return () => window.clearInterval(timer) + }, [contactsList.length, isContactsListLoading, contactsLoadIssue]) + useEffect(() => { tasksRef.current = tasks }, [tasks]) @@ -1035,28 +1422,39 @@ function ExportPage() { const openExportDialog = (payload: Omit) => { setExportDialog({ open: true, ...payload }) - if (payload.scope === 'sns') { - setOptions(prev => ({ - ...prev, - format: prev.format === 'json' || prev.format === 'html' ? prev.format : 'html' - })) - return - } + setOptions(prev => { + const nextDateRange = prev.dateRange ?? (() => { + const now = new Date() + const start = new Date(now) + start.setHours(0, 0, 0, 0) + return { start, end: now } + })() - if (payload.scope === 'content' && payload.contentType) { - if (payload.contentType === 'text') { - setOptions(prev => ({ ...prev, exportMedia: false })) - } else { - setOptions(prev => ({ - ...prev, - exportMedia: true, - exportImages: payload.contentType === 'image', - exportVoices: payload.contentType === 'voice', - exportVideos: payload.contentType === 'video', - exportEmojis: payload.contentType === 'emoji' - })) + const next: ExportOptions = { + ...prev, + useAllTime: true, + dateRange: nextDateRange } - } + + if (payload.scope === 'sns') { + next.format = prev.format === 'json' || prev.format === 'html' ? prev.format : 'html' + return next + } + + if (payload.scope === 'content' && payload.contentType) { + if (payload.contentType === 'text') { + next.exportMedia = false + } else { + next.exportMedia = true + next.exportImages = payload.contentType === 'image' + next.exportVoices = payload.contentType === 'voice' + next.exportVideos = payload.contentType === 'video' + next.exportEmojis = payload.contentType === 'emoji' + } + } + + return next + }) } const closeExportDialog = () => { @@ -1492,6 +1890,120 @@ function ExportPage() { return '公众号' }, [activeTab]) + const filteredContacts = useMemo(() => { + const keyword = searchKeyword.trim().toLowerCase() + return contactsList + .filter((contact) => { + if (!matchesContactTab(contact, activeTab)) return false + if (!keyword) return true + return ( + (contact.displayName || '').toLowerCase().includes(keyword) || + (contact.remark || '').toLowerCase().includes(keyword) || + contact.username.toLowerCase().includes(keyword) + ) + }) + .sort((a, b) => (a.displayName || a.username).localeCompare(b.displayName || b.username, 'zh-Hans-CN')) + }, [contactsList, activeTab, searchKeyword]) + + const contactsUpdatedAtLabel = useMemo(() => { + if (!contactsUpdatedAt) return '' + return new Date(contactsUpdatedAt).toLocaleString() + }, [contactsUpdatedAt]) + + const avatarCacheUpdatedAtLabel = useMemo(() => { + if (!avatarCacheUpdatedAt) return '' + return new Date(avatarCacheUpdatedAt).toLocaleString() + }, [avatarCacheUpdatedAt]) + + const contactsAvatarCachedCount = useMemo(() => { + return contactsList.reduce((count, contact) => ( + contact.avatarUrl ? count + 1 : count + ), 0) + }, [contactsList]) + + useEffect(() => { + if (!contactsListRef.current) return + contactsListRef.current.scrollTop = 0 + setContactsListScrollTop(0) + }, [activeTab, searchKeyword]) + + useEffect(() => { + const node = contactsListRef.current + if (!node) return + const updateViewportHeight = () => { + setContactsListViewportHeight(Math.max(node.clientHeight, CONTACTS_LIST_VIRTUAL_ROW_HEIGHT)) + } + updateViewportHeight() + const observer = new ResizeObserver(() => updateViewportHeight()) + observer.observe(node) + return () => observer.disconnect() + }, [filteredContacts.length, isContactsListLoading]) + + useEffect(() => { + const maxScroll = Math.max(0, filteredContacts.length * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT - contactsListViewportHeight) + if (contactsListScrollTop <= maxScroll) return + setContactsListScrollTop(maxScroll) + if (contactsListRef.current) { + contactsListRef.current.scrollTop = maxScroll + } + }, [filteredContacts.length, contactsListViewportHeight, contactsListScrollTop]) + + const { startIndex: contactStartIndex, endIndex: contactEndIndex } = useMemo(() => { + if (filteredContacts.length === 0) { + return { startIndex: 0, endIndex: 0 } + } + const baseStart = Math.floor(contactsListScrollTop / CONTACTS_LIST_VIRTUAL_ROW_HEIGHT) + const visibleCount = Math.ceil(contactsListViewportHeight / CONTACTS_LIST_VIRTUAL_ROW_HEIGHT) + const nextStart = Math.max(0, baseStart - CONTACTS_LIST_VIRTUAL_OVERSCAN) + const nextEnd = Math.min(filteredContacts.length, nextStart + visibleCount + CONTACTS_LIST_VIRTUAL_OVERSCAN * 2) + return { + startIndex: nextStart, + endIndex: nextEnd + } + }, [filteredContacts.length, contactsListViewportHeight, contactsListScrollTop]) + + const visibleContacts = useMemo(() => { + return filteredContacts.slice(contactStartIndex, contactEndIndex) + }, [filteredContacts, contactStartIndex, contactEndIndex]) + + const onContactsListScroll = useCallback((event: UIEvent) => { + setContactsListScrollTop(event.currentTarget.scrollTop) + }, []) + + const contactsIssueElapsedMs = useMemo(() => { + if (!contactsLoadIssue) return 0 + if (isContactsListLoading && contactsLoadSession) { + return Math.max(contactsLoadIssue.elapsedMs, contactsDiagnosticTick - contactsLoadSession.startedAt) + } + return contactsLoadIssue.elapsedMs + }, [contactsDiagnosticTick, isContactsListLoading, contactsLoadIssue, contactsLoadSession]) + + const contactsDiagnosticsText = useMemo(() => { + if (!contactsLoadIssue || !contactsLoadSession) return '' + return [ + `请求ID: ${contactsLoadSession.requestId}`, + `请求序号: 第 ${contactsLoadSession.attempt} 次`, + `阈值配置: ${contactsLoadSession.timeoutMs}ms`, + `当前状态: ${contactsLoadIssue.kind === 'timeout' ? '超时等待中' : '请求失败'}`, + `累计耗时: ${(contactsIssueElapsedMs / 1000).toFixed(1)}s`, + `发生时间: ${new Date(contactsLoadIssue.occurredAt).toLocaleString()}`, + '阶段: chat.getContacts', + `原因: ${contactsLoadIssue.reason}`, + `错误详情: ${contactsLoadIssue.errorDetail || '无'}` + ].join('\n') + }, [contactsIssueElapsedMs, contactsLoadIssue, contactsLoadSession]) + + const copyContactsDiagnostics = useCallback(async () => { + if (!contactsDiagnosticsText) return + try { + await navigator.clipboard.writeText(contactsDiagnosticsText) + alert('诊断信息已复制') + } catch (error) { + console.error('复制诊断信息失败:', error) + alert('复制失败,请手动复制诊断信息') + } + }, [contactsDiagnosticsText]) + const sessionContactsUpdatedAtLabel = useMemo(() => { if (!sessionContactsUpdatedAt) return '' return new Date(sessionContactsUpdatedAt).toLocaleString() @@ -1797,7 +2309,7 @@ function ExportPage() { setSearchKeyword(event.target.value)} - placeholder={`搜索${activeTabLabel}会话...`} + placeholder={`搜索${activeTabLabel}联系人...`} /> {searchKeyword && ( )}
- - - - {selectedCount > 0 && ( -
- 已选中 {selectedCount} 个会话 - - -
- )}
- {sessionContactsUpdatedAt && ( + + 共 {filteredContacts.length} / {contactsList.length} 个联系人 + + {contactsUpdatedAt && ( - {sessionDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {sessionContactsUpdatedAtLabel} + {contactsDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {contactsUpdatedAtLabel} )} - {sessions.length > 0 && ( + {contactsList.length > 0 && ( - 头像缓存 {sessionAvatarCachedCount}/{sessions.length} - {sessionAvatarUpdatedAtLabel ? ` · 更新于 ${sessionAvatarUpdatedAtLabel}` : ''} + 头像缓存 {contactsAvatarCachedCount}/{contactsList.length} + {avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''} )} - {(isLoading || isSessionEnriching) && sessions.length > 0 && ( + {(isContactsListLoading || contactsAvatarEnrichProgress.running) && contactsList.length > 0 && ( 后台同步中... )} + {contactsAvatarEnrichProgress.running && ( + + 头像补全中 {contactsAvatarEnrichProgress.loaded}/{contactsAvatarEnrichProgress.total} + + )}
- {!showInitialSkeleton && (isLoading || isSessionEnriching) && ( + {contactsList.length > 0 && (isContactsListLoading || contactsAvatarEnrichProgress.running) && (
- {isLoading ? '导出板块数据加载中…' : '正在补充头像…'} + {isContactsListLoading ? '联系人列表同步中…' : '正在补充头像…'}
)}
- {showInitialSkeleton ? ( -
- {Array.from({ length: 8 }).map((_, rowIndex) => ( -
- - - - - - + {contactsList.length === 0 && contactsLoadIssue ? ( +
+
+
+ + {contactsLoadIssue.title}
- ))} +

{contactsLoadIssue.message}

+

{contactsLoadIssue.reason}

+
    +
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • +
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • +
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • +
+
+ + + +
+ {showContactsDiagnostics && ( +
{contactsDiagnosticsText}
+ )} +
+
+ ) : isContactsListLoading && contactsList.length === 0 ? ( +
+ + 联系人加载中... +
+ ) : filteredContacts.length === 0 ? ( +
+ 暂无联系人
- ) : visibleSessions.length === 0 ? ( -
暂无会话
) : ( - session.username} - itemContent={(_, session) => renderRowCells(session)} - overscan={420} - /> +
+
+ {visibleContacts.map((contact, idx) => { + const absoluteIndex = contactStartIndex + idx + const top = absoluteIndex * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT + return ( +
+
+
+ {contact.avatarUrl ? ( + + ) : ( + {getAvatarLetter(contact.displayName)} + )} +
+
+
{contact.displayName}
+
{contact.username}
+
+
+ {getContactTypeName(contact.type)} +
+
+
+ ) + })} +
+
)}
@@ -1994,18 +2557,23 @@ function ExportPage() {

发送者名称显示

-
- {displayNameOptions.map(option => ( - - ))} +
+ {displayNameOptions.map(option => { + const isActive = options.displayNamePreference === option.value + return ( + + ) + })}
From 1414a4a9cff98a9d9bbecde398ddfd66c82924cf Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 12:30:15 +0800 Subject: [PATCH 041/162] fix(export): style mirrored contacts list in export panel --- src/pages/ExportPage.scss | 219 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index a33df95..e13ecfc 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -576,6 +576,225 @@ min-height: 320px; height: 100%; flex: 1; + display: flex; + flex-direction: column; +} + +.table-wrap { + .loading-state, + .empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--text-tertiary); + font-size: 14px; + + .spin { + animation: exportSpin 1s linear infinite; + } + } + + .load-issue-state { + flex: 1; + padding: 14px; + overflow-y: auto; + } + + .issue-card { + border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color)); + background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--card-bg)); + border-radius: 12px; + padding: 14px; + color: var(--text-primary); + } + + .issue-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary)); + margin-bottom: 8px; + } + + .issue-message { + margin: 0 0 8px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + + .issue-reason { + margin: 0; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + + .issue-hints { + margin: 10px 0 0; + padding-left: 18px; + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.6; + } + + .issue-actions { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .issue-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 8px; + padding: 7px 10px; + font-size: 12px; + color: var(--text-secondary); + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + color: var(--text-primary); + border-color: var(--text-tertiary); + background: var(--bg-hover); + } + + &.primary { + background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary)); + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + color: var(--primary); + } + } + + .issue-diagnostics { + margin-top: 12px; + border-radius: 8px; + background: var(--bg-primary); + border: 1px dashed var(--border-color); + padding: 10px; + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; + } + + .contacts-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 12px 12px; + position: relative; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + border-radius: 3px; + opacity: 0.3; + } + } + + .contacts-list-virtual { + position: relative; + min-height: 100%; + } + + .contact-row { + position: absolute; + left: 0; + right: 0; + height: 76px; + padding-bottom: 4px; + will-change: transform; + } + + .contact-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + height: 72px; + box-sizing: border-box; + border-radius: 10px; + transition: all 0.2s; + cursor: default; + + &:hover { + background: var(--bg-hover); + } + } + + .contact-avatar { + width: 44px; + height: 44px; + border-radius: 10px; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: #fff; + font-size: 16px; + font-weight: 600; + } + } + + .contact-info { + flex: 1; + min-width: 0; + } + + .contact-name { + font-size: 14px; + color: var(--text-primary); + margin-bottom: 2px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .contact-remark { + font-size: 12px; + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .contact-type { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + padding: 4px 8px; + border-radius: 999px; + background: var(--bg-secondary); + color: var(--text-secondary); + flex-shrink: 0; + } } .table-virtuoso { From 983783ea95a448368d5a8a4793324c6c46e44eb3 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 12:35:58 +0800 Subject: [PATCH 042/162] feat(export): add per-contact single export action button --- src/pages/ExportPage.tsx | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index b87c021..10a11c0 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1905,6 +1905,14 @@ function ExportPage() { .sort((a, b) => (a.displayName || a.username).localeCompare(b.displayName || b.username, 'zh-Hans-CN')) }, [contactsList, activeTab, searchKeyword]) + const sessionRowByUsername = useMemo(() => { + const map = new Map() + for (const session of sessions) { + map.set(session.username, session) + } + return map + }, [sessions]) + const contactsUpdatedAtLabel = useMemo(() => { if (!contactsUpdatedAt) return '' return new Date(contactsUpdatedAt).toLocaleString() @@ -2407,6 +2415,11 @@ function ExportPage() { {visibleContacts.map((contact, idx) => { const absoluteIndex = contactStartIndex + idx const top = absoluteIndex * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT + const matchedSession = sessionRowByUsername.get(contact.username) + const canExport = Boolean(matchedSession?.hasSession) + const isRunning = canExport && runningSessionIds.has(contact.username) + const isQueued = canExport && queuedSessionIds.has(contact.username) + const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '' return (
{getContactTypeName(contact.type)}
+
+ + {recent && {recent}} +
) From 64616b91365950342f1e16ef7645f218c005727a Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 13:31:42 +0800 Subject: [PATCH 043/162] feat(sns): add header overview stats line --- src/pages/SnsPage.scss | 29 +++++++++++--- src/pages/SnsPage.tsx | 85 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 10 deletions(-) diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index e9620ae..b30ec19 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -45,11 +45,28 @@ margin-bottom: 8px; padding: 0 4px; - h2 { - font-size: 20px; - font-weight: 700; - margin: 0; - color: var(--text-primary); + .feed-header-main { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + + h2 { + font-size: 20px; + font-weight: 700; + margin: 0; + color: var(--text-primary); + } + } + + .feed-stats-line { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.4; + + &.loading { + opacity: 0.7; + } } .header-actions { @@ -2091,4 +2108,4 @@ cursor: not-allowed; } } -} \ No newline at end of file +} diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 452931c..60f79f1 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -13,11 +13,25 @@ interface Contact { type?: 'friend' | 'former_friend' | 'sns_only' } +interface SnsOverviewStats { + totalPosts: number + totalFriends: number + earliestTime: number | null + latestTime: number | null +} + export default function SnsPage() { const [posts, setPosts] = useState([]) const [loading, setLoading] = useState(false) const [hasMore, setHasMore] = useState(true) const loadingRef = useRef(false) + const [overviewStats, setOverviewStats] = useState({ + totalPosts: 0, + totalFriends: 0, + earliestTime: null, + latestTime: null + }) + const [overviewStatsLoading, setOverviewStatsLoading] = useState(false) // Filter states const [searchKeyword, setSearchKeyword] = useState('') @@ -75,6 +89,58 @@ export default function SnsPage() { } }, [posts]) + const formatDateOnly = (timestamp: number | null): string => { + if (!timestamp || timestamp <= 0) return '--' + const date = new Date(timestamp * 1000) + if (Number.isNaN(date.getTime())) return '--' + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` + } + + const loadOverviewStats = useCallback(async () => { + setOverviewStatsLoading(true) + try { + const statsResult = await window.electronAPI.sns.getExportStats() + if (!statsResult.success || !statsResult.data) { + throw new Error(statsResult.error || '获取朋友圈统计失败') + } + + const totalPosts = Math.max(0, Number(statsResult.data.totalPosts || 0)) + const totalFriends = Math.max(0, Number(statsResult.data.totalFriends || 0)) + let earliestTime: number | null = null + let latestTime: number | null = null + + if (totalPosts > 0) { + const [latestResult, earliestResult] = await Promise.all([ + window.electronAPI.sns.getTimeline(1, 0), + window.electronAPI.sns.getTimeline(1, Math.max(totalPosts - 1, 0)) + ]) + const latestTs = Number(latestResult.timeline?.[0]?.createTime || 0) + const earliestTs = Number(earliestResult.timeline?.[0]?.createTime || 0) + + if (latestResult.success && Number.isFinite(latestTs) && latestTs > 0) { + latestTime = Math.floor(latestTs) + } + if (earliestResult.success && Number.isFinite(earliestTs) && earliestTs > 0) { + earliestTime = Math.floor(earliestTs) + } + } + + setOverviewStats({ + totalPosts, + totalFriends, + earliestTime, + latestTime + }) + } catch (error) { + console.error('Failed to load SNS overview stats:', error) + } finally { + setOverviewStatsLoading(false) + } + }, []) + const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => { const { reset = false, direction = 'older' } = options if (loadingRef.current) return @@ -244,7 +310,8 @@ export default function SnsPage() { // Initial Load & Listeners useEffect(() => { loadContacts() - }, [loadContacts]) + loadOverviewStats() + }, [loadContacts, loadOverviewStats]) useEffect(() => { const handleChange = () => { @@ -252,11 +319,12 @@ export default function SnsPage() { setPosts([]); setHasMore(true); setHasNewer(false); setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined); loadContacts(); + loadOverviewStats(); loadPosts({ reset: true }); } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) - }, [loadContacts, loadPosts]) + }, [loadContacts, loadOverviewStats, loadPosts]) useEffect(() => { const timer = setTimeout(() => { @@ -288,7 +356,12 @@ export default function SnsPage() {
-

朋友圈

+
+

朋友圈

+
+ 共 {overviewStats.totalPosts} 条 | {formatDateOnly(overviewStats.earliestTime)} ~ {formatDateOnly(overviewStats.latestTime)} | {overviewStats.totalFriends} 位好友 +
+
From bc739dc4a0766057189c46bbc05406a12b8a0ba0 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 13:38:06 +0800 Subject: [PATCH 044/162] style(sns): keep header and actions sticky --- src/pages/SnsPage.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index b30ec19..896e18c 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -44,6 +44,13 @@ justify-content: space-between; margin-bottom: 8px; padding: 0 4px; + position: sticky; + top: 0; + z-index: 20; + background: var(--sns-bg-color); + border-bottom: 1px solid var(--border-color); + padding-top: 10px; + padding-bottom: 10px; .feed-header-main { display: flex; From 204baa52ab3980505d7f958f0e8e1a8f59f3d295 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 13:43:21 +0800 Subject: [PATCH 045/162] feat(sns): show per-contact post counts in filter panel --- electron/main.ts | 4 +++ electron/preload.ts | 1 + electron/services/snsService.ts | 34 ++++++++++++++++++++++++ src/components/Sns/SnsFilterPanel.tsx | 6 ++++- src/pages/SnsPage.scss | 38 ++++++++++++++++++++------- src/pages/SnsPage.tsx | 17 ++++++++---- src/types/electron.d.ts | 1 + 7 files changed, 86 insertions(+), 15 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 5985639..85a8ffb 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1044,6 +1044,10 @@ function registerIpcHandlers() { return snsService.getSnsUsernames() }) + ipcMain.handle('sns:getUserPostCounts', async () => { + return snsService.getUserPostCounts() + }) + ipcMain.handle('sns:getExportStats', async () => { return snsService.getExportStats() }) diff --git a/electron/preload.ts b/electron/preload.ts index 8723db5..1ac66df 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -294,6 +294,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), + getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'), getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'), getExportStats: () => ipcRenderer.invoke('sns:getExportStats'), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 369a003..d22c853 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -407,6 +407,40 @@ class SnsService { return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) } } + async getUserPostCounts(): Promise<{ success: boolean; data?: Record; error?: string }> { + try { + const counts: Record = {} + const primary = await wcdbService.execQuery( + 'sns', + null, + "SELECT user_name AS username, COUNT(1) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> '' GROUP BY user_name" + ) + + let rows = primary.rows + if (!primary.success || !rows) { + const fallback = await wcdbService.execQuery( + 'sns', + null, + "SELECT userName AS username, COUNT(1) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> '' GROUP BY userName" + ) + if (!fallback.success || !fallback.rows) { + return { success: false, error: primary.error || fallback.error || '获取朋友圈联系人条数失败' } + } + rows = fallback.rows + } + + for (const row of rows) { + const usernameRaw = row?.username ?? row?.user_name ?? row?.userName ?? '' + const username = typeof usernameRaw === 'string' ? usernameRaw.trim() : String(usernameRaw || '').trim() + if (!username) continue + counts[username] = this.parseCountValue(row) + } + return { success: true, data: counts } + } catch (e) { + return { success: false, error: String(e) } + } + } + private async getExportStatsFromTableCount(): Promise<{ totalPosts: number; totalFriends: number }> { let totalPosts = 0 let totalFriends = 0 diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index 9894689..6c914a0 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -7,6 +7,7 @@ interface Contact { username: string displayName: string avatarUrl?: string + postCount?: number } interface SnsFilterPanelProps { @@ -150,7 +151,10 @@ export const SnsFilterPanel: React.FC = ({ onClick={() => toggleUserSelection(contact.username)} > - {contact.displayName} +
+ {contact.displayName} + {Math.max(0, Number(contact.postCount || 0))} 条 +
))} {filteredContacts.length === 0 && ( diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 896e18c..98c2286 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -1055,9 +1055,16 @@ margin-bottom: 0; /* Remove margin to merge */ - .contact-name { - color: var(--primary); - font-weight: 600; + .contact-meta { + .contact-name { + color: var(--primary); + font-weight: 600; + } + + .contact-post-count { + color: var(--primary); + opacity: 0.9; + } } /* If the NEXT item is also selected */ @@ -1080,13 +1087,26 @@ /* Compensate for missing border */ } - .contact-name { + .contact-meta { flex: 1; - font-size: 14px; - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + + .contact-name { + font-size: 14px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .contact-post-count { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.2; + } } } } diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 60f79f1..6416700 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -11,6 +11,7 @@ interface Contact { displayName: string avatarUrl?: string type?: 'friend' | 'former_friend' | 'sns_only' + postCount: number } interface SnsOverviewStats { @@ -251,11 +252,16 @@ export default function SnsPage() { const loadContacts = useCallback(async () => { setContactsLoading(true) try { - // 并行获取联系人列表和朋友圈发布者列表 - const [contactsResult, snsResult] = await Promise.all([ + // 并行获取联系人列表、朋友圈发布者列表和每个发布者的动态条数 + const [contactsResult, snsResult, snsCountsResult] = await Promise.all([ window.electronAPI.chat.getContacts(), - window.electronAPI.sns.getSnsUsernames() + window.electronAPI.sns.getSnsUsernames(), + window.electronAPI.sns.getUserPostCounts() ]) + const snsPostCountMap = new Map( + Object.entries(snsCountsResult.success ? (snsCountsResult.data || {}) : {}) + .map(([username, count]) => [username, Math.max(0, Number(count || 0))]) + ) // 以联系人为基础,按 username 去重 const contactMap = new Map() @@ -268,7 +274,8 @@ export default function SnsPage() { username: c.username, displayName: c.displayName, avatarUrl: c.avatarUrl, - type: c.type === 'former_friend' ? 'former_friend' : 'friend' + type: c.type === 'former_friend' ? 'former_friend' : 'friend', + postCount: snsPostCountMap.get(c.username) || 0 }) } } @@ -278,7 +285,7 @@ export default function SnsPage() { if (snsResult.success && snsResult.usernames) { for (const u of snsResult.usernames) { if (!contactMap.has(u)) { - contactMap.set(u, { username: u, displayName: u, type: 'sns_only' }) + contactMap.set(u, { username: u, displayName: u, type: 'sns_only', postCount: snsPostCountMap.get(u) || 0 }) } } } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index f7a1e57..e24c3d5 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -591,6 +591,7 @@ export interface ElectronAPI { onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }> getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }> + getUserPostCounts: () => Promise<{ success: boolean; data?: Record; error?: string }> getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> From b5507b9f5d98bfd68eac7c649502738071369eab Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 13:46:04 +0800 Subject: [PATCH 046/162] feat(export): add session detail sidebar entry --- src/pages/ExportPage.scss | 228 ++++++++++++++ src/pages/ExportPage.tsx | 645 +++++++++++++++++++++++++++++++------- 2 files changed, 759 insertions(+), 114 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index e13ecfc..eaad4bc 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -569,6 +569,18 @@ color: var(--text-secondary); } +.session-table-layout { + display: flex; + flex: 1; + min-height: 0; + gap: 10px; + + .table-wrap { + flex: 1; + min-width: 0; + } +} + .table-wrap { overflow: hidden; border: 1px solid var(--border-color); @@ -936,6 +948,35 @@ align-items: flex-end; gap: 4px; + .row-action-main { + display: inline-flex; + align-items: center; + gap: 6px; + } + + .row-detail-btn { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 7px 10px; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + white-space: nowrap; + + &:hover { + border-color: var(--text-tertiary); + color: var(--text-primary); + background: var(--bg-hover); + } + + &.active { + border-color: var(--primary); + color: var(--primary); + background: rgba(var(--primary-rgb), 0.12); + } + } + .row-export-btn { border: none; border-radius: 8px; @@ -974,6 +1015,179 @@ } } +.export-session-detail-panel { + width: 300px; + min-width: 300px; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--card-bg); + display: flex; + flex-direction: column; + overflow: hidden; + + .detail-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px; + border-bottom: 1px solid var(--border-color); + + h4 { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + } + + .close-btn { + border: none; + background: transparent; + color: var(--text-secondary); + width: 26px; + height: 26px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + } + + .detail-loading, + .detail-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--text-secondary); + font-size: 13px; + padding: 14px; + } + + .detail-content { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 14px; + } + + .detail-section { + margin-bottom: 18px; + + &:last-child { + margin-bottom: 0; + } + + .section-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; + } + } + + .detail-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; + border-bottom: 1px solid var(--border-color); + font-size: 13px; + + &:last-child { + border-bottom: none; + } + + .label { + color: var(--text-secondary); + flex-shrink: 0; + } + + .value { + flex: 1; + text-align: right; + color: var(--text-primary); + word-break: break-all; + user-select: text; + + &.highlight { + color: var(--primary); + font-weight: 600; + } + } + + .copy-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s, color 0.15s, background 0.15s; + + &:hover { + background: var(--bg-secondary); + color: var(--text-primary); + } + } + + &:hover .copy-btn { + opacity: 1; + } + } + + .table-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + .detail-table-placeholder { + padding: 10px 12px; + border-radius: 8px; + background: var(--bg-secondary); + font-size: 12px; + color: var(--text-secondary); + } + + .table-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-radius: 8px; + background: var(--bg-secondary); + font-size: 12px; + + .db-name { + color: var(--text-primary); + font-weight: 500; + } + + .table-count { + color: var(--text-secondary); + } + } +} + .table-state { display: flex; align-items: center; @@ -1401,6 +1615,16 @@ .media-check-grid { grid-template-columns: repeat(2, minmax(120px, 1fr)); } + + .session-table-layout.with-detail { + flex-direction: column; + } + + .export-session-detail-panel { + width: 100%; + min-width: 0; + max-height: 360px; + } } @media (max-width: 720px) { @@ -1421,4 +1645,8 @@ .date-range-row { grid-template-columns: 1fr; } + + .export-session-detail-panel { + max-height: 320px; + } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 10a11c0..5051bea 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -3,16 +3,22 @@ import { useLocation } from 'react-router-dom' import { TableVirtuoso } from 'react-virtuoso' import { Aperture, + Calendar, + Check, ChevronDown, ChevronRight, CheckSquare, + Copy, + Database, Download, ExternalLink, FolderOpen, + Hash, Image as ImageIcon, Loader2, AlertTriangle, ClipboardList, + MessageSquare, MessageSquareText, Mic, RefreshCw, @@ -169,6 +175,15 @@ const formatAbsoluteDate = (timestamp: number): string => { return `${y}-${m}-${day}` } +const formatYmdDateFromSeconds = (timestamp?: number): string => { + if (!timestamp || !Number.isFinite(timestamp)) return '—' + const d = new Date(timestamp * 1000) + const y = d.getFullYear() + const m = `${d.getMonth() + 1}`.padStart(2, '0') + const day = `${d.getDate()}`.padStart(2, '0') + return `${y}-${m}-${day}` +} + const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => { if (!timestamp) return '' const diff = Math.max(0, now - timestamp) @@ -270,6 +285,28 @@ interface ContactsLoadIssue { elapsedMs: number } +interface SessionDetail { + wxid: string + displayName: string + remark?: string + nickName?: string + alias?: string + avatarUrl?: string + messageCount: number + voiceMessages?: number + imageMessages?: number + videoMessages?: number + emojiMessages?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number + firstMessageTime?: number + latestMessageTime?: number + messageTables: { dbName: string; tableName: string; count: number }[] +} + const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null try { @@ -536,6 +573,11 @@ function ExportPage() { total: 0, running: false }) + const [showSessionDetailPanel, setShowSessionDetailPanel] = useState(false) + const [sessionDetail, setSessionDetail] = useState(null) + const [isLoadingSessionDetail, setIsLoadingSessionDetail] = useState(false) + const [isLoadingSessionDetailExtra, setIsLoadingSessionDetailExtra] = useState(false) + const [copiedDetailField, setCopiedDetailField] = useState(null) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('A') @@ -598,6 +640,7 @@ function ExportPage() { const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) const contactsAvatarCacheRef = useRef>({}) const contactsListRef = useRef(null) + const detailRequestSeqRef = useRef(0) const ensureExportCacheScope = useCallback(async (): Promise => { if (exportCacheScopeReadyRef.current) { @@ -1913,6 +1956,163 @@ function ExportPage() { return map }, [sessions]) + const contactByUsername = useMemo(() => { + const map = new Map() + for (const contact of contactsList) { + map.set(contact.username, contact) + } + return map + }, [contactsList]) + + const loadSessionDetail = useCallback(async (sessionId: string) => { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return + + const requestSeq = ++detailRequestSeqRef.current + const mappedSession = sessionRowByUsername.get(normalizedSessionId) + const mappedContact = contactByUsername.get(normalizedSessionId) + const hintedCount = typeof mappedSession?.messageCountHint === 'number' && Number.isFinite(mappedSession.messageCountHint) && mappedSession.messageCountHint >= 0 + ? Math.floor(mappedSession.messageCountHint) + : undefined + + setCopiedDetailField(null) + setSessionDetail((prev) => { + const sameSession = prev?.wxid === normalizedSessionId + return { + wxid: normalizedSessionId, + displayName: mappedSession?.displayName || mappedContact?.displayName || prev?.displayName || normalizedSessionId, + remark: sameSession ? prev?.remark : mappedContact?.remark, + nickName: sameSession ? prev?.nickName : mappedContact?.nickname, + alias: sameSession ? prev?.alias : undefined, + avatarUrl: mappedSession?.avatarUrl || mappedContact?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined), + messageCount: hintedCount ?? (sameSession ? prev.messageCount : Number.NaN), + voiceMessages: sameSession ? prev?.voiceMessages : undefined, + imageMessages: sameSession ? prev?.imageMessages : undefined, + videoMessages: sameSession ? prev?.videoMessages : undefined, + emojiMessages: sameSession ? prev?.emojiMessages : undefined, + privateMutualGroups: sameSession ? prev?.privateMutualGroups : undefined, + groupMemberCount: sameSession ? prev?.groupMemberCount : undefined, + groupMyMessages: sameSession ? prev?.groupMyMessages : undefined, + groupActiveSpeakers: sameSession ? prev?.groupActiveSpeakers : undefined, + groupMutualFriends: sameSession ? prev?.groupMutualFriends : undefined, + firstMessageTime: sameSession ? prev?.firstMessageTime : undefined, + latestMessageTime: sameSession ? prev?.latestMessageTime : undefined, + messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : [] + } + }) + setIsLoadingSessionDetail(true) + setIsLoadingSessionDetailExtra(true) + + try { + const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId) + if (requestSeq !== detailRequestSeqRef.current) return + if (result.success && result.detail) { + setSessionDetail((prev) => ({ + wxid: normalizedSessionId, + displayName: result.detail!.displayName || prev?.displayName || normalizedSessionId, + remark: result.detail!.remark ?? prev?.remark, + nickName: result.detail!.nickName ?? prev?.nickName, + alias: result.detail!.alias ?? prev?.alias, + avatarUrl: result.detail!.avatarUrl || prev?.avatarUrl, + messageCount: Number.isFinite(result.detail!.messageCount) ? result.detail!.messageCount : prev?.messageCount ?? Number.NaN, + voiceMessages: prev?.voiceMessages, + imageMessages: prev?.imageMessages, + videoMessages: prev?.videoMessages, + emojiMessages: prev?.emojiMessages, + privateMutualGroups: prev?.privateMutualGroups, + groupMemberCount: prev?.groupMemberCount, + groupMyMessages: prev?.groupMyMessages, + groupActiveSpeakers: prev?.groupActiveSpeakers, + groupMutualFriends: prev?.groupMutualFriends, + firstMessageTime: prev?.firstMessageTime, + latestMessageTime: prev?.latestMessageTime, + messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : [] + })) + } + } catch (error) { + console.error('导出页加载会话详情失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingSessionDetail(false) + } + } + + try { + const [extraResultSettled, statsResultSettled] = await Promise.allSettled([ + window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), + window.electronAPI.chat.getExportSessionStats([normalizedSessionId]) + ]) + + if (requestSeq !== detailRequestSeqRef.current) return + + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + + let next = { ...prev } + if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success && extraResultSettled.value.detail) { + next = { + ...next, + firstMessageTime: extraResultSettled.value.detail.firstMessageTime, + latestMessageTime: extraResultSettled.value.detail.latestMessageTime, + messageTables: Array.isArray(extraResultSettled.value.detail.messageTables) ? extraResultSettled.value.detail.messageTables : [] + } + } + + if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success && statsResultSettled.value.data) { + const metric = statsResultSettled.value.data[normalizedSessionId] + if (metric) { + next = { + ...next, + messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : next.messageCount, + voiceMessages: metric.voiceMessages, + imageMessages: metric.imageMessages, + videoMessages: metric.videoMessages, + emojiMessages: metric.emojiMessages, + privateMutualGroups: metric.privateMutualGroups, + groupMemberCount: metric.groupMemberCount, + groupMyMessages: metric.groupMyMessages, + groupActiveSpeakers: metric.groupActiveSpeakers, + groupMutualFriends: metric.groupMutualFriends, + firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : next.firstMessageTime, + latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : next.latestMessageTime + } + } + } + + return next + }) + } catch (error) { + console.error('导出页加载会话详情补充统计失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingSessionDetailExtra(false) + } + } + }, [contactByUsername, sessionRowByUsername]) + + const openSessionDetail = useCallback((sessionId: string) => { + if (!sessionId) return + setShowSessionDetailPanel(true) + void loadSessionDetail(sessionId) + }, [loadSessionDetail]) + + const handleCopyDetailField = useCallback(async (text: string, field: string) => { + try { + await navigator.clipboard.writeText(text) + setCopiedDetailField(field) + setTimeout(() => setCopiedDetailField(null), 1500) + } catch { + const textarea = document.createElement('textarea') + textarea.value = text + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) + setCopiedDetailField(field) + setTimeout(() => setCopiedDetailField(null), 1500) + } + }, []) + const contactsUpdatedAtLabel = useMemo(() => { if (!contactsUpdatedAt) return '' return new Date(contactsUpdatedAt).toLocaleString() @@ -2044,12 +2244,21 @@ function ExportPage() { } const renderActionCell = (session: SessionRow) => { + const isDetailActive = showSessionDetailPanel && sessionDetail?.wxid === session.username if (!session.hasSession) { return (
- +
+ + +
) } @@ -2060,18 +2269,26 @@ function ExportPage() { return (
- +
+ + +
{recent && {recent}}
) @@ -2364,110 +2581,310 @@ function ExportPage() {
)} -
- {contactsList.length === 0 && contactsLoadIssue ? ( -
-
-
- - {contactsLoadIssue.title} +
+
+ {contactsList.length === 0 && contactsLoadIssue ? ( +
+
+
+ + {contactsLoadIssue.title} +
+

{contactsLoadIssue.message}

+

{contactsLoadIssue.reason}

+
    +
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • +
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • +
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • +
+
+ + + +
+ {showContactsDiagnostics && ( +
{contactsDiagnosticsText}
+ )}
-

{contactsLoadIssue.message}

-

{contactsLoadIssue.reason}

-
    -
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • -
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • -
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • -
-
- - - -
- {showContactsDiagnostics && ( -
{contactsDiagnosticsText}
- )}
-
- ) : isContactsListLoading && contactsList.length === 0 ? ( -
- - 联系人加载中... -
- ) : filteredContacts.length === 0 ? ( -
- 暂无联系人 -
- ) : ( -
-
- {visibleContacts.map((contact, idx) => { - const absoluteIndex = contactStartIndex + idx - const top = absoluteIndex * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT - const matchedSession = sessionRowByUsername.get(contact.username) - const canExport = Boolean(matchedSession?.hasSession) - const isRunning = canExport && runningSessionIds.has(contact.username) - const isQueued = canExport && queuedSessionIds.has(contact.username) - const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '' - return ( -
-
-
- {contact.avatarUrl ? ( - - ) : ( - {getAvatarLetter(contact.displayName)} - )} -
-
-
{contact.displayName}
-
{contact.username}
-
-
- {getContactTypeName(contact.type)} -
-
- - {recent && {recent}} + ) : isContactsListLoading && contactsList.length === 0 ? ( +
+ + 联系人加载中... +
+ ) : filteredContacts.length === 0 ? ( +
+ 暂无联系人 +
+ ) : ( +
+
+ {visibleContacts.map((contact, idx) => { + const absoluteIndex = contactStartIndex + idx + const top = absoluteIndex * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT + const matchedSession = sessionRowByUsername.get(contact.username) + const canExport = Boolean(matchedSession?.hasSession) + const isRunning = canExport && runningSessionIds.has(contact.username) + const isQueued = canExport && queuedSessionIds.has(contact.username) + const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '' + return ( +
+
+
+ {contact.avatarUrl ? ( + + ) : ( + {getAvatarLetter(contact.displayName)} + )} +
+
+
{contact.displayName}
+
{contact.username}
+
+
+ {getContactTypeName(contact.type)} +
+
+
+ + +
+ {recent && {recent}} +
-
- ) - })} + ) + })} +
-
+ )} +
+ + {showSessionDetailPanel && ( + )}
From 89f0758fbb3c10bbb291636ff97c481d064d5b5b Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 13:49:16 +0800 Subject: [PATCH 047/162] fix(sns): keep header area always visible --- src/pages/SnsPage.scss | 17 +++-- src/pages/SnsPage.tsx | 140 +++++++++++++++++++++-------------------- 2 files changed, 83 insertions(+), 74 deletions(-) diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 98c2286..b2e77b4 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -23,7 +23,7 @@ ========================================= */ .sns-main-viewport { flex: 1; - overflow-y: scroll; + overflow: hidden; position: relative; display: flex; justify-content: center; @@ -35,7 +35,9 @@ padding: 20px 24px 60px 24px; display: flex; flex-direction: column; - gap: 24px; + gap: 0; + min-height: 0; + height: 100%; } .feed-header { @@ -44,9 +46,7 @@ justify-content: space-between; margin-bottom: 8px; padding: 0 4px; - position: sticky; - top: 0; - z-index: 20; + z-index: 2; background: var(--sns-bg-color); border-bottom: 1px solid var(--border-color); padding-top: 10px; @@ -109,6 +109,13 @@ } } +.sns-posts-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding-top: 16px; +} + .posts-list { display: flex; flex-direction: column; diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 6416700..dc1d86f 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -360,7 +360,7 @@ export default function SnsPage() { return (
-
+
@@ -417,78 +417,80 @@ export default function SnsPage() {
- {loadingNewer && ( -
- - 正在检查更新的动态... -
- )} - - {!loadingNewer && hasNewer && ( -
loadPosts({ direction: 'newer' })}> - 有新动态,点击查看 -
- )} - -
- {posts.map(post => ( - { - if (isVideo) { - void window.electronAPI.window.openVideoPlayerWindow(src) - } else { - void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined) - } - }} - onDebug={(p) => setDebugPost(p)} - onDelete={(postId) => { - setPosts(prev => prev.filter(p => p.id !== postId)) - loadOverviewStats() - }} - /> - ))} -
- - {loading && posts.length === 0 && ( -
-
-
- 正在加载朋友圈... +
+ {loadingNewer && ( +
+ + 正在检查更新的动态...
-
- )} + )} - {loading && posts.length > 0 && ( -
- - 正在加载更多... -
- )} + {!loadingNewer && hasNewer && ( +
loadPosts({ direction: 'newer' })}> + 有新动态,点击查看 +
+ )} - {!hasMore && posts.length > 0 && ( -
{ - selectedUsernames.length === 1 && - contacts.find(c => c.username === selectedUsernames[0])?.type === 'former_friend' - ? '在时间的长河里刻舟求剑' - : '或许过往已无可溯洄,但好在还有可以与你相遇的明天' - }
- )} - - {!loading && posts.length === 0 && ( -
-
-

未找到相关动态

- {(selectedUsernames.length > 0 || searchKeyword || jumpTargetDate) && ( - - )} +
+ {posts.map(post => ( + { + if (isVideo) { + void window.electronAPI.window.openVideoPlayerWindow(src) + } else { + void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined) + } + }} + onDebug={(p) => setDebugPost(p)} + onDelete={(postId) => { + setPosts(prev => prev.filter(p => p.id !== postId)) + loadOverviewStats() + }} + /> + ))}
- )} + + {loading && posts.length === 0 && ( +
+
+
+ 正在加载朋友圈... +
+
+ )} + + {loading && posts.length > 0 && ( +
+ + 正在加载更多... +
+ )} + + {!hasMore && posts.length > 0 && ( +
{ + selectedUsernames.length === 1 && + contacts.find(c => c.username === selectedUsernames[0])?.type === 'former_friend' + ? '在时间的长河里刻舟求剑' + : '或许过往已无可溯洄,但好在还有可以与你相遇的明天' + }
+ )} + + {!loading && posts.length === 0 && ( +
+
+

未找到相关动态

+ {(selectedUsernames.length > 0 || searchKeyword || jumpTargetDate) && ( + + )} +
+ )} +
From 1347136b54ad733643b6d06df7cb9a053e3dd0f8 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 13:52:54 +0800 Subject: [PATCH 048/162] feat(export): use window-level detail drawer overlay --- electron/services/snsService.ts | 10 +++++++--- src/pages/ExportPage.scss | 31 +++++++++++++++---------------- src/pages/ExportPage.tsx | 28 +++++++++++++++++++++++++--- 3 files changed, 47 insertions(+), 22 deletions(-) diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index d22c853..e6e144c 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -417,13 +417,13 @@ class SnsService { ) let rows = primary.rows - if (!primary.success || !rows) { + if (!primary.success || !rows || rows.length === 0) { const fallback = await wcdbService.execQuery( 'sns', null, "SELECT userName AS username, COUNT(1) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> '' GROUP BY userName" ) - if (!fallback.success || !fallback.rows) { + if (!fallback.success || !fallback.rows || fallback.rows.length === 0) { return { success: false, error: primary.error || fallback.error || '获取朋友圈联系人条数失败' } } rows = fallback.rows @@ -433,7 +433,11 @@ class SnsService { const usernameRaw = row?.username ?? row?.user_name ?? row?.userName ?? '' const username = typeof usernameRaw === 'string' ? usernameRaw.trim() : String(usernameRaw || '').trim() if (!username) continue - counts[username] = this.parseCountValue(row) + const countRaw = row?.total ?? row?.count ?? row?.cnt + const parsedCount = Number(countRaw) + counts[username] = Number.isFinite(parsedCount) && parsedCount > 0 + ? Math.floor(parsedCount) + : this.parseCountValue(row) } return { success: true, data: counts } } catch (e) { diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index eaad4bc..08a8183 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -573,7 +573,6 @@ display: flex; flex: 1; min-height: 0; - gap: 10px; .table-wrap { flex: 1; @@ -1015,15 +1014,25 @@ } } +.export-session-detail-overlay { + position: fixed; + inset: 0; + z-index: 1100; + display: flex; + justify-content: flex-end; + background: rgba(15, 23, 42, 0.24); +} + .export-session-detail-panel { - width: 300px; - min-width: 300px; - border: 1px solid var(--border-color); - border-radius: 10px; + width: min(360px, calc(100vw - 16px)); + height: 100vh; + border-left: 1px solid var(--border-color); + border-radius: 0; background: var(--card-bg); display: flex; flex-direction: column; overflow: hidden; + box-shadow: -12px 0 30px rgba(0, 0, 0, 0.18); .detail-header { display: flex; @@ -1615,16 +1624,6 @@ .media-check-grid { grid-template-columns: repeat(2, minmax(120px, 1fr)); } - - .session-table-layout.with-detail { - flex-direction: column; - } - - .export-session-detail-panel { - width: 100%; - min-width: 0; - max-height: 360px; - } } @media (max-width: 720px) { @@ -1647,6 +1646,6 @@ } .export-session-detail-panel { - max-height: 320px; + width: calc(100vw - 12px); } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 5051bea..e783995 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -2096,6 +2096,17 @@ function ExportPage() { void loadSessionDetail(sessionId) }, [loadSessionDetail]) + useEffect(() => { + if (!showSessionDetailPanel) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setShowSessionDetailPanel(false) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [showSessionDetailPanel]) + const handleCopyDetailField = useCallback(async (text: string, field: string) => { try { await navigator.clipboard.writeText(text) @@ -2581,7 +2592,7 @@ function ExportPage() {
)} -
+
{contactsList.length === 0 && contactsLoadIssue ? (
@@ -2698,7 +2709,17 @@ function ExportPage() {
{showSessionDetailPanel && ( - +
)}
From f47eba5764c667ff0e6cb35a2273e12fdb061d1a Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 13:59:25 +0800 Subject: [PATCH 049/162] fix(export): avoid overlap with window close controls --- src/pages/ExportPage.scss | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 08a8183..66bcc4b 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1016,7 +1016,10 @@ .export-session-detail-overlay { position: fixed; - inset: 0; + top: 40px; + right: 0; + bottom: 0; + left: 0; z-index: 1100; display: flex; justify-content: flex-end; @@ -1025,7 +1028,7 @@ .export-session-detail-panel { width: min(360px, calc(100vw - 16px)); - height: 100vh; + height: calc(100vh - 40px); border-left: 1px solid var(--border-color); border-radius: 0; background: var(--card-bg); From b8ede4cfd0a426d6a0d46f1ba542fac33e941f0e Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 14:02:52 +0800 Subject: [PATCH 050/162] fix(export): use solid background for detail drawer --- src/pages/ExportPage.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 66bcc4b..f546c82 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1031,7 +1031,7 @@ height: calc(100vh - 40px); border-left: 1px solid var(--border-color); border-radius: 0; - background: var(--card-bg); + background: var(--bg-secondary-solid, #ffffff); display: flex; flex-direction: column; overflow: hidden; From 21a97b887118c085ae51117af47e4be01523e4c5 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 14:09:07 +0800 Subject: [PATCH 051/162] feat(sns): cache page data and show count loading state --- src/components/Sns/SnsFilterPanel.tsx | 20 ++- src/pages/SnsPage.scss | 8 ++ src/pages/SnsPage.tsx | 176 +++++++++++++++++++++++--- src/services/config.ts | 78 ++++++++++++ 4 files changed, 258 insertions(+), 24 deletions(-) diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index 6c914a0..f514f94 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -8,6 +8,7 @@ interface Contact { displayName: string avatarUrl?: string postCount?: number + postCountStatus?: 'loading' | 'ready' | 'error' } interface SnsFilterPanelProps { @@ -58,6 +59,16 @@ export const SnsFilterPanel: React.FC = ({ setJumpTargetDate(undefined) } + const getPostCountDisplay = (contact: Contact) => { + if (contact.postCountStatus === 'error') { + return { text: '统计失败', className: 'is-error' } + } + if (contact.postCountStatus !== 'ready') { + return { text: '统计中', className: 'is-loading' } + } + return { text: `${Math.max(0, Number(contact.postCount || 0))} 条`, className: '' } + } + return (