mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
feat: 优化 Windows 缓存加载和图片显示
This commit is contained in:
@@ -131,7 +131,7 @@ export class ImageDecryptService {
|
|||||||
join(attachDir, dir1, dir2, 'Image', variant),
|
join(attachDir, dir1, dir2, 'Image', variant),
|
||||||
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) {
|
if (found) {
|
||||||
console.log('[ImageDecrypt] prefix path hit:', found)
|
console.log('[ImageDecrypt] prefix path hit:', found)
|
||||||
return found
|
return found
|
||||||
@@ -157,9 +157,10 @@ export class ImageDecryptService {
|
|||||||
const imgDir = join(attachDir, sessDir, month, sub)
|
const imgDir = join(attachDir, sessDir, month, sub)
|
||||||
if (!existsSync(imgDir)) continue
|
if (!existsSync(imgDir)) continue
|
||||||
|
|
||||||
const found = variants
|
const found = this.getLargestExistingPath(
|
||||||
.map((variant) => join(imgDir, variant))
|
variants.map((variant) => join(imgDir, variant)),
|
||||||
.find((candidate) => existsSync(candidate))
|
true
|
||||||
|
)
|
||||||
if (found) {
|
if (found) {
|
||||||
console.log('[ImageDecrypt] found at:', found)
|
console.log('[ImageDecrypt] found at:', found)
|
||||||
return found
|
return found
|
||||||
@@ -418,9 +419,9 @@ export class ImageDecryptService {
|
|||||||
const base = this.normalizeDatBase(baseName)
|
const base = this.normalizeDatBase(baseName)
|
||||||
if (!base) return []
|
if (!base) return []
|
||||||
return [
|
return [
|
||||||
`${base}_h.dat`,
|
|
||||||
`${base}.dat`,
|
`${base}.dat`,
|
||||||
`${base}_hd.dat`,
|
`${base}_hd.dat`,
|
||||||
|
`${base}_h.dat`,
|
||||||
`${base}_c.dat`,
|
`${base}_c.dat`,
|
||||||
`${base}_t.dat`,
|
`${base}_t.dat`,
|
||||||
`${base}.thumb.dat`,
|
`${base}.thumb.dat`,
|
||||||
@@ -435,13 +436,35 @@ export class ImageDecryptService {
|
|||||||
const ordered = allowThumbnail
|
const ordered = allowThumbnail
|
||||||
? variants
|
? variants
|
||||||
: variants.filter((name) => !this.isThumbnailName(name))
|
: variants.filter((name) => !this.isThumbnailName(name))
|
||||||
for (const variant of ordered) {
|
const largest = this.getLargestExistingPath(
|
||||||
const candidate = join(actualDir, variant)
|
ordered.map((variant) => join(actualDir, variant)),
|
||||||
if (existsSync(candidate)) return candidate
|
allowThumbnail
|
||||||
}
|
)
|
||||||
|
if (largest) return largest
|
||||||
return inputPath
|
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 {
|
private isThumbnailName(fileName: string): boolean {
|
||||||
const lower = fileName.toLowerCase()
|
const lower = fileName.toLowerCase()
|
||||||
return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat')
|
return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat')
|
||||||
|
|||||||
+44
-5
@@ -24,6 +24,15 @@ import {
|
|||||||
getSettingsPath,
|
getSettingsPath,
|
||||||
AppSettings
|
AppSettings
|
||||||
} from './services/settings-store'
|
} from './services/settings-store'
|
||||||
|
import {
|
||||||
|
getBootstrapCache,
|
||||||
|
getCachedMessages,
|
||||||
|
mergeBootstrapAvatars,
|
||||||
|
mergeCachedContactAvatars,
|
||||||
|
saveBootstrapContacts,
|
||||||
|
saveBootstrapSelf,
|
||||||
|
saveCachedMessages
|
||||||
|
} from './services/bootstrap-cache'
|
||||||
import { installSafeConsole } from './safe-log'
|
import { installSafeConsole } from './safe-log'
|
||||||
|
|
||||||
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
// 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[]) =>
|
ipcMain.handle(
|
||||||
chat.getContactAvatars(usernames)
|
'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) =>
|
ipcMain.handle('db:getContacts', (_, filter?: string) => {
|
||||||
chat.listMessages(userMd5, startTime, endTime)
|
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))
|
ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => chat.getGroupSnapshot(userMd5))
|
||||||
@@ -374,6 +412,7 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.handle('settings:getSelf', () => {
|
ipcMain.handle('settings:getSelf', () => {
|
||||||
const info = chat.getSelfAccountInfo()
|
const info = chat.getSelfAccountInfo()
|
||||||
if (!info) return { ready: false }
|
if (!info) return { ready: false }
|
||||||
|
if (chat.isReady()) saveBootstrapSelf(chat.getCurrentAccountRoot(), info)
|
||||||
return { ready: true, info }
|
return { ready: true, info }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, CachedMessageBucket>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<BootstrapCacheFile>
|
||||||
|
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<string, CachedMessageBucket>): 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<string, string>): 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)
|
||||||
|
}
|
||||||
@@ -155,17 +155,19 @@ export function getContactAvatars(usernames: string[]): Record<string, string> {
|
|||||||
export function listMessages(
|
export function listMessages(
|
||||||
userMd5: string,
|
userMd5: string,
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
endTime?: number
|
endTime?: number,
|
||||||
|
options?: { limit?: number }
|
||||||
): FormattedMessage[] {
|
): FormattedMessage[] {
|
||||||
if (!dbRef) return []
|
if (!dbRef) return []
|
||||||
|
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
const wcdb4Client = dbRef.getWcdb4Client()
|
const wcdb4Client = dbRef.getWcdb4Client()
|
||||||
const username = wcdb4Client.getUsernameByMd5(userMd5)
|
const username = wcdb4Client.getUsernameByMd5(userMd5)
|
||||||
|
const isGroupChat = Boolean(username?.endsWith('@chatroom'))
|
||||||
console.log(
|
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(
|
console.log(
|
||||||
`[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
|
`[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.senderAvatar === 'string') img = msg.senderAvatar
|
||||||
if (typeof msg.senderNickname === 'string') name = msg.senderNickname
|
if (typeof msg.senderNickname === 'string') name = msg.senderNickname
|
||||||
}
|
}
|
||||||
if (content && typeof content === 'string') {
|
if (isGroupChat && content && typeof content === 'string') {
|
||||||
const colonIndex = content.indexOf(':')
|
const colonIndex = content.indexOf(':')
|
||||||
if (colonIndex > 0) {
|
if (colonIndex > 0) {
|
||||||
const potentialWxid = content.substring(0, colonIndex)
|
const potentialSenderId = content.substring(0, colonIndex).trim()
|
||||||
if (potentialWxid.startsWith('wxid_')) {
|
if (/^[a-zA-Z0-9_@.-]{3,64}$/.test(potentialSenderId)) {
|
||||||
senderId = senderId || potentialWxid
|
senderId = senderId || potentialSenderId
|
||||||
name = name || potentialWxid
|
name = name || potentialSenderId
|
||||||
content = content.substring(colonIndex + 1)
|
content = content.substring(colonIndex + 1).replace(/^\s+/, '')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-15
@@ -24,6 +24,10 @@ export interface Wcdb4Message {
|
|||||||
raw: Record<string, unknown>
|
raw: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Wcdb4MessageQueryOptions {
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface Wcdb4GroupMember {
|
export interface Wcdb4GroupMember {
|
||||||
m_nsUsrName: string
|
m_nsUsrName: string
|
||||||
nickname: string
|
nickname: string
|
||||||
@@ -597,8 +601,6 @@ export class Wcdb4Client {
|
|||||||
.map((row) => this.normalizeSession(row))
|
.map((row) => this.normalizeSession(row))
|
||||||
.filter((session) => session.username)
|
.filter((session) => session.username)
|
||||||
|
|
||||||
const sessionUsernames = sessions.map((session) => session.username)
|
|
||||||
this.hydrateDisplayNames(sessionUsernames)
|
|
||||||
this.cachedSessions = sessions.map((session) => ({
|
this.cachedSessions = sessions.map((session) => ({
|
||||||
...session,
|
...session,
|
||||||
nickname: this.displayNameCache.get(session.username) || session.nickname || session.username
|
nickname: this.displayNameCache.get(session.username) || session.nickname || session.username
|
||||||
@@ -621,13 +623,19 @@ export class Wcdb4Client {
|
|||||||
return this.cachedChatTables
|
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 startedAt = Date.now()
|
||||||
|
const maxRows = this.normalizeMessageLimit(options.limit)
|
||||||
console.log(
|
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 {
|
try {
|
||||||
const cursorMessages = this.getMessagesByCursor(username, startTime, endTime)
|
const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows)
|
||||||
if (cursorMessages) {
|
if (cursorMessages) {
|
||||||
console.log(
|
console.log(
|
||||||
`[WCDB4] getMessages cursor ok username=${username} rows=${cursorMessages.length} cost=${Date.now() - startedAt}ms`
|
`[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 : []
|
const batch = Array.isArray(rows) ? rows : []
|
||||||
allRows.push(...batch)
|
allRows.push(...batch)
|
||||||
if (batch.length < limit) break
|
if (batch.length < limit) break
|
||||||
|
if (maxRows && allRows.length >= maxRows) break
|
||||||
offset += limit
|
offset += limit
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allRows.length === 0) {
|
if (allRows.length === 0) {
|
||||||
const tableRows = this.getMessagesByTableScan(username, startTime, endTime)
|
const tableRows = this.getMessagesByTableScan(username, startTime, endTime, maxRows)
|
||||||
if (tableRows.length > 0) {
|
if (tableRows.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
`[WCDB4] getMessages table scan ok username=${username} rows=${tableRows.length} cost=${Date.now() - startedAt}ms`
|
`[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(
|
console.log(
|
||||||
`[WCDB4] getMessages direct ok username=${username} rows=${messages.length} cost=${Date.now() - startedAt}ms`
|
`[WCDB4] getMessages direct ok username=${username} rows=${messages.length} cost=${Date.now() - startedAt}ms`
|
||||||
)
|
)
|
||||||
@@ -691,7 +700,8 @@ export class Wcdb4Client {
|
|||||||
private getMessagesByTableScan(
|
private getMessagesByTableScan(
|
||||||
username: string,
|
username: string,
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
endTime?: number
|
endTime?: number,
|
||||||
|
limit?: number
|
||||||
): Wcdb4Message[] {
|
): Wcdb4Message[] {
|
||||||
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
|
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
|
||||||
|
|
||||||
@@ -722,7 +732,9 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
try {
|
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<Record<string, unknown>[]>((handle, outJson) =>
|
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||||
this.wcdbExecQuery!(handle, 'message', table.dbPath, sql, 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 {
|
getMyAvatarUrl(): string | undefined {
|
||||||
@@ -773,7 +785,8 @@ export class Wcdb4Client {
|
|||||||
private getMessagesByCursor(
|
private getMessagesByCursor(
|
||||||
username: string,
|
username: string,
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
endTime?: number
|
endTime?: number,
|
||||||
|
limit?: number
|
||||||
): Wcdb4Message[] | null {
|
): Wcdb4Message[] | null {
|
||||||
if (
|
if (
|
||||||
!this.wcdbOpenMessageCursor ||
|
!this.wcdbOpenMessageCursor ||
|
||||||
@@ -784,7 +797,7 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handle = this.ensureHandle()
|
const handle = this.ensureHandle()
|
||||||
const batchSize = 1000
|
const batchSize = limit ? Math.min(500, limit) : 1000
|
||||||
const cursorOut: WcdbHandleOut = [0]
|
const cursorOut: WcdbHandleOut = [0]
|
||||||
const begin = this.normalizeTimestamp(startTime || 0)
|
const begin = this.normalizeTimestamp(startTime || 0)
|
||||||
const end = this.normalizeTimestamp(endTime || 0)
|
const end = this.normalizeTimestamp(endTime || 0)
|
||||||
@@ -820,6 +833,7 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!outHasMore[0]) break
|
if (!outHasMore[0]) break
|
||||||
|
if (limit && allRows.length >= limit) break
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
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(
|
private finalizeMessages(
|
||||||
username: string,
|
username: string,
|
||||||
rows: Record<string, unknown>[],
|
rows: Record<string, unknown>[],
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
endTime?: number
|
endTime?: number,
|
||||||
|
limit?: number
|
||||||
): Wcdb4Message[] {
|
): Wcdb4Message[] {
|
||||||
const messages = rows.map((row) => this.normalizeMessage(row))
|
const messages = rows.map((row) => this.normalizeMessage(row))
|
||||||
|
|
||||||
return messages
|
const sorted = messages
|
||||||
.filter((message) => {
|
.filter((message) => {
|
||||||
const createTime = Number(message.msgCreateTime)
|
const createTime = Number(message.msgCreateTime)
|
||||||
if (startTime && createTime < startTime) return false
|
if (startTime && createTime < startTime) return false
|
||||||
@@ -848,6 +863,10 @@ export class Wcdb4Client {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
.sort((a, b) => Number(a.msgCreateTime) - Number(b.msgCreateTime))
|
.sort((a, b) => Number(a.msgCreateTime) - Number(b.msgCreateTime))
|
||||||
|
|
||||||
|
const visibleMessages = limit && sorted.length > limit ? sorted.slice(-limit) : sorted
|
||||||
|
|
||||||
|
return visibleMessages
|
||||||
.map((message) => {
|
.map((message) => {
|
||||||
if (!message.sender) return message
|
if (!message.sender) return message
|
||||||
const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname
|
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[] {
|
getGroupMembers(chatroomId: string): Wcdb4GroupMember[] {
|
||||||
if (!this.wcdbGetGroupMembers || !chatroomId) return []
|
if (!this.wcdbGetGroupMembers || !chatroomId) return []
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Wcdb4Client } from './wcdb4-client'
|
import { Wcdb4Client, Wcdb4MessageQueryOptions } from './wcdb4-client'
|
||||||
|
|
||||||
export interface UserContact {
|
export interface UserContact {
|
||||||
m_nsUsrName: string
|
m_nsUsrName: string
|
||||||
@@ -146,10 +146,15 @@ export class WechatDb {
|
|||||||
this.wcdb4Client.close()
|
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)
|
const username = this.chatMd5ToUsername.get(userMd5)
|
||||||
if (!username) return []
|
if (!username) return []
|
||||||
return this.wcdb4Client.getMessages(username, startTime, endTime).map((message) => ({
|
return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({
|
||||||
...message,
|
...message,
|
||||||
...message.raw
|
...message.raw
|
||||||
}))
|
}))
|
||||||
|
|||||||
Vendored
+16
-1
@@ -37,9 +37,24 @@ declare global {
|
|||||||
initDb: (
|
initDb: (
|
||||||
key: string
|
key: string
|
||||||
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
||||||
|
getBootstrapCache: () => Promise<{
|
||||||
|
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
||||||
|
contacts: Contact[]
|
||||||
|
updatedAt: number
|
||||||
|
} | null>
|
||||||
getContacts: (filter?: string) => Promise<Contact[]>
|
getContacts: (filter?: string) => Promise<Contact[]>
|
||||||
getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
|
getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
|
||||||
getMessages: (userMd5: string, startTime?: number, endTime?: number) => Promise<Message[]>
|
getCachedMessages: (
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number
|
||||||
|
) => Promise<Message[]>
|
||||||
|
getMessages: (
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
options?: { limit?: number }
|
||||||
|
) => Promise<Message[]>
|
||||||
getGroupSnapshot: (userMd5: string) => Promise<{
|
getGroupSnapshot: (userMd5: string) => Promise<{
|
||||||
roomId: string
|
roomId: string
|
||||||
memberCount: number
|
memberCount: number
|
||||||
|
|||||||
@@ -5,10 +5,17 @@ import { GroupReportExportRequest } from '../shared/group-report'
|
|||||||
// 渲染器的自定义 API
|
// 渲染器的自定义 API
|
||||||
const api = {
|
const api = {
|
||||||
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
||||||
|
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
||||||
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
||||||
getContactAvatars: (usernames: string[]) => ipcRenderer.invoke('db:getContactAvatars', usernames),
|
getContactAvatars: (usernames: string[]) => ipcRenderer.invoke('db:getContactAvatars', usernames),
|
||||||
getMessages: (userMd5: string, startTime?: number, endTime?: number) =>
|
getCachedMessages: (userMd5: string, startTime?: number, endTime?: number) =>
|
||||||
ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime),
|
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),
|
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
|
||||||
search: (keyword: string) => ipcRenderer.invoke('db:search', keyword),
|
search: (keyword: string) => ipcRenderer.invoke('db:search', keyword),
|
||||||
aiChat: (
|
aiChat: (
|
||||||
|
|||||||
+193
-29
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Sidebar } from './components/Sidebar'
|
import { Sidebar } from './components/Sidebar'
|
||||||
import ChatWindow from './components/ChatWindow'
|
import ChatWindow from './components/ChatWindow'
|
||||||
import { SettingsPanel } from './components/SettingsPanel'
|
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 MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
||||||
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
|
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
|
||||||
|
const VIEW_MESSAGE_LIMIT = 600
|
||||||
|
|
||||||
const getMessageIdentity = (message: Message): string => {
|
const getMessageIdentity = (message: Message): string => {
|
||||||
if (message.localId) return `local:${message.localId}`
|
if (message.localId) return `local:${message.localId}`
|
||||||
@@ -36,6 +37,12 @@ type GroupSnapshot = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GroupMemberMeta = { nickname: string; avatar: string }
|
type GroupMemberMeta = { nickname: string; avatar: string }
|
||||||
|
type StartupProgress = {
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
detail?: string
|
||||||
|
percent?: number
|
||||||
|
}
|
||||||
|
|
||||||
const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string =>
|
const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string =>
|
||||||
member.nickname || member.wxid
|
member.nickname || member.wxid
|
||||||
@@ -100,8 +107,9 @@ function App(): React.ReactElement {
|
|||||||
const [contacts, setContacts] = useState<Contact[]>([])
|
const [contacts, setContacts] = useState<Contact[]>([])
|
||||||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
||||||
const [messages, setMessages] = useState<Message[]>([])
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
|
const [isMessagesLoading, setIsMessagesLoading] = useState(false)
|
||||||
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
|
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
|
||||||
const [dateRange, setDateRange] = useState('today') // 默认为今天
|
const [dateRange, setDateRange] = useState('today') // 默认今天
|
||||||
const [contentFilter, setContentFilter] = useState('')
|
const [contentFilter, setContentFilter] = useState('')
|
||||||
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
||||||
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
||||||
@@ -113,10 +121,15 @@ function App(): React.ReactElement {
|
|||||||
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
||||||
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
|
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
|
||||||
const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null)
|
const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null)
|
||||||
|
const [startupProgress, setStartupProgress] = useState<StartupProgress | null>(null)
|
||||||
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
|
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
|
||||||
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
||||||
const groupMemberMetaRef = React.useRef<Record<string, Map<string, GroupMemberMeta>>>({})
|
const groupMemberMetaRef = React.useRef<Record<string, Map<string, GroupMemberMeta>>>({})
|
||||||
const selectedContactMd5Ref = React.useRef<string>('')
|
const selectedContactMd5Ref = React.useRef<string>('')
|
||||||
|
const contactAvatarHydrationRunRef = React.useRef(0)
|
||||||
|
|
||||||
|
const waitForPaint = (): Promise<void> =>
|
||||||
|
new Promise((resolve) => window.setTimeout(resolve, 80))
|
||||||
|
|
||||||
const refreshSelfInfo = async (): Promise<void> => {
|
const refreshSelfInfo = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -132,27 +145,80 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadContacts = async (): Promise<void> => {
|
const loadBootstrapCache = async (): Promise<boolean> => {
|
||||||
|
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<void> => {
|
||||||
|
options?.onProgress?.('正在加载联系人...', 35)
|
||||||
const list = await window.api.getContacts()
|
const list = await window.api.getContacts()
|
||||||
setContacts(list)
|
setContacts(list)
|
||||||
setFilteredContacts(list)
|
setFilteredContacts(list)
|
||||||
void hydrateContactAvatars(list)
|
const runId = ++contactAvatarHydrationRunRef.current
|
||||||
|
const hydrate = (): Promise<void> => hydrateContactAvatars(list, runId, options?.onProgress)
|
||||||
|
if (options?.waitForAvatars) {
|
||||||
|
await hydrate()
|
||||||
|
} else {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
void hydrate()
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hydrateContactAvatars = async (list: Contact[]): Promise<void> => {
|
const hydrateContactAvatars = async (
|
||||||
|
list: Contact[],
|
||||||
|
runId: number,
|
||||||
|
onProgress?: (message: string, percent?: number) => void
|
||||||
|
): Promise<void> => {
|
||||||
const usernames = Array.from(
|
const usernames = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
list
|
list
|
||||||
.map((contact) => contact.m_nsUsrName)
|
.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) {
|
for (let index = 0; index < usernames.length; index += chunkSize) {
|
||||||
|
if (runId !== contactAvatarHydrationRunRef.current) return
|
||||||
const chunk = usernames.slice(index, index + chunkSize)
|
const chunk = usernames.slice(index, index + chunkSize)
|
||||||
if (chunk.length === 0) continue
|
if (chunk.length === 0) continue
|
||||||
try {
|
try {
|
||||||
const avatars = await window.api.getContactAvatars(chunk)
|
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) =>
|
setContacts((current) =>
|
||||||
current.map((contact) => avatars[contact.m_nsUsrName] ? { ...contact, avatar: avatars[contact.m_nsUsrName] } : contact)
|
current.map((contact) => avatars[contact.m_nsUsrName] ? { ...contact, avatar: avatars[contact.m_nsUsrName] } : contact)
|
||||||
)
|
)
|
||||||
@@ -162,14 +228,15 @@ function App(): React.ReactElement {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Contacts] avatar hydrate failed:', 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(() => {
|
React.useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
const attemptAutoConnect = async (): Promise<void> => {
|
const attemptAutoConnect = async (): Promise<void> => {
|
||||||
// 优先级 1:构建期环境变量 VITE_DB_KEY(本地开发/打包时硬编码的密钥)
|
// 优先级 1: 构建期环境变量 VITE_DB_KEY(本地开发用)
|
||||||
const envKey = String(import.meta.env.VITE_DB_KEY || '').trim()
|
const envKey = String(import.meta.env.VITE_DB_KEY || '').trim()
|
||||||
// 优先级 2: 上一次保存到 safeStorage 的密钥
|
// 优先级 2: 上一次保存到 safeStorage 的密钥
|
||||||
let savedKey = ''
|
let savedKey = ''
|
||||||
@@ -197,7 +264,7 @@ function App(): React.ReactElement {
|
|||||||
setDbKey(key)
|
setDbKey(key)
|
||||||
setAutoConnectSource(envKey ? 'env' : 'saved')
|
setAutoConnectSource(envKey ? 'env' : 'saved')
|
||||||
setDbKeyStatus(
|
setDbKeyStatus(
|
||||||
envKey ? '检测到环境变量中的密钥,正在自动连接...' : '已加载安全保存的密钥,正在自动连接...'
|
envKey ? '检测到环境变量中的密钥,正在自动连接...' : '已加载安全保存的密钥,正在自动连接...'
|
||||||
)
|
)
|
||||||
setDbKeyStatusKind('normal')
|
setDbKeyStatusKind('normal')
|
||||||
}
|
}
|
||||||
@@ -215,7 +282,7 @@ function App(): React.ReactElement {
|
|||||||
} else {
|
} else {
|
||||||
const error = typeof result === 'boolean' ? '' : result.error
|
const error = typeof result === 'boolean' ? '' : result.error
|
||||||
setDbKeyStatus(
|
setDbKeyStatus(
|
||||||
`自动连接失败,请重新输入${error ? `: ${error}` : ''}`
|
`自动连接失败,请重新输入${error ? `: ${error}` : ''}`
|
||||||
)
|
)
|
||||||
setDbKeyStatusKind('error')
|
setDbKeyStatusKind('error')
|
||||||
setBootState('login')
|
setBootState('login')
|
||||||
@@ -249,22 +316,62 @@ function App(): React.ReactElement {
|
|||||||
const handleLogin = async (keyInput?: string): Promise<void> => {
|
const handleLogin = async (keyInput?: string): Promise<void> => {
|
||||||
const keyToUse = keyInput || dbKey
|
const keyToUse = keyInput || dbKey
|
||||||
if (!keyToUse) return
|
if (!keyToUse) return
|
||||||
|
setBootState('connecting')
|
||||||
|
setStartupProgress({
|
||||||
|
title: '正在连接数据库...',
|
||||||
|
subtitle: '正在初始化微信数据',
|
||||||
|
detail: '请稍候',
|
||||||
|
percent: 8
|
||||||
|
})
|
||||||
try {
|
try {
|
||||||
|
setStartupProgress({
|
||||||
|
title: '正在连接数据库...',
|
||||||
|
subtitle: '正在初始化微信数据',
|
||||||
|
detail: '正在打开 WCDB 数据库',
|
||||||
|
percent: 15
|
||||||
|
})
|
||||||
const result = await window.api.initDb(keyToUse)
|
const result = await window.api.initDb(keyToUse)
|
||||||
const success = typeof result === 'boolean' ? result : result.success
|
const success = typeof result === 'boolean' ? result : result.success
|
||||||
if (success) {
|
if (success) {
|
||||||
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
||||||
setIsAuthenticated(true)
|
setStartupProgress({
|
||||||
// 手动输入也持久化,下次启动可自动连接(参考 WeFlow)
|
title: '正在读取缓存...',
|
||||||
|
subtitle: '正在恢复上次联系人和头像',
|
||||||
|
detail: '正在读取本地缓存',
|
||||||
|
percent: 25
|
||||||
|
})
|
||||||
|
const hasBootstrapCache = await loadBootstrapCache()
|
||||||
|
// 持久化手动输入的密钥,供下次启动继续使用
|
||||||
void window.api.saveDbKey(keyToUse).catch(() => undefined)
|
void window.api.saveDbKey(keyToUse).catch(() => undefined)
|
||||||
loadContacts()
|
setStartupProgress({
|
||||||
|
title: '正在加载账号信息...',
|
||||||
|
subtitle: '即将进入 WechatExplorer',
|
||||||
|
detail: '正在读取当前账号信息',
|
||||||
|
percent: 95
|
||||||
|
})
|
||||||
void refreshSelfInfo()
|
void refreshSelfInfo()
|
||||||
|
setStartupProgress({
|
||||||
|
title: '加载完成',
|
||||||
|
subtitle: '正在进入主页面',
|
||||||
|
detail: '联系人和头像已准备好',
|
||||||
|
percent: 100
|
||||||
|
})
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
setBootState('login')
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setStartupProgress(null)
|
||||||
|
if (!hasBootstrapCache) void loadContacts({ waitForAvatars: false })
|
||||||
|
}, 500)
|
||||||
} else {
|
} else {
|
||||||
const error = typeof result === 'boolean' ? '' : result.error
|
const error = typeof result === 'boolean' ? '' : result.error
|
||||||
|
setBootState('login')
|
||||||
|
setStartupProgress(null)
|
||||||
alert(`Failed to open database.${error ? `\n\n${error}` : '\nCheck your key.'}`)
|
alert(`Failed to open database.${error ? `\n\n${error}` : '\nCheck your key.'}`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
setBootState('login')
|
||||||
|
setStartupProgress(null)
|
||||||
alert('Error connecting to database')
|
alert('Error connecting to database')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,10 +414,17 @@ function App(): React.ReactElement {
|
|||||||
|
|
||||||
return baseMessages.map((message) => {
|
return baseMessages.map((message) => {
|
||||||
const senderId = String(message.senderId || message.name || '').trim()
|
const senderId = String(message.senderId || message.name || '').trim()
|
||||||
if (!senderId || !senderId.startsWith('wxid_')) return message
|
if (!senderId) return message
|
||||||
const member = memberMap.get(senderId)
|
const member = memberMap.get(senderId)
|
||||||
if (!member) return message
|
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 {
|
return {
|
||||||
...message,
|
...message,
|
||||||
name: nickname,
|
name: nickname,
|
||||||
@@ -423,12 +537,30 @@ function App(): React.ReactElement {
|
|||||||
setSelectedContact(contact)
|
setSelectedContact(contact)
|
||||||
selectedContactMd5Ref.current = contact.md5
|
selectedContactMd5Ref.current = contact.md5
|
||||||
currentGroupSnapshotRef.current = null
|
currentGroupSnapshotRef.current = null
|
||||||
|
setIsMessagesLoading(true)
|
||||||
const { startTime, endTime } = getDateRangeParams(dateRange)
|
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
|
||||||
|
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
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
const cachedMessages = applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, msgs))
|
const cachedMessages = applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, msgs))
|
||||||
setMessages(cachedMessages)
|
setMessages(cachedMessages)
|
||||||
if (contact.type === 'group') {
|
if (contact.type === 'group') {
|
||||||
|
window.setTimeout(() => {
|
||||||
void loadGroupMemberMeta(contact).then((snapshot) => {
|
void loadGroupMemberMeta(contact).then((snapshot) => {
|
||||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
if (!snapshot) return
|
if (!snapshot) return
|
||||||
@@ -436,6 +568,10 @@ function App(): React.ReactElement {
|
|||||||
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, current, snapshot.roomId))
|
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)
|
setDateRange(range)
|
||||||
if (selectedContact) {
|
if (selectedContact) {
|
||||||
const { startTime, endTime } = getDateRangeParams(range)
|
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)))
|
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(
|
const latestMessages = await window.api.getMessages(
|
||||||
contactMd5,
|
contactMd5,
|
||||||
range.startTime,
|
range.startTime,
|
||||||
range.endTime
|
range.endTime,
|
||||||
|
{ limit: VIEW_MESSAGE_LIMIT }
|
||||||
)
|
)
|
||||||
const nextMessages = applyGroupMemberMeta(
|
const nextMessages = applyGroupMemberMeta(
|
||||||
selectedContact,
|
selectedContact,
|
||||||
@@ -561,19 +712,31 @@ function App(): React.ReactElement {
|
|||||||
}, [resize, stopResizing])
|
}, [resize, stopResizing])
|
||||||
|
|
||||||
if (!isAuthenticated && bootState !== 'login') {
|
if (!isAuthenticated && bootState !== 'login') {
|
||||||
return (
|
const title =
|
||||||
<div className="boot-splash">
|
startupProgress?.title || (bootState === 'connecting' ? '正在自动连接数据库...' : '正在准备...')
|
||||||
<div className="boot-splash-spinner" aria-hidden />
|
const subtitle =
|
||||||
<div className="boot-splash-title">
|
startupProgress?.subtitle ||
|
||||||
{bootState === 'connecting' ? '正在自动连接数据库...' : '正在准备...'}
|
(bootState === 'connecting'
|
||||||
</div>
|
|
||||||
<div className="boot-splash-subtitle">
|
|
||||||
{bootState === 'connecting'
|
|
||||||
? autoConnectSource === 'env'
|
? autoConnectSource === 'env'
|
||||||
? '检测到环境变量中的密钥'
|
? '检测到环境变量中的密钥'
|
||||||
: '使用上次安全保存的密钥'
|
: '使用上次安全保存的密钥'
|
||||||
: 'WechatExplorer'}
|
: 'WechatExplorer')
|
||||||
|
return (
|
||||||
|
<div className="boot-splash">
|
||||||
|
<div className="boot-splash-spinner" aria-hidden />
|
||||||
|
<div className="boot-splash-title">{title}</div>
|
||||||
|
<div className="boot-splash-subtitle">{subtitle}</div>
|
||||||
|
{startupProgress?.detail && (
|
||||||
|
<div className="boot-splash-detail">{startupProgress.detail}</div>
|
||||||
|
)}
|
||||||
|
{typeof startupProgress?.percent === 'number' && (
|
||||||
|
<div className="boot-splash-progress" aria-label="加载进度">
|
||||||
|
<div
|
||||||
|
className="boot-splash-progress-bar"
|
||||||
|
style={{ width: `${Math.max(0, Math.min(100, startupProgress.percent))}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -597,7 +760,7 @@ function App(): React.ReactElement {
|
|||||||
onClick={() => setShowDbKey(!showDbKey)}
|
onClick={() => setShowDbKey(!showDbKey)}
|
||||||
title={showDbKey ? '隐藏密钥' : '显示密钥'}
|
title={showDbKey ? '隐藏密钥' : '显示密钥'}
|
||||||
>
|
>
|
||||||
{showDbKey ? '👁️' : '👁️🗨️'}
|
{showDbKey ? '隐藏' : '显示'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -654,6 +817,7 @@ function App(): React.ReactElement {
|
|||||||
key={`${selectedContact?.md5}-${contentFilter}`}
|
key={`${selectedContact?.md5}-${contentFilter}`}
|
||||||
contact={selectedContact}
|
contact={selectedContact}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
|
isLoadingMessages={isMessagesLoading}
|
||||||
contentFilter={contentFilter}
|
contentFilter={contentFilter}
|
||||||
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
|
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
|
||||||
onRefreshData={loadContacts}
|
onRefreshData={loadContacts}
|
||||||
|
|||||||
@@ -244,6 +244,18 @@ body {
|
|||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-loading-pill {
|
||||||
|
align-self: center;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
color: #7a858b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.wechat-message-row {
|
.wechat-message-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -947,11 +959,12 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba(16, 24, 28, 0.28);
|
background: rgba(16, 24, 28, 0.28);
|
||||||
backdrop-filter: blur(1px);
|
backdrop-filter: blur(1px);
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-viewer-window {
|
.image-viewer-window {
|
||||||
width: min(900px, 86vw);
|
width: min(1280px, 96vw);
|
||||||
height: min(700px, 82vh);
|
height: min(900px, 92vh);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1018,14 +1031,17 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-viewer-stage img {
|
.image-viewer-stage img {
|
||||||
max-width: 100%;
|
max-width: none;
|
||||||
max-height: 100%;
|
max-height: none;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
box-shadow: 0 3px 16px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 3px 16px rgba(0, 0, 0, 0.12);
|
||||||
transform-origin: center center;
|
transform-origin: center center;
|
||||||
@@ -1329,6 +1345,28 @@ body {
|
|||||||
color: #6f767c;
|
color: #6f767c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boot-splash-detail {
|
||||||
|
min-height: 18px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8a9298;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-splash-progress {
|
||||||
|
width: min(320px, 72vw);
|
||||||
|
height: 6px;
|
||||||
|
margin-top: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(7, 193, 96, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-splash-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: #07c160;
|
||||||
|
transition: width 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
/* 设置面板 */
|
/* 设置面板 */
|
||||||
.settings-overlay {
|
.settings-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
interface ChatWindowProps {
|
interface ChatWindowProps {
|
||||||
contact: Contact | null
|
contact: Contact | null
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
|
isLoadingMessages?: boolean
|
||||||
contentFilter?: string
|
contentFilter?: string
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
onRefreshData?: () => void
|
onRefreshData?: () => void
|
||||||
@@ -76,6 +77,7 @@ const getSummaryDateRange = (range: SummaryDateRange): { startTime: number; endT
|
|||||||
const ChatWindow: React.FC<ChatWindowProps> = ({
|
const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||||
contact,
|
contact,
|
||||||
messages,
|
messages,
|
||||||
|
isLoadingMessages,
|
||||||
contentFilter,
|
contentFilter,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onRefreshData
|
onRefreshData
|
||||||
@@ -90,6 +92,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
const [imageScale, setImageScale] = useState(0.75)
|
const [imageScale, setImageScale] = useState(0.75)
|
||||||
const [imageRotation, setImageRotation] = useState(0)
|
const [imageRotation, setImageRotation] = useState(0)
|
||||||
const [imageOffset, setImageOffset] = useState({ x: 0, y: 0 })
|
const [imageOffset, setImageOffset] = useState({ x: 0, y: 0 })
|
||||||
|
const imageViewerStageRef = useRef<HTMLDivElement>(null)
|
||||||
const imageDragRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
|
const imageDragRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
@@ -133,7 +136,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
|
|
||||||
const openImagePreview = (imageUrl: string): void => {
|
const openImagePreview = (imageUrl: string): void => {
|
||||||
setPreviewImage(imageUrl)
|
setPreviewImage(imageUrl)
|
||||||
setImageScale(0.75)
|
setImageScale(1)
|
||||||
setImageRotation(0)
|
setImageRotation(0)
|
||||||
setImageOffset({ x: 0, y: 0 })
|
setImageOffset({ x: 0, y: 0 })
|
||||||
}
|
}
|
||||||
@@ -144,17 +147,18 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const zoomImage = (delta: number): void => {
|
const zoomImage = (delta: number): void => {
|
||||||
setImageScale((prev) => Math.min(3, Math.max(0.25, Number((prev + delta).toFixed(2)))))
|
setImageScale((prev) => Math.min(8, Math.max(0.1, Number((prev + delta).toFixed(2)))))
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetImageTransform = (): void => {
|
const resetImageTransform = (): void => {
|
||||||
setImageScale(0.75)
|
setImageScale(1)
|
||||||
setImageRotation(0)
|
setImageRotation(0)
|
||||||
setImageOffset({ x: 0, y: 0 })
|
setImageOffset({ x: 0, y: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleViewerWheel = (event: React.WheelEvent): void => {
|
const handleViewerWheel = (event: React.WheelEvent): void => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
zoomImage(event.deltaY > 0 ? -0.1 : 0.1)
|
zoomImage(event.deltaY > 0 ? -0.1 : 0.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +185,24 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
imageDragRef.current = null
|
imageDragRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!previewImage) return
|
||||||
|
|
||||||
|
const previousOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
|
||||||
|
const stage = imageViewerStageRef.current
|
||||||
|
const preventBackgroundWheel = (event: WheelEvent): void => {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
stage?.addEventListener('wheel', preventBackgroundWheel, { passive: false })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previousOverflow
|
||||||
|
stage?.removeEventListener('wheel', preventBackgroundWheel)
|
||||||
|
}
|
||||||
|
}, [previewImage])
|
||||||
|
|
||||||
const handleExport = (days: number | 'all'): void => {
|
const handleExport = (days: number | 'all'): void => {
|
||||||
if (!messages.length) return
|
if (!messages.length) return
|
||||||
|
|
||||||
@@ -354,6 +376,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="message-list wechat-message-list">
|
<div className="message-list wechat-message-list">
|
||||||
|
{isLoadingMessages && (
|
||||||
|
<div className="message-loading-pill">正在加载聊天记录...</div>
|
||||||
|
)}
|
||||||
{hiddenMessageCount > 0 && (
|
{hiddenMessageCount > 0 && (
|
||||||
<div className="wechat-system-message-row">
|
<div className="wechat-system-message-row">
|
||||||
<div className="wechat-system-message">
|
<div className="wechat-system-message">
|
||||||
@@ -562,6 +587,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
ref={imageViewerStageRef}
|
||||||
className="image-viewer-stage"
|
className="image-viewer-stage"
|
||||||
onWheel={handleViewerWheel}
|
onWheel={handleViewerWheel}
|
||||||
onMouseDown={handleViewerMouseDown}
|
onMouseDown={handleViewerMouseDown}
|
||||||
|
|||||||
@@ -63,7 +63,13 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
>
|
>
|
||||||
<div className="contact-avatar">
|
<div className="contact-avatar">
|
||||||
{contact.avatar ? (
|
{contact.avatar ? (
|
||||||
<img src={contact.avatar} alt={contact.m_nsNickName} referrerPolicy="no-referrer" />
|
<img
|
||||||
|
src={contact.avatar}
|
||||||
|
alt={contact.m_nsNickName}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
(contact.m_nsNickName || contact.m_nsUsrName || '?').charAt(0)
|
(contact.m_nsNickName || contact.m_nsUsrName || '?').charAt(0)
|
||||||
)}
|
)}
|
||||||
@@ -131,7 +137,13 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
<div className="sidebar-footer" onClick={onOpenSettings} title="设置">
|
<div className="sidebar-footer" onClick={onOpenSettings} title="设置">
|
||||||
<div className="sidebar-self-avatar">
|
<div className="sidebar-self-avatar">
|
||||||
{selfInfo?.avatar ? (
|
{selfInfo?.avatar ? (
|
||||||
<img src={selfInfo.avatar} alt={selfInfo.nickname} referrerPolicy="no-referrer" />
|
<img
|
||||||
|
src={selfInfo.avatar}
|
||||||
|
alt={selfInfo.nickname}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
((selfInfo?.nickname || selfInfo?.wxid || '我').charAt(0))
|
((selfInfo?.nickname || selfInfo?.wxid || '我').charAt(0))
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user