重构与优化,旨在解决遗留的性能问题并优化用户体验,本次提交遗留了较多的待测功能

This commit is contained in:
cc
2026-03-18 23:49:50 +08:00
parent 4c32bf5934
commit 48c4197b16
22 changed files with 2726 additions and 1598 deletions

1
.gitignore vendored
View File

@@ -62,6 +62,7 @@ server/
chatlab-format.md chatlab-format.md
*.bak *.bak
AGENTS.md AGENTS.md
AGENT.md
.claude/ .claude/
CLAUDE.md CLAUDE.md
.agents/ .agents/

4
.npmrc
View File

@@ -1,3 +1,3 @@
registry=https://registry.npmmirror.com registry=https://registry.npmmirror.com
electron_mirror=https://npmmirror.com/mirrors/electron/ electron-mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-builder-binaries-mirror=https://npmmirror.com/mirrors/electron-builder-binaries/

47
electron/exportWorker.ts Normal file
View File

@@ -0,0 +1,47 @@
import { parentPort, workerData } from 'worker_threads'
import { wcdbService } from './services/wcdbService'
import { exportService, ExportOptions } from './services/exportService'
interface ExportWorkerConfig {
sessionIds: string[]
outputDir: string
options: ExportOptions
resourcesPath?: string
userDataPath?: string
logEnabled?: boolean
}
const config = workerData as ExportWorkerConfig
process.env.WEFLOW_WORKER = '1'
if (config.resourcesPath) {
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
}
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
wcdbService.setLogEnabled(config.logEnabled === true)
async function run() {
const result = await exportService.exportSessions(
Array.isArray(config.sessionIds) ? config.sessionIds : [],
String(config.outputDir || ''),
config.options || { format: 'json' },
(progress) => {
parentPort?.postMessage({
type: 'export:progress',
data: progress
})
}
)
parentPort?.postMessage({
type: 'export:result',
data: result
})
}
run().catch((error) => {
parentPort?.postMessage({
type: 'export:error',
error: String(error)
})
})

View File

@@ -17,7 +17,6 @@ import { annualReportService } from './services/annualReportService'
import { exportService, ExportOptions, ExportProgress } from './services/exportService' import { exportService, ExportOptions, ExportProgress } from './services/exportService'
import { KeyService } from './services/keyService' import { KeyService } from './services/keyService'
import { KeyServiceMac } from './services/keyServiceMac' import { KeyServiceMac } from './services/keyServiceMac'
import { KeyServiceLinux} from "./services/keyServiceLinux"
import { voiceTranscribeService } from './services/voiceTranscribeService' import { voiceTranscribeService } from './services/voiceTranscribeService'
import { videoService } from './services/videoService' import { videoService } from './services/videoService'
import { snsService, isVideoUrl } from './services/snsService' import { snsService, isVideoUrl } from './services/snsService'
@@ -96,7 +95,7 @@ let keyService: any
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
keyService = new KeyServiceMac() keyService = new KeyServiceMac()
} else if (process.platform === 'linux') { } else if (process.platform === 'linux') {
// const { KeyServiceLinux } = require('./services/keyServiceLinux') const { KeyServiceLinux } = require('./services/keyServiceLinux')
keyService = new KeyServiceLinux() keyService = new KeyServiceLinux()
} else { } else {
keyService = new KeyService() keyService = new KeyService()
@@ -1629,7 +1628,7 @@ function registerIpcHandlers() {
ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => { ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => {
return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => { return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => {
event.sender.send('chat:voiceTranscriptPartial', { msgId, text }) event.sender.send('chat:voiceTranscriptPartial', { sessionId, msgId, createTime, text })
}) })
}) })
@@ -1641,10 +1640,6 @@ function registerIpcHandlers() {
return chatService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp) return chatService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp)
}) })
ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => {
return chatService.execQuery(kind, path, sql)
})
ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => { ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => {
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
}) })
@@ -1856,7 +1851,83 @@ function registerIpcHandlers() {
} }
} }
return exportService.exportSessions(sessionIds, outputDir, options, onProgress) const runMainFallback = async (reason: string) => {
console.warn(`[fallback-export-main] ${reason}`)
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
}
const cfg = configService || new ConfigService()
configService = cfg
const logEnabled = cfg.get('logEnabled')
const resourcesPath = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
const userDataPath = app.getPath('userData')
const workerPath = join(__dirname, 'exportWorker.js')
const runWorker = async () => {
return await new Promise<any>((resolve, reject) => {
const worker = new Worker(workerPath, {
workerData: {
sessionIds,
outputDir,
options,
resourcesPath,
userDataPath,
logEnabled
}
})
let settled = false
const finalizeResolve = (value: any) => {
if (settled) return
settled = true
worker.removeAllListeners()
void worker.terminate()
resolve(value)
}
const finalizeReject = (error: Error) => {
if (settled) return
settled = true
worker.removeAllListeners()
void worker.terminate()
reject(error)
}
worker.on('message', (msg: any) => {
if (msg && msg.type === 'export:progress') {
onProgress(msg.data as ExportProgress)
return
}
if (msg && msg.type === 'export:result') {
finalizeResolve(msg.data)
return
}
if (msg && msg.type === 'export:error') {
finalizeReject(new Error(String(msg.error || '导出 Worker 执行失败')))
}
})
worker.on('error', (error) => {
finalizeReject(error instanceof Error ? error : new Error(String(error)))
})
worker.on('exit', (code) => {
if (settled) return
if (code === 0) {
finalizeResolve({ success: false, successCount: 0, failCount: 0, error: '导出 Worker 未返回结果' })
} else {
finalizeReject(new Error(`导出 Worker 异常退出: ${code}`))
}
})
})
}
try {
return await runWorker()
} catch (error) {
return runMainFallback(error instanceof Error ? error.message : String(error))
}
}) })
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => { ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {

View File

@@ -215,13 +215,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId), getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId), resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime), getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => { onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => {
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload) const listener = (_: any, payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => callback(payload)
ipcRenderer.on('chat:voiceTranscriptPartial', listener) ipcRenderer.on('chat:voiceTranscriptPartial', listener)
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener) return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
}, },
execQuery: (kind: string, path: string | null, sql: string) =>
ipcRenderer.invoke('chat:execQuery', kind, path, sql),
getContacts: () => ipcRenderer.invoke('chat:getContacts'), getContacts: () => ipcRenderer.invoke('chat:getContacts'),
getMessage: (sessionId: string, localId: number) => getMessage: (sessionId: string, localId: number) =>
ipcRenderer.invoke('chat:getMessage', sessionId, localId), ipcRenderer.invoke('chat:getMessage', sessionId, localId),
@@ -352,7 +350,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options), ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
exportContacts: (outputDir: string, options: any) => exportContacts: (outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportContacts', outputDir, options), ipcRenderer.invoke('export:exportContacts', outputDir, options),
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; currentSessionId?: string; phase: string }) => void) => { onProgress: (callback: (payload: {
current: number
total: number
currentSession: string
currentSessionId?: string
phase: string
phaseProgress?: number
phaseTotal?: number
phaseLabel?: string
collectedMessages?: number
exportedMessages?: number
estimatedTotalMessages?: number
writtenFiles?: number
}) => void) => {
ipcRenderer.on('export:progress', (_, payload) => callback(payload)) ipcRenderer.on('export:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('export:progress') return () => ipcRenderer.removeAllListeners('export:progress')
} }

View File

@@ -68,29 +68,14 @@ class AnalyticsService {
return new Set(this.getExcludedUsernamesList()) return new Set(this.getExcludedUsernamesList())
} }
private escapeSqlValue(value: string): string {
return value.replace(/'/g, "''")
}
private async getAliasMap(usernames: string[]): Promise<Record<string, string>> { private async getAliasMap(usernames: string[]): Promise<Record<string, string>> {
const map: Record<string, string> = {} const map: Record<string, string> = {}
if (usernames.length === 0) return map if (usernames.length === 0) return map
// C++ 层不支持参数绑定,直接内联转义后的字符串值 const result = await wcdbService.getContactAliasMap(usernames)
const chunkSize = 200 if (!result.success || !result.map) return map
for (let i = 0; i < usernames.length; i += chunkSize) { for (const [username, alias] of Object.entries(result.map)) {
const chunk = usernames.slice(i, i + chunkSize) if (username && alias) map[username] = alias
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',')
const sql = `SELECT username, alias FROM contact WHERE username IN (${inList})`
const result = await wcdbService.execQuery('contact', null, sql)
if (!result.success || !result.rows) continue
for (const row of result.rows as Record<string, any>[]) {
const username = row.username || ''
const alias = row.alias || ''
if (username && alias) {
map[username] = alias
}
}
} }
return map return map

View File

@@ -278,16 +278,16 @@ class AnnualReportService {
return cached || null return cached || null
} }
const result = await wcdbService.execQuery('message', dbPath, `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})`) const result = await wcdbService.getMessageTableColumns(dbPath, tableName)
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) { if (!result.success || !Array.isArray(result.columns) || result.columns.length === 0) {
this.availableYearsColumnCache.set(cacheKey, '') this.availableYearsColumnCache.set(cacheKey, '')
return null return null
} }
const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time'] const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time']
const columns = new Set<string>() const columns = new Set<string>()
for (const row of result.rows as Record<string, any>[]) { for (const columnName of result.columns) {
const name = String(row.name || row.column_name || row.columnName || '').trim().toLowerCase() const name = String(columnName || '').trim().toLowerCase()
if (name) columns.add(name) if (name) columns.add(name)
} }
@@ -309,10 +309,11 @@ class AnnualReportService {
const tried = new Set<string>() const tried = new Set<string>()
const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => { const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => {
const sql = `SELECT MIN(${this.quoteSqlIdentifier(column)}) AS first_ts, MAX(${this.quoteSqlIdentifier(column)}) AS last_ts FROM ${this.quoteSqlIdentifier(tableName)}` const result = await wcdbService.getMessageTableTimeRange(dbPath, tableName)
const result = await wcdbService.execQuery('message', dbPath, sql) if (!result.success || !result.data) return null
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) return null const row = result.data as Record<string, any>
const row = result.rows[0] as Record<string, any> const actualColumn = String(row.column || '').trim().toLowerCase()
if (column && actualColumn && column.toLowerCase() !== actualColumn) return null
const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs) const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs)
const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs) const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs)
return { first, last } return { first, last }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -230,10 +230,9 @@ class GroupAnalyticsService {
} }
try { try {
const escapedChatroomId = chatroomId.replace(/'/g, "''") const roomExt = await wcdbService.getChatRoomExtBuffer(chatroomId)
const roomResult = await wcdbService.execQuery('contact', null, `SELECT * FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`) if (roomExt.success && roomExt.extBuffer) {
if (roomResult.success && roomResult.rows && roomResult.rows.length > 0) { const owner = tryResolve({ ext_buffer: roomExt.extBuffer })
const owner = tryResolve(roomResult.rows[0])
if (owner) return owner if (owner) return owner
} }
} catch { } catch {
@@ -273,13 +272,12 @@ class GroupAnalyticsService {
} }
try { try {
const sql = 'SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1' const result = await wcdbService.getChatRoomExtBuffer(chatroomId)
const result = await wcdbService.execQuery('contact', null, sql, [chatroomId]) if (!result.success || !result.extBuffer) {
if (!result.success || !result.rows || result.rows.length === 0) {
return nicknameMap return nicknameMap
} }
const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer) const extBuffer = this.decodeExtBuffer(result.extBuffer)
if (!extBuffer) return nicknameMap if (!extBuffer) return nicknameMap
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries()) this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
return nicknameMap return nicknameMap
@@ -583,19 +581,9 @@ class GroupAnalyticsService {
const batch = candidates.slice(i, i + batchSize) const batch = candidates.slice(i, i + batchSize)
if (batch.length === 0) continue if (batch.length === 0) continue
const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',') const result = await wcdbService.getContactsCompact(batch)
const lightweightSql = ` if (!result.success || !result.contacts) continue
SELECT username, user_name, encrypt_username, encrypt_user_name, remark, nick_name, alias, local_type appendContactsToLookup(result.contacts as Record<string, unknown>[])
FROM contact
WHERE username IN (${inList})
`
let result = await wcdbService.execQuery('contact', null, lightweightSql)
if (!result.success || !result.rows) {
// 兼容历史/变体列名,轻查询失败时回退全字段查询,避免好友标识丢失
result = await wcdbService.execQuery('contact', null, `SELECT * FROM contact WHERE username IN (${inList})`)
}
if (!result.success || !result.rows) continue
appendContactsToLookup(result.rows as Record<string, unknown>[])
} }
return lookup return lookup
} }
@@ -774,31 +762,111 @@ class GroupAnalyticsService {
return '' return ''
} }
private normalizeCursorTimestamp(value: number): number {
if (!Number.isFinite(value) || value <= 0) return 0
const normalized = Math.floor(value)
return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized
}
private extractRowSenderUsername(row: Record<string, any>): string {
const candidates = [
row.sender_username,
row.senderUsername,
row.sender,
row.WCDB_CT_sender_username
]
for (const candidate of candidates) {
const value = String(candidate || '').trim()
if (value) return value
}
for (const [key, value] of Object.entries(row)) {
const normalizedKey = key.toLowerCase()
if (
normalizedKey === 'sender_username' ||
normalizedKey === 'senderusername' ||
normalizedKey === 'sender' ||
normalizedKey === 'wcdb_ct_sender_username'
) {
const normalizedValue = String(value || '').trim()
if (normalizedValue) return normalizedValue
}
}
return ''
}
private parseSingleMessageRow(row: Record<string, any>): Message | null {
try {
const mapped = chatService.mapRowsToMessagesForApi([row])
return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null
} catch {
return null
}
}
private async openMemberMessageCursor(
chatroomId: string,
batchSize: number,
ascending: boolean,
startTime: number,
endTime: number
): Promise<{ success: boolean; cursor?: number; error?: string }> {
const beginTimestamp = this.normalizeCursorTimestamp(startTime)
const endTimestamp = this.normalizeCursorTimestamp(endTime)
const liteResult = await wcdbService.openMessageCursorLite(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp)
if (liteResult.success && liteResult.cursor) return liteResult
return wcdbService.openMessageCursor(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp)
}
private async collectMessagesByMember( private async collectMessagesByMember(
chatroomId: string, chatroomId: string,
memberUsername: string, memberUsername: string,
startTime: number, startTime: number,
endTime: number endTime: number
): Promise<{ success: boolean; data?: Message[]; error?: string }> { ): Promise<{ success: boolean; data?: Message[]; error?: string }> {
const batchSize = 500 const batchSize = 800
const matchedMessages: Message[] = [] const matchedMessages: Message[] = []
let offset = 0 const senderMatchCache = new Map<string, boolean>()
const matchesTargetSender = (sender: string | null | undefined): boolean => {
const key = String(sender || '').trim().toLowerCase()
if (!key) return false
const cached = senderMatchCache.get(key)
if (typeof cached === 'boolean') return cached
const matched = this.isSameAccountIdentity(memberUsername, sender)
senderMatchCache.set(key, matched)
return matched
}
while (true) { const cursorResult = await this.openMemberMessageCursor(chatroomId, batchSize, true, startTime, endTime)
const batch = await chatService.getMessages(chatroomId, offset, batchSize, startTime, endTime, true) if (!cursorResult.success || !cursorResult.cursor) {
if (!batch.success || !batch.messages) { return { success: false, error: cursorResult.error || '创建群消息游标失败' }
return { success: false, error: batch.error || '获取群消息失败' } }
}
for (const message of batch.messages) { const cursor = cursorResult.cursor
if (this.isSameAccountIdentity(memberUsername, message.senderUsername)) { try {
matchedMessages.push(message) while (true) {
const batch = await wcdbService.fetchMessageBatch(cursor)
if (!batch.success) {
return { success: false, error: batch.error || '获取群消息失败' }
} }
} const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
if (rows.length === 0) break
const fetchedCount = batch.messages.length for (const row of rows) {
if (fetchedCount <= 0 || !batch.hasMore) break const senderFromRow = this.extractRowSenderUsername(row)
offset += fetchedCount if (senderFromRow && !matchesTargetSender(senderFromRow)) {
continue
}
const message = this.parseSingleMessageRow(row)
if (!message) continue
if (matchesTargetSender(message.senderUsername)) {
matchedMessages.push(message)
}
}
if (!batch.hasMore) break
}
} finally {
await wcdbService.closeMessageCursor(cursor)
} }
return { success: true, data: matchedMessages } return { success: true, data: matchedMessages }
@@ -832,57 +900,93 @@ class GroupAnalyticsService {
: 0 : 0
const matchedMessages: Message[] = [] const matchedMessages: Message[] = []
const batchSize = Math.max(limit * 2, 100) const senderMatchCache = new Map<string, boolean>()
const matchesTargetSender = (sender: string | null | undefined): boolean => {
const key = String(sender || '').trim().toLowerCase()
if (!key) return false
const cached = senderMatchCache.get(key)
if (typeof cached === 'boolean') return cached
const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender)
senderMatchCache.set(key, matched)
return matched
}
const batchSize = Math.max(limit * 4, 240)
let hasMore = false let hasMore = false
while (matchedMessages.length < limit) { const cursorResult = await this.openMemberMessageCursor(
const batch = await chatService.getMessages( normalizedChatroomId,
normalizedChatroomId, batchSize,
cursor, false,
batchSize, startTimeValue,
startTimeValue, endTimeValue
endTimeValue, )
false if (!cursorResult.success || !cursorResult.cursor) {
) return { success: false, error: cursorResult.error || '创建群成员消息游标失败' }
if (!batch.success || !batch.messages) { }
return { success: false, error: batch.error || '获取群成员消息失败' }
}
const currentMessages = batch.messages let consumedRows = 0
const nextCursor = typeof batch.nextOffset === 'number' const dbCursor = cursorResult.cursor
? Math.max(cursor, Math.floor(batch.nextOffset))
: cursor + currentMessages.length
let overflowMatchFound = false try {
for (const message of currentMessages) { while (matchedMessages.length < limit) {
if (!this.isSameAccountIdentity(normalizedMemberUsername, message.senderUsername)) { const batch = await wcdbService.fetchMessageBatch(dbCursor)
continue if (!batch.success) {
return { success: false, error: batch.error || '获取群成员消息失败' }
} }
if (matchedMessages.length < limit) { const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
if (rows.length === 0) {
hasMore = false
break
}
let startIndex = 0
if (cursor > consumedRows) {
const skipCount = Math.min(cursor - consumedRows, rows.length)
consumedRows += skipCount
startIndex = skipCount
if (startIndex >= rows.length) {
if (!batch.hasMore) {
hasMore = false
break
}
continue
}
}
for (let index = startIndex; index < rows.length; index += 1) {
const row = rows[index]
consumedRows += 1
const senderFromRow = this.extractRowSenderUsername(row)
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
continue
}
const message = this.parseSingleMessageRow(row)
if (!message) continue
if (!matchesTargetSender(message.senderUsername)) {
continue
}
matchedMessages.push(message) matchedMessages.push(message)
} else { if (matchedMessages.length >= limit) {
overflowMatchFound = true cursor = consumedRows
hasMore = index < rows.length - 1 || batch.hasMore === true
break
}
}
if (matchedMessages.length >= limit) break
cursor = consumedRows
if (!batch.hasMore) {
hasMore = false
break break
} }
} }
} finally {
cursor = nextCursor await wcdbService.closeMessageCursor(dbCursor)
if (overflowMatchFound) {
hasMore = true
break
}
if (currentMessages.length === 0 || !batch.hasMore) {
hasMore = false
break
}
if (matchedMessages.length >= limit) {
hasMore = true
break
}
} }
return { return {

View File

@@ -55,14 +55,8 @@ type DecryptResult = {
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图) isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
} }
type HardlinkState = {
imageTable?: string
dirTable?: string
}
export class ImageDecryptService { export class ImageDecryptService {
private configService = new ConfigService() private configService = new ConfigService()
private hardlinkCache = new Map<string, HardlinkState>()
private resolvedCache = new Map<string, string>() private resolvedCache = new Map<string, string>()
private pending = new Map<string, Promise<DecryptResult>>() private pending = new Map<string, Promise<DecryptResult>>()
private readonly defaultV1AesKey = 'cfcd208495d565ef' private readonly defaultV1AesKey = 'cfcd208495d565ef'
@@ -683,45 +677,19 @@ export class ImageDecryptService {
private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise<string | null> { private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise<string | null> {
try { try {
const hardlinkPath = this.resolveHardlinkDbPath(accountDir)
if (!hardlinkPath) {
return null
}
const ready = await this.ensureWcdbReady() const ready = await this.ensureWcdbReady()
if (!ready) { if (!ready) {
this.logInfo('[ImageDecrypt] hardlink db not ready') this.logInfo('[ImageDecrypt] hardlink db not ready')
return null return null
} }
const state = await this.getHardlinkState(accountDir, hardlinkPath) const resolveResult = await wcdbService.resolveImageHardlink(md5, accountDir)
if (!state.imageTable) { if (!resolveResult.success || !resolveResult.data) return null
this.logInfo('[ImageDecrypt] hardlink table missing', { hardlinkPath }) const fileName = String(resolveResult.data.file_name || '').trim()
return null const fullPath = String(resolveResult.data.full_path || '').trim()
} if (!fileName) return null
const escapedMd5 = this.escapeSqlString(md5) const lowerFileName = String(fileName).toLowerCase()
const rowResult = await wcdbService.execQuery(
'media',
hardlinkPath,
`SELECT dir1, dir2, file_name FROM ${state.imageTable} WHERE lower(md5) = lower('${escapedMd5}') LIMIT 1`
)
const row = rowResult.success && rowResult.rows ? rowResult.rows[0] : null
if (!row) {
this.logInfo('[ImageDecrypt] hardlink row miss', { md5, table: state.imageTable })
return null
}
const dir1 = this.getRowValue(row, 'dir1')
const dir2 = this.getRowValue(row, 'dir2')
const fileName = this.getRowValue(row, 'file_name') ?? this.getRowValue(row, 'fileName')
if (dir1 === undefined || dir2 === undefined || !fileName) {
this.logInfo('[ImageDecrypt] hardlink row incomplete', { row })
return null
}
const lowerFileName = fileName.toLowerCase()
if (lowerFileName.endsWith('.dat')) { if (lowerFileName.endsWith('.dat')) {
const baseLower = lowerFileName.slice(0, -4) const baseLower = lowerFileName.slice(0, -4)
if (!this.isLikelyImageDatBase(baseLower) && !this.looksLikeMd5(baseLower)) { if (!this.isLikelyImageDatBase(baseLower) && !this.looksLikeMd5(baseLower)) {
@@ -730,57 +698,11 @@ export class ImageDecryptService {
} }
} }
// dir1 和 dir2 是 rowid需要从 dir2id 表查询对应的目录名 if (fullPath && existsSync(fullPath)) {
let dir1Name: string | null = null this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath })
let dir2Name: string | null = null return fullPath
if (state.dirTable) {
try {
// 通过 rowid 查询目录名
const dir1Result = await wcdbService.execQuery(
'media',
hardlinkPath,
`SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir1)} LIMIT 1`
)
if (dir1Result.success && dir1Result.rows && dir1Result.rows.length > 0) {
const value = this.getRowValue(dir1Result.rows[0], 'username')
if (value) dir1Name = String(value)
}
const dir2Result = await wcdbService.execQuery(
'media',
hardlinkPath,
`SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir2)} LIMIT 1`
)
if (dir2Result.success && dir2Result.rows && dir2Result.rows.length > 0) {
const value = this.getRowValue(dir2Result.rows[0], 'username')
if (value) dir2Name = String(value)
}
} catch {
// ignore
}
} }
this.logInfo('[ImageDecrypt] hardlink path miss', { fullPath, md5 })
if (!dir1Name || !dir2Name) {
this.logInfo('[ImageDecrypt] hardlink dir resolve miss', { dir1, dir2, dir1Name, dir2Name })
return null
}
// 构建路径: msg/attach/{dir1Name}/{dir2Name}/Img/{fileName}
const possiblePaths = [
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'Img', fileName),
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'mg', fileName),
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, fileName),
]
for (const fullPath of possiblePaths) {
if (existsSync(fullPath)) {
this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath })
return fullPath
}
}
this.logInfo('[ImageDecrypt] hardlink path miss', { possiblePaths })
return null return null
} catch { } catch {
// ignore // ignore
@@ -788,35 +710,6 @@ export class ImageDecryptService {
return null return null
} }
private async getHardlinkState(accountDir: string, hardlinkPath: string): Promise<HardlinkState> {
const cached = this.hardlinkCache.get(hardlinkPath)
if (cached) return cached
const imageResult = await wcdbService.execQuery(
'media',
hardlinkPath,
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'image_hardlink_info%' ORDER BY name DESC LIMIT 1"
)
const dirResult = await wcdbService.execQuery(
'media',
hardlinkPath,
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1"
)
const imageTable = imageResult.success && imageResult.rows && imageResult.rows.length > 0
? this.getRowValue(imageResult.rows[0], 'name')
: undefined
const dirTable = dirResult.success && dirResult.rows && dirResult.rows.length > 0
? this.getRowValue(dirResult.rows[0], 'name')
: undefined
const state: HardlinkState = {
imageTable: imageTable ? String(imageTable) : undefined,
dirTable: dirTable ? String(dirTable) : undefined
}
this.logInfo('[ImageDecrypt] hardlink state', { hardlinkPath, imageTable: state.imageTable, dirTable: state.dirTable })
this.hardlinkCache.set(hardlinkPath, state)
return state
}
private async ensureWcdbReady(): Promise<boolean> { private async ensureWcdbReady(): Promise<boolean> {
if (wcdbService.isReady()) return true if (wcdbService.isReady()) return true
const dbPath = this.configService.get('dbPath') const dbPath = this.configService.get('dbPath')
@@ -1992,7 +1885,6 @@ export class ImageDecryptService {
async clearCache(): Promise<{ success: boolean; error?: string }> { async clearCache(): Promise<{ success: boolean; error?: string }> {
this.resolvedCache.clear() this.resolvedCache.clear()
this.hardlinkCache.clear()
this.pending.clear() this.pending.clear()
this.updateFlags.clear() this.updateFlags.clear()
this.cacheIndexed = false this.cacheIndexed = false

View File

@@ -136,7 +136,7 @@ export class KeyServiceMac {
if (sipStatus.enabled) { if (sipStatus.enabled) {
return { return {
success: false, success: false,
error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法\n1. 重启 Mac 并按住 Command + R 进入恢复模式\n2. 打开终端,输入: csrutil disable\n3. 重启电脑' error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法\n1. Intel 芯片:重启 Mac 并按住 Command + R 进入恢复模式\n2. Apple 芯片M 系列):关机后长按开机(指纹)键,选择“设置(选项)”进入恢复模式\n3. 打开终端,输入: csrutil disable\n4. 重启电脑'
} }
} }

View File

@@ -663,100 +663,24 @@ class SnsService {
} }
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> { async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
const collect = (rows?: any[]): string[] => { const result = await wcdbService.getSnsUsernames()
if (!Array.isArray(rows)) return [] if (!result.success) {
const usernames: string[] = [] return { success: false, error: result.error || '获取朋友圈联系人失败' }
for (const row of rows) {
const raw = row?.user_name ?? row?.userName ?? row?.username ?? Object.values(row || {})[0]
const username = typeof raw === 'string' ? raw.trim() : String(raw || '').trim()
if (username) usernames.push(username)
}
return usernames
} }
return { success: true, usernames: result.usernames || [] }
const primary = await wcdbService.execQuery(
'sns',
null,
"SELECT DISTINCT user_name FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
)
const fallback = await wcdbService.execQuery(
'sns',
null,
"SELECT DISTINCT userName FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
)
const merged = Array.from(new Set([
...collect(primary.rows),
...collect(fallback.rows)
]))
// 任一查询成功且拿到用户名即视为成功,避免因为列名差异导致误判为空。
if (merged.length > 0) {
return { success: true, usernames: merged }
}
// 两条查询都成功但无数据,说明确实没有朋友圈发布者。
if (primary.success || fallback.success) {
return { success: true, usernames: [] }
}
return { success: false, error: primary.error || fallback.error || '获取朋友圈联系人失败' }
} }
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> { private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
let totalPosts = 0
let totalFriends = 0
let myPosts: number | null = null
const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine')
if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) {
totalPosts = this.parseCountValue(postCountResult.rows[0])
}
if (totalPosts > 0) {
const friendCountPrimary = await wcdbService.execQuery(
'sns',
null,
"SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
)
if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) {
totalFriends = this.parseCountValue(friendCountPrimary.rows[0])
} else {
const friendCountFallback = await wcdbService.execQuery(
'sns',
null,
"SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
)
if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) {
totalFriends = this.parseCountValue(friendCountFallback.rows[0])
}
}
}
const normalizedMyWxid = this.toOptionalString(myWxid) const normalizedMyWxid = this.toOptionalString(myWxid)
if (normalizedMyWxid) { const result = await wcdbService.getSnsExportStats(normalizedMyWxid || undefined)
const myPostPrimary = await wcdbService.execQuery( if (!result.success || !result.data) {
'sns', return { totalPosts: 0, totalFriends: 0, myPosts: normalizedMyWxid ? 0 : null }
null, }
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = ?", return {
[normalizedMyWxid] totalPosts: Number(result.data.totalPosts || 0),
) totalFriends: Number(result.data.totalFriends || 0),
if (myPostPrimary.success && myPostPrimary.rows && myPostPrimary.rows.length > 0) { myPosts: result.data.myPosts === null || result.data.myPosts === undefined ? null : Number(result.data.myPosts || 0)
myPosts = this.parseCountValue(myPostPrimary.rows[0])
} else {
const myPostFallback = await wcdbService.execQuery(
'sns',
null,
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE userName = ?",
[normalizedMyWxid]
)
if (myPostFallback.success && myPostFallback.rows && myPostFallback.rows.length > 0) {
myPosts = this.parseCountValue(myPostFallback.rows[0])
}
}
} }
return { totalPosts, totalFriends, myPosts }
} }
async getExportStats(options?: { async getExportStats(options?: {

View File

@@ -70,7 +70,7 @@ class VideoService {
/** /**
* 从 video_hardlink_info_v4 表查询视频文件名 * 从 video_hardlink_info_v4 表查询视频文件名
* 使用 wcdbService.execQuery 查询加密的 hardlink.db * 使用 wcdb 专属接口查询加密的 hardlink.db
*/ */
private async queryVideoFileName(md5: string): Promise<string | undefined> { private async queryVideoFileName(md5: string): Promise<string | undefined> {
const dbPath = this.getDbPath() const dbPath = this.getDbPath()
@@ -103,17 +103,11 @@ class VideoService {
if (existsSync(p)) { if (existsSync(p)) {
try { try {
this.log('尝试加密 hardlink.db', { path: p }) this.log('尝试加密 hardlink.db', { path: p })
const escapedMd5 = md5.replace(/'/g, "''") const result = await wcdbService.resolveVideoHardlinkMd5(md5, p)
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1` if (result.success && result.data?.resolved_md5) {
const result = await wcdbService.execQuery('media', p, sql) const realMd5 = String(result.data.resolved_md5)
this.log('加密 hardlink.db 命中', { file_name: result.data.file_name, realMd5 })
if (result.success && result.rows && result.rows.length > 0) { return realMd5
const row = result.rows[0]
if (row?.file_name) {
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
this.log('加密 hardlink.db 命中', { file_name: row.file_name, realMd5 })
return realMd5
}
} }
this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) }) this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) })
} catch (e) { } catch (e) {

View File

@@ -5,47 +5,6 @@ import { tmpdir } from 'os'
// DLL 初始化错误信息,用于帮助用户诊断问题 // DLL 初始化错误信息,用于帮助用户诊断问题
let lastDllInitError: string | null = null let lastDllInitError: string | null = null
/**
* 解析 extra_bufferprotobuf中的免打扰状态
* - field 12 (tag 0x60): 值非0 = 免打扰
* 折叠状态通过 contact.flag & 0x10000000 判断
*/
function parseExtraBuffer(raw: Buffer | string | null | undefined): { isMuted: boolean } {
if (!raw) return { isMuted: false }
// execQuery 返回的 BLOB 列是十六进制字符串,需要先解码
const buf: Buffer = typeof raw === 'string' ? Buffer.from(raw, 'hex') : raw
if (buf.length === 0) return { isMuted: false }
let isMuted = false
let i = 0
const len = buf.length
const readVarint = (): number => {
let result = 0, shift = 0
while (i < len) {
const b = buf[i++]
result |= (b & 0x7f) << shift
shift += 7
if (!(b & 0x80)) break
}
return result
}
while (i < len) {
const tag = readVarint()
const fieldNum = tag >>> 3
const wireType = tag & 0x07
if (wireType === 0) {
const val = readVarint()
if (fieldNum === 12 && val !== 0) isMuted = true
} else if (wireType === 2) {
const sz = readVarint()
i += sz
} else if (wireType === 5) { i += 4
} else if (wireType === 1) { i += 8
} else { break }
}
return { isMuted }
}
export function getLastDllInitError(): string | null { export function getLastDllInitError(): string | null {
return lastDllInitError return lastDllInitError
} }
@@ -86,6 +45,11 @@ export class WcdbCore {
private wcdbGetMessageMeta: any = null private wcdbGetMessageMeta: any = null
private wcdbGetContact: any = null private wcdbGetContact: any = null
private wcdbGetContactStatus: any = null private wcdbGetContactStatus: any = null
private wcdbGetContactTypeCounts: any = null
private wcdbGetContactsCompact: any = null
private wcdbGetContactAliasMap: any = null
private wcdbGetContactFriendFlags: any = null
private wcdbGetChatRoomExtBuffer: any = null
private wcdbGetMessageTableStats: any = null private wcdbGetMessageTableStats: any = null
private wcdbGetAggregateStats: any = null private wcdbGetAggregateStats: any = null
private wcdbGetAvailableYears: any = null private wcdbGetAvailableYears: any = null
@@ -106,9 +70,24 @@ export class WcdbCore {
private wcdbGetEmoticonCdnUrl: any = null private wcdbGetEmoticonCdnUrl: any = null
private wcdbGetDbStatus: any = null private wcdbGetDbStatus: any = null
private wcdbGetVoiceData: any = null private wcdbGetVoiceData: any = null
private wcdbGetVoiceDataBatch: any = null
private wcdbGetMediaSchemaSummary: any = null
private wcdbGetSessionMessageCounts: any = null
private wcdbGetSessionMessageTypeStats: any = null
private wcdbGetSessionMessageTypeStatsBatch: any = null
private wcdbGetSessionMessageDateCounts: any = null
private wcdbGetSessionMessageDateCountsBatch: any = null
private wcdbGetMessagesByType: any = null
private wcdbGetHeadImageBuffers: any = null
private wcdbSearchMessages: any = null private wcdbSearchMessages: any = null
private wcdbGetSnsTimeline: any = null private wcdbGetSnsTimeline: any = null
private wcdbGetSnsAnnualStats: any = null private wcdbGetSnsAnnualStats: any = null
private wcdbGetSnsUsernames: any = null
private wcdbGetSnsExportStats: any = null
private wcdbGetMessageTableColumns: any = null
private wcdbGetMessageTableTimeRange: any = null
private wcdbResolveImageHardlink: any = null
private wcdbResolveVideoHardlinkMd5: any = null
private wcdbInstallSnsBlockDeleteTrigger: any = null private wcdbInstallSnsBlockDeleteTrigger: any = null
private wcdbUninstallSnsBlockDeleteTrigger: any = null private wcdbUninstallSnsBlockDeleteTrigger: any = null
private wcdbCheckSnsBlockDeleteTrigger: any = null private wcdbCheckSnsBlockDeleteTrigger: any = null
@@ -719,6 +698,32 @@ export class WcdbCore {
this.wcdbGetContactStatus = null this.wcdbGetContactStatus = null
} }
try {
this.wcdbGetContactTypeCounts = this.lib.func('int32 wcdb_get_contact_type_counts(int64 handle, _Out_ void** outJson)')
} catch {
this.wcdbGetContactTypeCounts = null
}
try {
this.wcdbGetContactsCompact = this.lib.func('int32 wcdb_get_contacts_compact(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
} catch {
this.wcdbGetContactsCompact = null
}
try {
this.wcdbGetContactAliasMap = this.lib.func('int32 wcdb_get_contact_alias_map(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
} catch {
this.wcdbGetContactAliasMap = null
}
try {
this.wcdbGetContactFriendFlags = this.lib.func('int32 wcdb_get_contact_friend_flags(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
} catch {
this.wcdbGetContactFriendFlags = null
}
try {
this.wcdbGetChatRoomExtBuffer = this.lib.func('int32 wcdb_get_chat_room_ext_buffer(int64 handle, const char* chatroomId, _Out_ void** outJson)')
} catch {
this.wcdbGetChatRoomExtBuffer = null
}
// wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json) // wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json)
this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)') this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)')
@@ -821,6 +826,51 @@ export class WcdbCore {
} catch { } catch {
this.wcdbGetVoiceData = null this.wcdbGetVoiceData = null
} }
try {
this.wcdbGetVoiceDataBatch = this.lib.func('int32 wcdb_get_voice_data_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)')
} catch {
this.wcdbGetVoiceDataBatch = null
}
try {
this.wcdbGetMediaSchemaSummary = this.lib.func('int32 wcdb_get_media_schema_summary(int64 handle, const char* dbPath, _Out_ void** outJson)')
} catch {
this.wcdbGetMediaSchemaSummary = null
}
try {
this.wcdbGetSessionMessageCounts = this.lib.func('int32 wcdb_get_session_message_counts(int64 handle, const char* sessionIdsJson, _Out_ void** outJson)')
} catch {
this.wcdbGetSessionMessageCounts = null
}
try {
this.wcdbGetSessionMessageTypeStats = this.lib.func('int32 wcdb_get_session_message_type_stats(int64 handle, const char* sessionId, int32 beginTimestamp, int32 endTimestamp, _Out_ void** outJson)')
} catch {
this.wcdbGetSessionMessageTypeStats = null
}
try {
this.wcdbGetSessionMessageTypeStatsBatch = this.lib.func('int32 wcdb_get_session_message_type_stats_batch(int64 handle, const char* sessionIdsJson, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbGetSessionMessageTypeStatsBatch = null
}
try {
this.wcdbGetSessionMessageDateCounts = this.lib.func('int32 wcdb_get_session_message_date_counts(int64 handle, const char* sessionId, _Out_ void** outJson)')
} catch {
this.wcdbGetSessionMessageDateCounts = null
}
try {
this.wcdbGetSessionMessageDateCountsBatch = this.lib.func('int32 wcdb_get_session_message_date_counts_batch(int64 handle, const char* sessionIdsJson, _Out_ void** outJson)')
} catch {
this.wcdbGetSessionMessageDateCountsBatch = null
}
try {
this.wcdbGetMessagesByType = this.lib.func('int32 wcdb_get_messages_by_type(int64 handle, const char* sessionId, int64 localType, int32 ascending, int32 limit, int32 offset, _Out_ void** outJson)')
} catch {
this.wcdbGetMessagesByType = null
}
try {
this.wcdbGetHeadImageBuffers = this.lib.func('int32 wcdb_get_head_image_buffers(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
} catch {
this.wcdbGetHeadImageBuffers = null
}
// wcdb_status wcdb_search_messages(wcdb_handle handle, const char* session_id, const char* keyword, int32_t limit, int32_t offset, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) // wcdb_status wcdb_search_messages(wcdb_handle handle, const char* session_id, const char* keyword, int32_t limit, int32_t offset, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
try { try {
@@ -842,6 +892,36 @@ export class WcdbCore {
} catch { } catch {
this.wcdbGetSnsAnnualStats = null this.wcdbGetSnsAnnualStats = null
} }
try {
this.wcdbGetSnsUsernames = this.lib.func('int32 wcdb_get_sns_usernames(int64 handle, _Out_ void** outJson)')
} catch {
this.wcdbGetSnsUsernames = null
}
try {
this.wcdbGetSnsExportStats = this.lib.func('int32 wcdb_get_sns_export_stats(int64 handle, const char* myWxid, _Out_ void** outJson)')
} catch {
this.wcdbGetSnsExportStats = null
}
try {
this.wcdbGetMessageTableColumns = this.lib.func('int32 wcdb_get_message_table_columns(int64 handle, const char* dbPath, const char* tableName, _Out_ void** outJson)')
} catch {
this.wcdbGetMessageTableColumns = null
}
try {
this.wcdbGetMessageTableTimeRange = this.lib.func('int32 wcdb_get_message_table_time_range(int64 handle, const char* dbPath, const char* tableName, _Out_ void** outJson)')
} catch {
this.wcdbGetMessageTableTimeRange = null
}
try {
this.wcdbResolveImageHardlink = this.lib.func('int32 wcdb_resolve_image_hardlink(int64 handle, const char* md5, const char* accountDir, _Out_ void** outJson)')
} catch {
this.wcdbResolveImageHardlink = null
}
try {
this.wcdbResolveVideoHardlinkMd5 = this.lib.func('int32 wcdb_resolve_video_hardlink_md5(int64 handle, const char* md5, const char* dbPath, _Out_ void** outJson)')
} catch {
this.wcdbResolveVideoHardlinkMd5 = null
}
// wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error) // wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error)
try { try {
@@ -1392,6 +1472,197 @@ export class WcdbCore {
} }
} }
async getSessionMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetSessionMessageCounts) return this.getMessageCounts(sessionIds)
try {
const outPtr = [null as any]
const result = this.wcdbGetSessionMessageCounts(this.handle, JSON.stringify(sessionIds || []), outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取会话消息总数失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析会话消息总数失败' }
const raw = JSON.parse(jsonStr) || {}
const counts: Record<string, number> = {}
for (const sid of sessionIds || []) {
const value = Number(raw?.[sid] ?? 0)
counts[sid] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
}
return { success: true, counts }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getSessionMessageTypeStats(
sessionId: string,
beginTimestamp: number = 0,
endTimestamp: number = 0
): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetSessionMessageTypeStats) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbGetSessionMessageTypeStats(
this.handle,
sessionId,
this.normalizeTimestamp(beginTimestamp),
this.normalizeTimestamp(endTimestamp),
outPtr
)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取会话类型统计失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析会话类型统计失败' }
return { success: true, data: JSON.parse(jsonStr) || {} }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getSessionMessageTypeStatsBatch(
sessionIds: string[],
options?: {
beginTimestamp?: number
endTimestamp?: number
quickMode?: boolean
includeGroupSenderCount?: boolean
}
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
const normalizedSessionIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
if (normalizedSessionIds.length === 0) return { success: true, data: {} }
if (!this.wcdbGetSessionMessageTypeStatsBatch) {
const data: Record<string, any> = {}
for (const sessionId of normalizedSessionIds) {
const single = await this.getSessionMessageTypeStats(
sessionId,
options?.beginTimestamp || 0,
options?.endTimestamp || 0
)
if (single.success) {
data[sessionId] = single.data || {}
}
}
return { success: true, data }
}
try {
const outPtr = [null as any]
const optionsJson = JSON.stringify({
begin: this.normalizeTimestamp(options?.beginTimestamp || 0),
end: this.normalizeTimestamp(options?.endTimestamp || 0),
quick_mode: options?.quickMode === true,
include_group_sender_count: options?.includeGroupSenderCount !== false
})
const result = this.wcdbGetSessionMessageTypeStatsBatch(
this.handle,
JSON.stringify(normalizedSessionIds),
optionsJson,
outPtr
)
if (result !== 0 || !outPtr[0]) return { success: false, error: `批量获取会话类型统计失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析批量会话类型统计失败' }
return { success: true, data: JSON.parse(jsonStr) || {} }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getSessionMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetSessionMessageDateCounts) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbGetSessionMessageDateCounts(this.handle, sessionId, outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `获取会话日消息统计失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析会话日消息统计失败' }
const raw = JSON.parse(jsonStr) || {}
const counts: Record<string, number> = {}
for (const [dateKey, value] of Object.entries(raw)) {
const count = Number(value)
if (!dateKey || !Number.isFinite(count) || count <= 0) continue
counts[String(dateKey)] = Math.floor(count)
}
return { success: true, counts }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getSessionMessageDateCountsBatch(sessionIds: string[]): Promise<{ success: boolean; data?: Record<string, Record<string, number>>; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
const normalizedSessionIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
if (normalizedSessionIds.length === 0) return { success: true, data: {} }
if (!this.wcdbGetSessionMessageDateCountsBatch) {
const data: Record<string, Record<string, number>> = {}
for (const sessionId of normalizedSessionIds) {
const single = await this.getSessionMessageDateCounts(sessionId)
data[sessionId] = single.success && single.counts ? single.counts : {}
}
return { success: true, data }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetSessionMessageDateCountsBatch(this.handle, JSON.stringify(normalizedSessionIds), outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `批量获取会话日消息统计失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析批量会话日消息统计失败' }
const raw = JSON.parse(jsonStr) || {}
const data: Record<string, Record<string, number>> = {}
for (const sessionId of normalizedSessionIds) {
const source = raw?.[sessionId] || {}
const next: Record<string, number> = {}
for (const [dateKey, value] of Object.entries(source)) {
const count = Number(value)
if (!dateKey || !Number.isFinite(count) || count <= 0) continue
next[String(dateKey)] = Math.floor(count)
}
data[sessionId] = next
}
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessagesByType(
sessionId: string,
localType: number,
ascending = false,
limit = 0,
offset = 0
): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetMessagesByType) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbGetMessagesByType(
this.handle,
sessionId,
BigInt(localType),
ascending ? 1 : 0,
Math.max(0, Math.floor(limit || 0)),
Math.max(0, Math.floor(offset || 0)),
outPtr
)
if (result !== 0 || !outPtr[0]) return { success: false, error: `按类型读取消息失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析按类型消息失败' }
const rows = JSON.parse(jsonStr)
return { success: true, rows: Array.isArray(rows) ? rows : [] }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> { async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
if (!this.ensureReady()) { if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' } return { success: false, error: 'WCDB 未连接' }
@@ -1766,24 +2037,25 @@ export class WcdbCore {
if (!this.ensureReady()) { if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' } return { success: false, error: 'WCDB 未连接' }
} }
if (!this.wcdbGetContactStatus) {
return { success: false, error: '接口未就绪' }
}
try { try {
// 分批查询,避免 SQL 过长execQuery 不支持参数绑定,直接拼 SQL const outPtr = [null as any]
const BATCH = 200 const code = this.wcdbGetContactStatus(this.handle, JSON.stringify(usernames || []), outPtr)
if (code !== 0 || !outPtr[0]) {
return { success: false, error: `获取会话状态失败: ${code}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析会话状态失败' }
const rawMap = JSON.parse(jsonStr) || {}
const map: Record<string, { isFolded: boolean; isMuted: boolean }> = {} const map: Record<string, { isFolded: boolean; isMuted: boolean }> = {}
for (let i = 0; i < usernames.length; i += BATCH) { for (const username of usernames || []) {
const batch = usernames.slice(i, i + BATCH) const state = rawMap[username] || {}
const inList = batch.map(u => `'${u.replace(/'/g, "''")}'`).join(',') map[username] = {
const sql = `SELECT username, flag, extra_buffer FROM contact WHERE username IN (${inList})` isFolded: Boolean(state.isFolded),
const result = await this.execQuery('contact', null, sql) isMuted: Boolean(state.isMuted)
if (!result.success || !result.rows) continue
for (const row of result.rows) {
const uname: string = row.username
// 折叠flag bit 28 (0x10000000)
const flag = parseInt(row.flag ?? '0', 10)
const isFolded = (flag & 0x10000000) !== 0
// 免打扰extra_buffer field 12 非0
const { isMuted } = parseExtraBuffer(row.extra_buffer)
map[uname] = { isFolded, isMuted }
} }
} }
return { success: true, map } return { success: true, map }
@@ -1792,6 +2064,128 @@ export class WcdbCore {
} }
} }
async getMessageTableColumns(dbPath: string, tableName: string): Promise<{ success: boolean; columns?: string[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetMessageTableColumns) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbGetMessageTableColumns(this.handle, dbPath, tableName, outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `获取消息表列失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析消息表列失败' }
const columns = JSON.parse(jsonStr)
return { success: true, columns: Array.isArray(columns) ? columns.map((c: any) => String(c || '')) : [] }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetMessageTableTimeRange) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbGetMessageTableTimeRange(this.handle, dbPath, tableName, outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `获取消息表时间范围失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析消息表时间范围失败' }
const data = JSON.parse(jsonStr) || {}
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetContactTypeCounts) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const code = this.wcdbGetContactTypeCounts(this.handle, outPtr)
if (code !== 0 || !outPtr[0]) return { success: false, error: `获取联系人分类统计失败: ${code}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析联系人分类统计失败' }
const raw = JSON.parse(jsonStr) || {}
return {
success: true,
counts: {
private: Number(raw.private || 0),
group: Number(raw.group || 0),
official: Number(raw.official || 0),
former_friend: Number(raw.former_friend || 0)
}
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async getContactsCompact(usernames: string[] = []): Promise<{ success: boolean; contacts?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetContactsCompact) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const payload = Array.isArray(usernames) && usernames.length > 0 ? JSON.stringify(usernames) : null
const code = this.wcdbGetContactsCompact(this.handle, payload, outPtr)
if (code !== 0 || !outPtr[0]) return { success: false, error: `获取联系人列表失败: ${code}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析联系人列表失败' }
const contacts = JSON.parse(jsonStr)
return { success: true, contacts: Array.isArray(contacts) ? contacts : [] }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getContactAliasMap(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetContactAliasMap) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const code = this.wcdbGetContactAliasMap(this.handle, JSON.stringify(usernames || []), outPtr)
if (code !== 0 || !outPtr[0]) return { success: false, error: `获取联系人 alias 失败: ${code}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析联系人 alias 失败' }
const map = JSON.parse(jsonStr)
return { success: true, map }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getContactFriendFlags(usernames: string[]): Promise<{ success: boolean; map?: Record<string, boolean>; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetContactFriendFlags) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const code = this.wcdbGetContactFriendFlags(this.handle, JSON.stringify(usernames || []), outPtr)
if (code !== 0 || !outPtr[0]) return { success: false, error: `获取联系人好友标记失败: ${code}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析联系人好友标记失败' }
const map = JSON.parse(jsonStr)
return { success: true, map }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getChatRoomExtBuffer(chatroomId: string): Promise<{ success: boolean; extBuffer?: string; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetChatRoomExtBuffer) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const code = this.wcdbGetChatRoomExtBuffer(this.handle, chatroomId, outPtr)
if (code !== 0 || !outPtr[0]) return { success: false, error: `获取群聊 ext_buffer 失败: ${code}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析群聊 ext_buffer 失败' }
const data = JSON.parse(jsonStr) || {}
const extBuffer = String(data.ext_buffer || '').trim()
return { success: true, extBuffer: extBuffer || undefined }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) { if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' } return { success: false, error: 'WCDB 未连接' }
@@ -2078,8 +2472,11 @@ export class WcdbCore {
if (!this.ensureReady()) { if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' } return { success: false, error: 'WCDB 未连接' }
} }
const startedAt = Date.now()
try { try {
if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' } if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
const fallbackFlag = /fallback|diag|diagnostic/i.test(String(sql || ''))
this.writeLog(`[audit:execQuery] kind=${kind} path=${path || ''} sql_len=${String(sql || '').length} fallback=${fallbackFlag ? 1 : 0}`)
// 如果提供了参数,使用参数化查询(需要 C++ 层支持) // 如果提供了参数,使用参数化查询(需要 C++ 层支持)
// 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现 // 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现
@@ -2114,12 +2511,14 @@ export class WcdbCore {
const jsonStr = this.decodeJsonPtr(outPtr[0]) const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析查询结果失败' } if (!jsonStr) return { success: false, error: '解析查询结果失败' }
const rows = JSON.parse(jsonStr) const rows = JSON.parse(jsonStr)
this.writeLog(`[audit:execQuery] done kind=${kind} cost_ms=${Date.now() - startedAt} rows=${Array.isArray(rows) ? rows.length : -1}`)
if (isContactQuery) { if (isContactQuery) {
const count = Array.isArray(rows) ? rows.length : -1 const count = Array.isArray(rows) ? rows.length : -1
this.writeLog(`[diag:execQuery] contact query ok rows=${count} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true) this.writeLog(`[diag:execQuery] contact query ok rows=${count} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true)
} }
return { success: true, rows } return { success: true, rows }
} catch (e) { } catch (e) {
this.writeLog(`[audit:execQuery] fail kind=${kind} cost_ms=${Date.now() - startedAt} err=${String(e)}`)
const isContactQuery = String(kind).toLowerCase() === 'contact' || /\bfrom\s+contact\b/i.test(String(sql)) const isContactQuery = String(kind).toLowerCase() === 'contact' || /\bfrom\s+contact\b/i.test(String(sql))
if (isContactQuery) { if (isContactQuery) {
this.writeLog(`[diag:execQuery] contact query exception kind=${kind} path=${path || ''} sql="${this.formatSqlForLog(sql)}" err=${String(e)}`, true) this.writeLog(`[diag:execQuery] contact query exception kind=${kind} path=${path || ''} sql="${this.formatSqlForLog(sql)}" err=${String(e)}`, true)
@@ -2209,6 +2608,93 @@ export class WcdbCore {
} }
} }
async getVoiceDataBatch(
requests: Array<{ session_id: string; create_time: number; local_id?: number; svr_id?: string | number; candidates?: string[] }>
): Promise<{ success: boolean; rows?: Array<{ index: number; hex?: string }>; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetVoiceDataBatch) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const payload = JSON.stringify(Array.isArray(requests) ? requests : [])
const result = this.wcdbGetVoiceDataBatch(this.handle, payload, outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `批量获取语音数据失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析批量语音数据失败' }
const rows = JSON.parse(jsonStr)
const normalized = Array.isArray(rows) ? rows.map((row: any) => ({
index: Number(row?.index ?? 0),
hex: row?.hex ? String(row.hex) : undefined
})) : []
return { success: true, rows: normalized }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMediaSchemaSummary(dbPath: string): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetMediaSchemaSummary) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbGetMediaSchemaSummary(this.handle, dbPath, outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `获取媒体表结构摘要失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析媒体表结构摘要失败' }
const data = JSON.parse(jsonStr) || {}
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getHeadImageBuffers(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetHeadImageBuffers) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbGetHeadImageBuffers(this.handle, JSON.stringify(usernames || []), outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `获取头像二进制失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析头像二进制失败' }
const map = JSON.parse(jsonStr) || {}
return { success: true, map }
} catch (e) {
return { success: false, error: String(e) }
}
}
async resolveImageHardlink(md5: string, accountDir?: string): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbResolveImageHardlink) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbResolveImageHardlink(this.handle, md5, accountDir || null, outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `解析图片 hardlink 失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析图片 hardlink 响应失败' }
const data = JSON.parse(jsonStr) || {}
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbResolveVideoHardlinkMd5) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbResolveVideoHardlinkMd5(this.handle, md5, dbPath || null, outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `解析视频 hardlink 失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析视频 hardlink 响应失败' }
const data = JSON.parse(jsonStr) || {}
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
/** /**
* 数据收集初始化 * 数据收集初始化
*/ */
@@ -2373,6 +2859,45 @@ export class WcdbCore {
return { success: false, error: String(e) } return { success: false, error: String(e) }
} }
} }
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetSnsUsernames) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbGetSnsUsernames(this.handle, outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `获取朋友圈用户名失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析朋友圈用户名失败' }
const usernames = JSON.parse(jsonStr)
return { success: true, usernames: Array.isArray(usernames) ? usernames.map((u: any) => String(u || '').trim()).filter(Boolean) : [] }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getSnsExportStats(myWxid?: string): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetSnsExportStats) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbGetSnsExportStats(this.handle, myWxid || null, outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `获取朋友圈导出统计失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析朋友圈导出统计失败' }
const raw = JSON.parse(jsonStr) || {}
return {
success: true,
data: {
totalPosts: Number(raw.total_posts || 0),
totalFriends: Number(raw.total_friends || 0),
myPosts: raw.my_posts === null || raw.my_posts === undefined ? null : Number(raw.my_posts || 0)
}
}
} catch (e) {
return { success: false, error: String(e) }
}
}
/** /**
* 为朋友圈安装删除 * 为朋友圈安装删除
*/ */

View File

@@ -222,6 +222,48 @@ export class WcdbService {
return this.callWorker('getMessageCounts', { sessionIds }) return this.callWorker('getMessageCounts', { sessionIds })
} }
async getSessionMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
return this.callWorker('getSessionMessageCounts', { sessionIds })
}
async getSessionMessageTypeStats(
sessionId: string,
beginTimestamp: number = 0,
endTimestamp: number = 0
): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getSessionMessageTypeStats', { sessionId, beginTimestamp, endTimestamp })
}
async getSessionMessageTypeStatsBatch(
sessionIds: string[],
options?: {
beginTimestamp?: number
endTimestamp?: number
quickMode?: boolean
includeGroupSenderCount?: boolean
}
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
return this.callWorker('getSessionMessageTypeStatsBatch', { sessionIds, options })
}
async getSessionMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
return this.callWorker('getSessionMessageDateCounts', { sessionId })
}
async getSessionMessageDateCountsBatch(sessionIds: string[]): Promise<{ success: boolean; data?: Record<string, Record<string, number>>; error?: string }> {
return this.callWorker('getSessionMessageDateCountsBatch', { sessionIds })
}
async getMessagesByType(
sessionId: string,
localType: number,
ascending = false,
limit = 0,
offset = 0
): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset })
}
/** /**
* 获取联系人昵称 * 获取联系人昵称
*/ */
@@ -287,6 +329,14 @@ export class WcdbService {
return this.callWorker('getMessageMeta', { dbPath, tableName, limit, offset }) return this.callWorker('getMessageMeta', { dbPath, tableName, limit, offset })
} }
async getMessageTableColumns(dbPath: string, tableName: string): Promise<{ success: boolean; columns?: string[]; error?: string }> {
return this.callWorker('getMessageTableColumns', { dbPath, tableName })
}
async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getMessageTableTimeRange', { dbPath, tableName })
}
/** /**
* 获取联系人详情 * 获取联系人详情
*/ */
@@ -301,6 +351,26 @@ export class WcdbService {
return this.callWorker('getContactStatus', { usernames }) return this.callWorker('getContactStatus', { usernames })
} }
async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> {
return this.callWorker('getContactTypeCounts')
}
async getContactsCompact(usernames: string[] = []): Promise<{ success: boolean; contacts?: any[]; error?: string }> {
return this.callWorker('getContactsCompact', { usernames })
}
async getContactAliasMap(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
return this.callWorker('getContactAliasMap', { usernames })
}
async getContactFriendFlags(usernames: string[]): Promise<{ success: boolean; map?: Record<string, boolean>; error?: string }> {
return this.callWorker('getContactFriendFlags', { usernames })
}
async getChatRoomExtBuffer(chatroomId: string): Promise<{ success: boolean; extBuffer?: string; error?: string }> {
return this.callWorker('getChatRoomExtBuffer', { chatroomId })
}
/** /**
* 获取聚合统计数据 * 获取聚合统计数据
*/ */
@@ -372,7 +442,7 @@ export class WcdbService {
} }
/** /**
* 执行 SQL 查询(支持参数化查询 * 执行 SQL 查询(仅主进程内部使用fallback/diagnostic/低频兼容
*/ */
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> { async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('execQuery', { kind, path, sql, params }) return this.callWorker('execQuery', { kind, path, sql, params })
@@ -417,6 +487,28 @@ export class WcdbService {
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId }) return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
} }
async getVoiceDataBatch(
requests: Array<{ session_id: string; create_time: number; local_id?: number; svr_id?: string | number; candidates?: string[] }>
): Promise<{ success: boolean; rows?: Array<{ index: number; hex?: string }>; error?: string }> {
return this.callWorker('getVoiceDataBatch', { requests })
}
async getMediaSchemaSummary(dbPath: string): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getMediaSchemaSummary', { dbPath })
}
async getHeadImageBuffers(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
return this.callWorker('getHeadImageBuffers', { usernames })
}
async resolveImageHardlink(md5: string, accountDir?: string): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('resolveImageHardlink', { md5, accountDir })
}
async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('resolveVideoHardlinkMd5', { md5, dbPath })
}
/** /**
* 获取朋友圈 * 获取朋友圈
*/ */
@@ -431,6 +523,14 @@ export class WcdbService {
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp }) return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
} }
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
return this.callWorker('getSnsUsernames')
}
async getSnsExportStats(myWxid?: string): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> {
return this.callWorker('getSnsExportStats', { myWxid })
}
/** /**
* 安装朋友圈删除拦截 * 安装朋友圈删除拦截
*/ */

View File

@@ -59,6 +59,24 @@ if (parentPort) {
case 'getMessageCounts': case 'getMessageCounts':
result = await core.getMessageCounts(payload.sessionIds) result = await core.getMessageCounts(payload.sessionIds)
break break
case 'getSessionMessageCounts':
result = await core.getSessionMessageCounts(payload.sessionIds)
break
case 'getSessionMessageTypeStats':
result = await core.getSessionMessageTypeStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp)
break
case 'getSessionMessageTypeStatsBatch':
result = await core.getSessionMessageTypeStatsBatch(payload.sessionIds, payload.options)
break
case 'getSessionMessageDateCounts':
result = await core.getSessionMessageDateCounts(payload.sessionId)
break
case 'getSessionMessageDateCountsBatch':
result = await core.getSessionMessageDateCountsBatch(payload.sessionIds)
break
case 'getMessagesByType':
result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset)
break
case 'getDisplayNames': case 'getDisplayNames':
result = await core.getDisplayNames(payload.usernames) result = await core.getDisplayNames(payload.usernames)
break break
@@ -89,12 +107,33 @@ if (parentPort) {
case 'getMessageMeta': case 'getMessageMeta':
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset) result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
break break
case 'getMessageTableColumns':
result = await core.getMessageTableColumns(payload.dbPath, payload.tableName)
break
case 'getMessageTableTimeRange':
result = await core.getMessageTableTimeRange(payload.dbPath, payload.tableName)
break
case 'getContact': case 'getContact':
result = await core.getContact(payload.username) result = await core.getContact(payload.username)
break break
case 'getContactStatus': case 'getContactStatus':
result = await core.getContactStatus(payload.usernames) result = await core.getContactStatus(payload.usernames)
break break
case 'getContactTypeCounts':
result = await core.getContactTypeCounts()
break
case 'getContactsCompact':
result = await core.getContactsCompact(payload.usernames)
break
case 'getContactAliasMap':
result = await core.getContactAliasMap(payload.usernames)
break
case 'getContactFriendFlags':
result = await core.getContactFriendFlags(payload.usernames)
break
case 'getChatRoomExtBuffer':
result = await core.getChatRoomExtBuffer(payload.chatroomId)
break
case 'getAggregateStats': case 'getAggregateStats':
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp) result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
break break
@@ -149,12 +188,33 @@ if (parentPort) {
console.error('[wcdbWorker] getVoiceData failed:', result.error) console.error('[wcdbWorker] getVoiceData failed:', result.error)
} }
break break
case 'getVoiceDataBatch':
result = await core.getVoiceDataBatch(payload.requests)
break
case 'getMediaSchemaSummary':
result = await core.getMediaSchemaSummary(payload.dbPath)
break
case 'getHeadImageBuffers':
result = await core.getHeadImageBuffers(payload.usernames)
break
case 'resolveImageHardlink':
result = await core.resolveImageHardlink(payload.md5, payload.accountDir)
break
case 'resolveVideoHardlinkMd5':
result = await core.resolveVideoHardlinkMd5(payload.md5, payload.dbPath)
break
case 'getSnsTimeline': case 'getSnsTimeline':
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime) result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
break break
case 'getSnsAnnualStats': case 'getSnsAnnualStats':
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp) result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
break break
case 'getSnsUsernames':
result = await core.getSnsUsernames()
break
case 'getSnsExportStats':
result = await core.getSnsExportStats(payload.myWxid)
break
case 'installSnsBlockDeleteTrigger': case 'installSnsBlockDeleteTrigger':
result = await core.installSnsBlockDeleteTrigger() result = await core.installSnsBlockDeleteTrigger()
break break

Binary file not shown.

View File

@@ -1443,7 +1443,7 @@ function ChatPage(props: ChatPageProps) {
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
window.electronAPI.chat.getExportSessionStats( window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId], [normalizedSessionId],
{ includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } { includeRelations: false, allowStaleCache: true, cacheOnly: true }
) )
]) ])
@@ -1476,6 +1476,7 @@ function ChatPage(props: ChatPageProps) {
} }
let refreshIncludeRelations = false let refreshIncludeRelations = false
let shouldRefreshStatsInBackground = false
if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) { if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) {
const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined
const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
@@ -1493,11 +1494,49 @@ function ChatPage(props: ChatPageProps) {
} }
}) })
} }
shouldRefreshStatsInBackground = !metric || Boolean(cacheMeta?.stale)
} else {
shouldRefreshStatsInBackground = true
} }
finishBackgroundTask(taskId, 'completed', { finishBackgroundTask(taskId, 'completed', {
detail: '聊天页会话详情统计完成', detail: '聊天页会话详情统计完成',
progressText: '已完成' progressText: '已完成'
}) })
if (shouldRefreshStatsInBackground) {
setIsRefreshingDetailStats(true)
void (async () => {
try {
const freshResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: false, forceRefresh: true }
)
if (requestSeq !== detailRequestSeqRef.current) return
if (freshResult.success && freshResult.data) {
const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined
const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
if (freshMetric) {
applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, false)
} else if (freshMeta) {
setSessionDetail((prev) => {
if (!prev || prev.wxid !== normalizedSessionId) return prev
return {
...prev,
statsUpdatedAt: freshMeta.updatedAt,
statsStale: freshMeta.stale
}
})
}
}
} catch (error) {
console.error('聊天页后台刷新会话统计失败:', error)
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsRefreshingDetailStats(false)
}
}
})()
}
} catch (e) { } catch (e) {
console.error('加载会话详情补充统计失败:', e) console.error('加载会话详情补充统计失败:', e)
finishBackgroundTask(taskId, 'failed', { finishBackgroundTask(taskId, 'failed', {
@@ -5778,12 +5817,13 @@ function ChatPage(props: ChatPageProps) {
// 下载完成后,触发页面刷新让组件重新尝试转写 // 下载完成后,触发页面刷新让组件重新尝试转写
// 通过更新缓存触发组件重新检查 // 通过更新缓存触发组件重新检查
if (pendingVoiceTranscriptRequest) { if (pendingVoiceTranscriptRequest) {
// 清除缓存中的请求标记,让组件可以重新尝试
const cacheKey = `voice-transcript:${pendingVoiceTranscriptRequest.messageId}`
// 不直接调用转写,而是让组件自己重试 // 不直接调用转写,而是让组件自己重试
// 通过触发一个自定义事件来通知所有 MessageBubble 组件 // 通过触发一个自定义事件来通知所有 MessageBubble 组件
window.dispatchEvent(new CustomEvent('model-downloaded', { window.dispatchEvent(new CustomEvent('model-downloaded', {
detail: { messageId: pendingVoiceTranscriptRequest.messageId } detail: {
sessionId: pendingVoiceTranscriptRequest.sessionId,
messageId: pendingVoiceTranscriptRequest.messageId
}
})) }))
} }
setPendingVoiceTranscriptRequest(null) setPendingVoiceTranscriptRequest(null)
@@ -6298,6 +6338,20 @@ const voiceTranscriptCache = new Map<string, string>()
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>() const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>() const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
const buildVoiceCacheIdentity = (
sessionId: string,
message: Pick<Message, 'localId' | 'createTime' | 'serverId'>
): string => {
const normalizedSessionId = String(sessionId || '').trim()
const localId = Math.max(0, Math.floor(Number(message?.localId || 0)))
const createTime = Math.max(0, Math.floor(Number(message?.createTime || 0)))
const serverIdRaw = String(message?.serverId ?? '').trim()
const serverId = /^\d+$/.test(serverIdRaw)
? serverIdRaw.replace(/^0+(?=\d)/, '')
: String(Math.max(0, Math.floor(Number(serverIdRaw || 0))))
return `${normalizedSessionId}:${localId}:${createTime}:${serverId || '0'}`
}
// 引用消息中的动画表情组件 // 引用消息中的动画表情组件
function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) { function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) {
const cacheKey = md5 || cdnUrl const cacheKey = md5 || cdnUrl
@@ -6372,11 +6426,12 @@ function MessageBubble({
const [imageLocalPath, setImageLocalPath] = useState<string | undefined>( const [imageLocalPath, setImageLocalPath] = useState<string | undefined>(
() => imageDataUrlCache.get(imageCacheKey) () => imageDataUrlCache.get(imageCacheKey)
) )
const voiceCacheKey = `voice:${message.localId}` const voiceIdentityKey = buildVoiceCacheIdentity(session.username, message)
const voiceCacheKey = `voice:${voiceIdentityKey}`
const [voiceDataUrl, setVoiceDataUrl] = useState<string | undefined>( const [voiceDataUrl, setVoiceDataUrl] = useState<string | undefined>(
() => voiceDataUrlCache.get(voiceCacheKey) () => voiceDataUrlCache.get(voiceCacheKey)
) )
const voiceTranscriptCacheKey = `voice-transcript:${message.localId}` const voiceTranscriptCacheKey = `voice-transcript:${voiceIdentityKey}`
const [voiceTranscript, setVoiceTranscript] = useState<string | undefined>( const [voiceTranscript, setVoiceTranscript] = useState<string | undefined>(
() => voiceTranscriptCache.get(voiceTranscriptCacheKey) () => voiceTranscriptCache.get(voiceTranscriptCacheKey)
) )
@@ -6938,14 +6993,16 @@ function MessageBubble({
// 监听流式转写结果 // 监听流式转写结果
useEffect(() => { useEffect(() => {
if (!isVoice) return if (!isVoice) return
const removeListener = window.electronAPI.chat.onVoiceTranscriptPartial?.((payload: { msgId: string; text: string }) => { const removeListener = window.electronAPI.chat.onVoiceTranscriptPartial?.((payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => {
if (payload.msgId === String(message.localId)) { const sameSession = !payload.sessionId || payload.sessionId === session.username
setVoiceTranscript(payload.text) const sameMsgId = payload.msgId === String(message.localId)
voiceTranscriptCache.set(voiceTranscriptCacheKey, payload.text) const sameCreateTime = payload.createTime == null || Number(payload.createTime) === Number(message.createTime || 0)
} if (!sameSession || !sameMsgId || !sameCreateTime) return
setVoiceTranscript(payload.text)
voiceTranscriptCache.set(voiceTranscriptCacheKey, payload.text)
}) })
return () => removeListener?.() return () => removeListener?.()
}, [isVoice, message.localId, voiceTranscriptCacheKey]) }, [isVoice, message.createTime, message.localId, session.username, voiceTranscriptCacheKey])
const requestVoiceTranscript = useCallback(async () => { const requestVoiceTranscript = useCallback(async () => {
if (voiceTranscriptLoading || voiceTranscriptRequestedRef.current) return if (voiceTranscriptLoading || voiceTranscriptRequestedRef.current) return
@@ -6999,14 +7056,17 @@ function MessageBubble({
} finally { } finally {
setVoiceTranscriptLoading(false) setVoiceTranscriptLoading(false)
} }
}, [message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading, onRequireModelDownload]) }, [message.createTime, message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading, onRequireModelDownload])
// 监听模型下载完成事件 // 监听模型下载完成事件
useEffect(() => { useEffect(() => {
if (!isVoice) return if (!isVoice) return
const handleModelDownloaded = (event: CustomEvent) => { const handleModelDownloaded = (event: CustomEvent) => {
if (event.detail?.messageId === String(message.localId)) { if (
event.detail?.messageId === String(message.localId) &&
(!event.detail?.sessionId || event.detail?.sessionId === session.username)
) {
// 重置状态,允许重新尝试转写 // 重置状态,允许重新尝试转写
voiceTranscriptRequestedRef.current = false voiceTranscriptRequestedRef.current = false
setVoiceTranscriptError(false) setVoiceTranscriptError(false)
@@ -7019,7 +7079,7 @@ function MessageBubble({
return () => { return () => {
window.removeEventListener('model-downloaded', handleModelDownloaded as EventListener) window.removeEventListener('model-downloaded', handleModelDownloaded as EventListener)
} }
}, [isVoice, message.localId, requestVoiceTranscript]) }, [isVoice, message.localId, requestVoiceTranscript, session.username])
// 视频懒加载 // 视频懒加载
const videoAutoLoadTriggered = useRef(false) const videoAutoLoadTriggered = useRef(false)

View File

@@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type UIEvent, type WheelEvent } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type UIEvent, type WheelEvent } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { import {
@@ -44,6 +44,7 @@ import {
subscribeBackgroundTasks subscribeBackgroundTasks
} from '../services/backgroundTaskMonitor' } from '../services/backgroundTaskMonitor'
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import { useChatStore } from '../stores/chatStore'
import { SnsPostItem } from '../components/Sns/SnsPostItem' import { SnsPostItem } from '../components/Sns/SnsPostItem'
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog'
@@ -104,6 +105,10 @@ interface TaskProgress {
phaseLabel: string phaseLabel: string
phaseProgress: number phaseProgress: number
phaseTotal: number phaseTotal: number
exportedMessages: number
estimatedTotalMessages: number
collectedMessages: number
writtenFiles: number
} }
type TaskPerfStage = 'collect' | 'build' | 'write' | 'other' type TaskPerfStage = 'collect' | 'build' | 'write' | 'other'
@@ -166,7 +171,7 @@ interface ExportDialogState {
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000 const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10 const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10
const SESSION_MEDIA_METRIC_BATCH_SIZE = 12 const SESSION_MEDIA_METRIC_BATCH_SIZE = 8
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48 const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS = 120 const SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS = 120
const SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS = 1200 const SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS = 1200
@@ -254,7 +259,11 @@ const createEmptyProgress = (): TaskProgress => ({
phase: '', phase: '',
phaseLabel: '', phaseLabel: '',
phaseProgress: 0, phaseProgress: 0,
phaseTotal: 0 phaseTotal: 0,
exportedMessages: 0,
estimatedTotalMessages: 0,
collectedMessages: 0,
writtenFiles: 0
}) })
const createEmptyTaskPerformance = (): TaskPerformance => ({ const createEmptyTaskPerformance = (): TaskPerformance => ({
@@ -1280,6 +1289,14 @@ const TaskCenterModal = memo(function TaskCenterModal({
completedSessionTotal, completedSessionTotal,
(task.settledSessionIds || []).length (task.settledSessionIds || []).length
) )
const exportedMessages = Math.max(0, Math.floor(task.progress.exportedMessages || 0))
const estimatedTotalMessages = Math.max(0, Math.floor(task.progress.estimatedTotalMessages || 0))
const messageProgressLabel = estimatedTotalMessages > 0
? `已导出 ${Math.min(exportedMessages, estimatedTotalMessages)}/${estimatedTotalMessages}`
: `已导出 ${exportedMessages}`
const sessionProgressLabel = completedSessionTotal > 0
? `会话 ${completedSessionCount}/${completedSessionTotal}`
: '会话处理中'
const currentSessionRatio = task.progress.phaseTotal > 0 const currentSessionRatio = task.progress.phaseTotal > 0
? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal)) ? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal))
: null : null
@@ -1300,9 +1317,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
/> />
</div> </div>
<div className="task-progress-text"> <div className="task-progress-text">
{completedSessionTotal > 0 {`${sessionProgressLabel} · ${messageProgressLabel}`}
? `已完成 ${completedSessionCount} / ${completedSessionTotal}`
: '处理中'}
{task.status === 'running' && currentSessionRatio !== null {task.status === 'running' && currentSessionRatio !== null
? `(当前会话 ${Math.round(currentSessionRatio * 100)}%` ? `(当前会话 ${Math.round(currentSessionRatio * 100)}%`
: ''} : ''}
@@ -1387,6 +1402,8 @@ const TaskCenterModal = memo(function TaskCenterModal({
}) })
function ExportPage() { function ExportPage() {
const navigate = useNavigate()
const { setCurrentSession } = useChatStore()
const location = useLocation() const location = useLocation()
const isExportRoute = location.pathname === '/export' const isExportRoute = location.pathname === '/export'
@@ -2787,6 +2804,7 @@ function ExportPage() {
}, []) }, [])
const enqueueSessionMutualFriendsRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => { const enqueueSessionMutualFriendsRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => {
if (activeTaskCountRef.current > 0) return
const front = options?.front === true const front = options?.front === true
const incoming: string[] = [] const incoming: string[] = []
for (const sessionIdRaw of sessionIds) { for (const sessionIdRaw of sessionIds) {
@@ -2976,6 +2994,7 @@ function ExportPage() {
}, []) }, [])
const enqueueSessionMediaMetricRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => { const enqueueSessionMediaMetricRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => {
if (activeTaskCountRef.current > 0) return
const front = options?.front === true const front = options?.front === true
const incoming: string[] = [] const incoming: string[] = []
for (const sessionIdRaw of sessionIds) { for (const sessionIdRaw of sessionIds) {
@@ -3025,13 +3044,27 @@ function ExportPage() {
const runSessionMediaMetricWorker = useCallback(async (runId: number) => { const runSessionMediaMetricWorker = useCallback(async (runId: number) => {
if (sessionMediaMetricWorkerRunningRef.current) return if (sessionMediaMetricWorkerRunningRef.current) return
sessionMediaMetricWorkerRunningRef.current = true sessionMediaMetricWorkerRunningRef.current = true
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number, stage: string): Promise<T> => {
let timer: number | null = null
try {
const timeoutPromise = new Promise<never>((_, reject) => {
timer = window.setTimeout(() => {
reject(new Error(`会话多媒体统计超时(${stage}, ${timeoutMs}ms)`))
}, timeoutMs)
})
return await Promise.race([promise, timeoutPromise])
} finally {
if (timer !== null) {
window.clearTimeout(timer)
}
}
}
try { try {
while (runId === sessionMediaMetricRunIdRef.current) { while (runId === sessionMediaMetricRunIdRef.current) {
if (isLoadingSessionCountsRef.current || detailStatsPriorityRef.current) { if (activeTaskCountRef.current > 0) {
await new Promise(resolve => window.setTimeout(resolve, 80)) await new Promise(resolve => window.setTimeout(resolve, 150))
continue continue
} }
if (sessionMediaMetricQueueRef.current.length === 0) break if (sessionMediaMetricQueueRef.current.length === 0) break
const batchSessionIds: string[] = [] const batchSessionIds: string[] = []
@@ -3050,9 +3083,13 @@ function ExportPage() {
patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'loading') patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'loading')
try { try {
const cacheResult = await window.electronAPI.chat.getExportSessionStats( const cacheResult = await withTimeout(
batchSessionIds, window.electronAPI.chat.getExportSessionStats(
{ includeRelations: false, allowStaleCache: true, cacheOnly: true } batchSessionIds,
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
),
12000,
'cacheOnly'
) )
if (runId !== sessionMediaMetricRunIdRef.current) return if (runId !== sessionMediaMetricRunIdRef.current) return
if (cacheResult.success && cacheResult.data) { if (cacheResult.success && cacheResult.data) {
@@ -3061,15 +3098,26 @@ function ExportPage() {
const missingSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId)) const missingSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId))
if (missingSessionIds.length > 0) { if (missingSessionIds.length > 0) {
const freshResult = await window.electronAPI.chat.getExportSessionStats( const freshResult = await withTimeout(
missingSessionIds, window.electronAPI.chat.getExportSessionStats(
{ includeRelations: false, allowStaleCache: true } missingSessionIds,
{ includeRelations: false, allowStaleCache: true }
),
45000,
'fresh'
) )
if (runId !== sessionMediaMetricRunIdRef.current) return if (runId !== sessionMediaMetricRunIdRef.current) return
if (freshResult.success && freshResult.data) { if (freshResult.success && freshResult.data) {
applySessionMediaMetricsFromStats(freshResult.data as Record<string, SessionExportMetric>) applySessionMediaMetricsFromStats(freshResult.data as Record<string, SessionExportMetric>)
} }
} }
const unresolvedSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId))
if (unresolvedSessionIds.length > 0) {
patchSessionLoadTraceStage(unresolvedSessionIds, 'mediaMetrics', 'failed', {
error: '统计结果缺失,已跳过当前批次'
})
}
} catch (error) { } catch (error) {
console.error('导出页加载会话媒体统计失败:', error) console.error('导出页加载会话媒体统计失败:', error)
patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'failed', { patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'failed', {
@@ -3100,12 +3148,11 @@ function ExportPage() {
}, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage]) }, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage])
const scheduleSessionMediaMetricWorker = useCallback(() => { const scheduleSessionMediaMetricWorker = useCallback(() => {
if (!isSessionCountStageReady) return if (activeTaskCountRef.current > 0) return
if (isLoadingSessionCountsRef.current) return
if (sessionMediaMetricWorkerRunningRef.current) return if (sessionMediaMetricWorkerRunningRef.current) return
const runId = sessionMediaMetricRunIdRef.current const runId = sessionMediaMetricRunIdRef.current
void runSessionMediaMetricWorker(runId) void runSessionMediaMetricWorker(runId)
}, [isSessionCountStageReady, runSessionMediaMetricWorker]) }, [runSessionMediaMetricWorker])
const loadSessionMutualFriendsMetric = useCallback(async (sessionId: string): Promise<SessionMutualFriendsMetric> => { const loadSessionMutualFriendsMetric = useCallback(async (sessionId: string): Promise<SessionMutualFriendsMetric> => {
const normalizedSessionId = String(sessionId || '').trim() const normalizedSessionId = String(sessionId || '').trim()
@@ -3150,6 +3197,10 @@ function ExportPage() {
sessionMutualFriendsWorkerRunningRef.current = true sessionMutualFriendsWorkerRunningRef.current = true
try { try {
while (runId === sessionMutualFriendsRunIdRef.current) { while (runId === sessionMutualFriendsRunIdRef.current) {
if (activeTaskCountRef.current > 0) {
await new Promise(resolve => window.setTimeout(resolve, 150))
continue
}
if (hasPendingMetricLoads()) { if (hasPendingMetricLoads()) {
await new Promise(resolve => window.setTimeout(resolve, 120)) await new Promise(resolve => window.setTimeout(resolve, 120))
continue continue
@@ -3196,6 +3247,7 @@ function ExportPage() {
]) ])
const scheduleSessionMutualFriendsWorker = useCallback(() => { const scheduleSessionMutualFriendsWorker = useCallback(() => {
if (activeTaskCountRef.current > 0) return
if (!isSessionCountStageReady) return if (!isSessionCountStageReady) return
if (hasPendingMetricLoads()) return if (hasPendingMetricLoads()) return
if (sessionMutualFriendsWorkerRunningRef.current) return if (sessionMutualFriendsWorkerRunningRef.current) return
@@ -3291,9 +3343,6 @@ function ExportPage() {
setIsLoadingSessionCounts(true) setIsLoadingSessionCounts(true)
try { try {
if (detailStatsPriorityRef.current) {
return { ...accumulatedCounts }
}
if (prioritizedSessionIds.length > 0) { if (prioritizedSessionIds.length > 0) {
patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'loading') patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'loading')
const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds) const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds)
@@ -3311,9 +3360,6 @@ function ExportPage() {
} }
} }
if (detailStatsPriorityRef.current) {
return { ...accumulatedCounts }
}
if (remainingSessionIds.length > 0) { if (remainingSessionIds.length > 0) {
patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'loading') patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'loading')
const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds) const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds)
@@ -4135,6 +4181,126 @@ function ExportPage() {
progressUnsubscribeRef.current?.() progressUnsubscribeRef.current?.()
const settledSessionIdsFromProgress = new Set<string>() const settledSessionIdsFromProgress = new Set<string>()
const sessionMessageProgress = new Map<string, { exported: number; total: number; knownTotal: boolean }>()
let queuedProgressPayload: ExportProgress | null = null
let queuedProgressRaf: number | null = null
let queuedProgressTimer: number | null = null
const clearQueuedProgress = () => {
if (queuedProgressRaf !== null) {
window.cancelAnimationFrame(queuedProgressRaf)
queuedProgressRaf = null
}
if (queuedProgressTimer !== null) {
window.clearTimeout(queuedProgressTimer)
queuedProgressTimer = null
}
}
const updateSessionMessageProgress = (payload: ExportProgress) => {
const sessionId = String(payload.currentSessionId || '').trim()
if (!sessionId) return
const prev = sessionMessageProgress.get(sessionId) || { exported: 0, total: 0, knownTotal: false }
const nextExported = Number.isFinite(payload.exportedMessages)
? Math.max(prev.exported, Math.max(0, Math.floor(Number(payload.exportedMessages || 0))))
: prev.exported
const hasEstimatedTotal = Number.isFinite(payload.estimatedTotalMessages)
const nextTotal = hasEstimatedTotal
? Math.max(prev.total, Math.max(0, Math.floor(Number(payload.estimatedTotalMessages || 0))))
: prev.total
const knownTotal = prev.knownTotal || hasEstimatedTotal
sessionMessageProgress.set(sessionId, {
exported: nextExported,
total: nextTotal,
knownTotal
})
}
const resolveAggregatedMessageProgress = () => {
let exported = 0
let estimated = 0
let allKnown = true
for (const sessionId of next.payload.sessionIds) {
const entry = sessionMessageProgress.get(sessionId)
if (!entry) {
allKnown = false
continue
}
exported += entry.exported
estimated += entry.total
if (!entry.knownTotal) {
allKnown = false
}
}
return {
exported: Math.max(0, Math.floor(exported)),
estimated: allKnown ? Math.max(0, Math.floor(estimated)) : 0
}
}
const flushQueuedProgress = () => {
if (!queuedProgressPayload) return
const payload = queuedProgressPayload
queuedProgressPayload = null
const now = Date.now()
const currentSessionId = String(payload.currentSessionId || '').trim()
updateTask(next.id, task => {
if (task.status !== 'running') return task
const performance = applyProgressToTaskPerformance(task, payload, now)
const settledSessionIds = task.settledSessionIds || []
const nextSettledSessionIds = (
payload.phase === 'complete' &&
currentSessionId &&
!settledSessionIds.includes(currentSessionId)
)
? [...settledSessionIds, currentSessionId]
: settledSessionIds
const aggregatedMessageProgress = resolveAggregatedMessageProgress()
const collectedMessages = Number.isFinite(payload.collectedMessages)
? Math.max(0, Math.floor(Number(payload.collectedMessages || 0)))
: task.progress.collectedMessages
const writtenFiles = Number.isFinite(payload.writtenFiles)
? Math.max(task.progress.writtenFiles, Math.max(0, Math.floor(Number(payload.writtenFiles || 0))))
: task.progress.writtenFiles
return {
...task,
progress: {
current: payload.current,
total: payload.total,
currentName: payload.currentSession,
phase: payload.phase,
phaseLabel: payload.phaseLabel || '',
phaseProgress: payload.phaseProgress || 0,
phaseTotal: payload.phaseTotal || 0,
exportedMessages: Math.max(task.progress.exportedMessages, aggregatedMessageProgress.exported),
estimatedTotalMessages: aggregatedMessageProgress.estimated > 0
? Math.max(task.progress.estimatedTotalMessages, aggregatedMessageProgress.estimated)
: (task.progress.estimatedTotalMessages > 0 ? task.progress.estimatedTotalMessages : 0),
collectedMessages: Math.max(task.progress.collectedMessages, collectedMessages),
writtenFiles
},
settledSessionIds: nextSettledSessionIds,
performance
}
})
}
const queueProgressUpdate = (payload: ExportProgress) => {
queuedProgressPayload = payload
if (payload.phase === 'complete') {
clearQueuedProgress()
flushQueuedProgress()
return
}
if (queuedProgressRaf !== null || queuedProgressTimer !== null) return
queuedProgressRaf = window.requestAnimationFrame(() => {
queuedProgressRaf = null
queuedProgressTimer = window.setTimeout(() => {
queuedProgressTimer = null
flushQueuedProgress()
}, 100)
})
}
if (next.payload.scope === 'sns') { if (next.payload.scope === 'sns') {
progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => { progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => {
updateTask(next.id, task => { updateTask(next.id, task => {
@@ -4148,7 +4314,11 @@ function ExportPage() {
phase: 'exporting', phase: 'exporting',
phaseLabel: payload.status || '', phaseLabel: payload.status || '',
phaseProgress: payload.total > 0 ? payload.current : 0, phaseProgress: payload.total > 0 ? payload.current : 0,
phaseTotal: payload.total || 0 phaseTotal: payload.total || 0,
exportedMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.current || 0)) : task.progress.exportedMessages,
estimatedTotalMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.total || 0)) : task.progress.estimatedTotalMessages,
collectedMessages: task.progress.collectedMessages,
writtenFiles: task.progress.writtenFiles
} }
} }
}) })
@@ -4157,6 +4327,7 @@ function ExportPage() {
progressUnsubscribeRef.current = window.electronAPI.export.onProgress((payload: ExportProgress) => { progressUnsubscribeRef.current = window.electronAPI.export.onProgress((payload: ExportProgress) => {
const now = Date.now() const now = Date.now()
const currentSessionId = String(payload.currentSessionId || '').trim() const currentSessionId = String(payload.currentSessionId || '').trim()
updateSessionMessageProgress(payload)
if (payload.phase === 'complete' && currentSessionId && !settledSessionIdsFromProgress.has(currentSessionId)) { if (payload.phase === 'complete' && currentSessionId && !settledSessionIdsFromProgress.has(currentSessionId)) {
settledSessionIdsFromProgress.add(currentSessionId) settledSessionIdsFromProgress.add(currentSessionId)
const phaseLabel = String(payload.phaseLabel || '') const phaseLabel = String(payload.phaseLabel || '')
@@ -4172,33 +4343,7 @@ function ExportPage() {
markSessionExportRecords([currentSessionId], taskExportContentLabel, next.payload.outputDir, now) markSessionExportRecords([currentSessionId], taskExportContentLabel, next.payload.outputDir, now)
} }
} }
queueProgressUpdate(payload)
updateTask(next.id, task => {
if (task.status !== 'running') return task
const performance = applyProgressToTaskPerformance(task, payload, now)
const settledSessionIds = task.settledSessionIds || []
const nextSettledSessionIds = (
payload.phase === 'complete' &&
currentSessionId &&
!settledSessionIds.includes(currentSessionId)
)
? [...settledSessionIds, currentSessionId]
: settledSessionIds
return {
...task,
progress: {
current: payload.current,
total: payload.total,
currentName: payload.currentSession,
phase: payload.phase,
phaseLabel: payload.phaseLabel || '',
phaseProgress: payload.phaseProgress || 0,
phaseTotal: payload.phaseTotal || 0
},
settledSessionIds: nextSettledSessionIds,
performance
}
})
}) })
} }
@@ -4310,6 +4455,8 @@ function ExportPage() {
performance: finalizeTaskPerformance(task, doneAt) performance: finalizeTaskPerformance(task, doneAt)
})) }))
} finally { } finally {
clearQueuedProgress()
flushQueuedProgress()
progressUnsubscribeRef.current?.() progressUnsubscribeRef.current?.()
progressUnsubscribeRef.current = null progressUnsubscribeRef.current = null
runningTaskIdRef.current = null runningTaskIdRef.current = null
@@ -4715,10 +4862,22 @@ function ExportPage() {
return new Date(value).toLocaleTimeString('zh-CN', { hour12: false }) return new Date(value).toLocaleTimeString('zh-CN', { hour12: false })
}, []) }, [])
const getLoadDetailStatusLabel = useCallback((loaded: number, total: number, hasStarted: boolean): string => { const getLoadDetailStatusLabel = useCallback((
loaded: number,
total: number,
hasStarted: boolean,
hasLoading: boolean,
failedCount: number
): string => {
if (total <= 0) return '待加载' if (total <= 0) return '待加载'
if (loaded >= total) return `已完成 ${total}` const terminalCount = loaded + failedCount
if (hasStarted) return `加载中 ${loaded}/${total}` if (terminalCount >= total) {
if (failedCount > 0) return `已完成 ${loaded}/${total}(失败 ${failedCount}`
return `已完成 ${total}`
}
if (hasLoading) return `加载中 ${loaded}/${total}`
if (hasStarted && failedCount > 0) return `已完成 ${loaded}/${total}(失败 ${failedCount}`
if (hasStarted) return `已完成 ${loaded}/${total}`
return '待加载' return '待加载'
}, []) }, [])
@@ -4728,7 +4887,9 @@ function ExportPage() {
): SessionLoadStageSummary => { ): SessionLoadStageSummary => {
const total = sessionIds.length const total = sessionIds.length
let loaded = 0 let loaded = 0
let failedCount = 0
let hasStarted = false let hasStarted = false
let hasLoading = false
let earliestStart: number | undefined let earliestStart: number | undefined
let latestFinish: number | undefined let latestFinish: number | undefined
let latestProgressAt: number | undefined let latestProgressAt: number | undefined
@@ -4742,6 +4903,12 @@ function ExportPage() {
: Math.max(latestProgressAt, stage.finishedAt) : Math.max(latestProgressAt, stage.finishedAt)
} }
} }
if (stage?.status === 'failed') {
failedCount += 1
}
if (stage?.status === 'loading') {
hasLoading = true
}
if (stage?.status === 'loading' || stage?.status === 'failed' || typeof stage?.startedAt === 'number') { if (stage?.status === 'loading' || stage?.status === 'failed' || typeof stage?.startedAt === 'number') {
hasStarted = true hasStarted = true
} }
@@ -4759,9 +4926,9 @@ function ExportPage() {
return { return {
total, total,
loaded, loaded,
statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted), statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted, hasLoading, failedCount),
startedAt: earliestStart, startedAt: earliestStart,
finishedAt: loaded >= total ? latestFinish : undefined, finishedAt: (loaded + failedCount) >= total ? latestFinish : undefined,
latestProgressAt latestProgressAt
} }
}, [getLoadDetailStatusLabel, sessionLoadTraceMap]) }, [getLoadDetailStatusLabel, sessionLoadTraceMap])
@@ -4907,7 +5074,6 @@ function ExportPage() {
const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex
sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex } sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex }
sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex } sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex }
if (isLoadingSessionCountsRef.current || !isSessionCountStageReady) return
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts) const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
if (visibleTargets.length === 0) return if (visibleTargets.length === 0) return
enqueueSessionMediaMetricRequests(visibleTargets, { front: true }) enqueueSessionMediaMetricRequests(visibleTargets, { front: true })
@@ -4923,13 +5089,13 @@ function ExportPage() {
enqueueSessionMediaMetricRequests, enqueueSessionMediaMetricRequests,
enqueueSessionMutualFriendsRequests, enqueueSessionMutualFriendsRequests,
filteredContacts, filteredContacts,
isSessionCountStageReady,
scheduleSessionMediaMetricWorker, scheduleSessionMediaMetricWorker,
scheduleSessionMutualFriendsWorker scheduleSessionMutualFriendsWorker
]) ])
useEffect(() => { useEffect(() => {
if (!isSessionCountStageReady || filteredContacts.length === 0) return if (activeTaskCount > 0) return
if (filteredContacts.length === 0) return
const runId = sessionMediaMetricRunIdRef.current const runId = sessionMediaMetricRunIdRef.current
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts) const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
if (visibleTargets.length > 0) { if (visibleTargets.length > 0) {
@@ -4946,7 +5112,6 @@ function ExportPage() {
let cursor = 0 let cursor = 0
const feedNext = () => { const feedNext = () => {
if (runId !== sessionMediaMetricRunIdRef.current) return if (runId !== sessionMediaMetricRunIdRef.current) return
if (isLoadingSessionCountsRef.current) return
const batchIds: string[] = [] const batchIds: string[] = []
while (cursor < filteredContacts.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) { while (cursor < filteredContacts.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) {
const contact = filteredContacts[cursor] const contact = filteredContacts[cursor]
@@ -4976,15 +5141,61 @@ function ExportPage() {
} }
} }
}, [ }, [
activeTaskCount,
collectVisibleSessionMetricTargets, collectVisibleSessionMetricTargets,
enqueueSessionMediaMetricRequests, enqueueSessionMediaMetricRequests,
filteredContacts, filteredContacts,
isSessionCountStageReady,
scheduleSessionMediaMetricWorker, scheduleSessionMediaMetricWorker,
sessionRowByUsername sessionRowByUsername
]) ])
useEffect(() => { useEffect(() => {
if (activeTaskCount > 0) return
const runId = sessionMediaMetricRunIdRef.current
const allTargets = [
...(loadDetailTargetsByTab.private || []),
...(loadDetailTargetsByTab.group || []),
...(loadDetailTargetsByTab.former_friend || [])
]
if (allTargets.length === 0) return
let timer: number | null = null
let cursor = 0
const feedNext = () => {
if (runId !== sessionMediaMetricRunIdRef.current) return
const batchIds: string[] = []
while (cursor < allTargets.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) {
const sessionId = allTargets[cursor]
cursor += 1
if (!sessionId) continue
batchIds.push(sessionId)
}
if (batchIds.length > 0) {
enqueueSessionMediaMetricRequests(batchIds)
scheduleSessionMediaMetricWorker()
}
if (cursor < allTargets.length) {
timer = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS)
}
}
feedNext()
return () => {
if (timer !== null) {
window.clearTimeout(timer)
}
}
}, [
activeTaskCount,
enqueueSessionMediaMetricRequests,
loadDetailTargetsByTab.former_friend,
loadDetailTargetsByTab.group,
loadDetailTargetsByTab.private,
scheduleSessionMediaMetricWorker
])
useEffect(() => {
if (activeTaskCount > 0) return
if (!isSessionCountStageReady || filteredContacts.length === 0) return if (!isSessionCountStageReady || filteredContacts.length === 0) return
const runId = sessionMutualFriendsRunIdRef.current const runId = sessionMutualFriendsRunIdRef.current
const visibleTargets = collectVisibleSessionMutualFriendsTargets(filteredContacts) const visibleTargets = collectVisibleSessionMutualFriendsTargets(filteredContacts)
@@ -5031,6 +5242,7 @@ function ExportPage() {
} }
} }
}, [ }, [
activeTaskCount,
collectVisibleSessionMutualFriendsTargets, collectVisibleSessionMutualFriendsTargets,
enqueueSessionMutualFriendsRequests, enqueueSessionMutualFriendsRequests,
filteredContacts, filteredContacts,
@@ -5348,16 +5560,16 @@ function ExportPage() {
const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0 const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0
const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS
const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale)) const shouldRunBackgroundRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale))
if (shouldRunPreciseRefresh) { if (shouldRunBackgroundRefresh) {
setIsRefreshingSessionDetailStats(true) setIsRefreshingSessionDetailStats(true)
void (async () => { void (async () => {
try { try {
// 后台精确补算三类重字段(转账/红包/通话),不阻塞首屏基础统计显示 // 后台补齐非关系统计,不走精确特型扫描,避免阻塞列表统计队列
const freshResult = await window.electronAPI.chat.getExportSessionStats( const freshResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId], [normalizedSessionId],
{ includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } { includeRelations: false, forceRefresh: true }
) )
if (requestSeq !== detailRequestSeqRef.current) return if (requestSeq !== detailRequestSeqRef.current) return
if (freshResult.success && freshResult.data) { if (freshResult.success && freshResult.data) {
@@ -6083,14 +6295,10 @@ function ExportPage() {
<button <button
type="button" type="button"
className="row-open-chat-link" className="row-open-chat-link"
title="在新窗口打开该会话" title="切换到聊天页查看该会话"
onClick={() => { onClick={() => {
void window.electronAPI.window.openSessionChatWindow(contact.username, { setCurrentSession(contact.username)
source: 'export', navigate('/chat')
initialDisplayName: contact.displayName || contact.username,
initialAvatarUrl: contact.avatarUrl,
initialContactType: contact.type
})
}} }}
> >
{openChatLabel} {openChatLabel}
@@ -6198,6 +6406,7 @@ function ExportPage() {
) )
}, [ }, [
lastExportBySession, lastExportBySession,
navigate,
nowTick, nowTick,
openContactSnsTimeline, openContactSnsTimeline,
openSessionDetail, openSessionDetail,
@@ -6219,6 +6428,7 @@ function ExportPage() {
shouldShowSnsColumn, shouldShowSnsColumn,
snsUserPostCounts, snsUserPostCounts,
snsUserPostCountsStatus, snsUserPostCountsStatus,
setCurrentSession,
toggleSelectSession toggleSelectSession
]) ])
const handleContactsListWheelCapture = useCallback((event: WheelEvent<HTMLDivElement>) => { const handleContactsListWheelCapture = useCallback((event: WheelEvent<HTMLDivElement>) => {

View File

@@ -319,8 +319,7 @@ export interface ElectronAPI {
getMessageDateCounts: (sessionId: string) => Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> getMessageDateCounts: (sessionId: string) => Promise<{ success: boolean; counts?: Record<string, number>; error?: string }>
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }> resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }> getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => () => void
execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }>
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }> getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void
} }
@@ -862,6 +861,10 @@ export interface ExportProgress {
phaseProgress?: number phaseProgress?: number
phaseTotal?: number phaseTotal?: number
phaseLabel?: string phaseLabel?: string
collectedMessages?: number
exportedMessages?: number
estimatedTotalMessages?: number
writtenFiles?: number
} }
export interface WxidInfo { export interface WxidInfo {

View File

@@ -34,7 +34,8 @@ export default defineConfig({
'whisper-node', 'whisper-node',
'shelljs', 'shelljs',
'exceljs', 'exceljs',
'node-llama-cpp' 'node-llama-cpp',
'sudo-prompt'
] ]
} }
} }
@@ -126,6 +127,26 @@ export default defineConfig({
} }
} }
}, },
{
entry: 'electron/exportWorker.ts',
vite: {
build: {
outDir: 'dist-electron',
rollupOptions: {
external: [
'better-sqlite3',
'koffi',
'fsevents',
'exceljs'
],
output: {
entryFileNames: 'exportWorker.js',
inlineDynamicImports: true
}
}
}
}
},
{ {
entry: 'electron/preload.ts', entry: 'electron/preload.ts',
onstart(options) { onstart(options) {