feat(export): compute sns rankings from full contact timeline

This commit is contained in:
aits2026
2026-03-06 10:05:46 +08:00
parent 3de4951c96
commit ad217d4a3b
2 changed files with 225 additions and 49 deletions

View File

@@ -2370,6 +2370,18 @@
padding: 6px 0; 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 { .sns-dialog-rank-row {
display: grid; display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto; grid-template-columns: 20px minmax(0, 1fr) auto;

View File

@@ -174,6 +174,8 @@ const SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS = 120
const SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS = 1200 const SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS = 1200
const SNS_USER_POST_COUNT_BATCH_SIZE = 12 const SNS_USER_POST_COUNT_BATCH_SIZE = 12
const SNS_USER_POST_COUNT_BATCH_INTERVAL_MS = 120 const SNS_USER_POST_COUNT_BATCH_INTERVAL_MS = 120
const SNS_RANK_PAGE_SIZE = 50
const SNS_RANK_DISPLAY_LIMIT = 15
const contentTypeLabels: Record<ContentType, string> = { const contentTypeLabels: Record<ContentType, string> = {
text: '聊天文本', text: '聊天文本',
voice: '语音', voice: '语音',
@@ -684,6 +686,63 @@ interface SessionSnsTimelineTarget {
avatarUrl?: string 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<string, SessionSnsRankItem>()
const commentMap = new Map<string, SessionSnsRankItem>()
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 { interface SessionExportMetric {
totalMessages: number totalMessages: number
voiceMessages: number voiceMessages: number
@@ -1337,6 +1396,12 @@ function ExportPage() {
const [sessionSnsTimelineTotalPosts, setSessionSnsTimelineTotalPosts] = useState<number | null>(null) const [sessionSnsTimelineTotalPosts, setSessionSnsTimelineTotalPosts] = useState<number | null>(null)
const [sessionSnsTimelineStatsLoading, setSessionSnsTimelineStatsLoading] = useState(false) const [sessionSnsTimelineStatsLoading, setSessionSnsTimelineStatsLoading] = useState(false)
const [sessionSnsRankMode, setSessionSnsRankMode] = useState<SnsRankMode | null>(null) const [sessionSnsRankMode, setSessionSnsRankMode] = useState<SnsRankMode | null>(null)
const [sessionSnsLikeRankings, setSessionSnsLikeRankings] = useState<SessionSnsRankItem[]>([])
const [sessionSnsCommentRankings, setSessionSnsCommentRankings] = useState<SessionSnsRankItem[]>([])
const [sessionSnsRankLoading, setSessionSnsRankLoading] = useState(false)
const [sessionSnsRankError, setSessionSnsRankError] = useState<string | null>(null)
const [sessionSnsRankLoadedPosts, setSessionSnsRankLoadedPosts] = useState(0)
const [sessionSnsRankTotalPosts, setSessionSnsRankTotalPosts] = useState<number | null>(null)
const [exportFolder, setExportFolder] = useState('') const [exportFolder, setExportFolder] = useState('')
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('B') const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('B')
@@ -1429,6 +1494,9 @@ function ExportPage() {
const sessionSnsTimelinePostsRef = useRef<SnsPost[]>([]) const sessionSnsTimelinePostsRef = useRef<SnsPost[]>([])
const sessionSnsTimelineLoadingRef = useRef(false) const sessionSnsTimelineLoadingRef = useRef(false)
const sessionSnsTimelineRequestTokenRef = useRef(0) const sessionSnsTimelineRequestTokenRef = useRef(0)
const sessionSnsRankRequestTokenRef = useRef(0)
const sessionSnsRankLoadingRef = useRef(false)
const sessionSnsRankCacheRef = useRef<Record<string, SessionSnsRankCacheEntry>>({})
const snsUserPostCountsHydrationTokenRef = useRef(0) const snsUserPostCountsHydrationTokenRef = useRef(0)
const snsUserPostCountsBatchTimerRef = useRef<number | null>(null) const snsUserPostCountsBatchTimerRef = useRef<number | null>(null)
const sessionPreciseRefreshAtRef = useRef<Record<string, number>>({}) const sessionPreciseRefreshAtRef = useRef<Record<string, number>>({})
@@ -2155,7 +2223,15 @@ function ExportPage() {
const closeSessionSnsTimeline = useCallback(() => { const closeSessionSnsTimeline = useCallback(() => {
sessionSnsTimelineRequestTokenRef.current += 1 sessionSnsTimelineRequestTokenRef.current += 1
sessionSnsTimelineLoadingRef.current = false sessionSnsTimelineLoadingRef.current = false
sessionSnsRankRequestTokenRef.current += 1
sessionSnsRankLoadingRef.current = false
setSessionSnsRankMode(null) setSessionSnsRankMode(null)
setSessionSnsLikeRankings([])
setSessionSnsCommentRankings([])
setSessionSnsRankLoading(false)
setSessionSnsRankError(null)
setSessionSnsRankLoadedPosts(0)
setSessionSnsRankTotalPosts(null)
setSessionSnsTimelineTarget(null) setSessionSnsTimelineTarget(null)
setSessionSnsTimelinePosts([]) setSessionSnsTimelinePosts([])
setSessionSnsTimelineLoading(false) setSessionSnsTimelineLoading(false)
@@ -2166,7 +2242,14 @@ function ExportPage() {
}, []) }, [])
const openSessionSnsTimelineByTarget = useCallback((target: SessionSnsTimelineTarget) => { const openSessionSnsTimelineByTarget = useCallback((target: SessionSnsTimelineTarget) => {
sessionSnsRankRequestTokenRef.current += 1
sessionSnsRankLoadingRef.current = false
setSessionSnsRankMode(null) setSessionSnsRankMode(null)
setSessionSnsLikeRankings([])
setSessionSnsCommentRankings([])
setSessionSnsRankLoading(false)
setSessionSnsRankError(null)
setSessionSnsRankLoadedPosts(0)
setSessionSnsTimelineTarget(target) setSessionSnsTimelineTarget(target)
setSessionSnsTimelinePosts([]) setSessionSnsTimelinePosts([])
setSessionSnsTimelineHasMore(false) setSessionSnsTimelineHasMore(false)
@@ -2177,9 +2260,11 @@ function ExportPage() {
const count = Number(snsUserPostCounts[target.username] || 0) const count = Number(snsUserPostCounts[target.username] || 0)
setSessionSnsTimelineTotalPosts(Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0) setSessionSnsTimelineTotalPosts(Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0)
setSessionSnsTimelineStatsLoading(false) setSessionSnsTimelineStatsLoading(false)
setSessionSnsRankTotalPosts(Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0)
} else { } else {
setSessionSnsTimelineTotalPosts(null) setSessionSnsTimelineTotalPosts(null)
setSessionSnsTimelineStatsLoading(true) setSessionSnsTimelineStatsLoading(true)
setSessionSnsRankTotalPosts(null)
} }
void loadSessionSnsTimelinePosts(target, { reset: true }) void loadSessionSnsTimelinePosts(target, { reset: true })
@@ -2225,6 +2310,102 @@ function ExportPage() {
sessionSnsTimelineTarget 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 renderSessionSnsTimelineStats = useCallback((): string => {
const loadedCount = sessionSnsTimelinePosts.length const loadedCount = sessionSnsTimelinePosts.length
const loadPart = sessionSnsTimelineStatsLoading const loadPart = sessionSnsTimelineStatsLoading
@@ -2247,52 +2428,6 @@ function ExportPage() {
sessionSnsTimelineTotalPosts sessionSnsTimelineTotalPosts
]) ])
const sessionSnsLikeRankings = useMemo(() => {
const rankMap = new Map<string, { name: string; count: number; latestTime: number }>()
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<string, { name: string; count: number; latestTime: number }>()
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) => { const toggleSessionSnsRankMode = useCallback((mode: SnsRankMode) => {
setSessionSnsRankMode((prev) => (prev === mode ? null : mode)) setSessionSnsRankMode((prev) => (prev === mode ? null : mode))
}, []) }, [])
@@ -4844,11 +4979,14 @@ function ExportPage() {
} }
if (snsUserPostCountsStatus === 'ready') { if (snsUserPostCountsStatus === 'ready') {
const total = Number(snsUserPostCounts[sessionSnsTimelineTarget.username] || 0) 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) setSessionSnsTimelineStatsLoading(false)
return return
} }
setSessionSnsTimelineTotalPosts(null) setSessionSnsTimelineTotalPosts(null)
setSessionSnsRankTotalPosts(null)
setSessionSnsTimelineStatsLoading(false) setSessionSnsTimelineStatsLoading(false)
}, [sessionSnsTimelineTarget, snsUserPostCounts, snsUserPostCountsStatus]) }, [sessionSnsTimelineTarget, snsUserPostCounts, snsUserPostCountsStatus])
@@ -4859,16 +4997,30 @@ function ExportPage() {
} }
}, [sessionSnsTimelinePosts.length, sessionSnsTimelineTotalPosts]) }, [sessionSnsTimelinePosts.length, sessionSnsTimelineTotalPosts])
useEffect(() => {
if (!sessionSnsRankMode || !sessionSnsTimelineTarget) return
void loadSessionSnsRankings(sessionSnsTimelineTarget)
}, [loadSessionSnsRankings, sessionSnsRankMode, sessionSnsTimelineTarget])
const closeSessionDetailPanel = useCallback(() => { const closeSessionDetailPanel = useCallback(() => {
detailRequestSeqRef.current += 1 detailRequestSeqRef.current += 1
detailStatsPriorityRef.current = false detailStatsPriorityRef.current = false
sessionSnsTimelineRequestTokenRef.current += 1 sessionSnsTimelineRequestTokenRef.current += 1
sessionSnsTimelineLoadingRef.current = false sessionSnsTimelineLoadingRef.current = false
sessionSnsRankRequestTokenRef.current += 1
sessionSnsRankLoadingRef.current = false
setShowSessionDetailPanel(false) setShowSessionDetailPanel(false)
setIsLoadingSessionDetail(false) setIsLoadingSessionDetail(false)
setIsLoadingSessionDetailExtra(false) setIsLoadingSessionDetailExtra(false)
setIsRefreshingSessionDetailStats(false) setIsRefreshingSessionDetailStats(false)
setIsLoadingSessionRelationStats(false) setIsLoadingSessionRelationStats(false)
setSessionSnsRankMode(null)
setSessionSnsLikeRankings([])
setSessionSnsCommentRankings([])
setSessionSnsRankLoading(false)
setSessionSnsRankError(null)
setSessionSnsRankLoadedPosts(0)
setSessionSnsRankTotalPosts(null)
setSessionSnsTimelineTarget(null) setSessionSnsTimelineTarget(null)
setSessionSnsTimelinePosts([]) setSessionSnsTimelinePosts([])
setSessionSnsTimelineLoading(false) setSessionSnsTimelineLoading(false)
@@ -6107,12 +6259,24 @@ function ExportPage() {
role="region" role="region"
aria-label={sessionSnsRankMode === 'likes' ? '点赞排行' : '评论排行'} aria-label={sessionSnsRankMode === 'likes' ? '点赞排行' : '评论排行'}
> >
{sessionSnsActiveRankings.length === 0 ? ( {sessionSnsRankLoading && (
<div className="sns-dialog-rank-loading">
<Loader2 size={12} className="spin" />
<span>
{sessionSnsRankTotalPosts !== null && sessionSnsRankTotalPosts > 0
? `统计中,已加载 ${sessionSnsRankLoadedPosts} / ${sessionSnsRankTotalPosts}`
: `统计中,已加载 ${sessionSnsRankLoadedPosts}`}
</span>
</div>
)}
{!sessionSnsRankLoading && sessionSnsRankError ? (
<div className="sns-dialog-rank-empty">{sessionSnsRankError}</div>
) : !sessionSnsRankLoading && sessionSnsActiveRankings.length === 0 ? (
<div className="sns-dialog-rank-empty"> <div className="sns-dialog-rank-empty">
{sessionSnsRankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'} {sessionSnsRankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'}
</div> </div>
) : ( ) : (
sessionSnsActiveRankings.slice(0, 15).map((item, index) => ( sessionSnsActiveRankings.slice(0, SNS_RANK_DISPLAY_LIMIT).map((item, index) => (
<div className="sns-dialog-rank-row" key={`${sessionSnsRankMode}-${item.name}`}> <div className="sns-dialog-rank-row" key={`${sessionSnsRankMode}-${item.name}`}>
<span className="sns-dialog-rank-index">{index + 1}</span> <span className="sns-dialog-rank-index">{index + 1}</span>
<span className="sns-dialog-rank-name" title={item.name}>{item.name}</span> <span className="sns-dialog-rank-name" title={item.name}>{item.name}</span>