diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index d945f02..6ca8b61 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -260,6 +260,214 @@ flex-shrink: 0; } +.session-mutual-friends-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.42); + display: flex; + align-items: center; + justify-content: center; + z-index: 2250; + padding: 20px; +} + +.session-mutual-friends-modal { + width: min(760px, 100%); + max-height: min(82vh, 900px); + overflow: hidden; + border-radius: 16px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28); + display: flex; + flex-direction: column; +} + +.session-mutual-friends-header { + padding: 16px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.session-mutual-friends-header-main { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; +} + +.session-mutual-friends-avatar { + width: 44px; + height: 44px; + border-radius: 12px; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: #fff; + font-size: 16px; + font-weight: 700; + } +} + +.session-mutual-friends-meta { + min-width: 0; + + h4 { + margin: 0; + font-size: 16px; + color: var(--text-primary); + } +} + +.session-mutual-friends-stats { + margin-top: 4px; + font-size: 12px; + color: var(--text-secondary); +} + +.session-mutual-friends-close { + border: 1px solid var(--border-color); + border-radius: 8px; + width: 30px; + height: 30px; + background: var(--bg-secondary); + color: var(--text-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + color: var(--text-primary); + border-color: var(--text-tertiary); + } +} + +.session-mutual-friends-tip { + margin: 14px 16px 0; + padding: 11px 12px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--primary) 28%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 10%, var(--bg-secondary)); + color: var(--text-primary); + font-size: 14px; + line-height: 1.5; + font-weight: 700; +} + +.session-mutual-friends-toolbar { + padding: 12px 16px 0; + + input { + width: 100%; + height: 38px; + border-radius: 10px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + padding: 0 12px; + font-size: 13px; + + &:focus { + outline: none; + border-color: color-mix(in srgb, var(--primary) 58%, var(--border-color)); + } + } +} + +.session-mutual-friends-body { + padding: 14px 16px 16px; + overflow: auto; + min-height: 220px; +} + +.session-mutual-friends-list { + display: flex; + flex-direction: column; + border: 1px solid var(--border-color); + border-radius: 12px; + overflow: hidden; +} + +.session-mutual-friends-row { + display: grid; + grid-template-columns: 42px minmax(0, 1fr) 96px 72px 110px; + gap: 10px; + align-items: center; + padding: 11px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 68%, transparent); + font-size: 13px; + color: var(--text-secondary); + + &:last-child { + border-bottom: none; + } +} + +.session-mutual-friends-rank, +.session-mutual-friends-count, +.session-mutual-friends-latest { + font-variant-numeric: tabular-nums; +} + +.session-mutual-friends-rank { + color: var(--text-tertiary); +} + +.session-mutual-friends-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); + font-weight: 600; +} + +.session-mutual-friends-source { + justify-self: start; + border-radius: 999px; + padding: 4px 8px; + font-size: 11px; + line-height: 1; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + + &.both { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 10%, var(--bg-secondary)); + } +} + +.session-mutual-friends-count, +.session-mutual-friends-latest { + text-align: right; +} + +.session-mutual-friends-empty { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + font-size: 13px; +} + .global-export-controls { flex: 0 1 980px; width: min(980px, 100%); @@ -1708,6 +1916,21 @@ &.loading { color: var(--text-tertiary); } + + &:disabled { + color: var(--text-secondary); + cursor: default; + text-decoration: none; + opacity: 0.78; + } + } + + .row-mutual-friends-btn.ready { + color: #0f766e; + + &:hover:not(:disabled) { + color: #115e59; + } } .row-message-stats { @@ -3593,6 +3816,17 @@ width: min(94vw, 820px); } + .session-mutual-friends-modal { + width: min(94vw, 760px); + max-height: 86vh; + } + + .session-mutual-friends-row { + grid-template-columns: 34px minmax(0, 1fr) 82px 56px 88px; + gap: 8px; + font-size: 12px; + } + .session-load-detail-row { grid-template-columns: minmax(68px, 0.72fr) minmax(232px, 1.6fr) minmax(80px, 0.72fr) minmax(80px, 0.72fr); min-width: 560px; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 69ec349..b12203b 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -564,6 +564,25 @@ interface SessionSnsRankItem { latestTime: number } +type SessionMutualFriendSource = 'likes' | 'comments' | 'both' + +interface SessionMutualFriendItem { + name: string + likeCount: number + commentCount: number + totalCount: number + latestTime: number + source: SessionMutualFriendSource +} + +interface SessionMutualFriendsMetric { + count: number + items: SessionMutualFriendItem[] + loadedPosts: number + totalPosts: number | null + computedAt: number +} + interface SessionSnsRankCacheEntry { likes: SessionSnsRankItem[] comments: SessionSnsRankItem[] @@ -615,6 +634,79 @@ const buildSessionSnsRankings = (posts: SnsPost[]): { likes: SessionSnsRankItem[ } } +const buildSessionMutualFriendsMetric = ( + posts: SnsPost[], + totalPosts: number | null +): SessionMutualFriendsMetric => { + const friendMap = new Map() + + for (const post of posts) { + const createTime = Number(post?.createTime) || 0 + const likes = Array.isArray(post?.likes) ? post.likes : [] + const comments = Array.isArray(post?.comments) ? post.comments : [] + + for (const likeNameRaw of likes) { + const name = String(likeNameRaw || '').trim() || '未知用户' + const existing = friendMap.get(name) + if (existing) { + existing.likeCount += 1 + existing.totalCount += 1 + existing.source = existing.commentCount > 0 ? 'both' : 'likes' + if (createTime > existing.latestTime) existing.latestTime = createTime + continue + } + friendMap.set(name, { + name, + likeCount: 1, + commentCount: 0, + totalCount: 1, + latestTime: createTime, + source: 'likes' + }) + } + + for (const comment of comments) { + const name = String(comment?.nickname || '').trim() || '未知用户' + const existing = friendMap.get(name) + if (existing) { + existing.commentCount += 1 + existing.totalCount += 1 + existing.source = existing.likeCount > 0 ? 'both' : 'comments' + if (createTime > existing.latestTime) existing.latestTime = createTime + continue + } + friendMap.set(name, { + name, + likeCount: 0, + commentCount: 1, + totalCount: 1, + latestTime: createTime, + source: 'comments' + }) + } + } + + const items = [...friendMap.values()].sort((a, b) => { + if (b.totalCount !== a.totalCount) return b.totalCount - a.totalCount + if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime + return a.name.localeCompare(b.name, 'zh-CN') + }) + + return { + count: items.length, + items, + loadedPosts: posts.length, + totalPosts, + computedAt: Date.now() + } +} + +const getSessionMutualFriendSourceLabel = (source: SessionMutualFriendSource): string => { + if (source === 'both') return '点赞/评论' + if (source === 'likes') return '仅点赞' + return '仅评论' +} + interface SessionExportMetric { totalMessages: number voiceMessages: number @@ -664,6 +756,7 @@ interface SessionLoadTraceState { messageCount: SessionLoadStageState mediaMetrics: SessionLoadStageState snsPostCounts: SessionLoadStageState + mutualFriends: SessionLoadStageState } interface SessionLoadStageSummary { @@ -899,7 +992,8 @@ const createDefaultSessionLoadStage = (): SessionLoadStageState => ({ status: 'p const createDefaultSessionLoadTrace = (): SessionLoadTraceState => ({ messageCount: createDefaultSessionLoadStage(), mediaMetrics: createDefaultSessionLoadStage(), - snsPostCounts: createDefaultSessionLoadStage() + snsPostCounts: createDefaultSessionLoadStage(), + mutualFriends: createDefaultSessionLoadStage() }) const WriteLayoutSelector = memo(function WriteLayoutSelector({ @@ -1274,6 +1368,9 @@ function ExportPage() { const [sessionSnsRankError, setSessionSnsRankError] = useState(null) const [sessionSnsRankLoadedPosts, setSessionSnsRankLoadedPosts] = useState(0) const [sessionSnsRankTotalPosts, setSessionSnsRankTotalPosts] = useState(null) + const [sessionMutualFriendsMetrics, setSessionMutualFriendsMetrics] = useState>({}) + const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState(null) + const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('') const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('B') @@ -1398,6 +1495,18 @@ function ExportPage() { startIndex: 0, endIndex: -1 }) + const sessionMutualFriendsMetricsRef = useRef>({}) + const sessionMutualFriendsQueueRef = useRef([]) + const sessionMutualFriendsQueuedSetRef = useRef>(new Set()) + const sessionMutualFriendsLoadingSetRef = useRef>(new Set()) + const sessionMutualFriendsReadySetRef = useRef>(new Set()) + const sessionMutualFriendsRunIdRef = useRef(0) + const sessionMutualFriendsWorkerRunningRef = useRef(false) + const sessionMutualFriendsBackgroundFeedTimerRef = useRef(null) + const sessionMutualFriendsVisibleRangeRef = useRef<{ startIndex: number; endIndex: number }>({ + startIndex: 0, + endIndex: -1 + }) const ensureExportCacheScope = useCallback(async (): Promise => { if (exportCacheScopeReadyRef.current) { @@ -1455,6 +1564,10 @@ function ExportPage() { sessionContentMetricsRef.current = sessionContentMetrics }, [sessionContentMetrics]) + useEffect(() => { + sessionMutualFriendsMetricsRef.current = sessionMutualFriendsMetrics + }, [sessionMutualFriendsMetrics]) + const patchSessionLoadTraceStage = useCallback(( sessionIds: string[], stageKey: keyof SessionLoadTraceState, @@ -2223,6 +2336,24 @@ function ExportPage() { }) }, [openSessionSnsTimelineByTarget]) + const openSessionMutualFriendsDialog = useCallback((contact: ContactInfo) => { + const normalizedSessionId = String(contact?.username || '').trim() + if (!normalizedSessionId || !isSingleContactSession(normalizedSessionId)) return + const metric = sessionMutualFriendsMetricsRef.current[normalizedSessionId] + if (!metric) return + setSessionMutualFriendsSearch('') + setSessionMutualFriendsDialogTarget({ + username: normalizedSessionId, + displayName: contact.displayName || contact.remark || contact.nickname || normalizedSessionId, + avatarUrl: contact.avatarUrl + }) + }, []) + + const closeSessionMutualFriendsDialog = useCallback(() => { + setSessionMutualFriendsDialogTarget(null) + setSessionMutualFriendsSearch('') + }, []) + const loadMoreSessionSnsTimeline = useCallback(() => { if (!sessionSnsTimelineTarget || sessionSnsTimelineLoading || sessionSnsTimelineLoadingMore || !sessionSnsTimelineHasMore) return void loadSessionSnsTimelinePosts(sessionSnsTimelineTarget, { reset: false }) @@ -2503,6 +2634,61 @@ function ExportPage() { }, SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS) }, [flushSessionMediaMetricCache]) + const resetSessionMutualFriendsLoader = useCallback(() => { + sessionMutualFriendsRunIdRef.current += 1 + sessionMutualFriendsQueueRef.current = [] + sessionMutualFriendsQueuedSetRef.current.clear() + sessionMutualFriendsLoadingSetRef.current.clear() + sessionMutualFriendsReadySetRef.current.clear() + sessionMutualFriendsWorkerRunningRef.current = false + sessionMutualFriendsVisibleRangeRef.current = { startIndex: 0, endIndex: -1 } + if (sessionMutualFriendsBackgroundFeedTimerRef.current) { + window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current) + sessionMutualFriendsBackgroundFeedTimerRef.current = null + } + }, []) + + const isSessionMutualFriendsReady = useCallback((sessionId: string): boolean => { + if (!sessionId) return true + if (sessionMutualFriendsReadySetRef.current.has(sessionId)) return true + const existing = sessionMutualFriendsMetricsRef.current[sessionId] + if (existing && typeof existing.count === 'number' && Array.isArray(existing.items)) { + sessionMutualFriendsReadySetRef.current.add(sessionId) + return true + } + return false + }, []) + + const enqueueSessionMutualFriendsRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => { + const front = options?.front === true + const incoming: string[] = [] + for (const sessionIdRaw of sessionIds) { + const sessionId = String(sessionIdRaw || '').trim() + if (!sessionId) continue + if (sessionMutualFriendsQueuedSetRef.current.has(sessionId)) continue + if (sessionMutualFriendsLoadingSetRef.current.has(sessionId)) continue + if (isSessionMutualFriendsReady(sessionId)) continue + sessionMutualFriendsQueuedSetRef.current.add(sessionId) + incoming.push(sessionId) + } + if (incoming.length === 0) return + patchSessionLoadTraceStage(incoming, 'mutualFriends', 'pending') + if (front) { + sessionMutualFriendsQueueRef.current = [...incoming, ...sessionMutualFriendsQueueRef.current] + } else { + sessionMutualFriendsQueueRef.current.push(...incoming) + } + }, [isSessionMutualFriendsReady, patchSessionLoadTraceStage]) + + const hasPendingMetricLoads = useCallback((): boolean => ( + isLoadingSessionCountsRef.current || + sessionMediaMetricQueuedSetRef.current.size > 0 || + sessionMediaMetricLoadingSetRef.current.size > 0 || + sessionMediaMetricWorkerRunningRef.current || + snsUserPostCountsStatus === 'loading' || + snsUserPostCountsStatus === 'idle' + ), [snsUserPostCountsStatus]) + const isSessionMediaMetricReady = useCallback((sessionId: string): boolean => { if (!sessionId) return true if (sessionMediaMetricReadySetRef.current.has(sessionId)) return true @@ -2646,6 +2832,104 @@ function ExportPage() { void runSessionMediaMetricWorker(runId) }, [isSessionCountStageReady, runSessionMediaMetricWorker]) + const loadSessionMutualFriendsMetric = useCallback(async (sessionId: string): Promise => { + const normalizedSessionId = String(sessionId || '').trim() + const hasKnownTotal = Object.prototype.hasOwnProperty.call(snsUserPostCounts, normalizedSessionId) + const knownTotalRaw = hasKnownTotal ? Number(snsUserPostCounts[normalizedSessionId] || 0) : NaN + const knownTotal = Number.isFinite(knownTotalRaw) ? Math.max(0, Math.floor(knownTotalRaw)) : null + const allPosts: SnsPost[] = [] + let endTime: number | undefined + let hasMore = true + + while (hasMore) { + const result = await window.electronAPI.sns.getTimeline( + SNS_RANK_PAGE_SIZE, + 0, + [normalizedSessionId], + '', + undefined, + endTime + ) + if (!result.success) { + throw new Error(result.error || '共同好友统计失败') + } + + const pagePosts = Array.isArray(result.timeline) + ? [...(result.timeline as SnsPost[])].sort((a, b) => b.createTime - a.createTime) + : [] + if (pagePosts.length === 0) { + hasMore = false + break + } + + allPosts.push(...pagePosts) + endTime = pagePosts[pagePosts.length - 1].createTime - 1 + hasMore = pagePosts.length >= SNS_RANK_PAGE_SIZE + } + + return buildSessionMutualFriendsMetric(allPosts, knownTotal) + }, [snsUserPostCounts]) + + const runSessionMutualFriendsWorker = useCallback(async (runId: number) => { + if (sessionMutualFriendsWorkerRunningRef.current) return + sessionMutualFriendsWorkerRunningRef.current = true + try { + while (runId === sessionMutualFriendsRunIdRef.current) { + if (hasPendingMetricLoads()) { + await new Promise(resolve => window.setTimeout(resolve, 120)) + continue + } + + const sessionId = sessionMutualFriendsQueueRef.current.shift() + if (!sessionId) break + sessionMutualFriendsQueuedSetRef.current.delete(sessionId) + if (sessionMutualFriendsLoadingSetRef.current.has(sessionId)) continue + if (isSessionMutualFriendsReady(sessionId)) continue + + sessionMutualFriendsLoadingSetRef.current.add(sessionId) + patchSessionLoadTraceStage([sessionId], 'mutualFriends', 'loading') + + try { + const metric = await loadSessionMutualFriendsMetric(sessionId) + if (runId !== sessionMutualFriendsRunIdRef.current) return + setSessionMutualFriendsMetrics(prev => ({ + ...prev, + [sessionId]: metric + })) + sessionMutualFriendsReadySetRef.current.add(sessionId) + patchSessionLoadTraceStage([sessionId], 'mutualFriends', 'done') + } catch (error) { + console.error('导出页加载共同好友统计失败:', error) + patchSessionLoadTraceStage([sessionId], 'mutualFriends', 'failed', { + error: error instanceof Error ? error.message : String(error) + }) + } finally { + sessionMutualFriendsLoadingSetRef.current.delete(sessionId) + } + + await new Promise(resolve => window.setTimeout(resolve, 0)) + } + } finally { + sessionMutualFriendsWorkerRunningRef.current = false + if (runId === sessionMutualFriendsRunIdRef.current && sessionMutualFriendsQueueRef.current.length > 0) { + void runSessionMutualFriendsWorker(runId) + } + } + }, [ + hasPendingMetricLoads, + isSessionMutualFriendsReady, + loadSessionMutualFriendsMetric, + patchSessionLoadTraceStage + ]) + + const scheduleSessionMutualFriendsWorker = useCallback(() => { + if (!isSessionCountStageReady) return + if (hasPendingMetricLoads()) return + if (sessionMutualFriendsWorkerRunningRef.current) return + const runId = sessionMutualFriendsRunIdRef.current + void runSessionMutualFriendsWorker(runId) + }, [hasPendingMetricLoads, isSessionCountStageReady, runSessionMutualFriendsWorker]) + const loadSessionMessageCounts = useCallback(async ( sourceSessions: SessionRow[], priorityTab: ConversationTab, @@ -2800,11 +3084,16 @@ function ExportPage() { sessionsHydratedAtRef.current = 0 sessionPreciseRefreshAtRef.current = {} resetSessionMediaMetricLoader() + resetSessionMutualFriendsLoader() setIsLoading(true) setIsSessionEnriching(false) sessionCountRequestIdRef.current += 1 setSessionMessageCounts({}) setSessionContentMetrics({}) + setSessionMutualFriendsMetrics({}) + sessionMutualFriendsMetricsRef.current = {} + setSessionMutualFriendsDialogTarget(null) + setSessionMutualFriendsSearch('') setSessionLoadTraceMap({}) setSessionLoadProgressPulseMap({}) sessionLoadProgressSnapshotRef.current = {} @@ -3110,7 +3399,7 @@ function ExportPage() { } finally { if (!isStale()) setIsLoading(false) } - }, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, resetSessionMediaMetricLoader, syncContactTypeCounts]) + }, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, resetSessionMediaMetricLoader, resetSessionMutualFriendsLoader, syncContactTypeCounts]) useEffect(() => { if (!isExportRoute) return @@ -3147,10 +3436,11 @@ function ExportPage() { window.clearTimeout(snsUserPostCountsBatchTimerRef.current) snsUserPostCountsBatchTimerRef.current = null } + resetSessionMutualFriendsLoader() setIsSessionEnriching(false) setIsLoadingSessionCounts(false) setSnsUserPostCountsStatus(prev => (prev === 'loading' ? 'idle' : prev)) - }, [isExportRoute]) + }, [isExportRoute, resetSessionMutualFriendsLoader]) useEffect(() => { if (activeTab === 'official') { @@ -4038,6 +4328,7 @@ function ExportPage() { const shouldShowSnsColumn = useMemo(() => ( activeTab === 'private' || activeTab === 'former_friend' ), [activeTab]) + const shouldShowMutualFriendsColumn = shouldShowSnsColumn const sessionRowByUsername = useMemo(() => { const map = new Map() @@ -4197,15 +4488,19 @@ function ExportPage() { return tabOrder.map((tab) => { const sessionIds = loadDetailTargetsByTab[tab] || [] const snsSessionIds = sessionIds.filter((sessionId) => isSingleContactSession(sessionId)) - const snsPostCounts = tab === 'private' + const snsPostCounts = tab === 'private' || tab === 'former_friend' ? summarizeLoadTraceForTab(snsSessionIds, 'snsPostCounts') : createNotApplicableLoadSummary() + const mutualFriends = tab === 'private' || tab === 'former_friend' + ? summarizeLoadTraceForTab(snsSessionIds, 'mutualFriends') + : createNotApplicableLoadSummary() return { tab, label: conversationTabLabels[tab], messageCount: summarizeLoadTraceForTab(sessionIds, 'messageCount'), mediaMetrics: summarizeLoadTraceForTab(sessionIds, 'mediaMetrics'), - snsPostCounts + snsPostCounts, + mutualFriends } }) }, [createNotApplicableLoadSummary, loadDetailTargetsByTab, summarizeLoadTraceForTab]) @@ -4225,7 +4520,7 @@ function ExportPage() { const nextSnapshot: Record = {} const resetKeys: string[] = [] const updates: Array<{ key: string; at: number; delta: number }> = [] - const stageKeys: Array = ['messageCount', 'mediaMetrics', 'snsPostCounts'] + const stageKeys: Array = ['messageCount', 'mediaMetrics', 'snsPostCounts', 'mutualFriends'] for (const row of sessionLoadDetailRows) { for (const stageKey of stageKeys) { @@ -4296,16 +4591,51 @@ function ExportPage() { return sessionIds }, [sessionRowByUsername]) + const collectVisibleSessionMutualFriendsTargets = useCallback((sourceContacts: ContactInfo[]): string[] => { + if (sourceContacts.length === 0) return [] + const startCandidate = sessionMutualFriendsVisibleRangeRef.current.startIndex + const endCandidate = sessionMutualFriendsVisibleRangeRef.current.endIndex + const startIndex = Math.max(0, Math.min(sourceContacts.length - 1, startCandidate >= 0 ? startCandidate : 0)) + const visibleEnd = endCandidate >= startIndex + ? endCandidate + : Math.min(sourceContacts.length - 1, startIndex + 9) + const endIndex = Math.max(startIndex, Math.min(sourceContacts.length - 1, visibleEnd + SESSION_MEDIA_METRIC_PREFETCH_ROWS)) + const sessionIds: string[] = [] + for (let index = startIndex; index <= endIndex; index += 1) { + const contact = sourceContacts[index] + if (!contact?.username || !isSingleContactSession(contact.username)) continue + const mappedSession = sessionRowByUsername.get(contact.username) + if (!mappedSession?.hasSession) continue + sessionIds.push(contact.username) + } + return sessionIds + }, [sessionRowByUsername]) + const handleContactsRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { const startIndex = Number.isFinite(range?.startIndex) ? Math.max(0, Math.floor(range.startIndex)) : 0 const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex } + sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex } if (isLoadingSessionCountsRef.current || !isSessionCountStageReady) return const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts) if (visibleTargets.length === 0) return enqueueSessionMediaMetricRequests(visibleTargets, { front: true }) scheduleSessionMediaMetricWorker() - }, [collectVisibleSessionMetricTargets, enqueueSessionMediaMetricRequests, filteredContacts, isSessionCountStageReady, scheduleSessionMediaMetricWorker]) + const visibleMutualFriendsTargets = collectVisibleSessionMutualFriendsTargets(filteredContacts) + if (visibleMutualFriendsTargets.length > 0) { + enqueueSessionMutualFriendsRequests(visibleMutualFriendsTargets, { front: true }) + scheduleSessionMutualFriendsWorker() + } + }, [ + collectVisibleSessionMetricTargets, + collectVisibleSessionMutualFriendsTargets, + enqueueSessionMediaMetricRequests, + enqueueSessionMutualFriendsRequests, + filteredContacts, + isSessionCountStageReady, + scheduleSessionMediaMetricWorker, + scheduleSessionMutualFriendsWorker + ]) useEffect(() => { if (!isSessionCountStageReady || filteredContacts.length === 0) return @@ -4363,6 +4693,61 @@ function ExportPage() { sessionRowByUsername ]) + useEffect(() => { + if (!isSessionCountStageReady || filteredContacts.length === 0) return + const runId = sessionMutualFriendsRunIdRef.current + const visibleTargets = collectVisibleSessionMutualFriendsTargets(filteredContacts) + if (visibleTargets.length > 0) { + enqueueSessionMutualFriendsRequests(visibleTargets, { front: true }) + scheduleSessionMutualFriendsWorker() + } + + if (sessionMutualFriendsBackgroundFeedTimerRef.current) { + window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current) + sessionMutualFriendsBackgroundFeedTimerRef.current = null + } + + const visibleTargetSet = new Set(visibleTargets) + let cursor = 0 + const feedNext = () => { + if (runId !== sessionMutualFriendsRunIdRef.current) return + const batchIds: string[] = [] + while (cursor < filteredContacts.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) { + const contact = filteredContacts[cursor] + cursor += 1 + if (!contact?.username || !isSingleContactSession(contact.username)) continue + if (visibleTargetSet.has(contact.username)) continue + const mappedSession = sessionRowByUsername.get(contact.username) + if (!mappedSession?.hasSession) continue + batchIds.push(contact.username) + } + + if (batchIds.length > 0) { + enqueueSessionMutualFriendsRequests(batchIds) + scheduleSessionMutualFriendsWorker() + } + + if (cursor < filteredContacts.length) { + sessionMutualFriendsBackgroundFeedTimerRef.current = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS) + } + } + + feedNext() + return () => { + if (sessionMutualFriendsBackgroundFeedTimerRef.current) { + window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current) + sessionMutualFriendsBackgroundFeedTimerRef.current = null + } + } + }, [ + collectVisibleSessionMutualFriendsTargets, + enqueueSessionMutualFriendsRequests, + filteredContacts, + isSessionCountStageReady, + scheduleSessionMutualFriendsWorker, + sessionRowByUsername + ]) + useEffect(() => { return () => { snsUserPostCountsHydrationTokenRef.current += 1 @@ -4378,6 +4763,10 @@ function ExportPage() { window.clearTimeout(sessionMediaMetricPersistTimerRef.current) sessionMediaMetricPersistTimerRef.current = null } + if (sessionMutualFriendsBackgroundFeedTimerRef.current) { + window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current) + sessionMutualFriendsBackgroundFeedTimerRef.current = null + } void flushSessionMediaMetricCache() } }, [flushSessionMediaMetricCache]) @@ -4420,6 +4809,19 @@ function ExportPage() { return `朋友圈:${normalized}条` }, [sessionDetail?.wxid, sessionDetailSupportsSnsTimeline, snsUserPostCounts, snsUserPostCountsStatus]) + const sessionMutualFriendsDialogMetric = useMemo(() => { + const sessionId = String(sessionMutualFriendsDialogTarget?.username || '').trim() + if (!sessionId) return null + return sessionMutualFriendsMetrics[sessionId] || null + }, [sessionMutualFriendsDialogTarget, sessionMutualFriendsMetrics]) + + const filteredSessionMutualFriendsDialogItems = useMemo(() => { + const items = sessionMutualFriendsDialogMetric?.items || [] + const keyword = sessionMutualFriendsSearch.trim().toLowerCase() + if (!keyword) return items + return items.filter(item => item.name.toLowerCase().includes(keyword)) + }, [sessionMutualFriendsDialogMetric, sessionMutualFriendsSearch]) + const applySessionDetailStats = useCallback(( sessionId: string, metric: SessionExportMetric, @@ -4836,6 +5238,17 @@ function ExportPage() { return () => window.removeEventListener('keydown', handleKeyDown) }, [closeSessionSnsTimeline, sessionSnsTimelineTarget]) + useEffect(() => { + if (!sessionMutualFriendsDialogTarget) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeSessionMutualFriendsDialog() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [closeSessionMutualFriendsDialog, sessionMutualFriendsDialogTarget]) + useEffect(() => { if (!showSessionFormatSelect) return const handlePointerDown = (event: MouseEvent) => { @@ -4982,7 +5395,8 @@ function ExportPage() { const candidateTimes = [ row.messageCount.finishedAt || row.messageCount.startedAt || 0, row.mediaMetrics.finishedAt || row.mediaMetrics.startedAt || 0, - row.snsPostCounts.finishedAt || row.snsPostCounts.startedAt || 0 + row.snsPostCounts.finishedAt || row.snsPostCounts.startedAt || 0, + row.mutualFriends.finishedAt || row.mutualFriends.startedAt || 0 ] for (const candidate of candidateTimes) { if (candidate > latest) { @@ -5048,6 +5462,18 @@ function ExportPage() { ) const snsRawCount = Number(snsUserPostCounts[contact.username] || 0) const snsCount = Number.isFinite(snsRawCount) ? Math.max(0, Math.floor(snsRawCount)) : 0 + const mutualFriendsMetric = sessionMutualFriendsMetrics[contact.username] + const hasMutualFriendsMetric = Boolean(mutualFriendsMetric) + const mutualFriendsStageStatus = sessionLoadTraceMap[contact.username]?.mutualFriends?.status + const isMutualFriendsLoading = ( + supportsSnsTimeline && + canExport && + !hasMutualFriendsMetric && + ( + mutualFriendsStageStatus === 'pending' || + mutualFriendsStageStatus === 'loading' + ) + ) const openChatLabel = contact.type === 'friend' ? '打开私聊' : contact.type === 'group' @@ -5152,6 +5578,27 @@ function ExportPage() { )} )} + {shouldShowMutualFriendsColumn && ( +
+ {supportsSnsTimeline ? ( + + ) : ( + -- + )} +
+ )}
@@ -5187,18 +5634,21 @@ function ExportPage() { nowTick, openContactSnsTimeline, openSessionDetail, + openSessionMutualFriendsDialog, openSingleExport, queuedSessionIds, runningSessionIds, selectedSessions, sessionDetail?.wxid, sessionContentMetrics, + sessionMutualFriendsMetrics, sessionLoadTraceMap, sessionMessageCounts, sessionRowByUsername, isLoading, isSessionEnriching, showSessionDetailPanel, + shouldShowMutualFriendsColumn, shouldShowSnsColumn, snsUserPostCounts, snsUserPostCountsStatus, @@ -5546,6 +5996,9 @@ function ExportPage() { {shouldShowSnsColumn && ( 朋友圈 )} + {shouldShowMutualFriendsColumn && ( + 共同好友 + )} {selectedCount > 0 && ( <> @@ -5742,7 +6195,7 @@ function ExportPage() { 完成时间
{sessionLoadDetailRows - .filter((row) => row.tab === 'private') + .filter((row) => row.tab === 'private' || row.tab === 'former_friend') .map((row) => { const pulse = sessionLoadProgressPulseMap[`snsPostCounts:${row.tab}`] const isLoading = row.snsPostCounts.statusLabel.startsWith('加载中') @@ -5767,6 +6220,121 @@ function ExportPage() { })}
+ +
+
共同好友统计
+
+
+ 会话类型 + 加载状态 + 开始时间 + 完成时间 +
+ {sessionLoadDetailRows + .filter((row) => row.tab === 'private' || row.tab === 'former_friend') + .map((row) => { + const pulse = sessionLoadProgressPulseMap[`mutualFriends:${row.tab}`] + const isLoading = row.mutualFriends.statusLabel.startsWith('加载中') + return ( +
+ {row.label} + + {row.mutualFriends.statusLabel} + {isLoading && ( + + )} + {isLoading && pulse && pulse.delta > 0 && ( + + {formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}个 + + )} + + {formatLoadDetailTime(row.mutualFriends.startedAt)} + {formatLoadDetailTime(row.mutualFriends.finishedAt)} +
+ ) + })} +
+
+
+ + + )} + + {sessionMutualFriendsDialogTarget && sessionMutualFriendsDialogMetric && ( +
+
event.stopPropagation()} + > +
+
+
+ {sessionMutualFriendsDialogTarget.avatarUrl ? ( + + ) : ( + {getAvatarLetter(sessionMutualFriendsDialogTarget.displayName)} + )} +
+
+

{sessionMutualFriendsDialogTarget.displayName} 的共同好友

+
+ 共 {sessionMutualFriendsDialogMetric.count.toLocaleString('zh-CN')} 人 + {sessionMutualFriendsDialogMetric.totalPosts !== null + ? ` · 已统计 ${sessionMutualFriendsDialogMetric.loadedPosts.toLocaleString('zh-CN')} / ${sessionMutualFriendsDialogMetric.totalPosts.toLocaleString('zh-CN')} 条朋友圈` + : ` · 已统计 ${sessionMutualFriendsDialogMetric.loadedPosts.toLocaleString('zh-CN')} 条朋友圈`} +
+
+
+ +
+ +
+ 打开桌面端微信,进入到这个人的朋友圈中,刷ta 的朋友圈,刷的越多这里的数据聚合越多 +
+ +
+ setSessionMutualFriendsSearch(event.target.value)} + placeholder="搜索共同好友" + aria-label="搜索共同好友" + /> +
+ +
+ {filteredSessionMutualFriendsDialogItems.length === 0 ? ( +
+ {sessionMutualFriendsSearch.trim() ? '没有匹配的共同好友' : '暂无共同好友数据'} +
+ ) : ( +
+ {filteredSessionMutualFriendsDialogItems.map((item, index) => ( +
+ {index + 1} + {item.name} + + {getSessionMutualFriendSourceLabel(item.source)} + + {item.totalCount.toLocaleString('zh-CN')} + {formatYmdDateFromSeconds(item.latestTime)} +
+ ))} +
+ )}