mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
feat(export): add sns arkmejson format and consolidate export flow changes
This commit is contained in:
112
electron/main.ts
112
electron/main.ts
@@ -991,115 +991,11 @@ function registerIpcHandlers() {
|
|||||||
return chatService.getSessionMessageCounts(sessionIds)
|
return chatService.getSessionMessageCounts(sessionIds)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getExportContentSessionCounts', async (_, options?: {
|
ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[], options?: {
|
||||||
triggerRefresh?: boolean
|
skipDisplayName?: boolean
|
||||||
forceRefresh?: boolean
|
onlyMissingAvatar?: boolean
|
||||||
traceId?: string
|
|
||||||
}) => {
|
}) => {
|
||||||
const traceId = typeof options?.traceId === 'string' ? options.traceId.trim() : ''
|
return chatService.enrichSessionsContactInfo(usernames, options)
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => {
|
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => {
|
||||||
|
|||||||
@@ -143,12 +143,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
|
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
|
||||||
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
|
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
|
||||||
getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds),
|
getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds),
|
||||||
getExportContentSessionCounts: (options?: { triggerRefresh?: boolean; forceRefresh?: boolean }) =>
|
enrichSessionsContactInfo: (
|
||||||
ipcRenderer.invoke('chat:getExportContentSessionCounts', options),
|
usernames: string[],
|
||||||
refreshExportContentSessionCounts: (options?: { forceRefresh?: boolean }) =>
|
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
|
||||||
ipcRenderer.invoke('chat:refreshExportContentSessionCounts', options),
|
) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames, options),
|
||||||
enrichSessionsContactInfo: (usernames: string[]) =>
|
|
||||||
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
|
|
||||||
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
|
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
|
||||||
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
|
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
|
||||||
getLatestMessages: (sessionId: string, limit?: number) =>
|
getLatestMessages: (sessionId: string, limit?: number) =>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,68 @@ export interface SnsPost {
|
|||||||
linkUrl?: string
|
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) => {
|
const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => {
|
||||||
@@ -127,7 +189,7 @@ const extractVideoKey = (xml: string): string | undefined => {
|
|||||||
/**
|
/**
|
||||||
* 从 XML 中解析评论信息(含表情包、回复关系)
|
* 从 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 []
|
if (!xml) return []
|
||||||
|
|
||||||
type CommentItem = {
|
type CommentItem = {
|
||||||
@@ -239,6 +301,204 @@ class SnsService {
|
|||||||
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
|
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 {
|
private parseCountValue(row: any): number {
|
||||||
if (!row || typeof row !== 'object') return 0
|
if (!row || typeof row !== 'object') return 0
|
||||||
const raw = row.total ?? row.count ?? row.cnt ?? Object.values(row)[0]
|
const raw = row.total ?? row.count ?? row.cnt ?? Object.values(row)[0]
|
||||||
@@ -821,7 +1081,7 @@ class SnsService {
|
|||||||
*/
|
*/
|
||||||
async exportTimeline(options: {
|
async exportTimeline(options: {
|
||||||
outputDir: string
|
outputDir: string
|
||||||
format: 'json' | 'html'
|
format: 'json' | 'html' | 'arkmejson'
|
||||||
usernames?: string[]
|
usernames?: string[]
|
||||||
keyword?: string
|
keyword?: string
|
||||||
exportMedia?: boolean
|
exportMedia?: boolean
|
||||||
@@ -1026,6 +1286,71 @@ class SnsService {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
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 {
|
} else {
|
||||||
// HTML 格式
|
// HTML 格式
|
||||||
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
|
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ type SessionLayout = 'shared' | 'per-session'
|
|||||||
type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname'
|
type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname'
|
||||||
|
|
||||||
type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||||
|
type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson'
|
||||||
|
|
||||||
interface ExportOptions {
|
interface ExportOptions {
|
||||||
format: TextExportFormat
|
format: TextExportFormat
|
||||||
@@ -110,7 +111,7 @@ interface ExportTaskPayload {
|
|||||||
contentType?: ContentType
|
contentType?: ContentType
|
||||||
sessionNames: string[]
|
sessionNames: string[]
|
||||||
snsOptions?: {
|
snsOptions?: {
|
||||||
format: 'json' | 'html'
|
format: SnsTimelineExportFormat
|
||||||
exportMedia?: boolean
|
exportMedia?: boolean
|
||||||
startTime?: number
|
startTime?: number
|
||||||
endTime?: 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 createExportDiagTraceId = (): string => `export-card-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
const CONTACT_ENRICH_TIMEOUT_MS = 7000
|
const CONTACT_ENRICH_TIMEOUT_MS = 7000
|
||||||
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
|
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 EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80
|
||||||
const CONTACTS_LIST_VIRTUAL_ROW_HEIGHT = 76
|
const CONTACTS_LIST_VIRTUAL_ROW_HEIGHT = 76
|
||||||
const CONTACTS_LIST_VIRTUAL_OVERSCAN = 10
|
const CONTACTS_LIST_VIRTUAL_OVERSCAN = 10
|
||||||
@@ -541,18 +541,6 @@ interface SessionExportCacheMeta {
|
|||||||
source: 'memory' | 'disk' | 'fresh'
|
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 ExportCardDiagFilter = 'all' | 'frontend' | 'main' | 'backend' | 'worker' | 'warn' | 'error'
|
||||||
|
|
||||||
type ExportCardDiagSource = 'frontend' | 'main' | 'backend' | 'worker'
|
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 = {
|
const defaultExportCardDiagSnapshot: ExportCardDiagSnapshotState = {
|
||||||
logs: [],
|
logs: [],
|
||||||
activeSteps: [],
|
activeSteps: [],
|
||||||
@@ -888,7 +864,8 @@ function ExportPage() {
|
|||||||
const [contactsAvatarEnrichProgress, setContactsAvatarEnrichProgress] = useState({
|
const [contactsAvatarEnrichProgress, setContactsAvatarEnrichProgress] = useState({
|
||||||
loaded: 0,
|
loaded: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
running: false
|
running: false,
|
||||||
|
tab: null as ConversationTab | null
|
||||||
})
|
})
|
||||||
const [showSessionDetailPanel, setShowSessionDetailPanel] = useState(false)
|
const [showSessionDetailPanel, setShowSessionDetailPanel] = useState(false)
|
||||||
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
|
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
|
||||||
@@ -900,6 +877,7 @@ function ExportPage() {
|
|||||||
|
|
||||||
const [exportFolder, setExportFolder] = useState('')
|
const [exportFolder, setExportFolder] = useState('')
|
||||||
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('A')
|
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('A')
|
||||||
|
const [snsExportFormat, setSnsExportFormat] = useState<SnsTimelineExportFormat>('html')
|
||||||
|
|
||||||
const [options, setOptions] = useState<ExportOptions>({
|
const [options, setOptions] = useState<ExportOptions>({
|
||||||
format: 'arkme-json',
|
format: 'arkme-json',
|
||||||
@@ -937,8 +915,6 @@ function ExportPage() {
|
|||||||
totalPosts: 0,
|
totalPosts: 0,
|
||||||
totalFriends: 0
|
totalFriends: 0
|
||||||
})
|
})
|
||||||
const [contentSessionCounts, setContentSessionCounts] = useState<ExportContentSessionCountsSummary>(defaultContentSessionCounts)
|
|
||||||
const [hasSeededContentSessionCounts, setHasSeededContentSessionCounts] = useState(false)
|
|
||||||
const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false)
|
const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false)
|
||||||
const [showCardDiagnostics, setShowCardDiagnostics] = useState(false)
|
const [showCardDiagnostics, setShowCardDiagnostics] = useState(false)
|
||||||
const [diagFilter, setDiagFilter] = useState<ExportCardDiagFilter>('all')
|
const [diagFilter, setDiagFilter] = useState<ExportCardDiagFilter>('all')
|
||||||
@@ -960,6 +936,8 @@ function ExportPage() {
|
|||||||
const preselectAppliedRef = useRef(false)
|
const preselectAppliedRef = useRef(false)
|
||||||
const exportCacheScopeRef = useRef('default')
|
const exportCacheScopeRef = useRef('default')
|
||||||
const exportCacheScopeReadyRef = useRef(false)
|
const exportCacheScopeReadyRef = useRef(false)
|
||||||
|
const activeTabRef = useRef<ConversationTab>('private')
|
||||||
|
const contactsDataRef = useRef<ContactInfo[]>([])
|
||||||
const contactsLoadVersionRef = useRef(0)
|
const contactsLoadVersionRef = useRef(0)
|
||||||
const contactsLoadAttemptRef = useRef(0)
|
const contactsLoadAttemptRef = useRef(0)
|
||||||
const contactsLoadTimeoutTimerRef = useRef<number | null>(null)
|
const contactsLoadTimeoutTimerRef = useRef<number | null>(null)
|
||||||
@@ -970,9 +948,6 @@ function ExportPage() {
|
|||||||
const inProgressSessionIdsRef = useRef<string[]>([])
|
const inProgressSessionIdsRef = useRef<string[]>([])
|
||||||
const activeTaskCountRef = useRef(0)
|
const activeTaskCountRef = useRef(0)
|
||||||
const hasBaseConfigReadyRef = useRef(false)
|
const hasBaseConfigReadyRef = useRef(false)
|
||||||
const contentSessionCountsForceRetryRef = useRef(0)
|
|
||||||
const contentSessionCountsInFlightRef = useRef<Promise<void> | null>(null)
|
|
||||||
const contentSessionCountsInFlightTraceRef = useRef('')
|
|
||||||
|
|
||||||
const appendFrontendDiagLog = useCallback((entry: ExportCardDiagLogEntry) => {
|
const appendFrontendDiagLog = useCallback((entry: ExportCardDiagLogEntry) => {
|
||||||
setFrontendDiagLogs(prev => {
|
setFrontendDiagLogs(prev => {
|
||||||
@@ -1079,6 +1054,14 @@ function ExportPage() {
|
|||||||
contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs
|
contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs
|
||||||
}, [contactsLoadTimeoutMs])
|
}, [contactsLoadTimeoutMs])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeTabRef.current = activeTab
|
||||||
|
}, [activeTab])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
contactsDataRef.current = contactsList
|
||||||
|
}, [contactsList])
|
||||||
|
|
||||||
const applyEnrichedContactsToList = useCallback((enrichedMap: Record<string, { displayName?: string; avatarUrl?: string }>) => {
|
const applyEnrichedContactsToList = useCallback((enrichedMap: Record<string, { displayName?: string; avatarUrl?: string }>) => {
|
||||||
if (!enrichedMap || Object.keys(enrichedMap).length === 0) return
|
if (!enrichedMap || Object.keys(enrichedMap).length === 0) return
|
||||||
setContactsList(prev => {
|
setContactsList(prev => {
|
||||||
@@ -1105,7 +1088,8 @@ function ExportPage() {
|
|||||||
const enrichContactsListInBackground = useCallback(async (
|
const enrichContactsListInBackground = useCallback(async (
|
||||||
sourceContacts: ContactInfo[],
|
sourceContacts: ContactInfo[],
|
||||||
loadVersion: number,
|
loadVersion: number,
|
||||||
scopeKey: string
|
scopeKey: string,
|
||||||
|
targetTab: ConversationTab
|
||||||
) => {
|
) => {
|
||||||
const sourceByUsername = new Map<string, ContactInfo>()
|
const sourceByUsername = new Map<string, ContactInfo>()
|
||||||
for (const contact of sourceContacts) {
|
for (const contact of sourceContacts) {
|
||||||
@@ -1113,29 +1097,21 @@ function ExportPage() {
|
|||||||
sourceByUsername.set(contact.username, contact)
|
sourceByUsername.set(contact.username, contact)
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now()
|
const usernames = Array.from(new Set(sourceContacts
|
||||||
const usernames = sourceContacts
|
.filter(contact => matchesContactTab(contact, targetTab))
|
||||||
.map(contact => contact.username)
|
.map(contact => contact.username)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.filter((username) => {
|
.filter((username) => {
|
||||||
const currentContact = sourceByUsername.get(username)
|
const currentContact = sourceByUsername.get(username)
|
||||||
if (!currentContact) return false
|
return Boolean(currentContact && !currentContact.avatarUrl)
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
const total = usernames.length
|
const total = usernames.length
|
||||||
setContactsAvatarEnrichProgress({
|
setContactsAvatarEnrichProgress({
|
||||||
loaded: 0,
|
loaded: 0,
|
||||||
total,
|
total,
|
||||||
running: total > 0
|
running: total > 0,
|
||||||
|
tab: targetTab
|
||||||
})
|
})
|
||||||
if (total === 0) return
|
if (total === 0) return
|
||||||
|
|
||||||
@@ -1145,9 +1121,15 @@ function ExportPage() {
|
|||||||
if (batch.length === 0) continue
|
if (batch.length === 0) continue
|
||||||
|
|
||||||
try {
|
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 (contactsLoadVersionRef.current !== loadVersion) return
|
||||||
if (avatarResult.success && avatarResult.contacts) {
|
if (avatarResult?.success && avatarResult.contacts) {
|
||||||
applyEnrichedContactsToList(avatarResult.contacts)
|
applyEnrichedContactsToList(avatarResult.contacts)
|
||||||
for (const [username, enriched] of Object.entries(avatarResult.contacts)) {
|
for (const [username, enriched] of Object.entries(avatarResult.contacts)) {
|
||||||
const prev = sourceByUsername.get(username)
|
const prev = sourceByUsername.get(username)
|
||||||
@@ -1180,7 +1162,8 @@ function ExportPage() {
|
|||||||
setContactsAvatarEnrichProgress({
|
setContactsAvatarEnrichProgress({
|
||||||
loaded,
|
loaded,
|
||||||
total,
|
total,
|
||||||
running: loaded < total
|
running: loaded < total,
|
||||||
|
tab: targetTab
|
||||||
})
|
})
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
}
|
}
|
||||||
@@ -1192,6 +1175,7 @@ function ExportPage() {
|
|||||||
|
|
||||||
const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => {
|
const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => {
|
||||||
const scopeKey = options?.scopeKey || await ensureExportCacheScope()
|
const scopeKey = options?.scopeKey || await ensureExportCacheScope()
|
||||||
|
const targetTab = activeTabRef.current
|
||||||
const loadVersion = contactsLoadVersionRef.current + 1
|
const loadVersion = contactsLoadVersionRef.current + 1
|
||||||
contactsLoadVersionRef.current = loadVersion
|
contactsLoadVersionRef.current = loadVersion
|
||||||
contactsLoadAttemptRef.current += 1
|
contactsLoadAttemptRef.current += 1
|
||||||
@@ -1228,7 +1212,8 @@ function ExportPage() {
|
|||||||
setContactsAvatarEnrichProgress({
|
setContactsAvatarEnrichProgress({
|
||||||
loaded: 0,
|
loaded: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
running: false
|
running: false,
|
||||||
|
tab: null
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1276,7 +1261,7 @@ function ExportPage() {
|
|||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
console.error('写入导出页通讯录缓存失败:', error)
|
console.error('写入导出页通讯录缓存失败:', error)
|
||||||
})
|
})
|
||||||
void enrichContactsListInBackground(contactsWithAvatarCache, loadVersion, scopeKey)
|
void enrichContactsListInBackground(contactsWithAvatarCache, loadVersion, scopeKey, targetTab)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1351,13 +1336,28 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
}, [isExportRoute, ensureExportCacheScope, loadContactsList, syncContactTypeCounts])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (isExportRoute) return
|
if (isExportRoute) return
|
||||||
contactsLoadVersionRef.current += 1
|
contactsLoadVersionRef.current += 1
|
||||||
setContactsAvatarEnrichProgress({
|
setContactsAvatarEnrichProgress({
|
||||||
loaded: 0,
|
loaded: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
running: false
|
running: false,
|
||||||
|
tab: null
|
||||||
})
|
})
|
||||||
}, [isExportRoute])
|
}, [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 loadSessions = useCallback(async () => {
|
||||||
const loadToken = Date.now()
|
const loadToken = Date.now()
|
||||||
sessionLoadTokenRef.current = loadToken
|
sessionLoadTokenRef.current = loadToken
|
||||||
@@ -1795,7 +1638,6 @@ function ExportPage() {
|
|||||||
if (!contact?.username) continue
|
if (!contact?.username) continue
|
||||||
sourceByUsername.set(contact.username, contact)
|
sourceByUsername.set(contact.username, contact)
|
||||||
}
|
}
|
||||||
const now = Date.now()
|
|
||||||
const rawSessionMap = rawSessions.reduce<Record<string, AppChatSession>>((map, session) => {
|
const rawSessionMap = rawSessions.reduce<Record<string, AppChatSession>>((map, session) => {
|
||||||
map[session.username] = session
|
map[session.username] = session
|
||||||
return map
|
return map
|
||||||
@@ -1807,17 +1649,9 @@ function ExportPage() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.filter((username) => {
|
.filter((username) => {
|
||||||
const currentContact = sourceByUsername.get(username)
|
const currentContact = sourceByUsername.get(username)
|
||||||
const cacheEntry = avatarEntries[username]
|
|
||||||
const session = rawSessionMap[username]
|
const session = rawSessionMap[username]
|
||||||
const currentAvatarUrl = currentContact?.avatarUrl || session?.avatarUrl
|
const currentAvatarUrl = currentContact?.avatarUrl || session?.avatarUrl
|
||||||
if (!cacheEntry || !cacheEntry.avatarUrl) {
|
|
||||||
return !currentAvatarUrl
|
return !currentAvatarUrl
|
||||||
}
|
|
||||||
if (currentAvatarUrl && currentAvatarUrl !== cacheEntry.avatarUrl) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const checkedAt = cacheEntry.checkedAt || 0
|
|
||||||
return now - checkedAt >= EXPORT_AVATAR_RECHECK_INTERVAL_MS
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let extraContactMap: Record<string, { displayName?: string; avatarUrl?: string }> = {}
|
let extraContactMap: Record<string, { displayName?: string; avatarUrl?: string }> = {}
|
||||||
@@ -1828,7 +1662,10 @@ function ExportPage() {
|
|||||||
if (batch.length === 0) continue
|
if (batch.length === 0) continue
|
||||||
try {
|
try {
|
||||||
const enrichResult = await withTimeout(
|
const enrichResult = await withTimeout(
|
||||||
window.electronAPI.chat.enrichSessionsContactInfo(batch),
|
window.electronAPI.chat.enrichSessionsContactInfo(batch, {
|
||||||
|
skipDisplayName: true,
|
||||||
|
onlyMissingAvatar: true
|
||||||
|
}),
|
||||||
CONTACT_ENRICH_TIMEOUT_MS
|
CONTACT_ENRICH_TIMEOUT_MS
|
||||||
)
|
)
|
||||||
if (isStale()) return
|
if (isStale()) return
|
||||||
@@ -1941,7 +1778,6 @@ function ExportPage() {
|
|||||||
void loadBaseConfig()
|
void loadBaseConfig()
|
||||||
void ensureSharedTabCountsLoaded()
|
void ensureSharedTabCountsLoaded()
|
||||||
void loadSessions()
|
void loadSessions()
|
||||||
void loadContentSessionCounts()
|
|
||||||
|
|
||||||
// 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。
|
// 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
@@ -1949,15 +1785,7 @@ function ExportPage() {
|
|||||||
}, 120)
|
}, 120)
|
||||||
|
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [isExportRoute, ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats, loadContentSessionCounts])
|
}, [isExportRoute, ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isExportRoute) return
|
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
void loadContentSessionCounts({ silent: true })
|
|
||||||
}, 3000)
|
|
||||||
return () => window.clearInterval(timer)
|
|
||||||
}, [isExportRoute, loadContentSessionCounts])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExportRoute || !showCardDiagnostics) return
|
if (!isExportRoute || !showCardDiagnostics) return
|
||||||
@@ -2066,7 +1894,6 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.scope === 'sns') {
|
if (payload.scope === 'sns') {
|
||||||
next.format = prev.format === 'json' || prev.format === 'html' ? prev.format : 'html'
|
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2210,7 +2037,7 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buildSnsExportOptions = () => {
|
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 exportMediaEnabled = Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
|
||||||
const dateRange = options.useAllTime
|
const dateRange = options.useAllTime
|
||||||
? null
|
? null
|
||||||
@@ -2332,7 +2159,7 @@ function ExportPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (next.payload.scope === 'sns') {
|
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({
|
const result = await window.electronAPI.sns.exportTimeline({
|
||||||
outputDir: next.payload.outputDir,
|
outputDir: next.payload.outputDir,
|
||||||
format: snsOptions.format,
|
format: snsOptions.format,
|
||||||
@@ -2830,13 +2657,6 @@ function ExportPage() {
|
|||||||
const contentCards = useMemo(() => {
|
const contentCards = useMemo(() => {
|
||||||
const scopeSessions = sessions.filter(isContentScopeSession)
|
const scopeSessions = sessions.filter(isContentScopeSession)
|
||||||
const snsExportedCount = Math.min(lastSnsExportPostCount, snsStats.totalPosts)
|
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 = [
|
const sessionCards = [
|
||||||
{ type: 'text' as ContentType, icon: MessageSquareText },
|
{ type: 'text' as ContentType, icon: MessageSquareText },
|
||||||
@@ -2856,7 +2676,6 @@ function ExportPage() {
|
|||||||
...item,
|
...item,
|
||||||
label: contentTypeLabels[item.type],
|
label: contentTypeLabels[item.type],
|
||||||
stats: [
|
stats: [
|
||||||
{ label: '可导出会话数', value: contentSessionCountByType[item.type] || 0 },
|
|
||||||
{ label: '已导出', value: exported }
|
{ label: '已导出', value: exported }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2873,7 +2692,7 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [...sessionCards, snsCard]
|
return [...sessionCards, snsCard]
|
||||||
}, [sessions, contentSessionCounts, lastExportByContent, snsStats, lastSnsExportPostCount])
|
}, [sessions, lastExportByContent, snsStats, lastSnsExportPostCount])
|
||||||
|
|
||||||
const mergedCardDiagLogs = useMemo(() => {
|
const mergedCardDiagLogs = useMemo(() => {
|
||||||
const merged = [...backendDiagSnapshot.logs, ...frontendDiagLogs]
|
const merged = [...backendDiagSnapshot.logs, ...frontendDiagLogs]
|
||||||
@@ -3376,6 +3195,7 @@ function ExportPage() {
|
|||||||
contact.avatarUrl ? count + 1 : count
|
contact.avatarUrl ? count + 1 : count
|
||||||
), 0)
|
), 0)
|
||||||
}, [contactsList])
|
}, [contactsList])
|
||||||
|
const isCurrentTabAvatarEnrichRunning = contactsAvatarEnrichProgress.running && contactsAvatarEnrichProgress.tab === activeTab
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!contactsListRef.current) return
|
if (!contactsListRef.current) return
|
||||||
@@ -3598,16 +3418,19 @@ function ExportPage() {
|
|||||||
const scopeCountLabel = exportDialog.scope === 'sns'
|
const scopeCountLabel = exportDialog.scope === 'sns'
|
||||||
? `共 ${snsStats.totalPosts} 条朋友圈动态`
|
? `共 ${snsStats.totalPosts} 条朋友圈动态`
|
||||||
: `共 ${exportDialog.sessionIds.length} 个会话`
|
: `共 ${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'
|
const formatCandidateOptions = exportDialog.scope === 'sns'
|
||||||
? formatOptions.filter(option => option.value === 'html' || option.value === 'json')
|
? snsFormatOptions
|
||||||
: formatOptions
|
: formatOptions
|
||||||
const isContentScopeDialog = exportDialog.scope === 'content'
|
const isContentScopeDialog = exportDialog.scope === 'content'
|
||||||
const isContentTextDialog = isContentScopeDialog && exportDialog.contentType === 'text'
|
const isContentTextDialog = isContentScopeDialog && exportDialog.contentType === 'text'
|
||||||
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
|
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
|
||||||
const shouldShowMediaSection = !isContentScopeDialog
|
const shouldShowMediaSection = !isContentScopeDialog
|
||||||
const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady
|
const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady
|
||||||
const isSessionCardStatsLoading = isBaseConfigLoading || !hasSeededContentSessionCounts
|
|
||||||
const isSessionCardStatsRefreshing = contentSessionCounts.refreshing || contentSessionCounts.pendingMediaSessions > 0
|
|
||||||
const isSnsCardStatsLoading = !hasSeededSnsStats
|
const isSnsCardStatsLoading = !hasSeededSnsStats
|
||||||
const taskRunningCount = tasks.filter(task => task.status === 'running').length
|
const taskRunningCount = tasks.filter(task => task.status === 'running').length
|
||||||
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
||||||
@@ -3873,17 +3696,12 @@ function ExportPage() {
|
|||||||
const Icon = card.icon
|
const Icon = card.icon
|
||||||
const isCardStatsLoading = card.type === 'sns'
|
const isCardStatsLoading = card.type === 'sns'
|
||||||
? isSnsCardStatsLoading
|
? isSnsCardStatsLoading
|
||||||
: isSessionCardStatsLoading
|
: false
|
||||||
const isCardRunning = runningCardTypes.has(card.type)
|
const isCardRunning = runningCardTypes.has(card.type)
|
||||||
return (
|
return (
|
||||||
<div key={card.type} className="content-card">
|
<div key={card.type} className="content-card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<div className="card-title"><Icon size={16} /> {card.label}</div>
|
<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>
|
||||||
<div className="card-stats">
|
<div className="card-stats">
|
||||||
{card.stats.map((stat) => (
|
{card.stats.map((stat) => (
|
||||||
@@ -4112,17 +3930,17 @@ function ExportPage() {
|
|||||||
{avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''}
|
{avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(isContactsListLoading || contactsAvatarEnrichProgress.running) && contactsList.length > 0 && (
|
{(isContactsListLoading || isCurrentTabAvatarEnrichRunning) && contactsList.length > 0 && (
|
||||||
<span className="meta-item syncing">后台同步中...</span>
|
<span className="meta-item syncing">后台同步中...</span>
|
||||||
)}
|
)}
|
||||||
{contactsAvatarEnrichProgress.running && (
|
{isCurrentTabAvatarEnrichRunning && (
|
||||||
<span className="meta-item syncing">
|
<span className="meta-item syncing">
|
||||||
头像补全中 {contactsAvatarEnrichProgress.loaded}/{contactsAvatarEnrichProgress.total}
|
头像补全中 {contactsAvatarEnrichProgress.loaded}/{contactsAvatarEnrichProgress.total}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{contactsList.length > 0 && (isContactsListLoading || contactsAvatarEnrichProgress.running) && (
|
{contactsList.length > 0 && (isContactsListLoading || isCurrentTabAvatarEnrichRunning) && (
|
||||||
<div className="table-stage-hint">
|
<div className="table-stage-hint">
|
||||||
<Loader2 size={14} className="spin" />
|
<Loader2 size={14} className="spin" />
|
||||||
{isContactsListLoading ? '联系人列表同步中…' : '正在补充头像…'}
|
{isContactsListLoading ? '联系人列表同步中…' : '正在补充头像…'}
|
||||||
@@ -4513,7 +4331,7 @@ function ExportPage() {
|
|||||||
|
|
||||||
{shouldShowFormatSection && (
|
{shouldShowFormatSection && (
|
||||||
<div className="dialog-section">
|
<div className="dialog-section">
|
||||||
<h4>对话文本导出格式选择</h4>
|
<h4>{exportDialog.scope === 'sns' ? '朋友圈导出格式选择' : '对话文本导出格式选择'}</h4>
|
||||||
{isContentTextDialog && (
|
{isContentTextDialog && (
|
||||||
<div className="format-note">说明:此模式默认导出头像,不导出图片、语音、视频、表情包等媒体内容。</div>
|
<div className="format-note">说明:此模式默认导出头像,不导出图片、语音、视频、表情包等媒体内容。</div>
|
||||||
)}
|
)}
|
||||||
@@ -4521,8 +4339,16 @@ function ExportPage() {
|
|||||||
{formatCandidateOptions.map(option => (
|
{formatCandidateOptions.map(option => (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className={`format-card ${options.format === option.value ? 'active' : ''}`}
|
className={`format-card ${exportDialog.scope === 'sns'
|
||||||
onClick={() => setOptions(prev => ({ ...prev, format: option.value }))}
|
? (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-label">{option.label}</div>
|
||||||
<div className="format-desc">{option.desc}</div>
|
<div className="format-desc">{option.desc}</div>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function SnsPage() {
|
|||||||
|
|
||||||
// 导出相关状态
|
// 导出相关状态
|
||||||
const [showExportDialog, setShowExportDialog] = useState(false)
|
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 [exportFolder, setExportFolder] = useState('')
|
||||||
const [exportMedia, setExportMedia] = useState(false)
|
const [exportMedia, setExportMedia] = useState(false)
|
||||||
const [exportDateRange, setExportDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' })
|
const [exportDateRange, setExportDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' })
|
||||||
@@ -880,6 +880,15 @@ export default function SnsPage() {
|
|||||||
<span>JSON</span>
|
<span>JSON</span>
|
||||||
<small>结构化数据</small>
|
<small>结构化数据</small>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`format-option ${exportFormat === 'arkmejson' ? 'active' : ''}`}
|
||||||
|
onClick={() => setExportFormat('arkmejson')}
|
||||||
|
disabled={isExporting}
|
||||||
|
>
|
||||||
|
<FileJson size={20} />
|
||||||
|
<span>ArkmeJSON</span>
|
||||||
|
<small>结构化数据(含互动身份)</small>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
30
src/types/electron.d.ts
vendored
30
src/types/electron.d.ts
vendored
@@ -160,30 +160,10 @@ export interface ElectronAPI {
|
|||||||
counts?: Record<string, number>
|
counts?: Record<string, number>
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
getExportContentSessionCounts: (options?: {
|
enrichSessionsContactInfo: (
|
||||||
triggerRefresh?: boolean
|
usernames: string[],
|
||||||
forceRefresh?: boolean
|
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
|
||||||
traceId?: string
|
) => Promise<{
|
||||||
}) => 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<{
|
|
||||||
success: boolean
|
success: boolean
|
||||||
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
|
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
|
||||||
error?: 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 }>
|
downloadImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; data?: any; contentType?: string; error?: string }>
|
||||||
exportTimeline: (options: {
|
exportTimeline: (options: {
|
||||||
outputDir: string
|
outputDir: string
|
||||||
format: 'json' | 'html'
|
format: 'json' | 'html' | 'arkmejson'
|
||||||
usernames?: string[]
|
usernames?: string[]
|
||||||
keyword?: string
|
keyword?: string
|
||||||
exportMedia?: boolean
|
exportMedia?: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user