mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
feat: 优化聊天记录缓存、虚拟分页与群聊媒体展示
- 使用持久化缓存加速启动并异步读取 WCDB 消息 - 修复空群缓存、头像和群成员名称丢失问题 - 修复引用图片缩略图、虚拟卸载缓存和图片预览 - 修正引用消息发送者显示为群 ID 的问题
This commit is contained in:
+68
-22
@@ -17,7 +17,7 @@ import { extname } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
import { WechatDb } from './wechat-db'
|
||||
import { bootstrapWcdbNative, Wcdb4Client } from './wcdb4-client'
|
||||
import { bootstrapWcdbNativeAsync, Wcdb4Client } from './wcdb4-client'
|
||||
import { VoiceService } from './voice-service'
|
||||
import { StickerService } from './sticker-service'
|
||||
import { parseMessageContent } from './message-parser'
|
||||
@@ -61,12 +61,15 @@ import {
|
||||
import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption'
|
||||
import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
|
||||
import {
|
||||
flushBootstrapCacheWritesSync,
|
||||
getBootstrapCache,
|
||||
getCachedMessagePage,
|
||||
getCachedMessages,
|
||||
mergeBootstrapAvatars,
|
||||
mergeCachedContactAvatars,
|
||||
saveBootstrapContacts,
|
||||
saveBootstrapSelf,
|
||||
saveCachedGroupSnapshot,
|
||||
saveCachedMessages
|
||||
} from './services/bootstrap-cache'
|
||||
import { installSafeConsole } from './safe-log'
|
||||
@@ -95,16 +98,23 @@ const keyServiceWin = new KeyServiceWin()
|
||||
let tray: Tray | null = null
|
||||
let recallArchiveMonitor: RecallArchiveMonitor | null = null
|
||||
let recallProtectionGeneration = 0
|
||||
let recallJournalTimer: NodeJS.Timeout | null = null
|
||||
let wcdbBootstrapPromise: Promise<unknown> | null = null
|
||||
|
||||
function configureRecallProtection(
|
||||
wcdb4Client: Wcdb4Client,
|
||||
accountRoot: string,
|
||||
enabled: boolean
|
||||
enabled: boolean,
|
||||
installJournal = false
|
||||
): void {
|
||||
recallProtectionGeneration += 1
|
||||
const generation = recallProtectionGeneration
|
||||
recallArchiveMonitor?.stop()
|
||||
recallArchiveMonitor = null
|
||||
if (recallJournalTimer) {
|
||||
clearTimeout(recallJournalTimer)
|
||||
recallJournalTimer = null
|
||||
}
|
||||
configureRecallArchive(enabled ? accountRoot : '')
|
||||
if (!enabled) return
|
||||
|
||||
@@ -128,8 +138,19 @@ function configureRecallProtection(
|
||||
})
|
||||
)
|
||||
recallArchiveMonitor = monitor
|
||||
monitor.seedAll()
|
||||
setTimeout(() => {
|
||||
// Session indexing is not required to open the UI. Defer it so large databases
|
||||
// do not make db:init wait for every conversation to be enumerated.
|
||||
setImmediate(() => {
|
||||
if (generation === recallProtectionGeneration && recallArchiveMonitor === monitor) {
|
||||
monitor.seedAll()
|
||||
}
|
||||
})
|
||||
if (!installJournal) return
|
||||
// Creating recall triggers scans every message table and runs synchronously in
|
||||
// the main process. Only do this after the user explicitly enables protection;
|
||||
// existing installations remain active without repeating the scan at startup.
|
||||
recallJournalTimer = setTimeout(() => {
|
||||
recallJournalTimer = null
|
||||
if (generation !== recallProtectionGeneration || recallArchiveMonitor !== monitor) return
|
||||
const result = wcdb4Client.installRecallJournal(
|
||||
wcdb4Client.getSessions().map((session) => session.username)
|
||||
@@ -137,7 +158,7 @@ function configureRecallProtection(
|
||||
console.log(
|
||||
`[WCDB4] recall journal ready installed=${result.installed} failed=${result.failed}`
|
||||
)
|
||||
}, 0)
|
||||
}, 30_000)
|
||||
}
|
||||
|
||||
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
|
||||
@@ -305,15 +326,11 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
})
|
||||
|
||||
// WCDB's Windows runtime returns -1006 if wcdb_init is called more than once
|
||||
// per process. Bootstrap native once here so any later Wcdb4Client instance
|
||||
// reuses the already-initialized library and skips wcdb_init.
|
||||
try {
|
||||
bootstrapWcdbNative()
|
||||
console.log('[WCDB4] bootstrap complete at whenReady top')
|
||||
} catch (bootstrapError) {
|
||||
console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError)
|
||||
}
|
||||
// Create the renderer before native WCDB bootstrap so startup progress is visible immediately.
|
||||
createWindow()
|
||||
wcdbBootstrapPromise = bootstrapWcdbNativeAsync().then(() => {
|
||||
console.log('[WCDB4] async bootstrap complete')
|
||||
})
|
||||
|
||||
// 设置应用程序用户模型 ID
|
||||
electronApp.setAppUserModelId('com.wechatexplorer.app')
|
||||
@@ -338,6 +355,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
dbInitInFlight = (async () => {
|
||||
try {
|
||||
if (wcdbBootstrapPromise) await wcdbBootstrapPromise
|
||||
const trimmedKey = String(key || '').trim()
|
||||
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
||||
const settings = loadSettings()
|
||||
@@ -372,6 +390,13 @@ app.whenReady().then(async () => {
|
||||
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
||||
}
|
||||
})
|
||||
setImmediate(() => {
|
||||
const recentSession = wcdb4Client.getSessions()[0]
|
||||
if (!recentSession?.username) return
|
||||
void wcdb4Client
|
||||
.getMessagesAsync(recentSession.username, undefined, undefined, { limit: 1 })
|
||||
.catch((error) => console.warn('[WCDB4] message cursor warmup failed:', error))
|
||||
})
|
||||
imageDecryptService = null
|
||||
return { success: true, monitoring }
|
||||
} catch (error) {
|
||||
@@ -512,11 +537,26 @@ app.whenReady().then(async () => {
|
||||
return getBootstrapCache(chat.getCurrentAccountRoot())
|
||||
})
|
||||
|
||||
ipcMain.handle('db:getStartupCache', () => {
|
||||
const settings = loadSettings()
|
||||
return settings.dbRoot ? getBootstrapCache(settings.dbRoot) : null
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'db:getCachedMessages',
|
||||
(_, userMd5: string, startTime?: number, endTime?: number) => {
|
||||
if (!chat.isReady()) return []
|
||||
return getCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime)
|
||||
const accountRoot = chat.isReady() ? chat.getCurrentAccountRoot() : loadSettings().dbRoot
|
||||
return accountRoot ? getCachedMessages(accountRoot, userMd5, startTime, endTime) : []
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'db:getCachedMessagePage',
|
||||
(_, userMd5: string, startTime?: number, endTime?: number) => {
|
||||
const accountRoot = chat.isReady() ? chat.getCurrentAccountRoot() : loadSettings().dbRoot
|
||||
return accountRoot
|
||||
? getCachedMessagePage(accountRoot, userMd5, startTime, endTime)
|
||||
: { hit: false, messages: [] }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -539,8 +579,8 @@ app.whenReady().then(async () => {
|
||||
|
||||
ipcMain.handle(
|
||||
'db:getMessages',
|
||||
(_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => {
|
||||
const messages = chat.listMessages(userMd5, startTime, endTime, options)
|
||||
async (_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => {
|
||||
const messages = await chat.listMessagesAsync(userMd5, startTime, endTime, options)
|
||||
if (chat.isReady()) {
|
||||
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
|
||||
}
|
||||
@@ -548,7 +588,13 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => chat.getGroupSnapshot(userMd5))
|
||||
ipcMain.handle('db:getGroupSnapshot', async (_, userMd5: string) => {
|
||||
const snapshot = await chat.getGroupSnapshotAsync(userMd5)
|
||||
if (snapshot && chat.isReady()) {
|
||||
saveCachedGroupSnapshot(chat.getCurrentAccountRoot(), userMd5, snapshot)
|
||||
}
|
||||
return snapshot
|
||||
})
|
||||
|
||||
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
||||
|
||||
@@ -824,7 +870,8 @@ app.whenReady().then(async () => {
|
||||
configureRecallProtection(
|
||||
currentDb.getWcdb4Client(),
|
||||
chat.getCurrentAccountRoot(),
|
||||
nextSettings.recallProtectionEnabled
|
||||
nextSettings.recallProtectionEnabled,
|
||||
nextSettings.recallProtectionEnabled && !before.recallProtectionEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -938,8 +985,6 @@ app.whenReady().then(async () => {
|
||||
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
|
||||
})
|
||||
|
||||
createWindow()
|
||||
|
||||
// 启动本地 HTTP API(由 settings.apiEnabled 控制)
|
||||
const settings = loadSettings()
|
||||
if (settings.apiEnabled) {
|
||||
@@ -972,6 +1017,7 @@ app.on('window-all-closed', () => {
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
agentHubService.stop()
|
||||
flushBootstrapCacheWritesSync()
|
||||
chat.setChatDb(null)
|
||||
await apiServer.stop().catch(() => undefined)
|
||||
if (tray) {
|
||||
|
||||
@@ -11,6 +11,19 @@ export interface CachedSelfInfo {
|
||||
accountRoot: string
|
||||
}
|
||||
|
||||
export interface CachedGroupSnapshot {
|
||||
roomId: string
|
||||
memberCount: number
|
||||
members: {
|
||||
wxid: string
|
||||
nickname: string
|
||||
groupNickname: string
|
||||
wechatNickname: string
|
||||
remark: string
|
||||
avatar: string
|
||||
}[]
|
||||
}
|
||||
|
||||
interface CachedMessageBucket {
|
||||
updatedAt: number
|
||||
startTime?: number
|
||||
@@ -26,11 +39,12 @@ interface BootstrapCacheFile {
|
||||
self?: CachedSelfInfo
|
||||
contacts?: Contact[]
|
||||
messages?: Record<string, CachedMessageBucket>
|
||||
groupSnapshots?: Record<string, { updatedAt: number; snapshot: CachedGroupSnapshot }>
|
||||
}
|
||||
|
||||
const CACHE_VERSION = 1
|
||||
const MAX_MESSAGE_BUCKETS = 24
|
||||
const MAX_MESSAGES_PER_BUCKET = 1200
|
||||
const MAX_MESSAGE_BUCKETS = 768
|
||||
const MAX_MESSAGES_PER_BUCKET = 120
|
||||
const WRITE_DEBOUNCE_MS = 300
|
||||
const memoryCache = new Map<string, BootstrapCacheFile>()
|
||||
const writeTimers = new Map<string, NodeJS.Timeout>()
|
||||
@@ -73,7 +87,9 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
||||
updatedAt: Number(raw.updatedAt) || 0,
|
||||
self: raw.self,
|
||||
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
|
||||
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {}
|
||||
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {},
|
||||
groupSnapshots:
|
||||
raw.groupSnapshots && typeof raw.groupSnapshots === 'object' ? raw.groupSnapshots : {}
|
||||
}
|
||||
memoryCache.set(file, result)
|
||||
return result
|
||||
@@ -122,7 +138,8 @@ function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Date.now(),
|
||||
contacts: [],
|
||||
messages: {}
|
||||
messages: {},
|
||||
groupSnapshots: {}
|
||||
}
|
||||
memoryCache.set(getCacheFile(normalizedRoot), created)
|
||||
return created
|
||||
@@ -132,6 +149,12 @@ function messageBucketKey(userMd5: string, startTime?: number, endTime?: number)
|
||||
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
|
||||
}
|
||||
|
||||
function cachedMessageIdentity(message: Message): string {
|
||||
if (message.localId) return `local:${message.localId}`
|
||||
if (message.serverId) return `server:${message.serverId}`
|
||||
return `id:${message.id}`
|
||||
}
|
||||
|
||||
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
|
||||
const entries = Object.entries(messages)
|
||||
if (entries.length <= MAX_MESSAGE_BUCKETS) return
|
||||
@@ -260,6 +283,71 @@ export function getCachedMessages(
|
||||
return bucket?.items || []
|
||||
}
|
||||
|
||||
export function getCachedMessagePage(
|
||||
accountRoot: string,
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): { hit: boolean; messages: Message[]; groupSnapshot?: CachedGroupSnapshot } {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
const key = messageBucketKey(userMd5, startTime, endTime)
|
||||
let bucket = cache?.messages?.[key]
|
||||
if (!bucket && cache?.messages && startTime === undefined && endTime === undefined) {
|
||||
const merged = new Map<string, Message>()
|
||||
for (const [cachedKey, candidate] of Object.entries(cache.messages)) {
|
||||
if (!cachedKey.startsWith(`${userMd5}:`)) continue
|
||||
for (const message of candidate.items || []) {
|
||||
merged.set(cachedMessageIdentity(message), message)
|
||||
}
|
||||
}
|
||||
const migratedMessages = Array.from(merged.values())
|
||||
.sort((left, right) => (left.createTime || 0) - (right.createTime || 0))
|
||||
.slice(-MAX_MESSAGES_PER_BUCKET)
|
||||
if (migratedMessages.length > 0) {
|
||||
bucket = {
|
||||
updatedAt: Date.now(),
|
||||
items: migratedMessages
|
||||
}
|
||||
cache.messages[key] = bucket
|
||||
cache.updatedAt = Date.now()
|
||||
pruneMessageBuckets(cache.messages)
|
||||
writeCacheFile(cache)
|
||||
}
|
||||
}
|
||||
return {
|
||||
hit: Boolean(bucket),
|
||||
messages: bucket?.items || [],
|
||||
groupSnapshot: cache?.groupSnapshots?.[userMd5]?.snapshot
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCachedGroupSnapshot(
|
||||
accountRoot: string,
|
||||
userMd5: string,
|
||||
snapshot: CachedGroupSnapshot
|
||||
): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
if (!cache) return
|
||||
cache.groupSnapshots ||= {}
|
||||
cache.groupSnapshots[userMd5] = { updatedAt: Date.now(), snapshot }
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
}
|
||||
|
||||
export function flushBootstrapCacheWritesSync(): void {
|
||||
for (const [file, cache] of memoryCache) {
|
||||
const timer = writeTimers.get(file)
|
||||
if (timer) clearTimeout(timer)
|
||||
writeTimers.delete(file)
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(file))
|
||||
fs.writeFileSync(file, JSON.stringify(cache), 'utf8')
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] flush failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCachedMessages(
|
||||
accountRoot: string,
|
||||
userMd5: string,
|
||||
|
||||
@@ -143,25 +143,29 @@ export function listContacts(filter?: string): FormattedContact[] {
|
||||
})
|
||||
}
|
||||
|
||||
const chatTables = dbRef.getAllChatTables()
|
||||
for (const table of chatTables) {
|
||||
if (!table.name.startsWith('Chat_')) continue
|
||||
const md5 = table.name.substring(5)
|
||||
if (existingMd5s.has(md5)) continue
|
||||
if (groupContacts[md5]) {
|
||||
contacts.push({
|
||||
m_nsUsrName: `Group_${md5}`,
|
||||
m_nsNickName: groupContacts[md5],
|
||||
md5,
|
||||
type: 'group'
|
||||
})
|
||||
} else {
|
||||
contacts.push({
|
||||
m_nsUsrName: `Unknown_${md5}`,
|
||||
m_nsNickName: `Chat_${md5}`,
|
||||
md5,
|
||||
type: 'user'
|
||||
})
|
||||
// The session list already covers normal conversations. Only scan Chat_*
|
||||
// tables as a recovery fallback when the session query returned nothing.
|
||||
if (userList.length === 0) {
|
||||
const chatTables = dbRef.getAllChatTables()
|
||||
for (const table of chatTables) {
|
||||
if (!table.name.startsWith('Chat_')) continue
|
||||
const md5 = table.name.substring(5)
|
||||
if (existingMd5s.has(md5)) continue
|
||||
if (groupContacts[md5]) {
|
||||
contacts.push({
|
||||
m_nsUsrName: `Group_${md5}`,
|
||||
m_nsNickName: groupContacts[md5],
|
||||
md5,
|
||||
type: 'group'
|
||||
})
|
||||
} else {
|
||||
contacts.push({
|
||||
m_nsUsrName: `Unknown_${md5}`,
|
||||
m_nsNickName: `Chat_${md5}`,
|
||||
md5,
|
||||
type: 'user'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return contacts
|
||||
@@ -180,7 +184,8 @@ function listSourceMessages(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
options?: { limit?: number }
|
||||
options?: { limit?: number },
|
||||
rawMessagesOverride?: WechatMessage[]
|
||||
): FormattedMessage[] {
|
||||
if (!dbRef) return []
|
||||
|
||||
@@ -191,7 +196,8 @@ function listSourceMessages(
|
||||
console.log(
|
||||
`[ChatService] listMessages begin md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
|
||||
)
|
||||
const rawMessages = dbRef.getUserMessages(userMd5, startTime, endTime, options)
|
||||
const rawMessages =
|
||||
rawMessagesOverride ?? dbRef.getUserMessages(userMd5, startTime, endTime, options)
|
||||
console.log(
|
||||
`[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
@@ -350,6 +356,26 @@ export function listMessages(
|
||||
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
|
||||
}
|
||||
|
||||
export async function listMessagesAsync(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
options?: { limit?: number }
|
||||
): Promise<FormattedMessage[]> {
|
||||
if (!dbRef) return []
|
||||
const rawMessages = await dbRef.getUserMessagesAsync(userMd5, startTime, endTime, options)
|
||||
const sourceMessages = listSourceMessages(
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
options,
|
||||
rawMessages
|
||||
)
|
||||
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
|
||||
}
|
||||
|
||||
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
||||
if (!dbRef) return null
|
||||
const wcdb4Client = dbRef.getWcdb4Client()
|
||||
@@ -371,6 +397,25 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
||||
return { roomId, memberCount: members.length, members }
|
||||
}
|
||||
|
||||
export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnapshot | null> {
|
||||
if (!dbRef) return null
|
||||
const wcdb4Client = dbRef.getWcdb4Client()
|
||||
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
|
||||
if (!roomId || !roomId.endsWith('@chatroom')) return null
|
||||
|
||||
const members = (await wcdb4Client.getGroupMembersAsync(roomId))
|
||||
.filter((member) => member?.m_nsUsrName)
|
||||
.map((member) => ({
|
||||
wxid: member.m_nsUsrName,
|
||||
nickname: member.nickname || '',
|
||||
groupNickname: member.groupNickname || '',
|
||||
wechatNickname: member.wechatNickname || '',
|
||||
remark: member.remark || '',
|
||||
avatar: member.m_nsHeadImgUrl || ''
|
||||
}))
|
||||
return { roomId, memberCount: members.length, members }
|
||||
}
|
||||
|
||||
export function searchMessages(keyword: string): string | null {
|
||||
if (!dbRef) return null
|
||||
return dbRef.searchAllMessages(keyword)
|
||||
|
||||
+361
-2
@@ -68,6 +68,8 @@ type KoffiModule = {
|
||||
// library reference for every Wcdb4Client instance.
|
||||
let wcdbBootstrapLib: KoffiLibrary | null = null
|
||||
|
||||
let wcdbBootstrapAsyncPromise: Promise<KoffiLibrary> | null = null
|
||||
|
||||
export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string): KoffiLibrary {
|
||||
if (wcdbBootstrapLib) return wcdbBootstrapLib
|
||||
|
||||
@@ -141,8 +143,82 @@ export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string):
|
||||
return lib
|
||||
}
|
||||
|
||||
export function bootstrapWcdbNativeAsync(
|
||||
libPath?: string,
|
||||
libDirOverride?: string
|
||||
): Promise<KoffiLibrary> {
|
||||
if (wcdbBootstrapLib) return Promise.resolve(wcdbBootstrapLib)
|
||||
if (wcdbBootstrapAsyncPromise) return wcdbBootstrapAsyncPromise
|
||||
|
||||
wcdbBootstrapAsyncPromise = (async () => {
|
||||
const koffi = nodeRequire('koffi') as KoffiModule
|
||||
const resolvedLibPath = libPath || Wcdb4Client.resolveNativeLibrary()
|
||||
const libDir = libDirOverride || path.dirname(resolvedLibPath)
|
||||
for (const name of process.platform === 'win32'
|
||||
? ['WCDB.dll', 'SDL2.dll']
|
||||
: process.platform === 'darwin'
|
||||
? ['libWCDB.dylib']
|
||||
: []) {
|
||||
const preloadPath = path.join(libDir, name)
|
||||
if (!fs.existsSync(preloadPath)) continue
|
||||
try {
|
||||
koffi.load(preloadPath)
|
||||
} catch {
|
||||
// The main library may still resolve its dependencies through the loader.
|
||||
}
|
||||
}
|
||||
const lib = koffi.load(resolvedLibPath)
|
||||
const initProtection = lib.func('int32 InitProtection(const char* resourcePath)') as (
|
||||
resourcePath: string
|
||||
) => number
|
||||
const resourceRoots = Array.from(
|
||||
new Set(
|
||||
[libDir, path.dirname(libDir), process.env.WCDB_RESOURCES_PATH || '', ...getResourceRoots()].filter(
|
||||
Boolean
|
||||
)
|
||||
)
|
||||
)
|
||||
let initOk = false
|
||||
for (const resourceRoot of resourceRoots) {
|
||||
try {
|
||||
if (Number(initProtection(resourceRoot)) === 0) {
|
||||
initOk = true
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// Try the next resource root.
|
||||
}
|
||||
}
|
||||
if (initOk) {
|
||||
const wcdbInit = lib.func('int32 wcdb_init()') as KoffiAsyncFunction
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
wcdbInit.async((error: unknown, result: unknown) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
if (Number(result) !== 0) {
|
||||
console.warn(`[WCDB4] async wcdb_init rc=${Number(result)}`)
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
wcdbBootstrapLib = lib
|
||||
return lib
|
||||
})().catch((error) => {
|
||||
wcdbBootstrapAsyncPromise = null
|
||||
throw error
|
||||
})
|
||||
return wcdbBootstrapAsyncPromise
|
||||
}
|
||||
|
||||
type KoffiLibrary = {
|
||||
func: (signature: string) => (...args: unknown[]) => unknown
|
||||
func: (signature: string) => KoffiAsyncFunction
|
||||
}
|
||||
|
||||
type KoffiAsyncFunction = ((...args: unknown[]) => unknown) & {
|
||||
async: (...args: unknown[]) => void
|
||||
}
|
||||
|
||||
type WcdbVoidOut = [unknown]
|
||||
@@ -170,6 +246,7 @@ export class Wcdb4Client {
|
||||
private wcdbOpenAccount:
|
||||
| ((sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number)
|
||||
| null = null
|
||||
private wcdbOpenAccountAsync: KoffiAsyncFunction | null = null
|
||||
private wcdbSetMyWxid: ((handle: number, wxid: string) => number) | null = null
|
||||
private wcdbFreeString: ((ptr: unknown) => void) | null = null
|
||||
private wcdbGetSessions: ((handle: number, outJson: WcdbVoidOut) => number) | null = null
|
||||
@@ -428,6 +505,39 @@ export class Wcdb4Client {
|
||||
}
|
||||
}
|
||||
|
||||
async openAsync(): Promise<void> {
|
||||
this.loadNativeLibrary()
|
||||
if (!this.wcdbOpenAccountAsync) {
|
||||
throw new Error('WCDB 4.0 native open async interface unavailable')
|
||||
}
|
||||
|
||||
const handleOut: WcdbHandleOut = [0]
|
||||
const openResult = await new Promise<number>((resolve, reject) => {
|
||||
this.wcdbOpenAccountAsync!.async(
|
||||
this.sessionDbPath,
|
||||
this.key,
|
||||
handleOut,
|
||||
(error: unknown, result: unknown) => {
|
||||
if (error) reject(error)
|
||||
else resolve(Number(result))
|
||||
}
|
||||
)
|
||||
})
|
||||
if (openResult !== 0 || handleOut[0] <= 0) {
|
||||
throw new Error(
|
||||
`wcdb_open_account failed, code=${openResult}; sessionDb=${this.sessionDbPath}; accountRoot=${this.accountRoot}; wxid=${this.wxid}; keyLength=${this.key.length}`
|
||||
)
|
||||
}
|
||||
this.handle = handleOut[0]
|
||||
if (this.wcdbSetMyWxid) {
|
||||
try {
|
||||
this.wcdbSetMyWxid(this.handle, this.wxid)
|
||||
} catch {
|
||||
// Optional helper. Failure does not block message reads.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.stopMonitor()
|
||||
if (this.handle === null || !this.wcdbShutdown) return
|
||||
@@ -699,6 +809,21 @@ export class Wcdb4Client {
|
||||
return messages
|
||||
}
|
||||
|
||||
async getMessagesAsync(
|
||||
username: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
options: Wcdb4MessageQueryOptions = {}
|
||||
): Promise<Wcdb4Message[]> {
|
||||
const startedAt = Date.now()
|
||||
const maxRows = this.normalizeMessageLimit(options.limit)
|
||||
const messages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows)
|
||||
console.log(
|
||||
`[WCDB4] getMessages async username=${username} rows=${messages.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
return messages
|
||||
}
|
||||
|
||||
private readSessionRows(): Record<string, unknown>[] {
|
||||
if (!this.wcdbGetSessions) return []
|
||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||
@@ -1139,6 +1264,140 @@ export class Wcdb4Client {
|
||||
}
|
||||
}
|
||||
|
||||
private async getMessagesByCursorAsync(
|
||||
username: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
limit?: number
|
||||
): Promise<Wcdb4Message[]> {
|
||||
if (!this.wcdbOpenMessageCursor || !this.wcdbFetchMessageBatch) return []
|
||||
|
||||
const handle = this.ensureHandle()
|
||||
const batchSize = limit ? Math.min(500, limit) : 1000
|
||||
const cursorOut: WcdbHandleOut = [0]
|
||||
const begin = this.normalizeTimestamp(startTime || 0)
|
||||
const end = this.normalizeTimestamp(endTime || 0)
|
||||
const ascending = limit ? 0 : 1
|
||||
const openResult = await this.callAsyncCode(
|
||||
this.wcdbOpenMessageCursor as unknown as KoffiAsyncFunction,
|
||||
handle,
|
||||
username,
|
||||
batchSize,
|
||||
ascending,
|
||||
begin,
|
||||
end,
|
||||
cursorOut
|
||||
)
|
||||
if (openResult !== 0 || cursorOut[0] <= 0) return []
|
||||
|
||||
const cursor = cursorOut[0]
|
||||
const allRows: Record<string, unknown>[] = []
|
||||
try {
|
||||
while (true) {
|
||||
const outJson: WcdbVoidOut = [null]
|
||||
const outHasMore: [number] = [0]
|
||||
const fetchResult = await this.callAsyncCode(
|
||||
this.wcdbFetchMessageBatch as unknown as KoffiAsyncFunction,
|
||||
handle,
|
||||
cursor,
|
||||
outJson,
|
||||
outHasMore
|
||||
)
|
||||
if (fetchResult !== 0 || !outJson[0]) break
|
||||
try {
|
||||
const json = this.koffi!.decode(outJson[0], 'char', -1)
|
||||
const batch = JSON.parse(json) as Record<string, unknown>[]
|
||||
if (Array.isArray(batch)) allRows.push(...batch)
|
||||
} finally {
|
||||
this.wcdbFreeString?.(outJson[0])
|
||||
}
|
||||
if (!outHasMore[0] || (limit && allRows.length >= limit)) break
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
this.wcdbCloseMessageCursor?.(handle, cursor)
|
||||
} catch {
|
||||
// Best-effort cursor cleanup.
|
||||
}
|
||||
}
|
||||
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
|
||||
}
|
||||
|
||||
async getGroupMembersAsync(chatroomId: string): Promise<Wcdb4GroupMember[]> {
|
||||
if (!this.wcdbGetGroupMembers || !chatroomId) return []
|
||||
|
||||
try {
|
||||
const groupNicknames = await this.getGroupNicknamesAsync(chatroomId)
|
||||
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
||||
this.wcdbGetGroupMembers as unknown as KoffiAsyncFunction,
|
||||
chatroomId
|
||||
)
|
||||
const members = (Array.isArray(rows) ? rows : []).map((row) => {
|
||||
const username = this.pickString(row, [
|
||||
'username',
|
||||
'userName',
|
||||
'user_name',
|
||||
'member_username',
|
||||
'm_nsUsrName'
|
||||
])
|
||||
const wechatNickname = this.pickString(row, [
|
||||
'nickname',
|
||||
'nickName',
|
||||
'wechatNickname',
|
||||
'wechat_nickname',
|
||||
'm_nsNickName'
|
||||
])
|
||||
const remark = this.pickString(row, [
|
||||
'remark',
|
||||
'remarkName',
|
||||
'remark_name',
|
||||
'contactRemark',
|
||||
'contact_remark'
|
||||
])
|
||||
const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name'])
|
||||
const avatar = this.pickString(row, [
|
||||
'avatarUrl',
|
||||
'avatar_url',
|
||||
'headImgUrl',
|
||||
'm_nsHeadImgUrl'
|
||||
])
|
||||
if (username && avatar) this.avatarCache.set(username, avatar)
|
||||
return {
|
||||
m_nsUsrName: username,
|
||||
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
||||
groupNickname: groupNicknames.get(username) || '',
|
||||
wechatNickname: wechatNickname || memberNickname,
|
||||
remark,
|
||||
m_nsHeadImgUrl: avatar
|
||||
}
|
||||
})
|
||||
|
||||
const missingNames = members
|
||||
.filter((member) => !member.nickname)
|
||||
.map((member) => member.m_nsUsrName)
|
||||
.filter(Boolean)
|
||||
const missingAvatars = members
|
||||
.filter((member) => !member.m_nsHeadImgUrl)
|
||||
.map((member) => member.m_nsUsrName)
|
||||
.filter(Boolean)
|
||||
await Promise.all([
|
||||
this.hydrateDisplayNamesAsync(missingNames),
|
||||
this.hydrateAvatarUrlsAsync(missingAvatars)
|
||||
])
|
||||
return members.map((member) => ({
|
||||
...member,
|
||||
nickname:
|
||||
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
|
||||
wechatNickname:
|
||||
member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '',
|
||||
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
|
||||
}))
|
||||
} catch (error) {
|
||||
console.warn(`[WCDB4] async group members failed chatroom=${chatroomId}:`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
getGroupNicknames(chatroomId: string): Map<string, string> {
|
||||
const cached = this.groupNicknameCache.get(chatroomId)
|
||||
if (cached) return cached
|
||||
@@ -1167,6 +1426,28 @@ export class Wcdb4Client {
|
||||
return nicknames
|
||||
}
|
||||
|
||||
private async getGroupNicknamesAsync(chatroomId: string): Promise<Map<string, string>> {
|
||||
const cached = this.groupNicknameCache.get(chatroomId)
|
||||
if (cached) return cached
|
||||
const nicknames = new Map<string, string>()
|
||||
if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames
|
||||
|
||||
const rows = await this.callJsonAsync<
|
||||
Record<string, string> | Record<string, unknown>[]
|
||||
>(this.wcdbGetGroupNicknames as unknown as KoffiAsyncFunction, chatroomId)
|
||||
this.readStringMap(rows, [
|
||||
'nickname',
|
||||
'nickName',
|
||||
'displayName',
|
||||
'display_name',
|
||||
'groupNickname',
|
||||
'group_nickname',
|
||||
'name'
|
||||
]).forEach((nickname, username) => nicknames.set(username, nickname))
|
||||
this.groupNicknameCache.set(chatroomId, nicknames)
|
||||
return nicknames
|
||||
}
|
||||
|
||||
async getVoiceData(
|
||||
sessionId: string,
|
||||
createTime: number,
|
||||
@@ -1354,9 +1635,11 @@ export class Wcdb4Client {
|
||||
}
|
||||
|
||||
this.wcdbShutdown = lib.func('int32 wcdb_shutdown()') as () => number
|
||||
this.wcdbOpenAccount = lib.func(
|
||||
const openAccount = lib.func(
|
||||
'int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)'
|
||||
) as (sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number
|
||||
this.wcdbOpenAccount = openAccount
|
||||
this.wcdbOpenAccountAsync = openAccount as unknown as KoffiAsyncFunction
|
||||
this.wcdbFreeString = lib.func('void wcdb_free_string(void* ptr)') as (ptr: unknown) => void
|
||||
this.wcdbGetSessions = lib.func(
|
||||
'int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)'
|
||||
@@ -1646,6 +1929,41 @@ export class Wcdb4Client {
|
||||
}
|
||||
}
|
||||
|
||||
private callJsonAsync<T>(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<T> {
|
||||
const handle = this.ensureHandle()
|
||||
const outJson: WcdbVoidOut = [null]
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
fn.async(handle, ...args, outJson, (error: unknown, result: unknown) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
const code = Number(result)
|
||||
if (code !== 0 || !outJson[0]) {
|
||||
reject(new Error(`WCDB async call failed, code: ${code}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const json = this.koffi!.decode(outJson[0], 'char', -1)
|
||||
resolve(JSON.parse(json) as T)
|
||||
} catch (decodeError) {
|
||||
reject(decodeError)
|
||||
} finally {
|
||||
this.wcdbFreeString?.(outJson[0])
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private callAsyncCode(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<number> {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
fn.async(...args, (error: unknown, result: unknown) => {
|
||||
if (error) reject(error)
|
||||
else resolve(Number(result))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private ensureHandle(): number {
|
||||
if (!this.handle) throw new Error('微信 4.0 数据库未打开')
|
||||
return this.handle
|
||||
@@ -1810,6 +2128,26 @@ export class Wcdb4Client {
|
||||
}
|
||||
}
|
||||
|
||||
private async hydrateDisplayNamesAsync(usernames: string[]): Promise<void> {
|
||||
if (!this.wcdbGetDisplayNames) return
|
||||
const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username))
|
||||
if (missing.length === 0) return
|
||||
try {
|
||||
const rows = await this.callJsonAsync<
|
||||
Record<string, string> | Record<string, unknown>[]
|
||||
>(this.wcdbGetDisplayNames as unknown as KoffiAsyncFunction, JSON.stringify(missing))
|
||||
this.readStringMap(rows, [
|
||||
'nickname',
|
||||
'displayName',
|
||||
'display_name',
|
||||
'remark',
|
||||
'name'
|
||||
]).forEach((name, username) => this.displayNameCache.set(username, name))
|
||||
} catch {
|
||||
// Names are optional; usernames remain usable.
|
||||
}
|
||||
}
|
||||
|
||||
private hydrateAvatarUrls(usernames: string[]): void {
|
||||
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
|
||||
if (missing.length === 0) return
|
||||
@@ -1844,6 +2182,27 @@ export class Wcdb4Client {
|
||||
}
|
||||
}
|
||||
|
||||
private async hydrateAvatarUrlsAsync(usernames: string[]): Promise<void> {
|
||||
if (!this.wcdbGetAvatarUrls) return
|
||||
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
|
||||
if (missing.length === 0) return
|
||||
try {
|
||||
const rows = await this.callJsonAsync<
|
||||
Record<string, string> | Record<string, unknown>[]
|
||||
>(this.wcdbGetAvatarUrls as unknown as KoffiAsyncFunction, JSON.stringify(missing))
|
||||
this.readStringMap(rows, [
|
||||
'avatarUrl',
|
||||
'avatar_url',
|
||||
'headImgUrl',
|
||||
'm_nsHeadImgUrl',
|
||||
'big_head_img_url',
|
||||
'small_head_img_url'
|
||||
]).forEach((avatar, username) => this.avatarCache.set(username, avatar))
|
||||
} catch {
|
||||
// Avatars are optional.
|
||||
}
|
||||
}
|
||||
|
||||
private readContactAvatarUrls(usernames: string[]): Map<string, string> {
|
||||
const result = new Map<string, string>()
|
||||
if (!this.wcdbExecQuery || usernames.length === 0) return result
|
||||
|
||||
+36
-13
@@ -35,20 +35,12 @@ export interface GroupMemberInfo {
|
||||
export class WechatDb {
|
||||
private wcdb4Client: Wcdb4Client
|
||||
private chatMd5ToUsername = new Map<string, string>()
|
||||
private chatTableMappingLoaded = false
|
||||
|
||||
static async create(rawKey: string, accountRoot?: string): Promise<WechatDb> {
|
||||
// WCDB native init must run on the Electron main process; worker threads
|
||||
// get -1006 from wcdb_init. Initialize the client synchronously here
|
||||
// (and keep create() async for callers that already await it).
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const client = new Wcdb4Client(rawKey, accountRoot)
|
||||
client.open()
|
||||
resolve(new WechatDb(rawKey, accountRoot, client))
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
const client = new Wcdb4Client(rawKey, accountRoot)
|
||||
await client.openAsync()
|
||||
return new WechatDb(rawKey, accountRoot, client)
|
||||
}
|
||||
|
||||
constructor(
|
||||
@@ -61,11 +53,22 @@ export class WechatDb {
|
||||
const client = clientOverride || new Wcdb4Client(rawKey, accountRoot)
|
||||
if (!clientOverride) client.open()
|
||||
this.wcdb4Client = client
|
||||
for (const table of initialChatTables || client.getChatTables()) {
|
||||
for (const table of initialChatTables || []) {
|
||||
if (table.name.startsWith('Chat_')) {
|
||||
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
|
||||
}
|
||||
}
|
||||
this.chatTableMappingLoaded = Boolean(initialChatTables)
|
||||
}
|
||||
|
||||
private ensureChatTableMapping(): void {
|
||||
if (this.chatTableMappingLoaded) return
|
||||
for (const table of this.wcdb4Client.getChatTables()) {
|
||||
if (table.name.startsWith('Chat_')) {
|
||||
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
|
||||
}
|
||||
}
|
||||
this.chatTableMappingLoaded = true
|
||||
}
|
||||
|
||||
public getUserList(nicknameFilter?: string): UserContact[] {
|
||||
@@ -112,6 +115,7 @@ export class WechatDb {
|
||||
}
|
||||
|
||||
public getGroupMembersForChat(userMd5: string): Record<string, string> {
|
||||
this.ensureChatTableMapping()
|
||||
const username = this.chatMd5ToUsername.get(userMd5)
|
||||
if (!username || !username.endsWith('@chatroom')) return {}
|
||||
|
||||
@@ -154,6 +158,7 @@ export class WechatDb {
|
||||
endTime?: number,
|
||||
options?: Wcdb4MessageQueryOptions
|
||||
): WechatMessage[] {
|
||||
this.ensureChatTableMapping()
|
||||
const username = this.chatMd5ToUsername.get(userMd5)
|
||||
if (!username) return []
|
||||
return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({
|
||||
@@ -162,6 +167,24 @@ export class WechatDb {
|
||||
}))
|
||||
}
|
||||
|
||||
public async getUserMessagesAsync(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
options?: Wcdb4MessageQueryOptions
|
||||
): Promise<WechatMessage[]> {
|
||||
this.ensureChatTableMapping()
|
||||
const username = this.chatMd5ToUsername.get(userMd5)
|
||||
if (!username) return []
|
||||
const messages = await this.wcdb4Client.getMessagesAsync(
|
||||
username,
|
||||
startTime,
|
||||
endTime,
|
||||
options
|
||||
)
|
||||
return messages.map((message) => ({ ...message, ...message.raw }))
|
||||
}
|
||||
|
||||
public searchAllMessages(keyword: string): string | null {
|
||||
const lowerKeyword = keyword.trim().toLowerCase()
|
||||
if (!lowerKeyword) return null
|
||||
|
||||
Vendored
+25
@@ -93,6 +93,11 @@ declare global {
|
||||
contacts: Contact[]
|
||||
updatedAt: number
|
||||
} | null>
|
||||
getStartupCache: () => Promise<{
|
||||
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
||||
contacts: Contact[]
|
||||
updatedAt: number
|
||||
} | null>
|
||||
getContacts: (filter?: string) => Promise<Contact[]>
|
||||
getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
|
||||
getCachedMessages: (
|
||||
@@ -100,6 +105,26 @@ declare global {
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
) => Promise<Message[]>
|
||||
getCachedMessagePage: (
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
) => Promise<{
|
||||
hit: boolean
|
||||
messages: Message[]
|
||||
groupSnapshot?: {
|
||||
roomId: string
|
||||
memberCount: number
|
||||
members: {
|
||||
wxid: string
|
||||
nickname: string
|
||||
groupNickname: string
|
||||
wechatNickname: string
|
||||
remark: string
|
||||
avatar: string
|
||||
}[]
|
||||
}
|
||||
}>
|
||||
getMessages: (
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
|
||||
@@ -26,10 +26,13 @@ const api = {
|
||||
revealAppLog: () => ipcRenderer.invoke('app-log:reveal'),
|
||||
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
||||
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
||||
getStartupCache: () => ipcRenderer.invoke('db:getStartupCache'),
|
||||
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
||||
getContactAvatars: (usernames: string[]) => ipcRenderer.invoke('db:getContactAvatars', usernames),
|
||||
getCachedMessages: (userMd5: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('db:getCachedMessages', userMd5, startTime, endTime),
|
||||
getCachedMessagePage: (userMd5: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('db:getCachedMessagePage', userMd5, startTime, endTime),
|
||||
getMessages: (
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
|
||||
+276
-122
@@ -40,7 +40,9 @@ 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 INITIAL_MESSAGE_COUNT = 20
|
||||
const MESSAGE_PAGE_SIZE = 100
|
||||
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
|
||||
const EXPORT_PREVIEW_LIMIT = 20
|
||||
const getMessageIdentity = (message: Message): string => {
|
||||
if (message.localId) return `local:${message.localId}`
|
||||
@@ -48,6 +50,70 @@ const getMessageIdentity = (message: Message): string => {
|
||||
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
|
||||
}
|
||||
|
||||
const normalizeQuotedText = (value: string | undefined): string =>
|
||||
String(value || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
const isInternalReferenceSender = (value: string | undefined): boolean => {
|
||||
const sender = String(value || '').trim()
|
||||
return (
|
||||
!sender ||
|
||||
sender.endsWith('@chatroom') ||
|
||||
sender.startsWith('wxid_') ||
|
||||
/^[a-z0-9_@.-]{12,}$/i.test(sender)
|
||||
)
|
||||
}
|
||||
|
||||
const enrichQuotedMessages = (messages: Message[], referenceMessages: Message[]): Message[] => {
|
||||
const imageDatNameByMd5 = new Map<string, string>()
|
||||
const messagesByContent = new Map<string, Message[]>()
|
||||
|
||||
for (const message of referenceMessages) {
|
||||
if (
|
||||
message.contentData?.type === 'image' &&
|
||||
message.contentData.md5 &&
|
||||
message.contentData.datName
|
||||
) {
|
||||
imageDatNameByMd5.set(message.contentData.md5, message.contentData.datName)
|
||||
}
|
||||
const content = normalizeQuotedText(message.content)
|
||||
if (!content) continue
|
||||
const candidates = messagesByContent.get(content) || []
|
||||
candidates.push(message)
|
||||
messagesByContent.set(content, candidates)
|
||||
}
|
||||
|
||||
return messages.map((message) => {
|
||||
if (message.contentData?.type !== 'quote') return message
|
||||
const quote = message.contentData
|
||||
let quotedImageDatName = quote.quotedImageDatName
|
||||
if (!quotedImageDatName && quote.quotedImageMd5) {
|
||||
quotedImageDatName = imageDatNameByMd5.get(quote.quotedImageMd5)
|
||||
}
|
||||
|
||||
let quotedSender = quote.quotedSender
|
||||
if (isInternalReferenceSender(quotedSender)) {
|
||||
const candidates = messagesByContent.get(normalizeQuotedText(quote.quotedContent)) || []
|
||||
const source = candidates
|
||||
.filter((candidate) => (candidate.createTime || 0) <= (message.createTime || Infinity))
|
||||
.sort((left, right) => (right.createTime || 0) - (left.createTime || 0))[0]
|
||||
if (source?.name && !isInternalReferenceSender(source.name)) quotedSender = source.name
|
||||
}
|
||||
|
||||
if (
|
||||
quotedSender === quote.quotedSender &&
|
||||
quotedImageDatName === quote.quotedImageDatName
|
||||
) {
|
||||
return message
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
contentData: { ...quote, quotedSender, quotedImageDatName }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
|
||||
if (left === right) return true
|
||||
if (left.length !== right.length) return false
|
||||
@@ -79,7 +145,7 @@ type StartupProgress = {
|
||||
}
|
||||
|
||||
const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string =>
|
||||
member.nickname || member.wxid
|
||||
member.groupNickname || member.nickname || member.remark || member.wechatNickname || member.wxid
|
||||
|
||||
const buildSyntheticGroupMessages = (
|
||||
previous: GroupSnapshot | null,
|
||||
@@ -150,7 +216,6 @@ function App(): React.ReactElement {
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [isMessagesLoading, setIsMessagesLoading] = useState(false)
|
||||
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
|
||||
const [dateRange, setDateRange] = useState('today') // 默认今天
|
||||
const [contentFilter, setContentFilter] = useState('')
|
||||
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
||||
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
||||
@@ -195,6 +260,10 @@ function App(): React.ReactElement {
|
||||
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
|
||||
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
||||
const groupMemberMetaRef = React.useRef<Record<string, Map<string, GroupMemberMeta>>>({})
|
||||
const messageHistoryRef = React.useRef<Message[]>([])
|
||||
const messagesRef = React.useRef<Message[]>([])
|
||||
const messagePrefetchRef = React.useRef<Promise<void> | null>(null)
|
||||
messagesRef.current = messages
|
||||
React.useEffect(() => {
|
||||
if (!reportNotice) return
|
||||
const timer = window.setTimeout(() => setReportNotice(''), 3200)
|
||||
@@ -486,7 +555,32 @@ function App(): React.ReactElement {
|
||||
}
|
||||
if (!autoLoginEnabled) return
|
||||
try {
|
||||
const result = await window.api.initDb(key)
|
||||
const startupCacheReady = await loadStartupCache()
|
||||
const initPromise = window.api.initDb(key)
|
||||
if (startupCacheReady) {
|
||||
setIsAuthenticated(true)
|
||||
setIsDatabaseConnected(false)
|
||||
setBootState('login')
|
||||
void initPromise.then(async (result) => {
|
||||
const success = typeof result === 'boolean' ? result : result.success
|
||||
if (!success) {
|
||||
const error = typeof result === 'boolean' ? '' : result.error
|
||||
setDbKeyStatus(`后台连接失败${error ? `: ${error}` : ''}`)
|
||||
setDbKeyStatusKind('error')
|
||||
return
|
||||
}
|
||||
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
||||
setIsDatabaseConnected(true)
|
||||
setDbKeyStatus('已连接数据库')
|
||||
// Cached contacts/self info are enough for startup. Native refresh is
|
||||
// intentionally user-triggered so it cannot freeze the first session.
|
||||
}).catch((error) => {
|
||||
console.warn('[Startup] background database init failed:', error)
|
||||
setDbKeyStatusKind('error')
|
||||
})
|
||||
return
|
||||
}
|
||||
const result = await initPromise
|
||||
if (!active) return
|
||||
const success = typeof result === 'boolean' ? result : result.success
|
||||
if (success) {
|
||||
@@ -497,9 +591,13 @@ function App(): React.ReactElement {
|
||||
setIsDatabaseConnected(true)
|
||||
setDbKeyStatus('已自动连接')
|
||||
setDbKeyStatusKind('success')
|
||||
await loadContacts()
|
||||
await refreshSelfInfo(3)
|
||||
setIsAuthenticated(true)
|
||||
const hasBootstrap = await loadBootstrapCache()
|
||||
if (hasBootstrap) {
|
||||
setIsAuthenticated(true)
|
||||
} else {
|
||||
await Promise.all([loadContacts(), refreshSelfInfo(3)])
|
||||
setIsAuthenticated(true)
|
||||
}
|
||||
} else {
|
||||
const error = typeof result === 'boolean' ? '' : result.error
|
||||
setDbKeyStatus(`自动连接失败,请重新输入${error ? `: ${error}` : ''}`)
|
||||
@@ -569,7 +667,7 @@ function App(): React.ReactElement {
|
||||
detail: '正在读取本地缓存',
|
||||
percent: 25
|
||||
})
|
||||
await loadBootstrapCache()
|
||||
const hasBootstrap = await loadBootstrapCache()
|
||||
// 持久化手动输入的密钥,供下次启动继续使用
|
||||
void window.api.saveDbKey(keyToUse).catch(() => undefined)
|
||||
void window.api.getSettings().then((current) => {
|
||||
@@ -585,15 +683,28 @@ function App(): React.ReactElement {
|
||||
})
|
||||
// 账号识别依赖联系人数据就绪。返回登录后数据已被清空,如果先查账号,
|
||||
// 会出现“数据库已连接,但账号未连接”的分离状态。手动连接与启动自动连接保持同一顺序。
|
||||
await loadContacts({ waitForAvatars: false })
|
||||
await refreshSelfInfo(3)
|
||||
if (hasBootstrap) {
|
||||
// Cached contacts are sufficient for the first paint. Refresh native data in the background.
|
||||
setIsAuthenticated(true)
|
||||
void Promise.all([
|
||||
loadContacts({ waitForAvatars: false }),
|
||||
refreshSelfInfo(3)
|
||||
]).catch((error) => {
|
||||
console.warn('[Startup] background refresh failed:', error)
|
||||
})
|
||||
} else {
|
||||
await Promise.all([
|
||||
loadContacts({ waitForAvatars: false }),
|
||||
refreshSelfInfo(3)
|
||||
])
|
||||
setIsAuthenticated(true)
|
||||
}
|
||||
setStartupProgress({
|
||||
title: '加载完成',
|
||||
subtitle: '正在进入主页面',
|
||||
detail: '联系人和头像已准备好',
|
||||
percent: 100
|
||||
})
|
||||
setIsAuthenticated(true)
|
||||
setIsDatabaseConnected(true)
|
||||
setBootState('login')
|
||||
window.setTimeout(() => {
|
||||
@@ -646,10 +757,14 @@ function App(): React.ReactElement {
|
||||
const applyGroupMemberMeta = React.useCallback(
|
||||
(contact: Contact | null, baseMessages: Message[]): Message[] => {
|
||||
if (!contact || contact.type !== 'group') return baseMessages
|
||||
const enrichedMessages = enrichQuotedMessages(baseMessages, [
|
||||
...messageHistoryRef.current,
|
||||
...baseMessages
|
||||
])
|
||||
const memberMap = groupMemberMetaRef.current[contact.md5]
|
||||
if (!memberMap || memberMap.size === 0) return baseMessages
|
||||
if (!memberMap || memberMap.size === 0) return enrichedMessages
|
||||
|
||||
return baseMessages.map((message) => {
|
||||
return enrichedMessages.map((message) => {
|
||||
const senderId = String(message.senderId || message.name || '').trim()
|
||||
if (!senderId) return message
|
||||
const member = memberMap.get(senderId)
|
||||
@@ -672,23 +787,57 @@ function App(): React.ReactElement {
|
||||
[]
|
||||
)
|
||||
|
||||
const storeGroupMemberMeta = React.useCallback(
|
||||
(contact: Contact, snapshot: GroupSnapshot): void => {
|
||||
currentGroupSnapshotRef.current = snapshot
|
||||
groupMemberMetaRef.current[contact.md5] = new Map(
|
||||
snapshot.members.map((member) => [
|
||||
member.wxid,
|
||||
{ nickname: formatGroupMemberName(member), avatar: member.avatar || '' }
|
||||
])
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const loadGroupMemberMeta = React.useCallback(
|
||||
async (contact: Contact | null): Promise<GroupSnapshot | null> => {
|
||||
if (!contact || contact.type !== 'group') return null
|
||||
const snapshot = await logGroupSnapshot(contact, 'load-member-meta')
|
||||
if (!snapshot) return null
|
||||
currentGroupSnapshotRef.current = snapshot
|
||||
groupMemberMetaRef.current[contact.md5] = new Map(
|
||||
snapshot.members.map((member) => [
|
||||
member.wxid,
|
||||
{ nickname: member.nickname || member.wxid, avatar: member.avatar || '' }
|
||||
])
|
||||
)
|
||||
storeGroupMemberMeta(contact, snapshot)
|
||||
return snapshot
|
||||
},
|
||||
[logGroupSnapshot]
|
||||
[logGroupSnapshot, storeGroupMemberMeta]
|
||||
)
|
||||
|
||||
const handleReloadCurrentAvatars = React.useCallback(async (): Promise<void> => {
|
||||
const contact = selectedContact
|
||||
if (!contact) return
|
||||
|
||||
try {
|
||||
const avatars = await window.api.getContactAvatars([contact.m_nsUsrName])
|
||||
const avatar = avatars[contact.m_nsUsrName]
|
||||
if (avatar) {
|
||||
const updateContact = (item: Contact): Contact =>
|
||||
item.md5 === contact.md5 ? { ...item, avatar } : item
|
||||
setContacts((current) => current.map(updateContact))
|
||||
setFilteredContacts((current) => current.map(updateContact))
|
||||
setSelectedContact((current) =>
|
||||
current?.md5 === contact.md5 ? updateContact(current) : current
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Contacts] current avatar reload failed:', error)
|
||||
}
|
||||
|
||||
const snapshot = await loadGroupMemberMeta(contact)
|
||||
if (!snapshot || selectedContactMd5Ref.current !== contact.md5) return
|
||||
setMessages((current) =>
|
||||
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, current, snapshot.roomId))
|
||||
)
|
||||
}, [applyGroupMemberMeta, loadGroupMemberMeta, mergeSyntheticMessages, selectedContact])
|
||||
|
||||
const handleAutoGetDbKey = async (): Promise<void> => {
|
||||
if (isFetchingDbKey) return
|
||||
setIsFetchingDbKey(true)
|
||||
@@ -761,65 +910,57 @@ function App(): React.ReactElement {
|
||||
setStartupProgress(null)
|
||||
}
|
||||
|
||||
const getDateRangeParams = (
|
||||
range: string
|
||||
): { startTime: number | undefined; endTime: number | undefined } => {
|
||||
const now = new Date()
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
|
||||
|
||||
let startTime: number | undefined
|
||||
let endTime: number | undefined
|
||||
|
||||
switch (range) {
|
||||
case 'today':
|
||||
startTime = startOfToday
|
||||
break
|
||||
case 'yesterday':
|
||||
startTime = startOfToday - 86400
|
||||
endTime = startOfToday - 1 // 昨天结束
|
||||
break
|
||||
case '7':
|
||||
startTime = Math.floor(Date.now() / 1000) - 7 * 86400
|
||||
break
|
||||
case '30':
|
||||
startTime = Math.floor(Date.now() / 1000) - 30 * 86400
|
||||
break
|
||||
case 'all':
|
||||
startTime = undefined
|
||||
break
|
||||
default:
|
||||
startTime = startOfToday
|
||||
}
|
||||
return { startTime, endTime }
|
||||
}
|
||||
|
||||
const handleSelectContact = async (contact: Contact): Promise<void> => {
|
||||
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
|
||||
setSelectedContact(contact)
|
||||
selectedContactMd5Ref.current = contact.md5
|
||||
currentGroupSnapshotRef.current = null
|
||||
setIsMessagesLoading(true)
|
||||
const { startTime, endTime } = getDateRangeParams(dateRange)
|
||||
const cachedMsgs = await window.api.getCachedMessages(contact.md5, startTime, endTime)
|
||||
const cachedPage = await window.api.getCachedMessagePage(contact.md5)
|
||||
const cachedMsgs = cachedPage.messages
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
if (cachedMsgs.length) {
|
||||
setMessages(
|
||||
applyGroupMemberMeta(
|
||||
contact,
|
||||
mergeSyntheticMessages(contact, cachedMsgs.slice(-MESSAGE_PAGE_SIZE))
|
||||
)
|
||||
)
|
||||
} else {
|
||||
setMessages([])
|
||||
if (cachedPage.groupSnapshot) {
|
||||
storeGroupMemberMeta(contact, cachedPage.groupSnapshot)
|
||||
}
|
||||
messageHistoryRef.current = cachedMsgs
|
||||
setMessages(
|
||||
applyGroupMemberMeta(
|
||||
contact,
|
||||
mergeSyntheticMessages(contact, cachedMsgs.slice(-INITIAL_MESSAGE_COUNT))
|
||||
)
|
||||
)
|
||||
const needsLivePage =
|
||||
forceLive || (isDatabaseConnected && cachedMsgs.length < MESSAGE_PREFETCH_COUNT)
|
||||
if (!needsLivePage) {
|
||||
setIsMessagesLoading(false)
|
||||
if (contact.type === 'group' && cachedMsgs.length > 0 && !cachedPage.groupSnapshot) {
|
||||
window.setTimeout(() => {
|
||||
void loadGroupMemberMeta(contact).then((snapshot) => {
|
||||
if (!snapshot || selectedContactMd5Ref.current !== contact.md5) return
|
||||
setMessages((current) =>
|
||||
applyGroupMemberMeta(
|
||||
contact,
|
||||
mergeSyntheticMessages(contact, current, snapshot.roomId)
|
||||
)
|
||||
)
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (cachedMsgs.length >= INITIAL_MESSAGE_COUNT) setIsMessagesLoading(false)
|
||||
await waitForPaint()
|
||||
try {
|
||||
const msgs = await window.api.getMessages(contact.md5, startTime, endTime, {
|
||||
limit: MESSAGE_PAGE_SIZE
|
||||
const loadLivePage = async (): Promise<void> => {
|
||||
const msgs = await window.api.getMessages(contact.md5, undefined, undefined, {
|
||||
limit: MESSAGE_PREFETCH_COUNT
|
||||
})
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
const cachedMessages = applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, msgs))
|
||||
setMessages(cachedMessages)
|
||||
if (contact.type === 'group') {
|
||||
messageHistoryRef.current = msgs
|
||||
const visibleMessages = applyGroupMemberMeta(
|
||||
contact,
|
||||
mergeSyntheticMessages(contact, msgs.slice(-INITIAL_MESSAGE_COUNT))
|
||||
)
|
||||
setMessages(visibleMessages)
|
||||
if (contact.type === 'group' && visibleMessages.length > 0) {
|
||||
window.setTimeout(() => {
|
||||
void loadGroupMemberMeta(contact).then((snapshot) => {
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
@@ -833,28 +974,79 @@ function App(): React.ReactElement {
|
||||
})
|
||||
}, 120)
|
||||
}
|
||||
}
|
||||
const prefetchPromise = loadLivePage()
|
||||
messagePrefetchRef.current = prefetchPromise
|
||||
try {
|
||||
await prefetchPromise
|
||||
} finally {
|
||||
if (messagePrefetchRef.current === prefetchPromise) messagePrefetchRef.current = null
|
||||
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDatabaseConnected || !selectedContact || messages.length > 0) return
|
||||
// The first live page uses async native cursors, so a cache miss can be filled
|
||||
// without blocking the Electron main thread.
|
||||
void handleSelectContact(selectedContact)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDatabaseConnected])
|
||||
|
||||
const loadStartupCache = async (): Promise<boolean> => {
|
||||
try {
|
||||
const cache = await window.api.getStartupCache()
|
||||
if (!cache?.contacts.length) return false
|
||||
setContacts(cache.contacts)
|
||||
setFilteredContacts(cache.contacts)
|
||||
if (cache.self) setSelfInfo(cache.self)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('[StartupCache] load failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadOlderMessages = async (): Promise<void> => {
|
||||
const contact = selectedContact
|
||||
if (!contact || isMessagesLoading || messages.length === 0) return
|
||||
const oldestTime = messages[0]?.createTime
|
||||
if (!contact || messagesRef.current.length === 0) return
|
||||
if (messagePrefetchRef.current) await messagePrefetchRef.current
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
const currentMessages = messagesRef.current
|
||||
const historyMessages = messageHistoryRef.current
|
||||
const firstVisibleIndex = historyMessages.findIndex(
|
||||
(message) => getMessageIdentity(message) === getMessageIdentity(currentMessages[0])
|
||||
)
|
||||
if (firstVisibleIndex > 0) {
|
||||
const inMemoryOlder = historyMessages.slice(
|
||||
Math.max(0, firstVisibleIndex - MESSAGE_PAGE_SIZE),
|
||||
firstVisibleIndex
|
||||
)
|
||||
setMessages((current) =>
|
||||
applyGroupMemberMeta(
|
||||
contact,
|
||||
mergeSyntheticMessages(contact, mergeMessagePages(inMemoryOlder, current))
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
const oldestTime = currentMessages[0]?.createTime
|
||||
if (!oldestTime) return
|
||||
const { startTime } = getDateRangeParams(dateRange)
|
||||
if (startTime && oldestTime <= startTime) return
|
||||
|
||||
setIsMessagesLoading(true)
|
||||
try {
|
||||
const olderMessages = await window.api.getMessages(
|
||||
const cachedPage = await window.api.getCachedMessagePage(
|
||||
contact.md5,
|
||||
startTime,
|
||||
oldestTime - 1,
|
||||
{ limit: MESSAGE_PAGE_SIZE }
|
||||
undefined,
|
||||
oldestTime - 1
|
||||
)
|
||||
const olderMessages = cachedPage.hit
|
||||
? cachedPage.messages
|
||||
: await window.api.getMessages(contact.md5, undefined, oldestTime - 1, {
|
||||
limit: MESSAGE_PAGE_SIZE
|
||||
})
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages)
|
||||
setMessages((current) =>
|
||||
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, mergeMessagePages(olderMessages, current)))
|
||||
)
|
||||
@@ -878,40 +1070,6 @@ function App(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDateRangeChange = (range: string): void => {
|
||||
setDateRange(range)
|
||||
if (selectedContact) {
|
||||
const { startTime, endTime } = getDateRangeParams(range)
|
||||
window.api
|
||||
.getCachedMessages(selectedContact.md5, startTime, endTime)
|
||||
.then((cachedMessages) => {
|
||||
if (!cachedMessages.length) return
|
||||
setMessages(
|
||||
applyGroupMemberMeta(
|
||||
selectedContact,
|
||||
mergeSyntheticMessages(selectedContact, cachedMessages.slice(-MESSAGE_PAGE_SIZE))
|
||||
)
|
||||
)
|
||||
})
|
||||
setIsMessagesLoading(true)
|
||||
window.api
|
||||
.getMessages(selectedContact.md5, startTime, endTime, { limit: MESSAGE_PAGE_SIZE })
|
||||
.then((nextMessages) => {
|
||||
setMessages(
|
||||
applyGroupMemberMeta(
|
||||
selectedContact,
|
||||
mergeSyntheticMessages(selectedContact, nextMessages)
|
||||
)
|
||||
)
|
||||
setIsMessagesLoading(false)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[Messages] date range load failed:', error)
|
||||
setIsMessagesLoading(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !selectedContact || !isNativeMonitorActive) return
|
||||
|
||||
@@ -928,12 +1086,11 @@ function App(): React.ReactElement {
|
||||
}
|
||||
refreshInFlight = true
|
||||
try {
|
||||
const range = getDateRangeParams(dateRange)
|
||||
const latestMessages = await window.api.getMessages(
|
||||
contactMd5,
|
||||
range.startTime,
|
||||
range.endTime,
|
||||
{ limit: MESSAGE_PAGE_SIZE }
|
||||
undefined,
|
||||
undefined,
|
||||
{ limit: INITIAL_MESSAGE_COUNT }
|
||||
)
|
||||
const nextMessages = applyGroupMemberMeta(
|
||||
selectedContact,
|
||||
@@ -975,7 +1132,6 @@ function App(): React.ReactElement {
|
||||
unsubscribe()
|
||||
}
|
||||
}, [
|
||||
dateRange,
|
||||
isAuthenticated,
|
||||
isNativeMonitorActive,
|
||||
selectedContact,
|
||||
@@ -1201,8 +1357,6 @@ function App(): React.ReactElement {
|
||||
onSearch={handleSearchContacts}
|
||||
onContentFilter={setContentFilter}
|
||||
width={sidebarWidth}
|
||||
dateRange={dateRange}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={isDatabaseConnected}
|
||||
onOpenSettings={openSettings}
|
||||
@@ -1214,11 +1368,11 @@ function App(): React.ReactElement {
|
||||
messages={messages}
|
||||
isLoadingMessages={isMessagesLoading}
|
||||
contentFilter={contentFilter}
|
||||
dateRange={dateRange}
|
||||
onContentFilterChange={setContentFilter}
|
||||
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
|
||||
onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)}
|
||||
onRefreshData={loadContacts}
|
||||
onLoadOlderMessages={() => void handleLoadOlderMessages()}
|
||||
onReloadAvatars={handleReloadCurrentAvatars}
|
||||
onLoadOlderMessages={handleLoadOlderMessages}
|
||||
onCreateGroupReport={handleOpenReportWorkspace}
|
||||
isAiLoading={reportGeneration.isGenerating}
|
||||
/>
|
||||
|
||||
@@ -1803,6 +1803,41 @@ body {
|
||||
accent-color: var(--wxex-brand);
|
||||
}
|
||||
|
||||
.chat-avatar-note {
|
||||
max-width: 320px;
|
||||
color: var(--wxex-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-avatar-reload {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-avatar-reload svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
.chat-avatar-reload svg.is-spinning {
|
||||
animation: chat-avatar-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes chat-avatar-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-status-separator {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
@@ -1827,6 +1862,12 @@ body {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.chat-avatar-note {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-conversation-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -11,62 +11,24 @@ interface ChatWindowProps {
|
||||
messages: Message[]
|
||||
isLoadingMessages?: boolean
|
||||
contentFilter?: string
|
||||
dateRange?: string
|
||||
onContentFilterChange?: (keyword: string) => void
|
||||
onRefresh?: () => void
|
||||
onRefreshData?: () => void
|
||||
onLoadOlderMessages?: () => void
|
||||
onReloadAvatars?: () => Promise<void>
|
||||
onLoadOlderMessages?: () => Promise<void>
|
||||
onCreateGroupReport?: () => void
|
||||
isAiLoading?: boolean
|
||||
}
|
||||
|
||||
const DATE_RANGE_LABELS: Record<string, string> = {
|
||||
today: '今天',
|
||||
yesterday: '昨日',
|
||||
'7': '7 天',
|
||||
'30': '30 天',
|
||||
all: '全部'
|
||||
}
|
||||
|
||||
const formatClock = (date: Date): string =>
|
||||
`${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
|
||||
const formatRangeDate = (date: Date, now: Date): string => {
|
||||
const clock = formatClock(date)
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
return `${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
||||
}
|
||||
return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
||||
}
|
||||
|
||||
const getChatHeaderRangeLabel = (range: string): string => {
|
||||
const now = new Date()
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const endOfYesterday = new Date(startOfToday.getTime() - 60_000)
|
||||
|
||||
if (range === 'today') return `今天 00:00—现在`
|
||||
if (range === 'yesterday') return `昨天 00:00—${formatClock(endOfYesterday)}`
|
||||
if (range === '7') {
|
||||
const start = new Date(Date.now() - 7 * 86400000)
|
||||
return `${formatRangeDate(start, now)}—现在`
|
||||
}
|
||||
if (range === '30') {
|
||||
const start = new Date(Date.now() - 30 * 86400000)
|
||||
return `${formatRangeDate(start, now)}—现在`
|
||||
}
|
||||
if (range === 'all') return '全部记录'
|
||||
return DATE_RANGE_LABELS[range] || '当前范围'
|
||||
}
|
||||
|
||||
const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
contact,
|
||||
messages,
|
||||
isLoadingMessages,
|
||||
contentFilter,
|
||||
dateRange = 'today',
|
||||
onContentFilterChange,
|
||||
onRefresh,
|
||||
onRefreshData,
|
||||
onReloadAvatars,
|
||||
onLoadOlderMessages,
|
||||
onCreateGroupReport,
|
||||
isAiLoading = false
|
||||
@@ -86,6 +48,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
)
|
||||
const [showAvatar, setShowAvatar] = useState(true)
|
||||
const [isAtLatest, setIsAtLatest] = useState(true)
|
||||
const [isReloadingAvatars, setIsReloadingAvatars] = useState(false)
|
||||
const previousScrollTopRef = useRef(0)
|
||||
|
||||
const scrollToBottom = useCallback((): void => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
||||
@@ -95,15 +59,34 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
const handleMessageListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
|
||||
const target = event.currentTarget
|
||||
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
|
||||
setIsAtLatest(distanceToBottom <= 24)
|
||||
if (distanceToBottom <= 24) {
|
||||
setIsAtLatest(true)
|
||||
} else if (target.scrollTop < previousScrollTopRef.current - 1) {
|
||||
setIsAtLatest(false)
|
||||
}
|
||||
previousScrollTopRef.current = target.scrollTop
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
previousScrollTopRef.current = 0
|
||||
setIsAtLatest(true)
|
||||
}, [contact?.md5])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAtLatest) return
|
||||
const frame = window.requestAnimationFrame(() => scrollToBottom())
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [isAtLatest, messages, scrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAtLatest) return
|
||||
const content = messageListRef.current?.querySelector('.virtual-message-list')
|
||||
if (!content) return
|
||||
const observer = new ResizeObserver(() => scrollToBottom())
|
||||
observer.observe(content)
|
||||
return () => observer.disconnect()
|
||||
}, [contact?.md5, isAtLatest, scrollToBottom])
|
||||
|
||||
const openImagePreview = (imageUrl: string): void => {
|
||||
setPreviewImage(imageUrl)
|
||||
setImageScale(1)
|
||||
@@ -155,6 +138,16 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
imageDragRef.current = null
|
||||
}
|
||||
|
||||
const handleReloadAvatars = async (): Promise<void> => {
|
||||
if (!onReloadAvatars || isReloadingAvatars) return
|
||||
setIsReloadingAvatars(true)
|
||||
try {
|
||||
await onReloadAvatars()
|
||||
} finally {
|
||||
setIsReloadingAvatars(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewImage) return
|
||||
|
||||
@@ -186,14 +179,11 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
}, [messages, contentFilter])
|
||||
if (!contact) return <EmptyConversationState />
|
||||
|
||||
const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
|
||||
|
||||
return (
|
||||
<div className="chat-window">
|
||||
<ChatHeader
|
||||
contact={contact}
|
||||
isGroupChat={isGroupChat}
|
||||
dateRangeLabel={dateRangeLabel}
|
||||
loadedCount={messages.length}
|
||||
filteredCount={filteredMessages.length}
|
||||
contentFilter={contentFilter || ''}
|
||||
@@ -221,7 +211,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
count={filteredMessages.length}
|
||||
showAvatar={showAvatar}
|
||||
isAtLatest={isAtLatest}
|
||||
isReloadingAvatars={isReloadingAvatars}
|
||||
onShowAvatarChange={setShowAvatar}
|
||||
onReloadAvatars={() => void handleReloadAvatars()}
|
||||
onJumpToLatest={scrollToBottom}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,6 +1,45 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import type { JSX, MouseEvent } from 'react'
|
||||
|
||||
type CachedImage = { data: string; isThumbnail: boolean }
|
||||
|
||||
const MAX_IMAGE_CACHE_ENTRIES = 80
|
||||
const imageDataUrlCache = new Map<string, CachedImage>()
|
||||
|
||||
function imageCacheKeys(imageMd5?: string, imageDatName?: string): string[] {
|
||||
return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter(
|
||||
Boolean
|
||||
)
|
||||
}
|
||||
|
||||
function getCachedImage(imageMd5?: string, imageDatName?: string): CachedImage | undefined {
|
||||
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
|
||||
const cached = imageDataUrlCache.get(key)
|
||||
if (cached) {
|
||||
imageDataUrlCache.delete(key)
|
||||
imageDataUrlCache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function cacheImage(
|
||||
imageMd5: string | undefined,
|
||||
imageDatName: string | undefined,
|
||||
cached: CachedImage
|
||||
): void {
|
||||
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
|
||||
imageDataUrlCache.delete(key)
|
||||
imageDataUrlCache.set(key, cached)
|
||||
}
|
||||
while (imageDataUrlCache.size > MAX_IMAGE_CACHE_ENTRIES) {
|
||||
const oldestKey = imageDataUrlCache.keys().next().value
|
||||
if (!oldestKey) break
|
||||
imageDataUrlCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
interface ImageBubbleProps {
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
@@ -16,11 +55,12 @@ export function ImageBubble({
|
||||
isThumb = false,
|
||||
onImageClick
|
||||
}: ImageBubbleProps): JSX.Element {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const initialCachedImage = getCachedImage(imageMd5, imageDatName)
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(initialCachedImage?.data || null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [upgrading, setUpgrading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isThumbnail, setIsThumbnail] = useState(false)
|
||||
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const loadImage = useCallback(async () => {
|
||||
@@ -34,6 +74,10 @@ export function ImageBubble({
|
||||
try {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
|
||||
if (result.success && result.data?.startsWith('data:image/')) {
|
||||
cacheImage(imageMd5, imageDatName, {
|
||||
data: result.data,
|
||||
isThumbnail: Boolean(result.isThumb)
|
||||
})
|
||||
setImageUrl(result.data)
|
||||
setIsThumbnail(Boolean(result.isThumb))
|
||||
setError(null)
|
||||
@@ -92,6 +136,10 @@ export function ImageBubble({
|
||||
force: true
|
||||
})
|
||||
if (result.success && result.data?.startsWith('data:image/')) {
|
||||
cacheImage(imageMd5, imageDatName, {
|
||||
data: result.data,
|
||||
isThumbnail: Boolean(result.isThumb)
|
||||
})
|
||||
setImageUrl(result.data)
|
||||
setIsThumbnail(Boolean(result.isThumb))
|
||||
setError(null)
|
||||
|
||||
@@ -6,7 +6,6 @@ import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons'
|
||||
interface ChatHeaderProps {
|
||||
contact: Contact
|
||||
isGroupChat: boolean
|
||||
dateRangeLabel: string
|
||||
loadedCount: number
|
||||
filteredCount: number
|
||||
contentFilter: string
|
||||
@@ -20,7 +19,6 @@ interface ChatHeaderProps {
|
||||
export function ChatHeader({
|
||||
contact,
|
||||
isGroupChat,
|
||||
dateRangeLabel,
|
||||
loadedCount,
|
||||
filteredCount,
|
||||
contentFilter,
|
||||
@@ -65,7 +63,6 @@ export function ChatHeader({
|
||||
<h2>{displayName}</h2>
|
||||
<div className="chat-title-meta">
|
||||
<span>{typeLabel}</span>
|
||||
<span>{dateRangeLabel}</span>
|
||||
<span>{visibleCount} 条消息</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react'
|
||||
import { RefreshIcon } from './icons'
|
||||
|
||||
interface ChatStatusBarProps {
|
||||
count: number
|
||||
showAvatar: boolean
|
||||
isAtLatest: boolean
|
||||
isReloadingAvatars: boolean
|
||||
onShowAvatarChange: (show: boolean) => void
|
||||
onReloadAvatars: () => void
|
||||
onJumpToLatest: () => void
|
||||
}
|
||||
|
||||
@@ -12,7 +15,9 @@ export function ChatStatusBar({
|
||||
count,
|
||||
showAvatar,
|
||||
isAtLatest,
|
||||
isReloadingAvatars,
|
||||
onShowAvatarChange,
|
||||
onReloadAvatars,
|
||||
onJumpToLatest
|
||||
}: ChatStatusBarProps): React.ReactElement {
|
||||
const jumpDisabled = isAtLatest || count === 0
|
||||
@@ -29,6 +34,22 @@ export function ChatStatusBar({
|
||||
/>
|
||||
<span>显示头像</span>
|
||||
</label>
|
||||
<span
|
||||
className="chat-avatar-note"
|
||||
title="头像或名称缺失时,请先在微信中进入该聊天,再尝试重新加载"
|
||||
>
|
||||
头像或名称缺失时,请先在微信中进入该聊天
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-avatar-reload"
|
||||
onClick={onReloadAvatars}
|
||||
disabled={isReloadingAvatars}
|
||||
title="尝试重新加载当前聊天的头像和名称"
|
||||
>
|
||||
<RefreshIcon className={isReloadingAvatars ? 'is-spinning' : ''} />
|
||||
<span>{isReloadingAvatars ? '加载中' : '重新加载头像'}</span>
|
||||
</button>
|
||||
<span className="chat-status-separator" aria-hidden />
|
||||
<button type="button" onClick={onJumpToLatest} disabled={jumpDisabled}>
|
||||
{jumpDisabled ? '已是最新消息' : '跳转到最新消息'}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function MessageGroup({
|
||||
)}
|
||||
{!isMine && !shouldShowAvatar && <div className="message-avatar-spacer" aria-hidden />}
|
||||
<div className="message-stack">
|
||||
{!isMine && isGroupChat && shouldShowAvatar && (
|
||||
{!isMine && isGroupChat && (
|
||||
<div className="message-sender-name">{displayName}</div>
|
||||
)}
|
||||
{group.messages.map((message, index) => (
|
||||
|
||||
@@ -14,7 +14,7 @@ interface MessageListProps {
|
||||
listRef: React.RefObject<HTMLDivElement | null>
|
||||
bottomRef: React.RefObject<HTMLDivElement | null>
|
||||
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
|
||||
onReachTop?: () => void
|
||||
onReachTop?: () => Promise<void>
|
||||
onImageClick: (imageUrl: string) => void
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ export function MessageList({
|
||||
onImageClick
|
||||
}: MessageListProps): React.ReactElement {
|
||||
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
|
||||
const groupsRef = React.useRef(groups)
|
||||
const loadingOlderRef = React.useRef(false)
|
||||
groupsRef.current = groups
|
||||
// TanStack Virtual intentionally exposes mutable measurement methods.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const virtualizer = useVirtualizer({
|
||||
@@ -45,7 +48,54 @@ export function MessageList({
|
||||
|
||||
const handleScroll = (event: React.UIEvent<HTMLDivElement>): void => {
|
||||
onScroll(event)
|
||||
if (event.currentTarget.scrollTop < 120) onReachTop?.()
|
||||
const scrollElement = event.currentTarget
|
||||
if (
|
||||
scrollElement.scrollTop >= 48 ||
|
||||
loadingOlderRef.current ||
|
||||
isLoadingMessages ||
|
||||
!onReachTop
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
loadingOlderRef.current = true
|
||||
const previousGroupCount = groups.length
|
||||
const previousScrollTop = scrollElement.scrollTop
|
||||
const previousScrollHeight = scrollElement.scrollHeight
|
||||
const anchorMessageId = groups[0]?.messages[0]?.id
|
||||
void (async () => {
|
||||
try {
|
||||
await onReachTop()
|
||||
for (let frame = 0; frame < 8; frame += 1) {
|
||||
if (groupsRef.current.length > previousGroupCount) break
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
if (!anchorMessageId) return
|
||||
const anchorIndex = groupsRef.current.findIndex((group) =>
|
||||
group.messages.some((message) => message.id === anchorMessageId)
|
||||
)
|
||||
if (anchorIndex <= 0) return
|
||||
virtualizer.measure()
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||
const addedHeight = scrollElement.scrollHeight - previousScrollHeight
|
||||
if (addedHeight > 0) {
|
||||
scrollElement.scrollTop = previousScrollTop + addedHeight
|
||||
} else {
|
||||
virtualizer.scrollToIndex(anchorIndex, { align: 'start' })
|
||||
}
|
||||
|
||||
// Variable-height groups can finish measuring one frame later. Preserve
|
||||
// the same message anchor after that final measurement as well.
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||
if (scrollElement.scrollTop < 48) {
|
||||
virtualizer.scrollToIndex(anchorIndex, { align: 'start' })
|
||||
}
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
loadingOlderRef.current = false
|
||||
}, 250)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,8 +19,6 @@ export interface ConversationSidebarProps {
|
||||
onSearch: (keyword: string) => void
|
||||
onContentFilter: (keyword: string) => void
|
||||
width: number
|
||||
dateRange: string
|
||||
onDateRangeChange: (range: string) => void
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
onOpenSettings: () => void
|
||||
@@ -37,8 +35,6 @@ export function ConversationSidebar({
|
||||
onSelectContact,
|
||||
onSearch,
|
||||
width,
|
||||
dateRange,
|
||||
onDateRangeChange,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
onOpenSettings
|
||||
@@ -83,9 +79,7 @@ export function ConversationSidebar({
|
||||
<ConversationSidebarHeader
|
||||
totalCount={contacts.length}
|
||||
searchValue={searchTerm}
|
||||
dateRange={dateRange}
|
||||
onSearchChange={handleSearchChange}
|
||||
onDateRangeChange={onDateRangeChange}
|
||||
/>
|
||||
<div ref={listRef} className="conversation-list" aria-label="会话列表">
|
||||
<div className="conversation-virtual-content" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import React from 'react'
|
||||
import { ConversationSearch } from './ConversationSearch'
|
||||
import { DateRangeSelector } from './DateRangeSelector'
|
||||
|
||||
interface ConversationSidebarHeaderProps {
|
||||
totalCount: number
|
||||
searchValue: string
|
||||
dateRange: string
|
||||
onSearchChange: (value: string) => void
|
||||
onDateRangeChange: (range: string) => void
|
||||
}
|
||||
|
||||
export function ConversationSidebarHeader({
|
||||
totalCount,
|
||||
searchValue,
|
||||
dateRange,
|
||||
onSearchChange,
|
||||
onDateRangeChange
|
||||
onSearchChange
|
||||
}: ConversationSidebarHeaderProps): React.ReactElement {
|
||||
return (
|
||||
<div className="conversation-sidebar-header">
|
||||
@@ -24,7 +19,6 @@ export function ConversationSidebarHeader({
|
||||
<span>{totalCount} 个会话</span>
|
||||
</div>
|
||||
<ConversationSearch value={searchValue} onChange={onSearchChange} />
|
||||
<DateRangeSelector value={dateRange} onChange={onDateRangeChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
interface DateRangeSelectorProps {
|
||||
value: string
|
||||
onChange: (range: string) => void
|
||||
}
|
||||
|
||||
const DATE_RANGE_OPTIONS = [
|
||||
{ key: 'today', label: '今天' },
|
||||
{ key: 'yesterday', label: '昨日' },
|
||||
{ key: '7', label: '7 天' },
|
||||
{ key: '30', label: '30 天' },
|
||||
{ key: 'all', label: '全部' }
|
||||
]
|
||||
|
||||
export function DateRangeSelector({ value, onChange }: DateRangeSelectorProps): React.ReactElement {
|
||||
return (
|
||||
<div className="conversation-date-range" aria-label="时间范围">
|
||||
{DATE_RANGE_OPTIONS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={`conversation-date-range-button ${value === item.key ? 'active' : ''}`}
|
||||
onClick={() => onChange(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user