mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
重构与优化,旨在解决遗留的性能问题并优化用户体验,本次提交遗留了较多的待测功能
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
4
.npmrc
@@ -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
47
electron/exportWorker.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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. 重启电脑'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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?: {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -5,47 +5,6 @@ import { tmpdir } from 'os'
|
|||||||
// DLL 初始化错误信息,用于帮助用户诊断问题
|
// DLL 初始化错误信息,用于帮助用户诊断问题
|
||||||
let lastDllInitError: string | null = null
|
let lastDllInitError: string | null = null
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析 extra_buffer(protobuf)中的免打扰状态
|
|
||||||
* - 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* 为朋友圈安装删除
|
* 为朋友圈安装删除
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 安装朋友圈删除拦截
|
* 安装朋友圈删除拦截
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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.
@@ -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)
|
||||||
|
|||||||
@@ -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>) => {
|
||||||
|
|||||||
7
src/types/electron.d.ts
vendored
7
src/types/electron.d.ts
vendored
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user