feat(export): add sns arkmejson format and consolidate export flow changes

This commit is contained in:
tisonhuang committed 2026-03-04 21:19:10 +08:00
1 parent e1243522b0
commit c5eed25f06
7 files changed
+473 -1249

No files matched your search

+4 -108
View File
@@ -991,115 +991,11 @@ function registerIpcHandlers() {
return chatService.getSessionMessageCounts(sessionIds)
})
ipcMain.handle('chat:getExportContentSessionCounts', async (_, options?: {
triggerRefresh?: boolean
forceRefresh?: boolean
traceId?: string
ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[], options?: {
skipDisplayName?: boolean
onlyMissingAvatar?: boolean
}) => {
const traceId = typeof options?.traceId === 'string' ? options.traceId.trim() : ''
const startedAt = Date.now()
if (traceId) {
exportCardDiagnosticsService.stepStart({
traceId,
stepId: 'main-ipc-export-content-counts',
stepName: 'Main IPC: chat:getExportContentSessionCounts',
source: 'main',
message: '主进程收到导出卡片统计请求',
data: {
forceRefresh: options?.forceRefresh === true,
triggerRefresh: options?.triggerRefresh !== false
}
})
}
try {
const result = await chatService.getExportContentSessionCounts(options)
if (traceId) {
exportCardDiagnosticsService.stepEnd({
traceId,
stepId: 'main-ipc-export-content-counts',
stepName: 'Main IPC: chat:getExportContentSessionCounts',
source: 'main',
status: result?.success ? 'done' : 'failed',
durationMs: Date.now() - startedAt,
message: result?.success ? '主进程统计请求完成' : '主进程统计请求失败',
data: result?.success
? {
totalSessions: result?.data?.totalSessions || 0,
pendingMediaSessions: result?.data?.pendingMediaSessions || 0,
refreshing: result?.data?.refreshing === true
}
: { error: result?.error || '未知错误' }
})
}
return result
} catch (error) {
if (traceId) {
exportCardDiagnosticsService.stepEnd({
traceId,
stepId: 'main-ipc-export-content-counts',
stepName: 'Main IPC: chat:getExportContentSessionCounts',
source: 'main',
status: 'failed',
durationMs: Date.now() - startedAt,
message: '主进程统计请求抛出异常',
data: { error: String(error) }
})
}
throw error
}
})
ipcMain.handle('chat:refreshExportContentSessionCounts', async (_, options?: {
forceRefresh?: boolean
traceId?: string
}) => {
const traceId = typeof options?.traceId === 'string' ? options.traceId.trim() : ''
const startedAt = Date.now()
if (traceId) {
exportCardDiagnosticsService.stepStart({
traceId,
stepId: 'main-ipc-refresh-export-content-counts',
stepName: 'Main IPC: chat:refreshExportContentSessionCounts',
source: 'main',
message: '主进程收到刷新导出卡片统计请求',
data: { forceRefresh: options?.forceRefresh === true }
})
}
try {
const result = await chatService.refreshExportContentSessionCounts(options)
if (traceId) {
exportCardDiagnosticsService.stepEnd({
traceId,
stepId: 'main-ipc-refresh-export-content-counts',
stepName: 'Main IPC: chat:refreshExportContentSessionCounts',
source: 'main',
status: result?.success ? 'done' : 'failed',
durationMs: Date.now() - startedAt,
message: result?.success ? '主进程刷新请求完成' : '主进程刷新请求失败',
data: result?.success ? undefined : { error: result?.error || '未知错误' }
})
}
return result
} catch (error) {
if (traceId) {
exportCardDiagnosticsService.stepEnd({
traceId,
stepId: 'main-ipc-refresh-export-content-counts',
stepName: 'Main IPC: chat:refreshExportContentSessionCounts',
source: 'main',
status: 'failed',
durationMs: Date.now() - startedAt,
message: '主进程刷新请求抛出异常',
data: { error: String(error) }
})
}
throw error
}
})
ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => {
return chatService.enrichSessionsContactInfo(usernames)
return chatService.enrichSessionsContactInfo(usernames, options)
})
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => {
+4 -6
View File
@@ -143,12 +143,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds),
getExportContentSessionCounts: (options?: { triggerRefresh?: boolean; forceRefresh?: boolean }) =>
ipcRenderer.invoke('chat:getExportContentSessionCounts', options),
refreshExportContentSessionCounts: (options?: { forceRefresh?: boolean }) =>
ipcRenderer.invoke('chat:refreshExportContentSessionCounts', options),
enrichSessionsContactInfo: (usernames: string[]) =>
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
enrichSessionsContactInfo: (
usernames: string[],
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames, options),
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
getLatestMessages: (sessionId: string, limit?: number) =>
+39 -849
View File
@@ -14,11 +14,6 @@ import { MessageCacheService } from './messageCacheService'
import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
import { SessionStatsCacheService, SessionStatsCacheEntry, SessionStatsCacheStats } from './sessionStatsCacheService'
import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from './groupMyMessageCountCacheService'
import {
ExportContentScopeStatsEntry,
ExportContentSessionStatsEntry,
ExportContentStatsCacheService
} from './exportContentStatsCacheService'
import { exportCardDiagnosticsService } from './exportCardDiagnosticsService'
import { voiceTranscribeService } from './voiceTranscribeService'
import { LRUCache } from '../utils/LRUCache.js'
@@ -173,18 +168,6 @@ interface ExportSessionStatsCacheMeta {
source: 'memory' | 'disk' | 'fresh'
}
interface ExportContentSessionCounts {
totalSessions: number
textSessions: number
voiceSessions: number
imageSessions: number
videoSessions: number
emojiSessions: number
pendingMediaSessions: number
updatedAt: number
refreshing: boolean
}
interface ExportTabCounts {
private: number
group: number
@@ -229,7 +212,6 @@ class ChatService {
private readonly messageCacheService: MessageCacheService
private readonly sessionStatsCacheService: SessionStatsCacheService
private readonly groupMyMessageCountCacheService: GroupMyMessageCountCacheService
private readonly exportContentStatsCacheService: ExportContentStatsCacheService
private voiceWavCache: LRUCache<string, Buffer>
private voiceTranscriptCache: LRUCache<string, string>
private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>()
@@ -270,24 +252,6 @@ class ChatService {
private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000
private groupMyMessageCountCacheScope = ''
private groupMyMessageCountMemoryCache = new Map<string, GroupMyMessageCountCacheEntry>()
private exportContentStatsScope = ''
private exportContentStatsMemory = new Map<string, ExportContentSessionStatsEntry>()
private exportContentStatsScopeUpdatedAt = 0
private exportContentStatsRefreshPromise: Promise<void> | null = null
private exportContentStatsRefreshQueued = false
private exportContentStatsRefreshForceQueued = false
private exportContentTextRefreshPromise: Promise<void> | null = null
private exportContentTextRefreshQueued = false
private exportContentTextRefreshForceQueued = false
private exportContentSessionCountsInFlight: {
promise: Promise<{ success: boolean; data?: ExportContentSessionCounts; error?: string }>
forceRefresh: boolean
traceId: string
startedAt: number
} | null = null
private exportContentStatsDirtySessionIds = new Set<string>()
private exportContentScopeSessionIdsCache: { ids: string[]; updatedAt: number } | null = null
private readonly exportContentScopeSessionIdsCacheTtlMs = 60 * 1000
constructor() {
this.configService = new ConfigService()
@@ -297,7 +261,6 @@ class ChatService {
this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath())
this.sessionStatsCacheService = new SessionStatsCacheService(this.configService.getCacheBasePath())
this.groupMyMessageCountCacheService = new GroupMyMessageCountCacheService(this.configService.getCacheBasePath())
this.exportContentStatsCacheService = new ExportContentStatsCacheService(this.configService.getCacheBasePath())
// 初始化LRU缓存,限制大小防止内存泄漏
this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries)
this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录
@@ -368,10 +331,6 @@ class ChatService {
// 预热 listMediaDbs 缓存(后台异步执行,不阻塞连接)
this.warmupMediaDbsCache()
// 预热导出内容会话统计缓存(后台异步,不阻塞连接)
void this.startExportContentStatsRefresh(false).catch((error) => {
console.error('ChatService: 后台刷新导出内容统计失败:', error)
})
return { success: true }
} catch (e) {
@@ -440,14 +399,6 @@ class ChatService {
console.error('ChatService: 关闭数据库失败:', e)
}
this.connected = false
this.exportContentStatsRefreshPromise = null
this.exportContentStatsRefreshQueued = false
this.exportContentStatsRefreshForceQueued = false
this.exportContentTextRefreshPromise = null
this.exportContentTextRefreshQueued = false
this.exportContentTextRefreshForceQueued = false
this.exportContentSessionCountsInFlight = null
this.exportContentScopeSessionIdsCache = null
}
/**
@@ -649,15 +600,27 @@ class ChatService {
/**
* 异步补充会话列表的联系人信息(公开方法,供前端调用)
*/
async enrichSessionsContactInfo(usernames: string[]): Promise<{
async enrichSessionsContactInfo(
usernames: string[],
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
): Promise<{
success: boolean
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
error?: string
}> {
try {
if (usernames.length === 0) {
const normalizedUsernames = Array.from(
new Set(
(usernames || [])
.map((username) => String(username || '').trim())
.filter(Boolean)
)
)
if (normalizedUsernames.length === 0) {
return { success: true, contacts: {} }
}
const skipDisplayName = options?.skipDisplayName === true
const onlyMissingAvatar = options?.onlyMissingAvatar === true
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
@@ -670,15 +633,23 @@ class ChatService {
const updatedEntries: Record<string, ContactCacheEntry> = {}
// 检查缓存
for (const username of usernames) {
for (const username of normalizedUsernames) {
const cached = this.avatarCache.get(username)
const isValidAvatar = this.isValidAvatarUrl(cached?.avatarUrl)
const cachedAvatarUrl = isValidAvatar ? cached?.avatarUrl : undefined
if (onlyMissingAvatar && cachedAvatarUrl) {
result[username] = {
displayName: skipDisplayName ? undefined : cached?.displayName,
avatarUrl: cachedAvatarUrl
}
continue
}
// 如果缓存有效且有头像,直接使用;如果没有头像,也需要重新尝试获取
// 额外检查:如果头像是无效的 hex 格式(以 ffd8 开头),也需要重新获取
const isValidAvatar = this.isValidAvatarUrl(cached?.avatarUrl)
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs && isValidAvatar) {
result[username] = {
displayName: cached.displayName,
avatarUrl: cached.avatarUrl
displayName: skipDisplayName ? undefined : cached.displayName,
avatarUrl: cachedAvatarUrl
}
} else {
missing.push(username)
@@ -687,16 +658,19 @@ class ChatService {
// 批量查询缺失的联系人信息
if (missing.length > 0) {
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(missing),
wcdbService.getAvatarUrls(missing)
])
const displayNames = skipDisplayName
? null
: await wcdbService.getDisplayNames(missing)
const avatarUrls = await wcdbService.getAvatarUrls(missing)
// 收集没有头像 URL 的用户名
const missingAvatars: string[] = []
for (const username of missing) {
const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined
const previous = this.avatarCache.get(username)
const displayName = displayNames?.success && displayNames.map
? displayNames.map[username]
: undefined
let avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined
// 如果没有头像 URL,记录下来稍后从 head_image.db 获取
@@ -705,11 +679,14 @@ class ChatService {
}
const cacheEntry: ContactCacheEntry = {
displayName: displayName || username,
displayName: displayName || previous?.displayName || username,
avatarUrl,
updatedAt: now
}
result[username] = { displayName, avatarUrl }
result[username] = {
displayName: skipDisplayName ? undefined : (displayName || previous?.displayName),
avatarUrl
}
// 更新缓存并记录持久化
this.avatarCache.set(username, cacheEntry)
updatedEntries[username] = cacheEntry
@@ -1770,7 +1747,6 @@ class ChatService {
if (scope === this.sessionMessageCountCacheScope) {
this.refreshSessionStatsCacheScope(scope)
this.refreshGroupMyMessageCountCacheScope(scope)
this.refreshExportContentStatsScope(scope)
return
}
this.sessionMessageCountCacheScope = scope
@@ -1781,7 +1757,6 @@ class ChatService {
this.sessionStatusCache.clear()
this.refreshSessionStatsCacheScope(scope)
this.refreshGroupMyMessageCountCacheScope(scope)
this.refreshExportContentStatsScope(scope)
}
private refreshGroupMyMessageCountCacheScope(scope: string): void {
@@ -1790,780 +1765,6 @@ class ChatService {
this.groupMyMessageCountMemoryCache.clear()
}
private refreshExportContentStatsScope(scope: string): void {
if (scope === this.exportContentStatsScope) return
this.exportContentStatsScope = scope
this.exportContentStatsMemory.clear()
this.exportContentStatsDirtySessionIds.clear()
this.exportContentScopeSessionIdsCache = null
const scopeEntry = this.exportContentStatsCacheService.getScope(scope)
if (scopeEntry) {
this.exportContentStatsScopeUpdatedAt = scopeEntry.updatedAt
for (const [sessionId, entry] of Object.entries(scopeEntry.sessions)) {
this.exportContentStatsMemory.set(sessionId, { ...entry })
}
const snapshotIds = Object.keys(scopeEntry.sessions)
.map((sessionId) => String(sessionId || '').trim())
.filter(Boolean)
.filter((sessionId) => sessionId.endsWith('@chatroom') || !sessionId.startsWith('gh_'))
if (snapshotIds.length > 0) {
this.exportContentScopeSessionIdsCache = {
ids: snapshotIds,
updatedAt: Date.now()
}
}
} else {
this.exportContentStatsScopeUpdatedAt = 0
}
}
private persistExportContentStatsScope(validSessionIds?: Set<string>): void {
if (!this.exportContentStatsScope) return
const sessions: Record<string, ExportContentSessionStatsEntry> = {}
for (const [sessionId, entry] of this.exportContentStatsMemory.entries()) {
if (validSessionIds && !validSessionIds.has(sessionId)) continue
sessions[sessionId] = { ...entry }
}
const updatedAt = this.exportContentStatsScopeUpdatedAt || Date.now()
const scopeEntry: ExportContentScopeStatsEntry = {
updatedAt,
sessions
}
this.exportContentStatsCacheService.setScope(this.exportContentStatsScope, scopeEntry)
}
private async listExportContentScopeSessionIds(force = false, traceId?: string): Promise<string[]> {
const stepStartedAt = this.startExportDiagStep({
traceId,
stepId: 'backend-list-export-content-session-ids',
stepName: '列出导出内容会话范围',
message: '开始获取导出内容统计范围会话',
data: { force }
})
let success = false
let loadedCount = 0
let errorMessage = ''
const now = Date.now()
if (
!force &&
this.exportContentScopeSessionIdsCache &&
now - this.exportContentScopeSessionIdsCache.updatedAt <= this.exportContentScopeSessionIdsCacheTtlMs
) {
success = true
loadedCount = this.exportContentScopeSessionIdsCache.ids.length
this.endExportDiagStep({
traceId,
stepId: 'backend-list-export-content-session-ids',
stepName: '列出导出内容会话范围',
startedAt: stepStartedAt,
success,
message: '命中会话范围缓存',
data: { count: loadedCount, fromCache: true }
})
return this.exportContentScopeSessionIdsCache.ids
}
if (!force && this.exportContentStatsMemory.size > 0) {
const ids = Array.from(this.exportContentStatsMemory.keys())
.map((sessionId) => String(sessionId || '').trim())
.filter(Boolean)
.filter((sessionId) => sessionId.endsWith('@chatroom') || !sessionId.startsWith('gh_'))
if (ids.length > 0) {
this.exportContentScopeSessionIdsCache = {
ids,
updatedAt: now
}
success = true
loadedCount = ids.length
this.endExportDiagStep({
traceId,
stepId: 'backend-list-export-content-session-ids',
stepName: '列出导出内容会话范围',
startedAt: stepStartedAt,
success,
message: '命中持久化会话范围快照',
data: { count: loadedCount, fromCache: true, fromSnapshot: true }
})
return ids
}
}
try {
const sessionsResult = await this.getSessions()
if (!sessionsResult.success || !sessionsResult.sessions) {
errorMessage = sessionsResult.error || '获取会话失败'
return []
}
const ids = Array.from(
new Set(
sessionsResult.sessions
.map((session) => String(session.username || '').trim())
.filter(Boolean)
.filter((sessionId) => sessionId.endsWith('@chatroom') || !sessionId.startsWith('gh_'))
)
)
this.exportContentScopeSessionIdsCache = {
ids,
updatedAt: now
}
success = true
loadedCount = ids.length
return ids
} catch (error) {
errorMessage = String(error)
return []
} finally {
this.endExportDiagStep({
traceId,
stepId: 'backend-list-export-content-session-ids',
stepName: '列出导出内容会话范围',
startedAt: stepStartedAt,
success,
message: success ? '导出内容会话范围获取完成' : '导出内容会话范围获取失败',
data: success ? { count: loadedCount, fromCache: false } : { error: errorMessage || '未知错误' }
})
}
}
private createDefaultExportContentEntry(): ExportContentSessionStatsEntry {
return {
updatedAt: 0,
hasAny: false,
hasVoice: false,
hasImage: false,
hasVideo: false,
hasEmoji: false,
mediaReady: false
}
}
private isExportContentEntryDirty(sessionId: string): boolean {
return this.exportContentStatsDirtySessionIds.has(sessionId)
}
private buildExportContentSessionCountsSnapshot(
sessionIds: string[],
options?: { forceRefresh?: boolean }
): {
counts: ExportContentSessionCounts
missingTextCountSessionIds: string[]
pendingMediaSessionSet: Set<string>
} {
const forceRefresh = options?.forceRefresh === true
const sessionIdSet = new Set(sessionIds)
for (const sessionId of Array.from(this.exportContentStatsMemory.keys())) {
if (!sessionIdSet.has(sessionId)) {
this.exportContentStatsMemory.delete(sessionId)
this.exportContentStatsDirtySessionIds.delete(sessionId)
}
}
const missingTextCountSessionIds: string[] = []
let textSessions = 0
let voiceSessions = 0
let imageSessions = 0
let videoSessions = 0
let emojiSessions = 0
const pendingMediaSessionSet = new Set<string>()
for (const sessionId of sessionIds) {
const entry = this.exportContentStatsMemory.get(sessionId)
const hasKnownTextCount = Boolean(entry && entry.updatedAt > 0)
if (entry?.hasAny) {
textSessions += 1
}
if (forceRefresh || !entry || this.isExportContentEntryDirty(sessionId) || !hasKnownTextCount) {
missingTextCountSessionIds.push(sessionId)
}
const hasMediaSnapshot = Boolean(entry && entry.mediaReady)
if (hasMediaSnapshot && entry) {
if (entry.hasVoice) voiceSessions += 1
if (entry.hasImage) imageSessions += 1
if (entry.hasVideo) videoSessions += 1
if (entry.hasEmoji) emojiSessions += 1
} else {
pendingMediaSessionSet.add(sessionId)
}
if (this.isExportContentEntryDirty(sessionId) && hasMediaSnapshot) {
pendingMediaSessionSet.add(sessionId)
}
}
return {
counts: {
totalSessions: sessionIds.length,
textSessions,
voiceSessions,
imageSessions,
videoSessions,
emojiSessions,
pendingMediaSessions: pendingMediaSessionSet.size,
updatedAt: this.exportContentStatsScopeUpdatedAt,
refreshing: this.exportContentStatsRefreshPromise !== null || this.exportContentTextRefreshPromise !== null
},
missingTextCountSessionIds,
pendingMediaSessionSet
}
}
private async startExportContentTextCountsRefresh(
force = false,
traceId?: string,
sessionIdsHint?: string[]
): Promise<void> {
const refreshStartedAt = this.startExportDiagStep({
traceId,
stepId: 'backend-fill-text-counts',
stepName: '补全文本会话计数',
message: '开始补全文本会话计数',
data: { force, hintSessions: Array.isArray(sessionIdsHint) ? sessionIdsHint.length : 0 }
})
if (this.exportContentTextRefreshPromise) {
this.exportContentTextRefreshQueued = true
this.exportContentTextRefreshForceQueued = this.exportContentTextRefreshForceQueued || force
this.endExportDiagStep({
traceId,
stepId: 'backend-fill-text-counts',
stepName: '补全文本会话计数',
startedAt: refreshStartedAt,
success: true,
message: '复用进行中的文本会话补全任务',
data: { queued: true, forceQueued: this.exportContentTextRefreshForceQueued }
})
return this.exportContentTextRefreshPromise
}
const task = (async () => {
const sessionIds = Array.isArray(sessionIdsHint) && sessionIdsHint.length > 0
? sessionIdsHint
: await this.listExportContentScopeSessionIds(force, traceId)
if (sessionIds.length === 0) return
const snapshot = this.buildExportContentSessionCountsSnapshot(sessionIds, { forceRefresh: force })
const targets = snapshot.missingTextCountSessionIds
if (targets.length === 0) return
const stallStartedAt = Date.now()
const textCountStallTimer = setTimeout(() => {
this.logExportDiag({
traceId,
source: 'backend',
level: 'warn',
message: '补全文本会话计数耗时较长',
stepId: 'backend-fill-text-counts',
stepName: '补全文本会话计数',
status: 'running',
data: {
elapsedMs: Date.now() - stallStartedAt,
missingSessions: targets.length
}
})
}, 3000)
try {
const textCountResult = await this.getSessionMessageCounts(targets, {
preferHintCache: false,
bypassSessionCache: true,
traceId
})
if (!textCountResult.success || !textCountResult.counts) {
throw new Error(textCountResult.error || '文本会话计数补全失败')
}
const now = Date.now()
for (const sessionId of targets) {
const count = textCountResult.counts[sessionId]
const hasAny = Number.isFinite(count) && Number(count) > 0
const prevEntry = this.exportContentStatsMemory.get(sessionId) || this.createDefaultExportContentEntry()
this.exportContentStatsMemory.set(sessionId, {
...prevEntry,
hasAny,
updatedAt: now
})
}
this.exportContentStatsScopeUpdatedAt = now
this.persistExportContentStatsScope(new Set(sessionIds))
} finally {
clearTimeout(textCountStallTimer)
}
})()
this.exportContentTextRefreshPromise = task
try {
await task
this.endExportDiagStep({
traceId,
stepId: 'backend-fill-text-counts',
stepName: '补全文本会话计数',
startedAt: refreshStartedAt,
success: true,
message: '文本会话计数补全完成'
})
} catch (error) {
this.endExportDiagStep({
traceId,
stepId: 'backend-fill-text-counts',
stepName: '补全文本会话计数',
startedAt: refreshStartedAt,
success: false,
message: '文本会话计数补全失败',
data: { error: String(error) }
})
throw error
} finally {
this.exportContentTextRefreshPromise = null
if (this.exportContentTextRefreshQueued) {
const rerunForce = this.exportContentTextRefreshForceQueued
this.exportContentTextRefreshQueued = false
this.exportContentTextRefreshForceQueued = false
void this.startExportContentTextCountsRefresh(rerunForce, traceId).catch((error) => {
console.error('ChatService: 重新触发文本会话计数补全失败:', error)
})
}
}
}
private async collectExportContentEntry(sessionId: string, traceId?: string): Promise<ExportContentSessionStatsEntry> {
const stepStartedAt = this.startExportDiagStep({
traceId,
stepId: 'backend-collect-export-content-entry',
stepName: '扫描单会话内容类型',
message: '开始扫描会话内容类型',
data: { sessionId }
})
let fallback = false
const entry = this.createDefaultExportContentEntry()
const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 400, false, 0, 0)
if (!cursorResult.success || !cursorResult.cursor) {
fallback = true
this.endExportDiagStep({
traceId,
stepId: 'backend-collect-export-content-entry',
stepName: '扫描单会话内容类型',
startedAt: stepStartedAt,
success: false,
message: '会话内容扫描失败,游标未创建',
data: { sessionId, error: cursorResult.error || 'openMessageCursorLite failed' }
})
return {
...entry,
updatedAt: Date.now(),
mediaReady: false
}
}
const cursor = cursorResult.cursor
try {
try {
let done = false
while (!done) {
const batch = await wcdbService.fetchMessageBatch(cursor)
if (!batch.success) {
break
}
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
for (const row of rows) {
entry.hasAny = true
const localType = this.getRowInt(
row,
['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'],
1
)
if (localType === 34) entry.hasVoice = true
if (localType === 3) entry.hasImage = true
if (localType === 43) entry.hasVideo = true
if (localType === 47) entry.hasEmoji = true
if (entry.hasVoice && entry.hasImage && entry.hasVideo && entry.hasEmoji) {
done = true
break
}
}
if (!batch.hasMore || rows.length === 0) {
break
}
}
} catch (error) {
this.endExportDiagStep({
traceId,
stepId: 'backend-collect-export-content-entry',
stepName: '扫描单会话内容类型',
startedAt: stepStartedAt,
success: false,
message: '会话内容扫描异常',
data: { sessionId, error: String(error) }
})
throw error
}
entry.mediaReady = true
entry.updatedAt = Date.now()
if (!fallback) {
this.endExportDiagStep({
traceId,
stepId: 'backend-collect-export-content-entry',
stepName: '扫描单会话内容类型',
startedAt: stepStartedAt,
success: true,
message: '会话内容扫描完成',
data: {
sessionId,
hasAny: entry.hasAny,
hasVoice: entry.hasVoice,
hasImage: entry.hasImage,
hasVideo: entry.hasVideo,
hasEmoji: entry.hasEmoji
}
})
}
return entry
} finally {
await wcdbService.closeMessageCursor(cursor)
}
}
private async startExportContentStatsRefresh(force = false, traceId?: string): Promise<void> {
const refreshStartedAt = this.startExportDiagStep({
traceId,
stepId: 'backend-refresh-export-content-stats',
stepName: '后台刷新导出内容统计',
message: '开始后台刷新导出内容会话统计',
data: { force }
})
if (this.exportContentStatsRefreshPromise) {
this.exportContentStatsRefreshQueued = true
this.exportContentStatsRefreshForceQueued = this.exportContentStatsRefreshForceQueued || force
this.logExportDiag({
traceId,
level: 'debug',
source: 'backend',
message: '已有刷新任务在执行,已加入队列',
stepId: 'backend-refresh-export-content-stats',
stepName: '后台刷新导出内容统计',
status: 'running',
data: { forceQueued: this.exportContentStatsRefreshForceQueued }
})
this.endExportDiagStep({
traceId,
stepId: 'backend-refresh-export-content-stats',
stepName: '后台刷新导出内容统计',
startedAt: refreshStartedAt,
success: true,
message: '复用进行中的后台刷新任务',
data: { queued: true, forceQueued: this.exportContentStatsRefreshForceQueued }
})
return this.exportContentStatsRefreshPromise
}
const task = (async () => {
const sessionIds = await this.listExportContentScopeSessionIds(force, traceId)
const sessionIdSet = new Set(sessionIds)
const targets: string[] = []
for (const sessionId of sessionIds) {
const cached = this.exportContentStatsMemory.get(sessionId)
if (force || this.exportContentStatsDirtySessionIds.has(sessionId) || !cached || !cached.mediaReady) {
targets.push(sessionId)
}
}
if (targets.length > 0) {
this.logExportDiag({
traceId,
source: 'backend',
level: 'info',
message: '准备刷新导出内容会话统计目标',
stepId: 'backend-refresh-export-content-stats',
stepName: '后台刷新导出内容统计',
status: 'running',
data: {
totalSessions: sessionIds.length,
targetSessions: targets.length
}
})
await this.forEachWithConcurrency(targets, 3, async (sessionId) => {
try {
const nextEntry = await this.collectExportContentEntry(sessionId, traceId)
this.exportContentStatsMemory.set(sessionId, nextEntry)
if (nextEntry.mediaReady) {
this.exportContentStatsDirtySessionIds.delete(sessionId)
} else {
this.exportContentStatsDirtySessionIds.add(sessionId)
}
} catch (error) {
console.error('ChatService: 刷新导出内容会话统计失败:', sessionId, error)
this.exportContentStatsDirtySessionIds.add(sessionId)
}
})
}
for (const sessionId of Array.from(this.exportContentStatsMemory.keys())) {
if (!sessionIdSet.has(sessionId)) {
this.exportContentStatsMemory.delete(sessionId)
this.exportContentStatsDirtySessionIds.delete(sessionId)
}
}
this.exportContentStatsScopeUpdatedAt = Date.now()
this.persistExportContentStatsScope(sessionIdSet)
})()
this.exportContentStatsRefreshPromise = task
try {
await task
this.endExportDiagStep({
traceId,
stepId: 'backend-refresh-export-content-stats',
stepName: '后台刷新导出内容统计',
startedAt: refreshStartedAt,
success: true,
message: '后台刷新导出内容统计完成',
data: {
force
}
})
} catch (error) {
this.endExportDiagStep({
traceId,
stepId: 'backend-refresh-export-content-stats',
stepName: '后台刷新导出内容统计',
startedAt: refreshStartedAt,
success: false,
message: '后台刷新导出内容统计失败',
data: { error: String(error) }
})
throw error
} finally {
this.exportContentStatsRefreshPromise = null
if (this.exportContentStatsRefreshQueued) {
const rerunForce = this.exportContentStatsRefreshForceQueued
this.exportContentStatsRefreshQueued = false
this.exportContentStatsRefreshForceQueued = false
void this.startExportContentStatsRefresh(rerunForce, traceId).catch((error) => {
console.error('ChatService: 重新触发导出内容统计刷新失败:', error)
})
}
}
}
async getExportContentSessionCounts(options?: {
triggerRefresh?: boolean
forceRefresh?: boolean
traceId?: string
}): Promise<{ success: boolean; data?: ExportContentSessionCounts; error?: string }> {
const traceId = this.normalizeExportDiagTraceId(options?.traceId)
const forceRefresh = options?.forceRefresh === true
const triggerRefresh = options?.triggerRefresh !== false
const stepStartedAt = this.startExportDiagStep({
traceId,
stepId: 'backend-get-export-content-session-counts',
stepName: '获取导出卡片统计',
message: '开始计算导出卡片统计',
data: {
triggerRefresh,
forceRefresh
}
})
let stepSuccess = false
let stepError = ''
let stepResult: ExportContentSessionCounts | undefined
let activePromise: Promise<{ success: boolean; data?: ExportContentSessionCounts; error?: string }> | null = null
let createdInFlight = false
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
stepError = connectResult.error || '数据库未连接'
return { success: false, error: stepError }
}
this.refreshSessionMessageCountCacheScope()
if (!forceRefresh && this.exportContentSessionCountsInFlight) {
const sessionIds = this.exportContentScopeSessionIdsCache?.ids || Array.from(this.exportContentStatsMemory.keys())
const snapshot = this.buildExportContentSessionCountsSnapshot(sessionIds, { forceRefresh: false })
if (triggerRefresh) {
if (snapshot.missingTextCountSessionIds.length > 0) {
void this.startExportContentTextCountsRefresh(false, traceId, sessionIds).catch((error) => {
console.error('ChatService: 触发文本会话计数补全失败:', error)
})
}
if (snapshot.pendingMediaSessionSet.size > 0 || this.exportContentStatsDirtySessionIds.size > 0) {
void this.startExportContentStatsRefresh(false, traceId).catch((error) => {
console.error('ChatService: 触发导出内容统计刷新失败:', error)
})
}
}
this.logExportDiag({
traceId,
source: 'backend',
level: 'info',
message: '返回缓存快照,后台统计任务仍在进行',
stepId: 'backend-get-export-content-session-counts',
stepName: '获取导出卡片统计',
status: 'running',
data: {
inFlightTraceId: this.exportContentSessionCountsInFlight.traceId,
inFlightElapsedMs: Date.now() - this.exportContentSessionCountsInFlight.startedAt,
totalSessions: snapshot.counts.totalSessions
}
})
stepResult = {
...snapshot.counts,
refreshing: true
}
stepSuccess = true
return { success: true, data: stepResult }
}
if (this.exportContentSessionCountsInFlight) {
this.logExportDiag({
traceId,
source: 'backend',
level: 'info',
message: '复用进行中的导出卡片统计任务',
stepId: 'backend-get-export-content-session-counts',
stepName: '获取导出卡片统计',
status: 'running',
data: {
inFlightTraceId: this.exportContentSessionCountsInFlight.traceId,
inFlightForceRefresh: this.exportContentSessionCountsInFlight.forceRefresh,
requestedForceRefresh: forceRefresh,
inFlightElapsedMs: Date.now() - this.exportContentSessionCountsInFlight.startedAt
}
})
activePromise = this.exportContentSessionCountsInFlight.promise
} else {
const createdPromise = (async () => {
const sessionIds = await this.listExportContentScopeSessionIds(forceRefresh, traceId)
const snapshot = this.buildExportContentSessionCountsSnapshot(sessionIds, { forceRefresh })
if (triggerRefresh) {
if (snapshot.missingTextCountSessionIds.length > 0) {
void this.startExportContentTextCountsRefresh(forceRefresh, traceId, sessionIds).catch((error) => {
console.error('ChatService: 触发文本会话计数补全失败:', error)
})
}
if (forceRefresh || snapshot.pendingMediaSessionSet.size > 0 || this.exportContentStatsDirtySessionIds.size > 0) {
void this.startExportContentStatsRefresh(forceRefresh, traceId).catch((error) => {
console.error('ChatService: 触发导出内容统计刷新失败:', error)
})
}
}
return {
success: true,
data: {
...snapshot.counts,
refreshing: this.exportContentStatsRefreshPromise !== null || this.exportContentTextRefreshPromise !== null
}
}
})()
activePromise = createdPromise
this.exportContentSessionCountsInFlight = {
promise: createdPromise,
forceRefresh,
traceId,
startedAt: Date.now()
}
createdInFlight = true
}
if (!activePromise) {
stepError = '统计任务未初始化'
return { success: false, error: stepError }
}
const result = await activePromise
stepSuccess = result.success
if (result.success && result.data) {
stepResult = result.data
} else {
stepError = result.error || '未知错误'
}
return result
} catch (e) {
console.error('ChatService: 获取导出内容会话统计失败:', e)
stepError = String(e)
return { success: false, error: String(e) }
} finally {
if (createdInFlight && activePromise && this.exportContentSessionCountsInFlight?.promise === activePromise) {
this.exportContentSessionCountsInFlight = null
}
this.endExportDiagStep({
traceId,
stepId: 'backend-get-export-content-session-counts',
stepName: '获取导出卡片统计',
startedAt: stepStartedAt,
success: stepSuccess,
message: stepSuccess ? '导出卡片统计计算完成' : '导出卡片统计计算失败',
data: stepSuccess
? {
totalSessions: stepResult?.totalSessions || 0,
pendingMediaSessions: stepResult?.pendingMediaSessions || 0,
refreshing: stepResult?.refreshing === true
}
: { error: stepError || '未知错误' }
})
}
}
async refreshExportContentSessionCounts(options?: { forceRefresh?: boolean; traceId?: string }): Promise<{ success: boolean; error?: string }> {
const traceId = this.normalizeExportDiagTraceId(options?.traceId)
const stepStartedAt = this.startExportDiagStep({
traceId,
stepId: 'backend-refresh-export-content-session-counts',
stepName: '刷新导出卡片统计',
message: '开始刷新导出卡片统计',
data: { forceRefresh: options?.forceRefresh === true }
})
let stepSuccess = false
let stepError = ''
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
stepError = connectResult.error || '数据库未连接'
return { success: false, error: connectResult.error || '数据库未连接' }
}
this.refreshSessionMessageCountCacheScope()
const force = options?.forceRefresh === true
void this.startExportContentTextCountsRefresh(force, traceId).catch((error) => {
console.error('ChatService: 刷新导出卡片统计(文本计数)失败:', error)
})
void this.startExportContentStatsRefresh(force, traceId).catch((error) => {
console.error('ChatService: 刷新导出卡片统计(内容类型)失败:', error)
})
stepSuccess = true
return { success: true }
} catch (e) {
console.error('ChatService: 刷新导出内容会话统计失败:', e)
stepError = String(e)
return { success: false, error: String(e) }
} finally {
this.endExportDiagStep({
traceId,
stepId: 'backend-refresh-export-content-session-counts',
stepName: '刷新导出卡片统计',
startedAt: stepStartedAt,
success: stepSuccess,
message: stepSuccess ? '刷新导出卡片统计完成' : '刷新导出卡片统计失败',
data: stepSuccess ? undefined : { error: stepError || '未知错误' }
})
}
}
private refreshSessionStatsCacheScope(scope: string): void {
if (scope === this.sessionStatsCacheScope) return
this.sessionStatsCacheScope = scope
@@ -2751,8 +1952,6 @@ class ChatService {
if (ids.size > 0) {
ids.forEach((sessionId) => this.deleteSessionStatsCacheEntry(sessionId))
this.exportContentScopeSessionIdsCache = null
ids.forEach((sessionId) => this.exportContentStatsDirtySessionIds.add(sessionId))
if (Array.from(ids).some((id) => id.includes('@chatroom'))) {
this.allGroupSessionIdsCache = null
}
@@ -2768,10 +1967,6 @@ class ChatService {
normalizedType.includes('contact')
) {
this.clearSessionStatsCacheForScope()
this.exportContentScopeSessionIdsCache = null
for (const sessionId of this.exportContentStatsMemory.keys()) {
this.exportContentStatsDirtySessionIds.add(sessionId)
}
}
}
@@ -4939,11 +4134,6 @@ class ChatService {
this.sessionStatsCacheService.clearAll()
this.groupMyMessageCountMemoryCache.clear()
this.groupMyMessageCountCacheService.clearAll()
this.exportContentStatsMemory.clear()
this.exportContentStatsDirtySessionIds.clear()
this.exportContentScopeSessionIdsCache = null
this.exportContentStatsScopeUpdatedAt = 0
this.exportContentStatsCacheService.clearAll()
}
for (const state of this.hardlinkCache.values()) {
+327 -2
View File
@@ -44,6 +44,68 @@ export interface SnsPost {
linkUrl?: string
}
interface SnsContactIdentity {
username: string
wxid: string
alias?: string
wechatId?: string
remark?: string
nickName?: string
displayName: string
}
interface ParsedLikeUser {
username?: string
nickname?: string
}
interface ParsedCommentItem {
id: string
nickname: string
username?: string
content: string
refCommentId: string
refUsername?: string
refNickname?: string
emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[]
}
interface ArkmeLikeDetail {
nickname: string
username?: string
wxid?: string
alias?: string
wechatId?: string
remark?: string
nickName?: string
displayName: string
source: 'xml' | 'legacy'
}
interface ArkmeCommentDetail {
id: string
nickname: string
username?: string
wxid?: string
alias?: string
wechatId?: string
remark?: string
nickName?: string
displayName: string
content: string
refCommentId: string
refNickname?: string
refUsername?: string
refWxid?: string
refAlias?: string
refWechatId?: string
refRemark?: string
refNickName?: string
refDisplayName?: string
emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[]
source: 'xml' | 'legacy'
}
const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => {
@@ -127,7 +189,7 @@ const extractVideoKey = (xml: string): string | undefined => {
/**
* 从 XML 中解析评论信息(含表情包、回复关系)
*/
function parseCommentsFromXml(xml: string): { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[] {
function parseCommentsFromXml(xml: string): ParsedCommentItem[] {
if (!xml) return []
type CommentItem = {
@@ -239,6 +301,204 @@ class SnsService {
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
}
private toOptionalString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}
private async resolveContactIdentity(
username: string,
identityCache: Map<string, Promise<SnsContactIdentity | null>>
): Promise<SnsContactIdentity | null> {
const normalized = String(username || '').trim()
if (!normalized) return null
let pending = identityCache.get(normalized)
if (!pending) {
pending = (async () => {
const cached = this.contactCache.get(normalized)
let alias: string | undefined
let remark: string | undefined
let nickName: string | undefined
try {
const contactResult = await wcdbService.getContact(normalized)
if (contactResult.success && contactResult.contact) {
const contact = contactResult.contact
alias = this.toOptionalString(contact.alias ?? contact.Alias)
remark = this.toOptionalString(contact.remark ?? contact.Remark)
nickName = this.toOptionalString(contact.nickName ?? contact.nick_name ?? contact.nickname ?? contact.NickName)
}
} catch {
// 联系人补全失败不影响导出
}
const displayName = remark || nickName || alias || cached?.displayName || normalized
return {
username: normalized,
wxid: normalized,
alias,
wechatId: alias,
remark,
nickName,
displayName
}
})()
identityCache.set(normalized, pending)
}
return pending
}
private parseLikeUsersFromXml(xml: string): ParsedLikeUser[] {
if (!xml) return []
const likes: ParsedLikeUser[] = []
try {
let likeListMatch = xml.match(/<LikeUserList>([\s\S]*?)<\/LikeUserList>/i)
if (!likeListMatch) likeListMatch = xml.match(/<likeUserList>([\s\S]*?)<\/likeUserList>/i)
if (!likeListMatch) likeListMatch = xml.match(/<likeList>([\s\S]*?)<\/likeList>/i)
if (!likeListMatch) likeListMatch = xml.match(/<like_user_list>([\s\S]*?)<\/like_user_list>/i)
if (!likeListMatch) return likes
const likeUserRegex = /<(?:LikeUser|likeUser|user_comment)>([\s\S]*?)<\/(?:LikeUser|likeUser|user_comment)>/gi
let m: RegExpExecArray | null
while ((m = likeUserRegex.exec(likeListMatch[1])) !== null) {
const block = m[1]
const username = this.toOptionalString(block.match(/<username>([^<]*)<\/username>/i)?.[1])
const nickname = this.toOptionalString(
block.match(/<nickname>([^<]*)<\/nickname>/i)?.[1]
|| block.match(/<nickName>([^<]*)<\/nickName>/i)?.[1]
)
if (username || nickname) {
likes.push({ username, nickname })
}
}
} catch (e) {
console.error('[SnsService] 解析点赞用户失败:', e)
}
return likes
}
private async buildArkmeInteractionDetails(
post: SnsPost,
identityCache: Map<string, Promise<SnsContactIdentity | null>>
): Promise<{ likesDetail: ArkmeLikeDetail[]; commentsDetail: ArkmeCommentDetail[] }> {
const xmlLikes = this.parseLikeUsersFromXml(post.rawXml || '')
const likeCandidates: ParsedLikeUser[] = xmlLikes.length > 0
? xmlLikes
: (post.likes || []).map((nickname) => ({ nickname }))
const likeSource: 'xml' | 'legacy' = xmlLikes.length > 0 ? 'xml' : 'legacy'
const likesDetail: ArkmeLikeDetail[] = []
const likeSeen = new Set<string>()
for (const like of likeCandidates) {
const identity = like.username
? await this.resolveContactIdentity(like.username, identityCache)
: null
const nickname = like.nickname || identity?.displayName || like.username || ''
const username = identity?.username || like.username
const key = `${username || ''}|${nickname}`
if (likeSeen.has(key)) continue
likeSeen.add(key)
likesDetail.push({
nickname,
username,
wxid: username,
alias: identity?.alias,
wechatId: identity?.wechatId,
remark: identity?.remark,
nickName: identity?.nickName,
displayName: identity?.displayName || nickname || username || '',
source: likeSource
})
}
const xmlComments = parseCommentsFromXml(post.rawXml || '')
const commentMap = new Map<string, SnsPost['comments'][number]>()
for (const comment of post.comments || []) {
if (comment.id) commentMap.set(comment.id, comment)
}
const commentsBase: ParsedCommentItem[] = xmlComments.length > 0
? xmlComments.map((comment) => {
const fallback = comment.id ? commentMap.get(comment.id) : undefined
return {
id: comment.id || fallback?.id || '',
nickname: comment.nickname || fallback?.nickname || '',
username: comment.username,
content: comment.content || fallback?.content || '',
refCommentId: comment.refCommentId || fallback?.refCommentId || '',
refUsername: comment.refUsername,
refNickname: comment.refNickname || fallback?.refNickname,
emojis: comment.emojis && comment.emojis.length > 0 ? comment.emojis : fallback?.emojis
}
})
: (post.comments || []).map((comment) => ({
id: comment.id || '',
nickname: comment.nickname || '',
content: comment.content || '',
refCommentId: comment.refCommentId || '',
refNickname: comment.refNickname,
emojis: comment.emojis
}))
if (xmlComments.length > 0) {
const mappedIds = new Set(commentsBase.map((comment) => comment.id).filter(Boolean))
for (const comment of post.comments || []) {
if (comment.id && mappedIds.has(comment.id)) continue
commentsBase.push({
id: comment.id || '',
nickname: comment.nickname || '',
content: comment.content || '',
refCommentId: comment.refCommentId || '',
refNickname: comment.refNickname,
emojis: comment.emojis
})
}
}
const commentSource: 'xml' | 'legacy' = xmlComments.length > 0 ? 'xml' : 'legacy'
const commentsDetail: ArkmeCommentDetail[] = []
for (const comment of commentsBase) {
const actor = comment.username
? await this.resolveContactIdentity(comment.username, identityCache)
: null
const refActor = comment.refUsername
? await this.resolveContactIdentity(comment.refUsername, identityCache)
: null
const nickname = comment.nickname || actor?.displayName || comment.username || ''
const username = actor?.username || comment.username
const refUsername = refActor?.username || comment.refUsername
commentsDetail.push({
id: comment.id || '',
nickname,
username,
wxid: username,
alias: actor?.alias,
wechatId: actor?.wechatId,
remark: actor?.remark,
nickName: actor?.nickName,
displayName: actor?.displayName || nickname || username || '',
content: comment.content || '',
refCommentId: comment.refCommentId || '',
refNickname: comment.refNickname || refActor?.displayName,
refUsername,
refWxid: refUsername,
refAlias: refActor?.alias,
refWechatId: refActor?.wechatId,
refRemark: refActor?.remark,
refNickName: refActor?.nickName,
refDisplayName: refActor?.displayName,
emojis: comment.emojis,
source: commentSource
})
}
return { likesDetail, commentsDetail }
}
private parseCountValue(row: any): number {
if (!row || typeof row !== 'object') return 0
const raw = row.total ?? row.count ?? row.cnt ?? Object.values(row)[0]
@@ -821,7 +1081,7 @@ class SnsService {
*/
async exportTimeline(options: {
outputDir: string
format: 'json' | 'html'
format: 'json' | 'html' | 'arkmejson'
usernames?: string[]
keyword?: string
exportMedia?: boolean
@@ -1026,6 +1286,71 @@ class SnsService {
}))
}
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
} else if (format === 'arkmejson') {
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`)
progressCallback?.({ current: 0, total: allPosts.length, status: '正在构建 ArkmeJSON 数据...' })
const identityCache = new Map<string, Promise<SnsContactIdentity | null>>()
const posts: any[] = []
let built = 0
for (const post of allPosts) {
const controlState = getControlState()
if (controlState) {
return buildInterruptedResult(controlState, allPosts.length, mediaCount)
}
const authorIdentity = await this.resolveContactIdentity(post.username, identityCache)
const { likesDetail, commentsDetail } = await this.buildArkmeInteractionDetails(post, identityCache)
posts.push({
id: post.id,
username: post.username,
nickname: post.nickname,
author: authorIdentity
? {
...authorIdentity
}
: {
username: post.username,
wxid: post.username,
displayName: post.nickname || post.username
},
createTime: post.createTime,
createTimeStr: new Date(post.createTime * 1000).toLocaleString('zh-CN'),
contentDesc: post.contentDesc,
type: post.type,
media: post.media.map(m => ({
url: m.url,
thumb: m.thumb,
localPath: (m as any).localPath || undefined
})),
likes: post.likes,
comments: post.comments,
likesDetail,
commentsDetail,
linkTitle: (post as any).linkTitle,
linkUrl: (post as any).linkUrl
})
built++
if (built % 20 === 0 || built === allPosts.length) {
progressCallback?.({ current: built, total: allPosts.length, status: `正在构建 ArkmeJSON 数据 (${built}/${allPosts.length})...` })
}
}
const exportData = {
exportTime: new Date().toISOString(),
format: 'arkmejson',
schemaVersion: '1.0.0',
totalPosts: allPosts.length,
filters: {
usernames: usernames || [],
keyword: keyword || ''
},
posts
}
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
} else {
// HTML 格式
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
+84 -258
View File
@@ -51,6 +51,7 @@ type SessionLayout = 'shared' | 'per-session'
type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname'
type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson'
interface ExportOptions {
format: TextExportFormat
@@ -110,7 +111,7 @@ interface ExportTaskPayload {
contentType?: ContentType
sessionNames: string[]
snsOptions?: {
format: 'json' | 'html'
format: SnsTimelineExportFormat
exportMedia?: boolean
startTime?: number
endTime?: number
@@ -466,7 +467,6 @@ const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(
const createExportDiagTraceId = (): string => `export-card-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
const CONTACT_ENRICH_TIMEOUT_MS = 7000
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
const EXPORT_AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80
const CONTACTS_LIST_VIRTUAL_ROW_HEIGHT = 76
const CONTACTS_LIST_VIRTUAL_OVERSCAN = 10
@@ -541,18 +541,6 @@ interface SessionExportCacheMeta {
source: 'memory' | 'disk' | 'fresh'
}
interface ExportContentSessionCountsSummary {
totalSessions: number
textSessions: number
voiceSessions: number
imageSessions: number
videoSessions: number
emojiSessions: number
pendingMediaSessions: number
updatedAt: number
refreshing: boolean
}
type ExportCardDiagFilter = 'all' | 'frontend' | 'main' | 'backend' | 'worker' | 'warn' | 'error'
type ExportCardDiagSource = 'frontend' | 'main' | 'backend' | 'worker'
@@ -598,18 +586,6 @@ interface ExportCardDiagSnapshotState {
}
}
const defaultContentSessionCounts: ExportContentSessionCountsSummary = {
totalSessions: 0,
textSessions: 0,
voiceSessions: 0,
imageSessions: 0,
videoSessions: 0,
emojiSessions: 0,
pendingMediaSessions: 0,
updatedAt: 0,
refreshing: false
}
const defaultExportCardDiagSnapshot: ExportCardDiagSnapshotState = {
logs: [],
activeSteps: [],
@@ -888,7 +864,8 @@ function ExportPage() {
const [contactsAvatarEnrichProgress, setContactsAvatarEnrichProgress] = useState({
loaded: 0,
total: 0,
running: false
running: false,
tab: null as ConversationTab | null
})
const [showSessionDetailPanel, setShowSessionDetailPanel] = useState(false)
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
@@ -900,6 +877,7 @@ function ExportPage() {
const [exportFolder, setExportFolder] = useState('')
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('A')
const [snsExportFormat, setSnsExportFormat] = useState<SnsTimelineExportFormat>('html')
const [options, setOptions] = useState<ExportOptions>({
format: 'arkme-json',
@@ -937,8 +915,6 @@ function ExportPage() {
totalPosts: 0,
totalFriends: 0
})
const [contentSessionCounts, setContentSessionCounts] = useState<ExportContentSessionCountsSummary>(defaultContentSessionCounts)
const [hasSeededContentSessionCounts, setHasSeededContentSessionCounts] = useState(false)
const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false)
const [showCardDiagnostics, setShowCardDiagnostics] = useState(false)
const [diagFilter, setDiagFilter] = useState<ExportCardDiagFilter>('all')
@@ -960,6 +936,8 @@ function ExportPage() {
const preselectAppliedRef = useRef(false)
const exportCacheScopeRef = useRef('default')
const exportCacheScopeReadyRef = useRef(false)
const activeTabRef = useRef<ConversationTab>('private')
const contactsDataRef = useRef<ContactInfo[]>([])
const contactsLoadVersionRef = useRef(0)
const contactsLoadAttemptRef = useRef(0)
const contactsLoadTimeoutTimerRef = useRef<number | null>(null)
@@ -970,9 +948,6 @@ function ExportPage() {
const inProgressSessionIdsRef = useRef<string[]>([])
const activeTaskCountRef = useRef(0)
const hasBaseConfigReadyRef = useRef(false)
const contentSessionCountsForceRetryRef = useRef(0)
const contentSessionCountsInFlightRef = useRef<Promise<void> | null>(null)
const contentSessionCountsInFlightTraceRef = useRef('')
const appendFrontendDiagLog = useCallback((entry: ExportCardDiagLogEntry) => {
setFrontendDiagLogs(prev => {
@@ -1079,6 +1054,14 @@ function ExportPage() {
contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs
}, [contactsLoadTimeoutMs])
useEffect(() => {
activeTabRef.current = activeTab
}, [activeTab])
useEffect(() => {
contactsDataRef.current = contactsList
}, [contactsList])
const applyEnrichedContactsToList = useCallback((enrichedMap: Record<string, { displayName?: string; avatarUrl?: string }>) => {
if (!enrichedMap || Object.keys(enrichedMap).length === 0) return
setContactsList(prev => {
@@ -1105,7 +1088,8 @@ function ExportPage() {
const enrichContactsListInBackground = useCallback(async (
sourceContacts: ContactInfo[],
loadVersion: number,
scopeKey: string
scopeKey: string,
targetTab: ConversationTab
) => {
const sourceByUsername = new Map<string, ContactInfo>()
for (const contact of sourceContacts) {
@@ -1113,29 +1097,21 @@ function ExportPage() {
sourceByUsername.set(contact.username, contact)
}
const now = Date.now()
const usernames = sourceContacts
const usernames = Array.from(new Set(sourceContacts
.filter(contact => matchesContactTab(contact, targetTab))
.map(contact => contact.username)
.filter(Boolean)
.filter((username) => {
const currentContact = sourceByUsername.get(username)
if (!currentContact) return false
const cacheEntry = contactsAvatarCacheRef.current[username]
if (!cacheEntry || !cacheEntry.avatarUrl) {
return !currentContact.avatarUrl
}
if (currentContact.avatarUrl && currentContact.avatarUrl !== cacheEntry.avatarUrl) {
return true
}
const checkedAt = cacheEntry.checkedAt || 0
return now - checkedAt >= EXPORT_AVATAR_RECHECK_INTERVAL_MS
})
return Boolean(currentContact && !currentContact.avatarUrl)
})))
const total = usernames.length
setContactsAvatarEnrichProgress({
loaded: 0,
total,
running: total > 0
running: total > 0,
tab: targetTab
})
if (total === 0) return
@@ -1145,9 +1121,15 @@ function ExportPage() {
if (batch.length === 0) continue
try {
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(batch)
const avatarResult = await withTimeout(
window.electronAPI.chat.enrichSessionsContactInfo(batch, {
skipDisplayName: true,
onlyMissingAvatar: true
}),
CONTACT_ENRICH_TIMEOUT_MS
)
if (contactsLoadVersionRef.current !== loadVersion) return
if (avatarResult.success && avatarResult.contacts) {
if (avatarResult?.success && avatarResult.contacts) {
applyEnrichedContactsToList(avatarResult.contacts)
for (const [username, enriched] of Object.entries(avatarResult.contacts)) {
const prev = sourceByUsername.get(username)
@@ -1180,7 +1162,8 @@ function ExportPage() {
setContactsAvatarEnrichProgress({
loaded,
total,
running: loaded < total
running: loaded < total,
tab: targetTab
})
await new Promise(resolve => setTimeout(resolve, 0))
}
@@ -1192,6 +1175,7 @@ function ExportPage() {
const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => {
const scopeKey = options?.scopeKey || await ensureExportCacheScope()
const targetTab = activeTabRef.current
const loadVersion = contactsLoadVersionRef.current + 1
contactsLoadVersionRef.current = loadVersion
contactsLoadAttemptRef.current += 1
@@ -1228,7 +1212,8 @@ function ExportPage() {
setContactsAvatarEnrichProgress({
loaded: 0,
total: 0,
running: false
running: false,
tab: null
})
try {
@@ -1276,7 +1261,7 @@ function ExportPage() {
).catch((error) => {
console.error('写入导出页通讯录缓存失败:', error)
})
void enrichContactsListInBackground(contactsWithAvatarCache, loadVersion, scopeKey)
void enrichContactsListInBackground(contactsWithAvatarCache, loadVersion, scopeKey, targetTab)
return
}
@@ -1351,13 +1336,28 @@ function ExportPage() {
}
}, [isExportRoute, ensureExportCacheScope, loadContactsList, syncContactTypeCounts])
useEffect(() => {
if (!isExportRoute || isContactsListLoading || contactsDataRef.current.length === 0) return
let cancelled = false
const loadVersion = contactsLoadVersionRef.current
void (async () => {
const scopeKey = await ensureExportCacheScope()
if (cancelled || contactsLoadVersionRef.current !== loadVersion) return
await enrichContactsListInBackground(contactsDataRef.current, loadVersion, scopeKey, activeTab)
})()
return () => {
cancelled = true
}
}, [activeTab, ensureExportCacheScope, enrichContactsListInBackground, isContactsListLoading, isExportRoute])
useEffect(() => {
if (isExportRoute) return
contactsLoadVersionRef.current += 1
setContactsAvatarEnrichProgress({
loaded: 0,
total: 0,
running: false
running: false,
tab: null
})
}, [isExportRoute])
@@ -1538,163 +1538,6 @@ function ExportPage() {
}
}, [])
const loadContentSessionCounts = useCallback(async (options?: { silent?: boolean; forceRefresh?: boolean }) => {
if (contentSessionCountsInFlightRef.current) {
logFrontendDiag({
level: 'info',
stepId: 'frontend-load-content-session-counts',
stepName: '前端请求导出卡片统计',
status: 'running',
message: '统计请求仍在进行中,跳过本次轮询',
data: {
silent: options?.silent === true,
forceRefresh: options?.forceRefresh === true,
inFlightTraceId: contentSessionCountsInFlightTraceRef.current || undefined
}
})
return
}
const traceId = createExportDiagTraceId()
const startedAt = Date.now()
const task = (async () => {
logFrontendDiag({
traceId,
stepId: 'frontend-load-content-session-counts',
stepName: '前端请求导出卡片统计',
status: 'running',
message: '开始请求导出卡片统计',
data: {
silent: options?.silent === true,
forceRefresh: options?.forceRefresh === true
}
})
try {
const result = await withTimeout(
window.electronAPI.chat.getExportContentSessionCounts({
triggerRefresh: true,
forceRefresh: options?.forceRefresh === true,
traceId
}),
3200
)
if (!result) {
logFrontendDiag({
traceId,
level: 'warn',
stepId: 'frontend-load-content-session-counts',
stepName: '前端请求导出卡片统计',
status: 'timeout',
durationMs: Date.now() - startedAt,
message: '导出卡片统计请求超时(3200ms,后台可能仍在处理)'
})
return
}
if (result?.success && result.data) {
const next: ExportContentSessionCountsSummary = {
totalSessions: Number.isFinite(result.data.totalSessions) ? Math.max(0, Math.floor(result.data.totalSessions)) : 0,
textSessions: Number.isFinite(result.data.textSessions) ? Math.max(0, Math.floor(result.data.textSessions)) : 0,
voiceSessions: Number.isFinite(result.data.voiceSessions) ? Math.max(0, Math.floor(result.data.voiceSessions)) : 0,
imageSessions: Number.isFinite(result.data.imageSessions) ? Math.max(0, Math.floor(result.data.imageSessions)) : 0,
videoSessions: Number.isFinite(result.data.videoSessions) ? Math.max(0, Math.floor(result.data.videoSessions)) : 0,
emojiSessions: Number.isFinite(result.data.emojiSessions) ? Math.max(0, Math.floor(result.data.emojiSessions)) : 0,
pendingMediaSessions: Number.isFinite(result.data.pendingMediaSessions) ? Math.max(0, Math.floor(result.data.pendingMediaSessions)) : 0,
updatedAt: Number.isFinite(result.data.updatedAt) ? Math.max(0, Math.floor(result.data.updatedAt)) : 0,
refreshing: result.data.refreshing === true
}
setContentSessionCounts(next)
const looksLikeAllZero = next.totalSessions > 0 &&
next.textSessions === 0 &&
next.voiceSessions === 0 &&
next.imageSessions === 0 &&
next.videoSessions === 0 &&
next.emojiSessions === 0
if (looksLikeAllZero && contentSessionCountsForceRetryRef.current < 3) {
contentSessionCountsForceRetryRef.current += 1
const refreshTraceId = createExportDiagTraceId()
logFrontendDiag({
traceId: refreshTraceId,
stepId: 'frontend-force-refresh-content-session-counts',
stepName: '前端触发强制刷新导出卡片统计',
status: 'running',
message: '检测到统计全0,触发强制刷新'
})
void window.electronAPI.chat.refreshExportContentSessionCounts({ forceRefresh: true, traceId: refreshTraceId }).then((refreshResult) => {
logFrontendDiag({
traceId: refreshTraceId,
stepId: 'frontend-force-refresh-content-session-counts',
stepName: '前端触发强制刷新导出卡片统计',
status: refreshResult?.success ? 'done' : 'failed',
level: refreshResult?.success ? 'info' : 'warn',
message: refreshResult?.success ? '强制刷新请求已提交' : `强制刷新失败:${refreshResult?.error || '未知错误'}`
})
}).catch((error) => {
logFrontendDiag({
traceId: refreshTraceId,
stepId: 'frontend-force-refresh-content-session-counts',
stepName: '前端触发强制刷新导出卡片统计',
status: 'failed',
level: 'error',
message: '强制刷新请求异常',
data: { error: String(error) }
})
})
} else {
contentSessionCountsForceRetryRef.current = 0
setHasSeededContentSessionCounts(true)
}
logFrontendDiag({
traceId,
stepId: 'frontend-load-content-session-counts',
stepName: '前端请求导出卡片统计',
status: 'done',
durationMs: Date.now() - startedAt,
message: '导出卡片统计请求完成',
data: {
totalSessions: next.totalSessions,
pendingMediaSessions: next.pendingMediaSessions,
refreshing: next.refreshing
}
})
} else {
logFrontendDiag({
traceId,
level: 'warn',
stepId: 'frontend-load-content-session-counts',
stepName: '前端请求导出卡片统计',
status: 'failed',
durationMs: Date.now() - startedAt,
message: `导出卡片统计请求失败:${result?.error || '未知错误'}`
})
}
} catch (error) {
console.error('加载导出内容会话统计失败:', error)
logFrontendDiag({
traceId,
level: 'error',
stepId: 'frontend-load-content-session-counts',
stepName: '前端请求导出卡片统计',
status: 'failed',
durationMs: Date.now() - startedAt,
message: '导出卡片统计请求异常',
data: { error: String(error) }
})
}
})()
contentSessionCountsInFlightRef.current = task
contentSessionCountsInFlightTraceRef.current = traceId
try {
await task
} finally {
if (contentSessionCountsInFlightRef.current === task) {
contentSessionCountsInFlightRef.current = null
contentSessionCountsInFlightTraceRef.current = ''
}
}
}, [logFrontendDiag])
const loadSessions = useCallback(async () => {
const loadToken = Date.now()
sessionLoadTokenRef.current = loadToken
@@ -1795,7 +1638,6 @@ function ExportPage() {
if (!contact?.username) continue
sourceByUsername.set(contact.username, contact)
}
const now = Date.now()
const rawSessionMap = rawSessions.reduce<Record<string, AppChatSession>>((map, session) => {
map[session.username] = session
return map
@@ -1807,17 +1649,9 @@ function ExportPage() {
.filter(Boolean)
.filter((username) => {
const currentContact = sourceByUsername.get(username)
const cacheEntry = avatarEntries[username]
const session = rawSessionMap[username]
const currentAvatarUrl = currentContact?.avatarUrl || session?.avatarUrl
if (!cacheEntry || !cacheEntry.avatarUrl) {
return !currentAvatarUrl
}
if (currentAvatarUrl && currentAvatarUrl !== cacheEntry.avatarUrl) {
return true
}
const checkedAt = cacheEntry.checkedAt || 0
return now - checkedAt >= EXPORT_AVATAR_RECHECK_INTERVAL_MS
return !currentAvatarUrl
})
let extraContactMap: Record<string, { displayName?: string; avatarUrl?: string }> = {}
@@ -1828,7 +1662,10 @@ function ExportPage() {
if (batch.length === 0) continue
try {
const enrichResult = await withTimeout(
window.electronAPI.chat.enrichSessionsContactInfo(batch),
window.electronAPI.chat.enrichSessionsContactInfo(batch, {
skipDisplayName: true,
onlyMissingAvatar: true
}),
CONTACT_ENRICH_TIMEOUT_MS
)
if (isStale()) return
@@ -1941,7 +1778,6 @@ function ExportPage() {
void loadBaseConfig()
void ensureSharedTabCountsLoaded()
void loadSessions()
void loadContentSessionCounts()
// 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。
const timer = window.setTimeout(() => {
@@ -1949,15 +1785,7 @@ function ExportPage() {
}, 120)
return () => window.clearTimeout(timer)
}, [isExportRoute, ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats, loadContentSessionCounts])
useEffect(() => {
if (!isExportRoute) return
const timer = window.setInterval(() => {
void loadContentSessionCounts({ silent: true })
}, 3000)
return () => window.clearInterval(timer)
}, [isExportRoute, loadContentSessionCounts])
}, [isExportRoute, ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats])
useEffect(() => {
if (!isExportRoute || !showCardDiagnostics) return
@@ -2066,7 +1894,6 @@ function ExportPage() {
}
if (payload.scope === 'sns') {
next.format = prev.format === 'json' || prev.format === 'html' ? prev.format : 'html'
return next
}
@@ -2210,7 +2037,7 @@ function ExportPage() {
}
const buildSnsExportOptions = () => {
const format: 'json' | 'html' = options.format === 'json' ? 'json' : 'html'
const format: SnsTimelineExportFormat = snsExportFormat
const exportMediaEnabled = Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
const dateRange = options.useAllTime
? null
@@ -2332,7 +2159,7 @@ function ExportPage() {
try {
if (next.payload.scope === 'sns') {
const snsOptions = next.payload.snsOptions || { format: 'html' as const, exportMedia: false }
const snsOptions = next.payload.snsOptions || { format: 'html' as SnsTimelineExportFormat, exportMedia: false }
const result = await window.electronAPI.sns.exportTimeline({
outputDir: next.payload.outputDir,
format: snsOptions.format,
@@ -2830,13 +2657,6 @@ function ExportPage() {
const contentCards = useMemo(() => {
const scopeSessions = sessions.filter(isContentScopeSession)
const snsExportedCount = Math.min(lastSnsExportPostCount, snsStats.totalPosts)
const contentSessionCountByType: Record<ContentType, number> = {
text: contentSessionCounts.textSessions,
voice: contentSessionCounts.voiceSessions,
image: contentSessionCounts.imageSessions,
video: contentSessionCounts.videoSessions,
emoji: contentSessionCounts.emojiSessions
}
const sessionCards = [
{ type: 'text' as ContentType, icon: MessageSquareText },
@@ -2856,7 +2676,6 @@ function ExportPage() {
...item,
label: contentTypeLabels[item.type],
stats: [
{ label: '可导出会话数', value: contentSessionCountByType[item.type] || 0 },
{ label: '已导出', value: exported }
]
}
@@ -2873,7 +2692,7 @@ function ExportPage() {
}
return [...sessionCards, snsCard]
}, [sessions, contentSessionCounts, lastExportByContent, snsStats, lastSnsExportPostCount])
}, [sessions, lastExportByContent, snsStats, lastSnsExportPostCount])
const mergedCardDiagLogs = useMemo(() => {
const merged = [...backendDiagSnapshot.logs, ...frontendDiagLogs]
@@ -3376,6 +3195,7 @@ function ExportPage() {
contact.avatarUrl ? count + 1 : count
), 0)
}, [contactsList])
const isCurrentTabAvatarEnrichRunning = contactsAvatarEnrichProgress.running && contactsAvatarEnrichProgress.tab === activeTab
useEffect(() => {
if (!contactsListRef.current) return
@@ -3598,16 +3418,19 @@ function ExportPage() {
const scopeCountLabel = exportDialog.scope === 'sns'
? `共 ${snsStats.totalPosts} 条朋友圈动态`
: `共 ${exportDialog.sessionIds.length} 个会话`
const snsFormatOptions: Array<{ value: SnsTimelineExportFormat; label: string; desc: string }> = [
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'json', label: 'JSON', desc: '原始结构化格式(兼容旧导入)' },
{ value: 'arkmejson', label: 'ArkmeJSON', desc: '增强结构化格式,包含互动身份字段' }
]
const formatCandidateOptions = exportDialog.scope === 'sns'
? formatOptions.filter(option => option.value === 'html' || option.value === 'json')
? snsFormatOptions
: formatOptions
const isContentScopeDialog = exportDialog.scope === 'content'
const isContentTextDialog = isContentScopeDialog && exportDialog.contentType === 'text'
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
const shouldShowMediaSection = !isContentScopeDialog
const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady
const isSessionCardStatsLoading = isBaseConfigLoading || !hasSeededContentSessionCounts
const isSessionCardStatsRefreshing = contentSessionCounts.refreshing || contentSessionCounts.pendingMediaSessions > 0
const isSnsCardStatsLoading = !hasSeededSnsStats
const taskRunningCount = tasks.filter(task => task.status === 'running').length
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
@@ -3873,17 +3696,12 @@ function ExportPage() {
const Icon = card.icon
const isCardStatsLoading = card.type === 'sns'
? isSnsCardStatsLoading
: isSessionCardStatsLoading
: false
const isCardRunning = runningCardTypes.has(card.type)
return (
<div key={card.type} className="content-card">
<div className="card-header">
<div className="card-title"><Icon size={16} /> {card.label}</div>
{card.type !== 'sns' && !isCardStatsLoading && isSessionCardStatsRefreshing && (
<span className="card-refresh-hint">
刷新中<span className="animated-ellipsis" aria-hidden="true">...</span>
</span>
)}
</div>
<div className="card-stats">
{card.stats.map((stat) => (
@@ -4112,17 +3930,17 @@ function ExportPage() {
{avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''}
</span>
)}
{(isContactsListLoading || contactsAvatarEnrichProgress.running) && contactsList.length > 0 && (
{(isContactsListLoading || isCurrentTabAvatarEnrichRunning) && contactsList.length > 0 && (
<span className="meta-item syncing">后台同步中...</span>
)}
{contactsAvatarEnrichProgress.running && (
{isCurrentTabAvatarEnrichRunning && (
<span className="meta-item syncing">
头像补全中 {contactsAvatarEnrichProgress.loaded}/{contactsAvatarEnrichProgress.total}
</span>
)}
</div>
{contactsList.length > 0 && (isContactsListLoading || contactsAvatarEnrichProgress.running) && (
{contactsList.length > 0 && (isContactsListLoading || isCurrentTabAvatarEnrichRunning) && (
<div className="table-stage-hint">
<Loader2 size={14} className="spin" />
{isContactsListLoading ? '联系人列表同步中…' : '正在补充头像…'}
@@ -4513,7 +4331,7 @@ function ExportPage() {
{shouldShowFormatSection && (
<div className="dialog-section">
<h4>对话文本导出格式选择</h4>
<h4>{exportDialog.scope === 'sns' ? '朋友圈导出格式选择' : '对话文本导出格式选择'}</h4>
{isContentTextDialog && (
<div className="format-note">说明:此模式默认导出头像,不导出图片、语音、视频、表情包等媒体内容。</div>
)}
@@ -4521,8 +4339,16 @@ function ExportPage() {
{formatCandidateOptions.map(option => (
<button
key={option.value}
className={`format-card ${options.format === option.value ? 'active' : ''}`}
onClick={() => setOptions(prev => ({ ...prev, format: option.value }))}
className={`format-card ${exportDialog.scope === 'sns'
? (snsExportFormat === option.value ? 'active' : '')
: (options.format === option.value ? 'active' : '')}`}
onClick={() => {
if (exportDialog.scope === 'sns') {
setSnsExportFormat(option.value as SnsTimelineExportFormat)
} else {
setOptions(prev => ({ ...prev, format: option.value as TextExportFormat }))
}
}}
>
<div className="format-label">{option.label}</div>
<div className="format-desc">{option.desc}</div>
+10 -1
View File
@@ -60,7 +60,7 @@ export default function SnsPage() {
// 导出相关状态
const [showExportDialog, setShowExportDialog] = useState(false)
const [exportFormat, setExportFormat] = useState<'json' | 'html'>('html')
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html')
const [exportFolder, setExportFolder] = useState('')
const [exportMedia, setExportMedia] = useState(false)
const [exportDateRange, setExportDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' })
@@ -880,6 +880,15 @@ export default function SnsPage() {
<span>JSON</span>
<small>结构化数据</small>
</button>
<button
className={`format-option ${exportFormat === 'arkmejson' ? 'active' : ''}`}
onClick={() => setExportFormat('arkmejson')}
disabled={isExporting}
>
<FileJson size={20} />
<span>ArkmeJSON</span>
<small>结构化数据(含互动身份)</small>
</button>
</div>
</div>
+5 -25
View File
@@ -160,30 +160,10 @@ export interface ElectronAPI {
counts?: Record<string, number>
error?: string
}>
getExportContentSessionCounts: (options?: {
triggerRefresh?: boolean
forceRefresh?: boolean
traceId?: string
}) => Promise<{
success: boolean
data?: {
totalSessions: number
textSessions: number
voiceSessions: number
imageSessions: number
videoSessions: number
emojiSessions: number
pendingMediaSessions: number
updatedAt: number
refreshing: boolean
}
error?: string
}>
refreshExportContentSessionCounts: (options?: { forceRefresh?: boolean; traceId?: string }) => Promise<{
success: boolean
error?: string
}>
enrichSessionsContactInfo: (usernames: string[]) => Promise<{
enrichSessionsContactInfo: (
usernames: string[],
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
) => Promise<{
success: boolean
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
error?: string
@@ -736,7 +716,7 @@ export interface ElectronAPI {
downloadImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; data?: any; contentType?: string; error?: string }>
exportTimeline: (options: {
outputDir: string
format: 'json' | 'html'
format: 'json' | 'html' | 'arkmejson'
usernames?: string[]
keyword?: string
exportMedia?: boolean