diff --git a/electron/exportWorker.ts b/electron/exportWorker.ts index 21ac52d..1f98439 100644 --- a/electron/exportWorker.ts +++ b/electron/exportWorker.ts @@ -1,6 +1,5 @@ import { parentPort, workerData } from 'worker_threads' -import { wcdbService } from './services/wcdbService' -import { exportService, ExportOptions } from './services/exportService' +import type { ExportOptions } from './services/exportService' interface ExportWorkerConfig { sessionIds: string[] @@ -16,11 +15,21 @@ process.env.WEFLOW_WORKER = '1' if (config.resourcesPath) { process.env.WCDB_RESOURCES_PATH = config.resourcesPath } - -wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '') -wcdbService.setLogEnabled(config.logEnabled === true) +if (config.userDataPath) { + process.env.WEFLOW_USER_DATA_PATH = config.userDataPath + process.env.WEFLOW_CONFIG_CWD = config.userDataPath +} +process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow' async function run() { + const [{ wcdbService }, { exportService }] = await Promise.all([ + import('./services/wcdbService'), + import('./services/exportService') + ]) + + wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '') + wcdbService.setLogEnabled(config.logEnabled === true) + const result = await exportService.exportSessions( Array.isArray(config.sessionIds) ? config.sessionIds : [], String(config.outputDir || ''), diff --git a/electron/services/config.ts b/electron/services/config.ts index 38f3255..5eb9c05 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -84,45 +84,71 @@ export class ConfigService { return ConfigService.instance } ConfigService.instance = this - this.store = new Store({ + const defaults: ConfigSchema = { + dbPath: '', + decryptKey: '', + myWxid: '', + onboardingDone: false, + imageXorKey: 0, + imageAesKey: '', + wxidConfigs: {}, + cachePath: '', + lastOpenedDb: '', + lastSession: '', + theme: 'system', + themeId: 'cloud-dancer', + language: 'zh-CN', + logEnabled: false, + llmModelPath: '', + whisperModelName: 'base', + whisperModelDir: '', + whisperDownloadSource: 'tsinghua', + autoTranscribeVoice: false, + transcribeLanguages: ['zh'], + exportDefaultConcurrency: 4, + analyticsExcludedUsernames: [], + authEnabled: false, + authPassword: '', + authUseHello: false, + authHelloSecret: '', + ignoredUpdateVersion: '', + notificationEnabled: true, + notificationPosition: 'top-right', + notificationFilterMode: 'all', + notificationFilterList: [], + messagePushEnabled: false, + windowCloseBehavior: 'ask', + wordCloudExcludeWords: [] + } + + const storeOptions: any = { name: 'WeFlow-config', - defaults: { - dbPath: '', - decryptKey: '', - myWxid: '', - onboardingDone: false, - imageXorKey: 0, - imageAesKey: '', - wxidConfigs: {}, - cachePath: '', - lastOpenedDb: '', - lastSession: '', - theme: 'system', - themeId: 'cloud-dancer', - language: 'zh-CN', - logEnabled: false, - llmModelPath: '', - whisperModelName: 'base', - whisperModelDir: '', - whisperDownloadSource: 'tsinghua', - autoTranscribeVoice: false, - transcribeLanguages: ['zh'], - exportDefaultConcurrency: 4, - analyticsExcludedUsernames: [], - authEnabled: false, - authPassword: '', - authUseHello: false, - authHelloSecret: '', - ignoredUpdateVersion: '', - notificationEnabled: true, - notificationPosition: 'top-right', - notificationFilterMode: 'all', - notificationFilterList: [], - messagePushEnabled: false, - windowCloseBehavior: 'ask', - wordCloudExcludeWords: [] + defaults + } + const runningInWorker = process.env.WEFLOW_WORKER === '1' + if (runningInWorker) { + const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim() + if (cwd) { + storeOptions.cwd = cwd } - }) + storeOptions.projectName = String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow' + } + + try { + this.store = new Store(storeOptions) + } catch (error) { + const message = String((error as Error)?.message || error || '') + if (message.includes('projectName')) { + const fallbackOptions = { + ...storeOptions, + projectName: 'WeFlow', + cwd: storeOptions.cwd || process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || process.cwd() + } + this.store = new Store(fallbackOptions) + } else { + throw error + } + } this.migrateAuthFields() } diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 49ce8f2..7af4914 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -421,6 +421,34 @@ class ExportService { } } + private isCloneUnsupportedError(code: string | undefined): boolean { + return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EINVAL' || code === 'EXDEV' || code === 'ENOTTY' + } + + private async copyFileOptimized(sourcePath: string, destPath: string): Promise<{ success: boolean; code?: string }> { + const cloneFlag = typeof fs.constants.COPYFILE_FICLONE === 'number' ? fs.constants.COPYFILE_FICLONE : 0 + try { + if (cloneFlag) { + await fs.promises.copyFile(sourcePath, destPath, cloneFlag) + } else { + await fs.promises.copyFile(sourcePath, destPath) + } + return { success: true } + } catch (e) { + const code = (e as NodeJS.ErrnoException | undefined)?.code + if (!this.isCloneUnsupportedError(code)) { + return { success: false, code } + } + } + + try { + await fs.promises.copyFile(sourcePath, destPath) + return { success: true } + } catch (e) { + return { success: false, code: (e as NodeJS.ErrnoException | undefined)?.code } + } + } + private isMediaExportEnabled(options: ExportOptions): boolean { return options.exportMedia === true && Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) @@ -2387,14 +2415,18 @@ class ExportService { } // 复制文件 - if (!(await this.pathExists(sourcePath))) { - console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`) - return null - } const ext = path.extname(sourcePath) || '.jpg' const fileName = `${messageId}_${imageKey}${ext}` const destPath = path.join(imagesDir, fileName) - await fs.promises.copyFile(sourcePath, destPath) + const copied = await this.copyFileOptimized(sourcePath, destPath) + if (!copied.success) { + if (copied.code === 'ENOENT') { + console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`) + } else { + console.log(`[Export] 复制图片失败 (localId=${msg.localId}): ${sourcePath}, code=${copied.code || 'UNKNOWN'} → 将显示 [图片] 占位符`) + } + return null + } return { relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), @@ -2598,7 +2630,7 @@ class ExportService { // 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑) const localPath = await chatService.downloadEmojiFile(msg) - if (!localPath || !(await this.pathExists(localPath))) { + if (!localPath) { return null } @@ -2607,8 +2639,8 @@ class ExportService { const key = msg.emojiMd5 || String(msg.localId) const fileName = `${key}${ext}` const destPath = path.join(emojisDir, fileName) - - await fs.promises.copyFile(localPath, destPath) + const copied = await this.copyFileOptimized(localPath, destPath) + if (!copied.success) return null return { relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), @@ -2649,7 +2681,8 @@ class ExportService { const fileName = path.basename(sourcePath) const destPath = path.join(videosDir, fileName) - await fs.promises.copyFile(sourcePath, destPath) + const copied = await this.copyFileOptimized(sourcePath, destPath) + if (!copied.success) return null return { relativePath: path.posix.join(mediaRelativePrefix, 'videos', fileName), diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index cdd892a..4e36d00 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -162,21 +162,22 @@ class VideoService { new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)) ) const resolvedMap = new Map() - let unresolved = [...normalizedList] + const unresolvedSet = new Set(normalizedList) for (const md5 of normalizedList) { const cacheKey = `${scopeKey}|${md5}` const cached = this.readTimedCache(this.hardlinkResolveCache, cacheKey) if (cached === undefined) continue if (cached) resolvedMap.set(md5, cached) - unresolved = unresolved.filter((item) => item !== md5) + unresolvedSet.delete(md5) } - if (unresolved.length === 0) return resolvedMap + if (unresolvedSet.size === 0) return resolvedMap const encryptedDbPaths = this.getHardlinkDbPaths(dbPath, wxid, cleanedWxid) for (const p of encryptedDbPaths) { - if (!existsSync(p) || unresolved.length === 0) continue + if (!existsSync(p) || unresolvedSet.size === 0) continue + const unresolved = Array.from(unresolvedSet) const requests = unresolved.map((md5) => ({ md5, dbPath: p })) try { const batchResult = await wcdbService.resolveVideoHardlinkMd5Batch(requests) @@ -194,6 +195,7 @@ class VideoService { const cacheKey = `${scopeKey}|${inputMd5}` this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries) resolvedMap.set(inputMd5, resolvedMd5) + unresolvedSet.delete(inputMd5) } } else { // 兼容不支持批量接口的版本,回退单条请求。 @@ -207,17 +209,16 @@ class VideoService { const cacheKey = `${scopeKey}|${req.md5}` this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries) resolvedMap.set(req.md5, resolvedMd5) + unresolvedSet.delete(req.md5) } catch { } } } } catch (e) { this.log('resolveVideoHardlinks 批量查询失败', { path: p, error: String(e) }) } - - unresolved = unresolved.filter((md5) => !resolvedMap.has(md5)) } - for (const md5 of unresolved) { + for (const md5 of unresolvedSet) { const cacheKey = `${scopeKey}|${md5}` this.writeTimedCache(this.hardlinkResolveCache, cacheKey, null, this.hardlinkCacheTtlMs, this.maxCacheEntries) } diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 10e1ae2..af24a2f 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -87,7 +87,9 @@ export class WcdbCore { private wcdbGetMessageTableColumns: any = null private wcdbGetMessageTableTimeRange: any = null private wcdbResolveImageHardlink: any = null + private wcdbResolveImageHardlinkBatch: any = null private wcdbResolveVideoHardlinkMd5: any = null + private wcdbResolveVideoHardlinkMd5Batch: any = null private wcdbInstallSnsBlockDeleteTrigger: any = null private wcdbUninstallSnsBlockDeleteTrigger: any = null private wcdbCheckSnsBlockDeleteTrigger: any = null @@ -111,6 +113,7 @@ export class WcdbCore { private imageHardlinkCache: Map = new Map() private videoHardlinkCache: Map = new Map() private readonly hardlinkCacheTtlMs = 10 * 60 * 1000 + private readonly hardlinkCacheMaxEntries = 20000 private logTimer: NodeJS.Timeout | null = null private lastLogTail: string | null = null private lastResolvedLogPath: string | null = null @@ -962,11 +965,21 @@ export class WcdbCore { } catch { this.wcdbResolveImageHardlink = null } + try { + this.wcdbResolveImageHardlinkBatch = this.lib.func('int32 wcdb_resolve_image_hardlink_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)') + } catch { + this.wcdbResolveImageHardlinkBatch = null + } try { this.wcdbResolveVideoHardlinkMd5 = this.lib.func('int32 wcdb_resolve_video_hardlink_md5(int64 handle, const char* md5, const char* dbPath, _Out_ void** outJson)') } catch { this.wcdbResolveVideoHardlinkMd5 = null } + try { + this.wcdbResolveVideoHardlinkMd5Batch = this.lib.func('int32 wcdb_resolve_video_hardlink_md5_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)') + } catch { + this.wcdbResolveVideoHardlinkMd5Batch = null + } // wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error) try { @@ -1312,6 +1325,20 @@ export class WcdbCore { result: this.cloneHardlinkResult(result), updatedAt: Date.now() }) + if (cache.size <= this.hardlinkCacheMaxEntries) return + + const now = Date.now() + for (const [cacheKey, entry] of cache) { + if (now - entry.updatedAt > this.hardlinkCacheTtlMs) { + cache.delete(cacheKey) + } + } + + while (cache.size > this.hardlinkCacheMaxEntries) { + const oldestKey = cache.keys().next().value as string | undefined + if (!oldestKey) break + cache.delete(oldestKey) + } } private cloneHardlinkResult(result: { success: boolean; data?: any; error?: string }): { success: boolean; data?: any; error?: string } { @@ -2853,22 +2880,98 @@ export class WcdbCore { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!Array.isArray(requests)) return { success: false, error: '参数错误: requests 必须是数组' } try { - const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = [] - for (let i = 0; i < requests.length; i += 1) { - const req = requests[i] || { md5: '' } - const normalizedMd5 = String(req.md5 || '').trim().toLowerCase() - if (!normalizedMd5) { - rows.push({ index: i, md5: '', success: false, error: 'md5 为空' }) + const normalizedRequests = requests.map((req) => ({ + md5: String(req?.md5 || '').trim().toLowerCase(), + accountDir: String(req?.accountDir || '').trim() + })) + const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = new Array(normalizedRequests.length) + const unresolved: Array<{ index: number; md5: string; accountDir: string }> = [] + + for (let i = 0; i < normalizedRequests.length; i += 1) { + const req = normalizedRequests[i] + if (!req.md5) { + rows[i] = { index: i, md5: '', success: false, error: 'md5 为空' } continue } - const result = await this.resolveImageHardlink(normalizedMd5, req.accountDir) - rows.push({ - index: i, - md5: normalizedMd5, + const cacheKey = this.makeHardlinkCacheKey(req.md5, req.accountDir) + const cached = this.readHardlinkCache(this.imageHardlinkCache, cacheKey) + if (cached) { + rows[i] = { + index: i, + md5: req.md5, + success: cached.success === true, + data: cached.data, + error: cached.error + } + } else { + unresolved.push({ index: i, md5: req.md5, accountDir: req.accountDir }) + } + } + + if (unresolved.length === 0) { + return { success: true, rows } + } + + if (this.wcdbResolveImageHardlinkBatch) { + try { + const outPtr = [null as any] + const payload = JSON.stringify(unresolved.map((req) => ({ + md5: req.md5, + account_dir: req.accountDir || undefined + }))) + const result = this.wcdbResolveImageHardlinkBatch(this.handle, payload, outPtr) + if (result === 0 && outPtr[0]) { + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (jsonStr) { + const nativeRows = JSON.parse(jsonStr) + const mappedRows = Array.isArray(nativeRows) ? nativeRows.map((row: any, index: number) => { + const rowIndexRaw = Number(row?.index) + const rowIndex = Number.isFinite(rowIndexRaw) ? Math.floor(rowIndexRaw) : index + const fallbackReq = rowIndex >= 0 && rowIndex < unresolved.length ? unresolved[rowIndex] : { md5: '', accountDir: '', index: -1 } + const rowMd5 = String(row?.md5 || fallbackReq.md5 || '').trim().toLowerCase() + const success = row?.success === true || row?.success === 1 || row?.success === '1' + const data = row?.data && typeof row.data === 'object' ? row.data : {} + const error = row?.error ? String(row.error) : undefined + if (success && rowMd5) { + const cacheKey = this.makeHardlinkCacheKey(rowMd5, fallbackReq.accountDir) + this.writeHardlinkCache(this.imageHardlinkCache, cacheKey, { success: true, data }) + } + return { + index: rowIndex, + md5: rowMd5, + success, + data, + error + } + }) : [] + for (const row of mappedRows) { + const fallbackReq = row.index >= 0 && row.index < unresolved.length ? unresolved[row.index] : null + if (!fallbackReq) continue + rows[fallbackReq.index] = { + index: fallbackReq.index, + md5: row.md5 || fallbackReq.md5, + success: row.success, + data: row.data, + error: row.error + } + } + } + } + } catch { + // 回退到单条循环实现 + } + } + + for (const req of unresolved) { + if (rows[req.index]) continue + const result = await this.resolveImageHardlink(req.md5, req.accountDir) + rows[req.index] = { + index: req.index, + md5: req.md5, success: result.success === true, data: result.data, error: result.error - }) + } } return { success: true, rows } } catch (e) { @@ -2882,22 +2985,98 @@ export class WcdbCore { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!Array.isArray(requests)) return { success: false, error: '参数错误: requests 必须是数组' } try { - const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = [] - for (let i = 0; i < requests.length; i += 1) { - const req = requests[i] || { md5: '' } - const normalizedMd5 = String(req.md5 || '').trim().toLowerCase() - if (!normalizedMd5) { - rows.push({ index: i, md5: '', success: false, error: 'md5 为空' }) + const normalizedRequests = requests.map((req) => ({ + md5: String(req?.md5 || '').trim().toLowerCase(), + dbPath: String(req?.dbPath || '').trim() + })) + const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = new Array(normalizedRequests.length) + const unresolved: Array<{ index: number; md5: string; dbPath: string }> = [] + + for (let i = 0; i < normalizedRequests.length; i += 1) { + const req = normalizedRequests[i] + if (!req.md5) { + rows[i] = { index: i, md5: '', success: false, error: 'md5 为空' } continue } - const result = await this.resolveVideoHardlinkMd5(normalizedMd5, req.dbPath) - rows.push({ - index: i, - md5: normalizedMd5, + const cacheKey = this.makeHardlinkCacheKey(req.md5, req.dbPath) + const cached = this.readHardlinkCache(this.videoHardlinkCache, cacheKey) + if (cached) { + rows[i] = { + index: i, + md5: req.md5, + success: cached.success === true, + data: cached.data, + error: cached.error + } + } else { + unresolved.push({ index: i, md5: req.md5, dbPath: req.dbPath }) + } + } + + if (unresolved.length === 0) { + return { success: true, rows } + } + + if (this.wcdbResolveVideoHardlinkMd5Batch) { + try { + const outPtr = [null as any] + const payload = JSON.stringify(unresolved.map((req) => ({ + md5: req.md5, + db_path: req.dbPath || undefined + }))) + const result = this.wcdbResolveVideoHardlinkMd5Batch(this.handle, payload, outPtr) + if (result === 0 && outPtr[0]) { + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (jsonStr) { + const nativeRows = JSON.parse(jsonStr) + const mappedRows = Array.isArray(nativeRows) ? nativeRows.map((row: any, index: number) => { + const rowIndexRaw = Number(row?.index) + const rowIndex = Number.isFinite(rowIndexRaw) ? Math.floor(rowIndexRaw) : index + const fallbackReq = rowIndex >= 0 && rowIndex < unresolved.length ? unresolved[rowIndex] : { md5: '', dbPath: '', index: -1 } + const rowMd5 = String(row?.md5 || fallbackReq.md5 || '').trim().toLowerCase() + const success = row?.success === true || row?.success === 1 || row?.success === '1' + const data = row?.data && typeof row.data === 'object' ? row.data : {} + const error = row?.error ? String(row.error) : undefined + if (success && rowMd5) { + const cacheKey = this.makeHardlinkCacheKey(rowMd5, fallbackReq.dbPath) + this.writeHardlinkCache(this.videoHardlinkCache, cacheKey, { success: true, data }) + } + return { + index: rowIndex, + md5: rowMd5, + success, + data, + error + } + }) : [] + for (const row of mappedRows) { + const fallbackReq = row.index >= 0 && row.index < unresolved.length ? unresolved[row.index] : null + if (!fallbackReq) continue + rows[fallbackReq.index] = { + index: fallbackReq.index, + md5: row.md5 || fallbackReq.md5, + success: row.success, + data: row.data, + error: row.error + } + } + } + } + } catch { + // 回退到单条循环实现 + } + } + + for (const req of unresolved) { + if (rows[req.index]) continue + const result = await this.resolveVideoHardlinkMd5(req.md5, req.dbPath) + rows[req.index] = { + index: req.index, + md5: req.md5, success: result.success === true, data: result.data, error: result.error - }) + } } return { success: true, rows } } catch (e) { diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index a7ae51e..935132e 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 9ae0779..7d16fa4 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -566,7 +566,8 @@ flex: 1; background: var(--chat-pattern); background-color: var(--bg-secondary); - padding: 20px 24px; + padding: 20px 24px 112px; + padding-bottom: calc(112px + env(safe-area-inset-bottom)); &::-webkit-scrollbar { width: 6px; @@ -600,7 +601,8 @@ } .message-wrapper { - margin-bottom: 16px; + box-sizing: border-box; + padding-bottom: 16px; } .message-bubble { @@ -1748,7 +1750,8 @@ overflow-y: auto; overflow-x: hidden; min-height: 0; - padding: 20px 24px; + padding: 20px 24px 112px; + padding-bottom: calc(112px + env(safe-area-inset-bottom)); display: flex; flex-direction: column; gap: 16px; @@ -1898,7 +1901,8 @@ .message-wrapper { display: flex; flex-direction: column; - margin-bottom: 16px; + box-sizing: border-box; + padding-bottom: 16px; -webkit-app-region: no-drag; &.sent { diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 0aaaa99..d8fd2b1 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1021,11 +1021,25 @@ function ChatPage(props: ChatPageProps) { const sessionWindowCacheRef = useRef>(new Map()) const previewPersistTimerRef = useRef(null) const sessionListPersistTimerRef = useRef(null) + const scrollBottomButtonArmTimerRef = useRef(null) + const suppressScrollToBottomButtonRef = useRef(false) const pendingExportRequestIdRef = useRef(null) const exportPrepareLongWaitTimerRef = useRef(null) const jumpDatesRequestSeqRef = useRef(0) const jumpDateCountsRequestSeqRef = useRef(0) + const suppressScrollToBottomButton = useCallback((delayMs = 180) => { + suppressScrollToBottomButtonRef.current = true + if (scrollBottomButtonArmTimerRef.current !== null) { + window.clearTimeout(scrollBottomButtonArmTimerRef.current) + scrollBottomButtonArmTimerRef.current = null + } + scrollBottomButtonArmTimerRef.current = window.setTimeout(() => { + suppressScrollToBottomButtonRef.current = false + scrollBottomButtonArmTimerRef.current = null + }, delayMs) + }, []) + const isGroupChatSession = useCallback((username: string) => { return username.includes('@chatroom') }, []) @@ -2287,6 +2301,8 @@ function ChatPage(props: ChatPageProps) { setCurrentSession(null) setSessions([]) setMessages([]) + setShowScrollToBottom(false) + suppressScrollToBottomButton(260) setSearchKeyword('') setConnectionError(null) setConnected(false) @@ -2311,6 +2327,7 @@ function ChatPage(props: ChatPageProps) { setSessionDetail, setShowDetailPanel, setShowGroupMembersPanel, + suppressScrollToBottomButton, setSessions ]) @@ -2350,7 +2367,9 @@ function ChatPage(props: ChatPageProps) { currentSessionRef.current = currentSessionId topRangeLoadLockRef.current = false bottomRangeLoadLockRef.current = false - }, [currentSessionId]) + setShowScrollToBottom(false) + suppressScrollToBottomButton(260) + }, [currentSessionId, suppressScrollToBottomButton]) const hydrateSessionStatuses = useCallback(async (sessionList: ChatSession[]) => { const usernames = sessionList.map((s) => s.username).filter(Boolean) @@ -2820,6 +2839,8 @@ function ChatPage(props: ChatPageProps) { if (offset === 0) { + suppressScrollToBottomButton(260) + setShowScrollToBottom(false) setLoadingMessages(true) // 切会话时保留旧内容作为过渡,避免大面积闪烁 setHasInitialMessages(true) @@ -3903,10 +3924,6 @@ function ChatPage(props: ChatPageProps) { return } - const remaining = (total - 1) - range.endIndex - const shouldShowScrollButton = remaining > 3 - setShowScrollToBottom(prev => (prev === shouldShowScrollButton ? prev : shouldShowScrollButton)) - if ( range.startIndex <= 2 && !topRangeLoadLockRef.current && @@ -3948,7 +3965,13 @@ function ChatPage(props: ChatPageProps) { if (!atBottom) { bottomRangeLoadLockRef.current = false } - }, []) + if (messages.length <= 0 || isLoadingMessages || isSessionSwitching || suppressScrollToBottomButtonRef.current) { + setShowScrollToBottom(prev => (prev ? false : prev)) + return + } + const shouldShow = !atBottom + setShowScrollToBottom(prev => (prev === shouldShow ? prev : shouldShow)) + }, [messages.length, isLoadingMessages, isSessionSwitching]) const handleMessageAtTopStateChange = useCallback((atTop: boolean) => { if (!atTop) { @@ -4028,22 +4051,24 @@ function ChatPage(props: ChatPageProps) { // 滚动到底部 const scrollToBottom = useCallback(() => { + suppressScrollToBottomButton(220) + setShowScrollToBottom(false) const lastIndex = messages.length - 1 if (lastIndex >= 0 && messageVirtuosoRef.current) { messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', - behavior: 'smooth' + behavior: 'auto' }) return } if (messageListRef.current) { messageListRef.current.scrollTo({ top: messageListRef.current.scrollHeight, - behavior: 'smooth' + behavior: 'auto' }) } - }, [messages.length]) + }, [messages.length, suppressScrollToBottomButton]) // 拖动调节侧边栏宽度 const handleResizeStart = useCallback((e: React.MouseEvent) => { @@ -4086,6 +4111,10 @@ function ChatPage(props: ChatPageProps) { window.clearTimeout(sessionListPersistTimerRef.current) sessionListPersistTimerRef.current = null } + if (scrollBottomButtonArmTimerRef.current !== null) { + window.clearTimeout(scrollBottomButtonArmTimerRef.current) + scrollBottomButtonArmTimerRef.current = null + } if (contactUpdateTimerRef.current) { clearTimeout(contactUpdateTimerRef.current) } @@ -5055,15 +5084,28 @@ function ChatPage(props: ChatPageProps) { return `${y}年${m}月${d}日` }, []) + const clampContextMenuPosition = useCallback((x: number, y: number) => { + const viewportPadding = 12 + const estimatedMenuWidth = 180 + const estimatedMenuHeight = 188 + const maxLeft = Math.max(viewportPadding, window.innerWidth - estimatedMenuWidth - viewportPadding) + const maxTop = Math.max(viewportPadding, window.innerHeight - estimatedMenuHeight - viewportPadding) + return { + x: Math.min(Math.max(x, viewportPadding), maxLeft), + y: Math.min(Math.max(y, viewportPadding), maxTop) + } + }, []) + // 消息右键菜单处理 const handleContextMenu = useCallback((e: React.MouseEvent, message: Message) => { e.preventDefault() + const nextPos = clampContextMenuPosition(e.clientX, e.clientY) setContextMenu({ - x: e.clientX, - y: e.clientY, + x: nextPos.x, + y: nextPos.y, message }) - }, []) + }, [clampContextMenuPosition]) // 关闭右键菜单 useEffect(() => { @@ -5916,6 +5958,8 @@ function ChatPage(props: ChatPageProps) { customScrollParent={messageListScrollParent ?? undefined} data={messages} overscan={360} + followOutput={(isAtBottom) => (isAtBottom ? 'auto' : false)} + atBottomThreshold={80} atBottomStateChange={handleMessageAtBottomStateChange} atTopStateChange={handleMessageAtTopStateChange} rangeChanged={handleMessageRangeChanged} @@ -6464,14 +6508,16 @@ function ChatPage(props: ChatPageProps) { {contextMenu && createPortal( <>
setContextMenu(null)} - style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 9998 }} /> + style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 12040 }} />
e.stopPropagation()} > @@ -6922,6 +6968,7 @@ function MessageBubble({ const [imageInView, setImageInView] = useState(false) const imageForceHdAttempted = useRef(null) const imageForceHdPending = useRef(false) + const imageDecryptPendingRef = useRef(false) const [imageLiveVideoPath, setImageLiveVideoPath] = useState(undefined) const [voiceError, setVoiceError] = useState(false) const [voiceLoading, setVoiceLoading] = useState(false) @@ -7115,7 +7162,8 @@ function MessageBubble({ const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => { if (!isImage) return - if (imageLoading) return + if (imageLoading || imageDecryptPendingRef.current) return + imageDecryptPendingRef.current = true if (!silent) { setImageLoading(true) setImageError(false) @@ -7151,6 +7199,7 @@ function MessageBubble({ if (!silent) setImageError(true) } finally { if (!silent) setImageLoading(false) + imageDecryptPendingRef.current = false } return { success: false } as any }, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64]) @@ -7342,19 +7391,6 @@ function MessageBubble({ triggerForceHd() }, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd]) - useEffect(() => { - if (!isImage || !imageHasUpdate) return - if (imageAutoHdTriggered.current === imageCacheKey) return - imageAutoHdTriggered.current = imageCacheKey - triggerForceHd() - }, [isImage, imageHasUpdate, imageCacheKey, triggerForceHd]) - - // 更激进:进入视野/打开预览时,无论 hasUpdate 与否都尝试强制高清 - useEffect(() => { - if (!isImage || !imageInView) return - triggerForceHd() - }, [isImage, imageInView, triggerForceHd]) - useEffect(() => { if (!isVoice) return diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 248b946..b904a0e 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1291,9 +1291,26 @@ const TaskCenterModal = memo(function TaskCenterModal({ ) const exportedMessages = Math.max(0, Math.floor(task.progress.exportedMessages || 0)) const estimatedTotalMessages = Math.max(0, Math.floor(task.progress.estimatedTotalMessages || 0)) + const collectedMessages = Math.max(0, Math.floor(task.progress.collectedMessages || 0)) const messageProgressLabel = estimatedTotalMessages > 0 ? `已导出 ${Math.min(exportedMessages, estimatedTotalMessages)}/${estimatedTotalMessages} 条` : `已导出 ${exportedMessages} 条` + const effectiveMessageProgressLabel = ( + exportedMessages > 0 || estimatedTotalMessages > 0 || collectedMessages <= 0 || task.progress.phase !== 'preparing' + ) + ? messageProgressLabel + : `已收集 ${collectedMessages.toLocaleString()} 条` + const phaseProgress = Math.max(0, Math.floor(task.progress.phaseProgress || 0)) + const phaseTotal = Math.max(0, Math.floor(task.progress.phaseTotal || 0)) + const phaseMetricLabel = phaseTotal > 0 + ? ( + task.progress.phase === 'exporting-media' + ? `媒体 ${Math.min(phaseProgress, phaseTotal)}/${phaseTotal}` + : task.progress.phase === 'exporting-voice' + ? `语音 ${Math.min(phaseProgress, phaseTotal)}/${phaseTotal}` + : '' + ) + : '' const sessionProgressLabel = completedSessionTotal > 0 ? `会话 ${completedSessionCount}/${completedSessionTotal}` : '会话处理中' @@ -1317,7 +1334,8 @@ const TaskCenterModal = memo(function TaskCenterModal({ />
- {`${sessionProgressLabel} · ${messageProgressLabel}`} + {`${sessionProgressLabel} · ${effectiveMessageProgressLabel}`} + {phaseMetricLabel ? ` · ${phaseMetricLabel}` : ''} {task.status === 'running' && currentSessionRatio !== null ? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)` : ''} diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts index 691ae57..b4c04f7 100644 --- a/src/stores/chatStore.ts +++ b/src/stores/chatStore.ts @@ -81,13 +81,48 @@ export const useChatStore = create((set, get) => ({ setMessages: (messages) => set({ messages }), appendMessages: (newMessages, prepend = false) => set((state) => { - const getMsgKey = (m: Message) => { - if (m.messageKey) return m.messageKey + const buildPrimaryKey = (m: Message): string => { + if (m.messageKey) return String(m.messageKey) return `fallback:${m.serverId || 0}:${m.createTime}:${m.sortSeq || 0}:${m.localId || 0}:${m.senderUsername || ''}:${m.localType || 0}` } + const buildAliasKeys = (m: Message): string[] => { + const keys = [buildPrimaryKey(m)] + const localId = Math.max(0, Number(m.localId || 0)) + const serverId = Math.max(0, Number(m.serverId || 0)) + const createTime = Math.max(0, Number(m.createTime || 0)) + const localType = Math.floor(Number(m.localType || 0)) + const sender = String(m.senderUsername || '') + const isSend = Number(m.isSend ?? -1) + + if (localId > 0) { + keys.push(`lid:${localId}`) + } + if (serverId > 0) { + keys.push(`sid:${serverId}`) + } + if (localType === 3) { + const imageIdentity = String(m.imageMd5 || m.imageDatName || '').trim() + if (imageIdentity) { + keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`) + } + } + return keys + } + const currentMessages = state.messages || [] - const existingKeys = new Set(currentMessages.map(getMsgKey)) - const filtered = newMessages.filter(m => !existingKeys.has(getMsgKey(m))) + const existingAliases = new Set() + currentMessages.forEach((msg) => { + buildAliasKeys(msg).forEach((key) => existingAliases.add(key)) + }) + + const filtered: Message[] = [] + newMessages.forEach((msg) => { + const aliasKeys = buildAliasKeys(msg) + const exists = aliasKeys.some((key) => existingAliases.has(key)) + if (exists) return + filtered.push(msg) + aliasKeys.forEach((key) => existingAliases.add(key)) + }) if (filtered.length === 0) return state