diff --git a/src/main/image-decrypt-service.ts b/src/main/image-decrypt-service.ts index 9265401..abcdebb 100644 --- a/src/main/image-decrypt-service.ts +++ b/src/main/image-decrypt-service.ts @@ -131,7 +131,7 @@ export class ImageDecryptService { join(attachDir, dir1, dir2, 'Image', variant), join(attachDir, dir1, dir2, 'image', variant) ] - const found = candidates.find((candidate) => existsSync(candidate)) + const found = this.getLargestExistingPath(candidates, true) if (found) { console.log('[ImageDecrypt] prefix path hit:', found) return found @@ -157,9 +157,10 @@ export class ImageDecryptService { const imgDir = join(attachDir, sessDir, month, sub) if (!existsSync(imgDir)) continue - const found = variants - .map((variant) => join(imgDir, variant)) - .find((candidate) => existsSync(candidate)) + const found = this.getLargestExistingPath( + variants.map((variant) => join(imgDir, variant)), + true + ) if (found) { console.log('[ImageDecrypt] found at:', found) return found @@ -418,9 +419,9 @@ export class ImageDecryptService { const base = this.normalizeDatBase(baseName) if (!base) return [] return [ - `${base}_h.dat`, `${base}.dat`, `${base}_hd.dat`, + `${base}_h.dat`, `${base}_c.dat`, `${base}_t.dat`, `${base}.thumb.dat`, @@ -435,13 +436,35 @@ export class ImageDecryptService { const ordered = allowThumbnail ? variants : variants.filter((name) => !this.isThumbnailName(name)) - for (const variant of ordered) { - const candidate = join(actualDir, variant) - if (existsSync(candidate)) return candidate - } + const largest = this.getLargestExistingPath( + ordered.map((variant) => join(actualDir, variant)), + allowThumbnail + ) + if (largest) return largest return inputPath } + private getLargestExistingPath(paths: string[], allowThumbnail: boolean): string | null { + const toSized = (candidates: string[]): { candidate: string; size: number }[] => + candidates + .filter((candidate) => existsSync(candidate)) + .map((candidate) => { + try { + return { candidate, size: statSync(candidate).size } + } catch { + return { candidate, size: 0 } + } + }) + .sort((left, right) => right.size - left.size) + + const nonThumb = toSized(paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))) + if (nonThumb[0]) return nonThumb[0].candidate + if (!allowThumbnail) return null + + const existing = toSized(paths) + return existing[0]?.candidate || null + } + private isThumbnailName(fileName: string): boolean { const lower = fileName.toLowerCase() return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat') diff --git a/src/main/index.ts b/src/main/index.ts index 2872520..e0600c9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -24,6 +24,15 @@ import { getSettingsPath, AppSettings } from './services/settings-store' +import { + getBootstrapCache, + getCachedMessages, + mergeBootstrapAvatars, + mergeCachedContactAvatars, + saveBootstrapContacts, + saveBootstrapSelf, + saveCachedMessages +} from './services/bootstrap-cache' import { installSafeConsole } from './safe-log' // electron-vite can close the child's stdout/stderr after spawning Electron. @@ -228,14 +237,43 @@ app.whenReady().then(async () => { } }) - ipcMain.handle('db:getContacts', (_, filter?: string) => chat.listContacts(filter)) + ipcMain.handle('db:getBootstrapCache', () => { + if (!chat.isReady()) return null + return getBootstrapCache(chat.getCurrentAccountRoot()) + }) - ipcMain.handle('db:getContactAvatars', (_, usernames: string[]) => - chat.getContactAvatars(usernames) + ipcMain.handle( + 'db:getCachedMessages', + (_, userMd5: string, startTime?: number, endTime?: number) => { + if (!chat.isReady()) return [] + return getCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime) + } ) - ipcMain.handle('db:getMessages', (_, userMd5: string, startTime?: number, endTime?: number) => - chat.listMessages(userMd5, startTime, endTime) + ipcMain.handle('db:getContacts', (_, filter?: string) => { + const accountRoot = chat.getCurrentAccountRoot() + const contacts = accountRoot ? mergeCachedContactAvatars(accountRoot, chat.listContacts(filter)) : chat.listContacts(filter) + if (!filter && chat.isReady() && accountRoot) { + saveBootstrapContacts(accountRoot, contacts) + } + return contacts + }) + + ipcMain.handle('db:getContactAvatars', (_, usernames: string[]) => { + const avatars = chat.getContactAvatars(usernames) + if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars) + return avatars + }) + + ipcMain.handle( + 'db:getMessages', + (_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => { + const messages = chat.listMessages(userMd5, startTime, endTime, options) + if (chat.isReady()) { + saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages) + } + return messages + } ) ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => chat.getGroupSnapshot(userMd5)) @@ -374,6 +412,7 @@ app.whenReady().then(async () => { ipcMain.handle('settings:getSelf', () => { const info = chat.getSelfAccountInfo() if (!info) return { ready: false } + if (chat.isReady()) saveBootstrapSelf(chat.getCurrentAccountRoot(), info) return { ready: true, info } }) diff --git a/src/main/services/bootstrap-cache.ts b/src/main/services/bootstrap-cache.ts new file mode 100644 index 0000000..0087d85 --- /dev/null +++ b/src/main/services/bootstrap-cache.ts @@ -0,0 +1,214 @@ +import { app } from 'electron' +import crypto from 'crypto' +import fs from 'fs-extra' +import path from 'path' +import type { Contact, Message } from '../../shared/types' + +export interface CachedSelfInfo { + wxid: string + nickname: string + avatar?: string + accountRoot: string +} + +interface CachedMessageBucket { + updatedAt: number + startTime?: number + endTime?: number + items: Message[] +} + +interface BootstrapCacheFile { + version: 1 + platform: NodeJS.Platform + accountRoot: string + updatedAt: number + self?: CachedSelfInfo + contacts?: Contact[] + messages?: Record +} + +const CACHE_VERSION = 1 +const MAX_MESSAGE_BUCKETS = 24 +const MAX_MESSAGES_PER_BUCKET = 1200 + +function normalizeRoot(accountRoot?: string): string { + return String(accountRoot || '').trim() +} + +function getCacheFile(accountRoot?: string): string { + const normalizedRoot = normalizeRoot(accountRoot) || 'default' + const hash = crypto + .createHash('sha1') + .update(`${process.platform}:${normalizedRoot}`) + .digest('hex') + .slice(0, 16) + return path.join(app.getPath('userData'), 'cache', 'bootstrap', `${process.platform}-${hash}.json`) +} + +function readCacheFile(accountRoot?: string): BootstrapCacheFile | null { + const normalizedRoot = normalizeRoot(accountRoot) + if (!normalizedRoot) return null + const file = getCacheFile(normalizedRoot) + try { + if (!fs.existsSync(file)) return null + const raw = fs.readJsonSync(file) as Partial + if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null + if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null + return { + version: CACHE_VERSION, + platform: process.platform, + accountRoot: normalizedRoot, + updatedAt: Number(raw.updatedAt) || 0, + self: raw.self, + contacts: Array.isArray(raw.contacts) ? raw.contacts : [], + messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {} + } + } catch (error) { + console.warn('[BootstrapCache] read failed:', error) + return null + } +} + +function writeCacheFile(cache: BootstrapCacheFile): void { + try { + const file = getCacheFile(cache.accountRoot) + fs.ensureDirSync(path.dirname(file)) + fs.writeJsonSync(file, cache, { spaces: 2 }) + } catch (error) { + console.warn('[BootstrapCache] write failed:', error) + } +} + +function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null { + const normalizedRoot = normalizeRoot(accountRoot) + if (!normalizedRoot) return null + return ( + readCacheFile(normalizedRoot) || { + version: CACHE_VERSION, + platform: process.platform, + accountRoot: normalizedRoot, + updatedAt: Date.now(), + contacts: [], + messages: {} + } + ) +} + +function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string { + return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}` +} + +function pruneMessageBuckets(messages: Record): void { + const entries = Object.entries(messages) + if (entries.length <= MAX_MESSAGE_BUCKETS) return + entries + .sort((left, right) => (right[1].updatedAt || 0) - (left[1].updatedAt || 0)) + .slice(MAX_MESSAGE_BUCKETS) + .forEach(([key]) => { + delete messages[key] + }) +} + +export function getBootstrapCache(accountRoot?: string): { + self?: CachedSelfInfo + contacts: Contact[] + updatedAt: number +} | null { + const cache = readCacheFile(accountRoot) + if (!cache) return null + return { + self: cache.self, + contacts: cache.contacts || [], + updatedAt: cache.updatedAt + } +} + +export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact[]): Contact[] { + const cache = readCacheFile(accountRoot) + if (!cache?.contacts?.length) return contacts + const avatarByUsername = new Map( + cache.contacts + .filter((contact) => contact.m_nsUsrName && contact.avatar) + .map((contact) => [contact.m_nsUsrName, contact.avatar as string]) + ) + if (avatarByUsername.size === 0) return contacts + return contacts.map((contact) => + contact.avatar || !avatarByUsername.has(contact.m_nsUsrName) + ? contact + : { ...contact, avatar: avatarByUsername.get(contact.m_nsUsrName) } + ) +} + +export function saveBootstrapSelf(accountRoot: string, self: CachedSelfInfo): void { + const cache = loadOrCreate(accountRoot) + if (!cache) return + cache.self = self + cache.updatedAt = Date.now() + writeCacheFile(cache) +} + +export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]): void { + const cache = loadOrCreate(accountRoot) + if (!cache) return + const avatarByUsername = new Map( + (cache.contacts || []) + .filter((contact) => contact.m_nsUsrName && contact.avatar) + .map((contact) => [contact.m_nsUsrName, contact.avatar as string]) + ) + cache.contacts = contacts.map((contact) => + contact.avatar || !avatarByUsername.has(contact.m_nsUsrName) + ? contact + : { ...contact, avatar: avatarByUsername.get(contact.m_nsUsrName) } + ) + cache.updatedAt = Date.now() + writeCacheFile(cache) +} + +export function mergeBootstrapAvatars(accountRoot: string, avatars: Record): void { + const cache = loadOrCreate(accountRoot) + if (!cache || !cache.contacts?.length) return + let changed = false + cache.contacts = cache.contacts.map((contact) => { + const avatar = avatars[contact.m_nsUsrName] + if (!avatar || contact.avatar === avatar) return contact + changed = true + return { ...contact, avatar } + }) + if (!changed) return + cache.updatedAt = Date.now() + writeCacheFile(cache) +} + +export function getCachedMessages( + accountRoot: string, + userMd5: string, + startTime?: number, + endTime?: number +): Message[] { + const cache = readCacheFile(accountRoot) + const bucket = cache?.messages?.[messageBucketKey(userMd5, startTime, endTime)] + return bucket?.items || [] +} + +export function saveCachedMessages( + accountRoot: string, + userMd5: string, + startTime: number | undefined, + endTime: number | undefined, + messages: Message[] +): void { + const cache = loadOrCreate(accountRoot) + if (!cache) return + const nextMessages = cache.messages || {} + nextMessages[messageBucketKey(userMd5, startTime, endTime)] = { + updatedAt: Date.now(), + startTime, + endTime, + items: messages.slice(-MAX_MESSAGES_PER_BUCKET) + } + pruneMessageBuckets(nextMessages) + cache.messages = nextMessages + cache.updatedAt = Date.now() + writeCacheFile(cache) +} diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index 1d9c38c..f9f55e7 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -155,17 +155,19 @@ export function getContactAvatars(usernames: string[]): Record { export function listMessages( userMd5: string, startTime?: number, - endTime?: number + endTime?: number, + options?: { limit?: number } ): FormattedMessage[] { if (!dbRef) return [] const startedAt = Date.now() const wcdb4Client = dbRef.getWcdb4Client() const username = wcdb4Client.getUsernameByMd5(userMd5) + const isGroupChat = Boolean(username?.endsWith('@chatroom')) console.log( - `[ChatService] listMessages begin md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0}` + `[ChatService] listMessages begin md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}` ) - const rawMessages = dbRef.getUserMessages(userMd5, startTime, endTime) + const rawMessages = dbRef.getUserMessages(userMd5, startTime, endTime, options) console.log( `[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms` ) @@ -188,14 +190,14 @@ export function listMessages( if (typeof msg.senderAvatar === 'string') img = msg.senderAvatar if (typeof msg.senderNickname === 'string') name = msg.senderNickname } - if (content && typeof content === 'string') { + if (isGroupChat && content && typeof content === 'string') { const colonIndex = content.indexOf(':') if (colonIndex > 0) { - const potentialWxid = content.substring(0, colonIndex) - if (potentialWxid.startsWith('wxid_')) { - senderId = senderId || potentialWxid - name = name || potentialWxid - content = content.substring(colonIndex + 1) + const potentialSenderId = content.substring(0, colonIndex).trim() + if (/^[a-zA-Z0-9_@.-]{3,64}$/.test(potentialSenderId)) { + senderId = senderId || potentialSenderId + name = name || potentialSenderId + content = content.substring(colonIndex + 1).replace(/^\s+/, '') } } } diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index 90c08fd..1d1dc74 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -24,6 +24,10 @@ export interface Wcdb4Message { raw: Record } +export interface Wcdb4MessageQueryOptions { + limit?: number +} + export interface Wcdb4GroupMember { m_nsUsrName: string nickname: string @@ -597,8 +601,6 @@ export class Wcdb4Client { .map((row) => this.normalizeSession(row)) .filter((session) => session.username) - const sessionUsernames = sessions.map((session) => session.username) - this.hydrateDisplayNames(sessionUsernames) this.cachedSessions = sessions.map((session) => ({ ...session, nickname: this.displayNameCache.get(session.username) || session.nickname || session.username @@ -621,13 +623,19 @@ export class Wcdb4Client { return this.cachedChatTables } - getMessages(username: string, startTime?: number, endTime?: number): Wcdb4Message[] { + getMessages( + username: string, + startTime?: number, + endTime?: number, + options: Wcdb4MessageQueryOptions = {} + ): Wcdb4Message[] { const startedAt = Date.now() + const maxRows = this.normalizeMessageLimit(options.limit) console.log( - `[WCDB4] getMessages begin username=${username} start=${startTime || 0} end=${endTime || 0}` + `[WCDB4] getMessages begin username=${username} start=${startTime || 0} end=${endTime || 0} limit=${maxRows || 0}` ) try { - const cursorMessages = this.getMessagesByCursor(username, startTime, endTime) + const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows) if (cursorMessages) { console.log( `[WCDB4] getMessages cursor ok username=${username} rows=${cursorMessages.length} cost=${Date.now() - startedAt}ms` @@ -660,11 +668,12 @@ export class Wcdb4Client { const batch = Array.isArray(rows) ? rows : [] allRows.push(...batch) if (batch.length < limit) break + if (maxRows && allRows.length >= maxRows) break offset += limit } if (allRows.length === 0) { - const tableRows = this.getMessagesByTableScan(username, startTime, endTime) + const tableRows = this.getMessagesByTableScan(username, startTime, endTime, maxRows) if (tableRows.length > 0) { console.log( `[WCDB4] getMessages table scan ok username=${username} rows=${tableRows.length} cost=${Date.now() - startedAt}ms` @@ -673,7 +682,7 @@ export class Wcdb4Client { } } - const messages = this.finalizeMessages(username, allRows, startTime, endTime) + const messages = this.finalizeMessages(username, allRows, startTime, endTime, maxRows) console.log( `[WCDB4] getMessages direct ok username=${username} rows=${messages.length} cost=${Date.now() - startedAt}ms` ) @@ -691,7 +700,8 @@ export class Wcdb4Client { private getMessagesByTableScan( username: string, startTime?: number, - endTime?: number + endTime?: number, + limit?: number ): Wcdb4Message[] { if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return [] @@ -722,7 +732,9 @@ export class Wcdb4Client { for (const table of tables) { try { - const sql = `SELECT * FROM ${this.quoteSqlIdentifier(table.tableName)}${whereSql} ORDER BY "create_time" ASC LIMIT 5000` + const order = limit ? 'DESC' : 'ASC' + const rowLimit = limit || 5000 + const sql = `SELECT * FROM ${this.quoteSqlIdentifier(table.tableName)}${whereSql} ORDER BY "create_time" ${order} LIMIT ${rowLimit}` const rows = this.callJson[]>((handle, outJson) => this.wcdbExecQuery!(handle, 'message', table.dbPath, sql, outJson) ) @@ -735,7 +747,7 @@ export class Wcdb4Client { } } - return this.finalizeMessages(username, allRows, startTime, endTime) + return this.finalizeMessages(username, allRows, startTime, endTime, limit) } getMyAvatarUrl(): string | undefined { @@ -773,7 +785,8 @@ export class Wcdb4Client { private getMessagesByCursor( username: string, startTime?: number, - endTime?: number + endTime?: number, + limit?: number ): Wcdb4Message[] | null { if ( !this.wcdbOpenMessageCursor || @@ -784,7 +797,7 @@ export class Wcdb4Client { } const handle = this.ensureHandle() - const batchSize = 1000 + const batchSize = limit ? Math.min(500, limit) : 1000 const cursorOut: WcdbHandleOut = [0] const begin = this.normalizeTimestamp(startTime || 0) const end = this.normalizeTimestamp(endTime || 0) @@ -820,6 +833,7 @@ export class Wcdb4Client { } if (!outHasMore[0]) break + if (limit && allRows.length >= limit) break } } finally { try { @@ -829,18 +843,19 @@ export class Wcdb4Client { } } - return this.finalizeMessages(username, allRows, startTime, endTime) + return this.finalizeMessages(username, allRows, startTime, endTime, limit) } private finalizeMessages( username: string, rows: Record[], startTime?: number, - endTime?: number + endTime?: number, + limit?: number ): Wcdb4Message[] { const messages = rows.map((row) => this.normalizeMessage(row)) - return messages + const sorted = messages .filter((message) => { const createTime = Number(message.msgCreateTime) if (startTime && createTime < startTime) return false @@ -848,6 +863,10 @@ export class Wcdb4Client { return true }) .sort((a, b) => Number(a.msgCreateTime) - Number(b.msgCreateTime)) + + const visibleMessages = limit && sorted.length > limit ? sorted.slice(-limit) : sorted + + return visibleMessages .map((message) => { if (!message.sender) return message const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname @@ -869,6 +888,12 @@ export class Wcdb4Client { }) } + private normalizeMessageLimit(limit?: number): number | undefined { + const normalized = Number(limit) + if (!Number.isFinite(normalized) || normalized <= 0) return undefined + return Math.max(1, Math.min(5000, Math.floor(normalized))) + } + getGroupMembers(chatroomId: string): Wcdb4GroupMember[] { if (!this.wcdbGetGroupMembers || !chatroomId) return [] diff --git a/src/main/wechat-db.ts b/src/main/wechat-db.ts index b36ec7c..c9156cc 100644 --- a/src/main/wechat-db.ts +++ b/src/main/wechat-db.ts @@ -1,4 +1,4 @@ -import { Wcdb4Client } from './wcdb4-client' +import { Wcdb4Client, Wcdb4MessageQueryOptions } from './wcdb4-client' export interface UserContact { m_nsUsrName: string @@ -146,10 +146,15 @@ export class WechatDb { this.wcdb4Client.close() } - public getUserMessages(userMd5: string, startTime?: number, endTime?: number): WechatMessage[] { + public getUserMessages( + userMd5: string, + startTime?: number, + endTime?: number, + options?: Wcdb4MessageQueryOptions + ): WechatMessage[] { const username = this.chatMd5ToUsername.get(userMd5) if (!username) return [] - return this.wcdb4Client.getMessages(username, startTime, endTime).map((message) => ({ + return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({ ...message, ...message.raw })) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 71f7abc..d4001ab 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -37,9 +37,24 @@ declare global { initDb: ( key: string ) => Promise + getBootstrapCache: () => Promise<{ + self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string } + contacts: Contact[] + updatedAt: number + } | null> getContacts: (filter?: string) => Promise getContactAvatars: (usernames: string[]) => Promise> - getMessages: (userMd5: string, startTime?: number, endTime?: number) => Promise + getCachedMessages: ( + userMd5: string, + startTime?: number, + endTime?: number + ) => Promise + getMessages: ( + userMd5: string, + startTime?: number, + endTime?: number, + options?: { limit?: number } + ) => Promise getGroupSnapshot: (userMd5: string) => Promise<{ roomId: string memberCount: number diff --git a/src/preload/index.ts b/src/preload/index.ts index 67d0d58..14699cb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,10 +5,17 @@ import { GroupReportExportRequest } from '../shared/group-report' // 渲染器的自定义 API const api = { initDb: (key: string) => ipcRenderer.invoke('db:init', key), + getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'), getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter), getContactAvatars: (usernames: string[]) => ipcRenderer.invoke('db:getContactAvatars', usernames), - getMessages: (userMd5: string, startTime?: number, endTime?: number) => - ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime), + getCachedMessages: (userMd5: string, startTime?: number, endTime?: number) => + ipcRenderer.invoke('db:getCachedMessages', userMd5, startTime, endTime), + getMessages: ( + userMd5: string, + startTime?: number, + endTime?: number, + options?: { limit?: number } + ) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime, options), getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5), search: (keyword: string) => ipcRenderer.invoke('db:search', keyword), aiChat: ( diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 935baaf..4d436d0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useState } from 'react' import { Sidebar } from './components/Sidebar' import ChatWindow from './components/ChatWindow' import { SettingsPanel } from './components/SettingsPanel' @@ -13,6 +13,7 @@ interface SelfInfo { const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md' const MESSAGE_MONITOR_DEBOUNCE_MS = 8000 +const VIEW_MESSAGE_LIMIT = 600 const getMessageIdentity = (message: Message): string => { if (message.localId) return `local:${message.localId}` @@ -36,6 +37,12 @@ type GroupSnapshot = { } type GroupMemberMeta = { nickname: string; avatar: string } +type StartupProgress = { + title: string + subtitle: string + detail?: string + percent?: number +} const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string => member.nickname || member.wxid @@ -100,8 +107,9 @@ function App(): React.ReactElement { const [contacts, setContacts] = useState([]) const [selectedContact, setSelectedContact] = useState(null) const [messages, setMessages] = useState([]) + const [isMessagesLoading, setIsMessagesLoading] = useState(false) const [filteredContacts, setFilteredContacts] = useState([]) - const [dateRange, setDateRange] = useState('today') // 默认为今天 + const [dateRange, setDateRange] = useState('today') // 默认今天 const [contentFilter, setContentFilter] = useState('') const [isFetchingDbKey, setIsFetchingDbKey] = useState(false) const [dbKeyStatus, setDbKeyStatus] = useState('') @@ -113,10 +121,15 @@ function App(): React.ReactElement { const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false) const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading') const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null) + const [startupProgress, setStartupProgress] = useState(null) const currentGroupSnapshotRef = React.useRef(null) const syntheticGroupMessagesRef = React.useRef>({}) const groupMemberMetaRef = React.useRef>>({}) const selectedContactMd5Ref = React.useRef('') + const contactAvatarHydrationRunRef = React.useRef(0) + + const waitForPaint = (): Promise => + new Promise((resolve) => window.setTimeout(resolve, 80)) const refreshSelfInfo = async (): Promise => { try { @@ -132,27 +145,80 @@ function App(): React.ReactElement { } } - const loadContacts = async (): Promise => { + const loadBootstrapCache = async (): Promise => { + try { + const cache = await window.api.getBootstrapCache() + if (!cache) return false + if (cache.contacts.length) { + setContacts(cache.contacts) + setFilteredContacts(cache.contacts) + } + if (cache.self) setSelfInfo(cache.self) + return Boolean(cache.contacts.length || cache.self) + } catch (error) { + console.warn('[BootstrapCache] 加载失败:', error) + return false + } + } + + const loadContacts = async (options?: { + waitForAvatars?: boolean + onProgress?: (message: string, percent?: number) => void + }): Promise => { + options?.onProgress?.('正在加载联系人...', 35) const list = await window.api.getContacts() setContacts(list) setFilteredContacts(list) - void hydrateContactAvatars(list) + const runId = ++contactAvatarHydrationRunRef.current + const hydrate = (): Promise => hydrateContactAvatars(list, runId, options?.onProgress) + if (options?.waitForAvatars) { + await hydrate() + } else { + window.setTimeout(() => { + void hydrate() + }, 1500) + } } - const hydrateContactAvatars = async (list: Contact[]): Promise => { + const hydrateContactAvatars = async ( + list: Contact[], + runId: number, + onProgress?: (message: string, percent?: number) => void + ): Promise => { const usernames = Array.from( new Set( list .map((contact) => contact.m_nsUsrName) - .filter((username) => username && !username.startsWith('Group_') && !username.startsWith('Unknown_')) + .filter((username, index) => { + const contact = list[index] + return ( + username && + !contact.avatar && + !username.startsWith('Group_') && + !username.startsWith('Unknown_') + ) + }) ) ) - const chunkSize = 60 + onProgress?.( + usernames.length ? `正在加载头像 0/${usernames.length}...` : '头像缓存已就绪', + usernames.length ? 55 : 90 + ) + let loadedCount = 0 + const chunkSize = 8 for (let index = 0; index < usernames.length; index += chunkSize) { + if (runId !== contactAvatarHydrationRunRef.current) return const chunk = usernames.slice(index, index + chunkSize) if (chunk.length === 0) continue try { const avatars = await window.api.getContactAvatars(chunk) + if (runId !== contactAvatarHydrationRunRef.current) return + loadedCount += chunk.length + onProgress?.( + `正在加载头像 ${Math.min(loadedCount, usernames.length)}/${usernames.length}...`, + 55 + Math.round((Math.min(loadedCount, usernames.length) / usernames.length) * 35) + ) + if (Object.keys(avatars).length === 0) continue setContacts((current) => current.map((contact) => avatars[contact.m_nsUsrName] ? { ...contact, avatar: avatars[contact.m_nsUsrName] } : contact) ) @@ -162,16 +228,17 @@ function App(): React.ReactElement { } catch (error) { console.warn('[Contacts] avatar hydrate failed:', error) } - await new Promise((resolve) => window.setTimeout(resolve, 50)) + await new Promise((resolve) => window.setTimeout(resolve, 150)) } + if (usernames.length) onProgress?.('头像加载完成', 90) } React.useEffect(() => { let active = true const attemptAutoConnect = async (): Promise => { - // 优先级 1:构建期环境变量 VITE_DB_KEY(本地开发/打包时硬编码的密钥) + // 优先级 1: 构建期环境变量 VITE_DB_KEY(本地开发用) const envKey = String(import.meta.env.VITE_DB_KEY || '').trim() - // 优先级 2:上一次保存到 safeStorage 的密钥 + // 优先级 2: 上一次保存到 safeStorage 的密钥 let savedKey = '' if (!envKey) { const result = await window.api.getSavedDbKey() @@ -197,7 +264,7 @@ function App(): React.ReactElement { setDbKey(key) setAutoConnectSource(envKey ? 'env' : 'saved') setDbKeyStatus( - envKey ? '检测到环境变量中的密钥,正在自动连接...' : '已加载安全保存的密钥,正在自动连接...' + envKey ? '检测到环境变量中的密钥,正在自动连接...' : '已加载安全保存的密钥,正在自动连接...' ) setDbKeyStatusKind('normal') } @@ -215,7 +282,7 @@ function App(): React.ReactElement { } else { const error = typeof result === 'boolean' ? '' : result.error setDbKeyStatus( - `自动连接失败,请重新输入${error ? `: ${error}` : ''}` + `自动连接失败,请重新输入${error ? `: ${error}` : ''}` ) setDbKeyStatusKind('error') setBootState('login') @@ -249,22 +316,62 @@ function App(): React.ReactElement { const handleLogin = async (keyInput?: string): Promise => { const keyToUse = keyInput || dbKey if (!keyToUse) return + setBootState('connecting') + setStartupProgress({ + title: '正在连接数据库...', + subtitle: '正在初始化微信数据', + detail: '请稍候', + percent: 8 + }) try { + setStartupProgress({ + title: '正在连接数据库...', + subtitle: '正在初始化微信数据', + detail: '正在打开 WCDB 数据库', + percent: 15 + }) const result = await window.api.initDb(keyToUse) const success = typeof result === 'boolean' ? result : result.success if (success) { setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true) - setIsAuthenticated(true) - // 手动输入也持久化,下次启动可自动连接(参考 WeFlow) + setStartupProgress({ + title: '正在读取缓存...', + subtitle: '正在恢复上次联系人和头像', + detail: '正在读取本地缓存', + percent: 25 + }) + const hasBootstrapCache = await loadBootstrapCache() + // 持久化手动输入的密钥,供下次启动继续使用 void window.api.saveDbKey(keyToUse).catch(() => undefined) - loadContacts() + setStartupProgress({ + title: '正在加载账号信息...', + subtitle: '即将进入 WechatExplorer', + detail: '正在读取当前账号信息', + percent: 95 + }) void refreshSelfInfo() + setStartupProgress({ + title: '加载完成', + subtitle: '正在进入主页面', + detail: '联系人和头像已准备好', + percent: 100 + }) + setIsAuthenticated(true) + setBootState('login') + window.setTimeout(() => { + setStartupProgress(null) + if (!hasBootstrapCache) void loadContacts({ waitForAvatars: false }) + }, 500) } else { const error = typeof result === 'boolean' ? '' : result.error + setBootState('login') + setStartupProgress(null) alert(`Failed to open database.${error ? `\n\n${error}` : '\nCheck your key.'}`) } } catch (error) { console.error(error) + setBootState('login') + setStartupProgress(null) alert('Error connecting to database') } } @@ -307,10 +414,17 @@ function App(): React.ReactElement { return baseMessages.map((message) => { const senderId = String(message.senderId || message.name || '').trim() - if (!senderId || !senderId.startsWith('wxid_')) return message + if (!senderId) return message const member = memberMap.get(senderId) if (!member) return message - const nickname = member.nickname && !member.nickname.startsWith('wxid_') ? member.nickname : senderId + const rawNickname = String(member.nickname || '').trim() + const nickname = + rawNickname && + rawNickname !== senderId && + !rawNickname.startsWith('wxid_') && + !/^[a-z]{2,}\d{4,}$/i.test(rawNickname) + ? rawNickname + : senderId return { ...message, name: nickname, @@ -423,19 +537,41 @@ function App(): React.ReactElement { setSelectedContact(contact) selectedContactMd5Ref.current = contact.md5 currentGroupSnapshotRef.current = null + setIsMessagesLoading(true) const { startTime, endTime } = getDateRangeParams(dateRange) - const msgs = await window.api.getMessages(contact.md5, startTime, endTime) + const cachedMsgs = await window.api.getCachedMessages(contact.md5, startTime, endTime) if (selectedContactMd5Ref.current !== contact.md5) return - const cachedMessages = applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, msgs)) - setMessages(cachedMessages) - if (contact.type === 'group') { - void loadGroupMemberMeta(contact).then((snapshot) => { - if (selectedContactMd5Ref.current !== contact.md5) return - if (!snapshot) return - setMessages((current) => - applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, current, snapshot.roomId)) + if (cachedMsgs.length) { + setMessages( + applyGroupMemberMeta( + contact, + mergeSyntheticMessages(contact, cachedMsgs.slice(-VIEW_MESSAGE_LIMIT)) ) + ) + } else { + setMessages([]) + } + await waitForPaint() + try { + const msgs = await window.api.getMessages(contact.md5, startTime, endTime, { + limit: VIEW_MESSAGE_LIMIT }) + if (selectedContactMd5Ref.current !== contact.md5) return + const cachedMessages = applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, msgs)) + setMessages(cachedMessages) + if (contact.type === 'group') { + window.setTimeout(() => { + void loadGroupMemberMeta(contact).then((snapshot) => { + if (selectedContactMd5Ref.current !== contact.md5) return + if (!snapshot) return + setMessages((current) => + applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, current, snapshot.roomId)) + ) + }) + }, 120) + } + } finally { + if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false) } } @@ -443,8 +579,22 @@ function App(): React.ReactElement { setDateRange(range) if (selectedContact) { const { startTime, endTime } = getDateRangeParams(range) - window.api.getMessages(selectedContact.md5, startTime, endTime).then((nextMessages) => { + window.api.getCachedMessages(selectedContact.md5, startTime, endTime).then((cachedMessages) => { + if (!cachedMessages.length) return + setMessages( + applyGroupMemberMeta( + selectedContact, + mergeSyntheticMessages(selectedContact, cachedMessages.slice(-VIEW_MESSAGE_LIMIT)) + ) + ) + }) + setIsMessagesLoading(true) + window.api.getMessages(selectedContact.md5, startTime, endTime, { limit: VIEW_MESSAGE_LIMIT }).then((nextMessages) => { setMessages(applyGroupMemberMeta(selectedContact, mergeSyntheticMessages(selectedContact, nextMessages))) + setIsMessagesLoading(false) + }).catch((error) => { + console.warn('[Messages] date range load failed:', error) + setIsMessagesLoading(false) }) } } @@ -469,7 +619,8 @@ function App(): React.ReactElement { const latestMessages = await window.api.getMessages( contactMd5, range.startTime, - range.endTime + range.endTime, + { limit: VIEW_MESSAGE_LIMIT } ) const nextMessages = applyGroupMemberMeta( selectedContact, @@ -561,19 +712,31 @@ function App(): React.ReactElement { }, [resize, stopResizing]) if (!isAuthenticated && bootState !== 'login') { + const title = + startupProgress?.title || (bootState === 'connecting' ? '正在自动连接数据库...' : '正在准备...') + const subtitle = + startupProgress?.subtitle || + (bootState === 'connecting' + ? autoConnectSource === 'env' + ? '检测到环境变量中的密钥' + : '使用上次安全保存的密钥' + : 'WechatExplorer') return (
-
- {bootState === 'connecting' ? '正在自动连接数据库...' : '正在准备...'} -
-
- {bootState === 'connecting' - ? autoConnectSource === 'env' - ? '检测到环境变量中的密钥' - : '使用上次安全保存的密钥' - : 'WechatExplorer'} -
+
{title}
+
{subtitle}
+ {startupProgress?.detail && ( +
{startupProgress.detail}
+ )} + {typeof startupProgress?.percent === 'number' && ( +
+
+
+ )}
) } @@ -597,7 +760,7 @@ function App(): React.ReactElement { onClick={() => setShowDbKey(!showDbKey)} title={showDbKey ? '隐藏密钥' : '显示密钥'} > - {showDbKey ? '👁️' : '👁️‍🗨️'} + {showDbKey ? '隐藏' : '显示'}
+ {isLoadingMessages && ( +
正在加载聊天记录...
+ )} {hiddenMessageCount > 0 && (
@@ -562,6 +587,7 @@ const ChatWindow: React.FC = ({
= ({ >
{contact.avatar ? ( - {contact.m_nsNickName} + {contact.m_nsNickName} ) : ( (contact.m_nsNickName || contact.m_nsUsrName || '?').charAt(0) )} @@ -131,7 +137,13 @@ export const Sidebar: React.FC = ({
{selfInfo?.avatar ? ( - {selfInfo.nickname} + {selfInfo.nickname} ) : ( ((selfInfo?.nickname || selfInfo?.wxid || '我').charAt(0)) )}