diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 3410552..cfdc918 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2370,6 +2370,18 @@ padding: 6px 0; } + .sns-dialog-rank-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 28px; + padding: 4px 0 8px; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; + } + .sns-dialog-rank-row { display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 4f18a72..aa8e134 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -174,6 +174,8 @@ const SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS = 120 const SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS = 1200 const SNS_USER_POST_COUNT_BATCH_SIZE = 12 const SNS_USER_POST_COUNT_BATCH_INTERVAL_MS = 120 +const SNS_RANK_PAGE_SIZE = 50 +const SNS_RANK_DISPLAY_LIMIT = 15 const contentTypeLabels: Record = { text: '聊天文本', voice: '语音', @@ -684,6 +686,63 @@ interface SessionSnsTimelineTarget { avatarUrl?: string } +interface SessionSnsRankItem { + name: string + count: number + latestTime: number +} + +interface SessionSnsRankCacheEntry { + likes: SessionSnsRankItem[] + comments: SessionSnsRankItem[] + totalPosts: number + computedAt: number +} + +const buildSessionSnsRankings = (posts: SnsPost[]): { likes: SessionSnsRankItem[]; comments: SessionSnsRankItem[] } => { + const likeMap = new Map() + const commentMap = 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 current = likeMap.get(name) + if (current) { + current.count += 1 + if (createTime > current.latestTime) current.latestTime = createTime + continue + } + likeMap.set(name, { name, count: 1, latestTime: createTime }) + } + + for (const comment of comments) { + const name = String(comment?.nickname || '').trim() || '未知用户' + const current = commentMap.get(name) + if (current) { + current.count += 1 + if (createTime > current.latestTime) current.latestTime = createTime + continue + } + commentMap.set(name, { name, count: 1, latestTime: createTime }) + } + } + + const sorter = (a: SessionSnsRankItem, b: SessionSnsRankItem): number => { + if (b.count !== a.count) return b.count - a.count + if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime + return a.name.localeCompare(b.name, 'zh-CN') + } + + return { + likes: [...likeMap.values()].sort(sorter), + comments: [...commentMap.values()].sort(sorter) + } +} + interface SessionExportMetric { totalMessages: number voiceMessages: number @@ -1337,6 +1396,12 @@ function ExportPage() { const [sessionSnsTimelineTotalPosts, setSessionSnsTimelineTotalPosts] = useState(null) const [sessionSnsTimelineStatsLoading, setSessionSnsTimelineStatsLoading] = useState(false) const [sessionSnsRankMode, setSessionSnsRankMode] = useState(null) + const [sessionSnsLikeRankings, setSessionSnsLikeRankings] = useState([]) + const [sessionSnsCommentRankings, setSessionSnsCommentRankings] = useState([]) + const [sessionSnsRankLoading, setSessionSnsRankLoading] = useState(false) + const [sessionSnsRankError, setSessionSnsRankError] = useState(null) + const [sessionSnsRankLoadedPosts, setSessionSnsRankLoadedPosts] = useState(0) + const [sessionSnsRankTotalPosts, setSessionSnsRankTotalPosts] = useState(null) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('B') @@ -1429,6 +1494,9 @@ function ExportPage() { const sessionSnsTimelinePostsRef = useRef([]) const sessionSnsTimelineLoadingRef = useRef(false) const sessionSnsTimelineRequestTokenRef = useRef(0) + const sessionSnsRankRequestTokenRef = useRef(0) + const sessionSnsRankLoadingRef = useRef(false) + const sessionSnsRankCacheRef = useRef>({}) const snsUserPostCountsHydrationTokenRef = useRef(0) const snsUserPostCountsBatchTimerRef = useRef(null) const sessionPreciseRefreshAtRef = useRef>({}) @@ -2155,7 +2223,15 @@ function ExportPage() { const closeSessionSnsTimeline = useCallback(() => { sessionSnsTimelineRequestTokenRef.current += 1 sessionSnsTimelineLoadingRef.current = false + sessionSnsRankRequestTokenRef.current += 1 + sessionSnsRankLoadingRef.current = false setSessionSnsRankMode(null) + setSessionSnsLikeRankings([]) + setSessionSnsCommentRankings([]) + setSessionSnsRankLoading(false) + setSessionSnsRankError(null) + setSessionSnsRankLoadedPosts(0) + setSessionSnsRankTotalPosts(null) setSessionSnsTimelineTarget(null) setSessionSnsTimelinePosts([]) setSessionSnsTimelineLoading(false) @@ -2166,7 +2242,14 @@ function ExportPage() { }, []) const openSessionSnsTimelineByTarget = useCallback((target: SessionSnsTimelineTarget) => { + sessionSnsRankRequestTokenRef.current += 1 + sessionSnsRankLoadingRef.current = false setSessionSnsRankMode(null) + setSessionSnsLikeRankings([]) + setSessionSnsCommentRankings([]) + setSessionSnsRankLoading(false) + setSessionSnsRankError(null) + setSessionSnsRankLoadedPosts(0) setSessionSnsTimelineTarget(target) setSessionSnsTimelinePosts([]) setSessionSnsTimelineHasMore(false) @@ -2177,9 +2260,11 @@ function ExportPage() { const count = Number(snsUserPostCounts[target.username] || 0) setSessionSnsTimelineTotalPosts(Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0) setSessionSnsTimelineStatsLoading(false) + setSessionSnsRankTotalPosts(Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0) } else { setSessionSnsTimelineTotalPosts(null) setSessionSnsTimelineStatsLoading(true) + setSessionSnsRankTotalPosts(null) } void loadSessionSnsTimelinePosts(target, { reset: true }) @@ -2225,6 +2310,102 @@ function ExportPage() { sessionSnsTimelineTarget ]) + const loadSessionSnsRankings = useCallback(async (target: SessionSnsTimelineTarget) => { + const normalizedUsername = String(target?.username || '').trim() + if (!normalizedUsername || sessionSnsRankLoadingRef.current) return + + const knownTotal = snsUserPostCountsStatus === 'ready' + ? Number(snsUserPostCounts[normalizedUsername] || 0) + : null + const normalizedKnownTotal = knownTotal !== null && Number.isFinite(knownTotal) + ? Math.max(0, Math.floor(knownTotal)) + : null + const cached = sessionSnsRankCacheRef.current[normalizedUsername] + + if (cached && (normalizedKnownTotal === null || cached.totalPosts === normalizedKnownTotal)) { + setSessionSnsLikeRankings(cached.likes) + setSessionSnsCommentRankings(cached.comments) + setSessionSnsRankLoadedPosts(cached.totalPosts) + setSessionSnsRankTotalPosts(cached.totalPosts) + setSessionSnsRankError(null) + setSessionSnsRankLoading(false) + return + } + + sessionSnsRankLoadingRef.current = true + const requestToken = ++sessionSnsRankRequestTokenRef.current + setSessionSnsRankLoading(true) + setSessionSnsRankError(null) + setSessionSnsRankLoadedPosts(0) + setSessionSnsRankTotalPosts(normalizedKnownTotal) + + try { + const allPosts: SnsPost[] = [] + let endTime: number | undefined + let hasMore = true + + while (hasMore) { + const result = await window.electronAPI.sns.getTimeline( + SNS_RANK_PAGE_SIZE, + 0, + [normalizedUsername], + '', + undefined, + endTime + ) + if (requestToken !== sessionSnsRankRequestTokenRef.current) return + + 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) + setSessionSnsRankLoadedPosts(allPosts.length) + if (normalizedKnownTotal === null) { + setSessionSnsRankTotalPosts(allPosts.length) + } + + endTime = pagePosts[pagePosts.length - 1].createTime - 1 + hasMore = pagePosts.length >= SNS_RANK_PAGE_SIZE + } + + if (requestToken !== sessionSnsRankRequestTokenRef.current) return + + const rankings = buildSessionSnsRankings(allPosts) + const totalPosts = allPosts.length + sessionSnsRankCacheRef.current[normalizedUsername] = { + likes: rankings.likes, + comments: rankings.comments, + totalPosts, + computedAt: Date.now() + } + setSessionSnsLikeRankings(rankings.likes) + setSessionSnsCommentRankings(rankings.comments) + setSessionSnsRankLoadedPosts(totalPosts) + setSessionSnsRankTotalPosts(totalPosts) + setSessionSnsRankError(null) + } catch (error) { + if (requestToken !== sessionSnsRankRequestTokenRef.current) return + const message = error instanceof Error ? error.message : String(error) + setSessionSnsLikeRankings([]) + setSessionSnsCommentRankings([]) + setSessionSnsRankError(message || '加载朋友圈排行失败') + } finally { + if (requestToken === sessionSnsRankRequestTokenRef.current) { + sessionSnsRankLoadingRef.current = false + setSessionSnsRankLoading(false) + } + } + }, [snsUserPostCounts, snsUserPostCountsStatus]) + const renderSessionSnsTimelineStats = useCallback((): string => { const loadedCount = sessionSnsTimelinePosts.length const loadPart = sessionSnsTimelineStatsLoading @@ -2247,52 +2428,6 @@ function ExportPage() { sessionSnsTimelineTotalPosts ]) - const sessionSnsLikeRankings = useMemo(() => { - const rankMap = new Map() - for (const post of sessionSnsTimelinePosts) { - const createTime = Number(post?.createTime) || 0 - const likes = Array.isArray(post?.likes) ? post.likes : [] - for (const likeNameRaw of likes) { - const name = String(likeNameRaw || '').trim() || '未知用户' - const current = rankMap.get(name) - if (current) { - current.count += 1 - if (createTime > current.latestTime) current.latestTime = createTime - continue - } - rankMap.set(name, { name, count: 1, latestTime: createTime }) - } - } - return [...rankMap.values()].sort((a, b) => { - if (b.count !== a.count) return b.count - a.count - if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime - return a.name.localeCompare(b.name, 'zh-CN') - }) - }, [sessionSnsTimelinePosts]) - - const sessionSnsCommentRankings = useMemo(() => { - const rankMap = new Map() - for (const post of sessionSnsTimelinePosts) { - const createTime = Number(post?.createTime) || 0 - const comments = Array.isArray(post?.comments) ? post.comments : [] - for (const comment of comments) { - const name = String(comment?.nickname || '').trim() || '未知用户' - const current = rankMap.get(name) - if (current) { - current.count += 1 - if (createTime > current.latestTime) current.latestTime = createTime - continue - } - rankMap.set(name, { name, count: 1, latestTime: createTime }) - } - } - return [...rankMap.values()].sort((a, b) => { - if (b.count !== a.count) return b.count - a.count - if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime - return a.name.localeCompare(b.name, 'zh-CN') - }) - }, [sessionSnsTimelinePosts]) - const toggleSessionSnsRankMode = useCallback((mode: SnsRankMode) => { setSessionSnsRankMode((prev) => (prev === mode ? null : mode)) }, []) @@ -4844,11 +4979,14 @@ function ExportPage() { } if (snsUserPostCountsStatus === 'ready') { const total = Number(snsUserPostCounts[sessionSnsTimelineTarget.username] || 0) - setSessionSnsTimelineTotalPosts(Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0) + const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0 + setSessionSnsTimelineTotalPosts(normalizedTotal) + setSessionSnsRankTotalPosts(normalizedTotal) setSessionSnsTimelineStatsLoading(false) return } setSessionSnsTimelineTotalPosts(null) + setSessionSnsRankTotalPosts(null) setSessionSnsTimelineStatsLoading(false) }, [sessionSnsTimelineTarget, snsUserPostCounts, snsUserPostCountsStatus]) @@ -4859,16 +4997,30 @@ function ExportPage() { } }, [sessionSnsTimelinePosts.length, sessionSnsTimelineTotalPosts]) + useEffect(() => { + if (!sessionSnsRankMode || !sessionSnsTimelineTarget) return + void loadSessionSnsRankings(sessionSnsTimelineTarget) + }, [loadSessionSnsRankings, sessionSnsRankMode, sessionSnsTimelineTarget]) + const closeSessionDetailPanel = useCallback(() => { detailRequestSeqRef.current += 1 detailStatsPriorityRef.current = false sessionSnsTimelineRequestTokenRef.current += 1 sessionSnsTimelineLoadingRef.current = false + sessionSnsRankRequestTokenRef.current += 1 + sessionSnsRankLoadingRef.current = false setShowSessionDetailPanel(false) setIsLoadingSessionDetail(false) setIsLoadingSessionDetailExtra(false) setIsRefreshingSessionDetailStats(false) setIsLoadingSessionRelationStats(false) + setSessionSnsRankMode(null) + setSessionSnsLikeRankings([]) + setSessionSnsCommentRankings([]) + setSessionSnsRankLoading(false) + setSessionSnsRankError(null) + setSessionSnsRankLoadedPosts(0) + setSessionSnsRankTotalPosts(null) setSessionSnsTimelineTarget(null) setSessionSnsTimelinePosts([]) setSessionSnsTimelineLoading(false) @@ -6107,12 +6259,24 @@ function ExportPage() { role="region" aria-label={sessionSnsRankMode === 'likes' ? '点赞排行' : '评论排行'} > - {sessionSnsActiveRankings.length === 0 ? ( + {sessionSnsRankLoading && ( +
+ + + {sessionSnsRankTotalPosts !== null && sessionSnsRankTotalPosts > 0 + ? `统计中,已加载 ${sessionSnsRankLoadedPosts} / ${sessionSnsRankTotalPosts} 条` + : `统计中,已加载 ${sessionSnsRankLoadedPosts} 条`} + +
+ )} + {!sessionSnsRankLoading && sessionSnsRankError ? ( +
{sessionSnsRankError}
+ ) : !sessionSnsRankLoading && sessionSnsActiveRankings.length === 0 ? (
{sessionSnsRankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'}
) : ( - sessionSnsActiveRankings.slice(0, 15).map((item, index) => ( + sessionSnsActiveRankings.slice(0, SNS_RANK_DISPLAY_LIMIT).map((item, index) => (
{index + 1} {item.name}