From bc2ab60c593f77b8fcd912bb52d29d86d7575939 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Fri, 6 Mar 2026 10:22:24 +0800 Subject: [PATCH] feat(sns): add contact timeline dialog components --- .../Sns/ContactSnsTimelineDialog.scss | 329 ++++++++++ .../Sns/ContactSnsTimelineDialog.tsx | 577 ++++++++++++++++++ src/components/Sns/contactSnsTimeline.ts | 26 + src/pages/ContactsPage.scss | 22 + src/pages/ContactsPage.tsx | 105 +++- 5 files changed, 1049 insertions(+), 10 deletions(-) create mode 100644 src/components/Sns/ContactSnsTimelineDialog.scss create mode 100644 src/components/Sns/ContactSnsTimelineDialog.tsx create mode 100644 src/components/Sns/contactSnsTimeline.ts diff --git a/src/components/Sns/ContactSnsTimelineDialog.scss b/src/components/Sns/ContactSnsTimelineDialog.scss new file mode 100644 index 0000000..b479c0d --- /dev/null +++ b/src/components/Sns/ContactSnsTimelineDialog.scss @@ -0,0 +1,329 @@ +.contact-sns-dialog-overlay { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 24px 16px; + background: rgba(15, 23, 42, 0.38); +} + +.contact-sns-dialog { + width: min(760px, 100%); + max-height: min(86vh, 860px); + border-radius: 14px; + border: 1px solid var(--border-color); + background: var(--bg-secondary-solid, #ffffff); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.24); + display: flex; + flex-direction: column; + overflow: hidden; + + .spin { + animation: contactSnsDialogSpin 1s linear infinite; + } + + .contact-sns-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-color); + } + + .contact-sns-dialog-header-main { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + } + + .contact-sns-dialog-avatar { + width: 42px; + height: 42px; + border-radius: 10px; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + overflow: hidden; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: #fff; + font-size: 14px; + font-weight: 600; + } + } + + .contact-sns-dialog-meta { + min-width: 0; + + h4 { + margin: 0; + font-size: 15px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .contact-sns-dialog-username { + margin-top: 2px; + font-size: 12px; + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .contact-sns-dialog-stats { + margin-top: 4px; + font-size: 12px; + color: var(--text-secondary); + } + + .contact-sns-dialog-header-actions { + display: flex; + align-items: flex-start; + gap: 8px; + flex-shrink: 0; + } + + .contact-sns-dialog-rank-switch { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + } + + .contact-sns-dialog-rank-btn { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-secondary); + height: 28px; + padding: 0 10px; + font-size: 12px; + line-height: 1; + cursor: pointer; + white-space: nowrap; + + &:hover { + color: var(--text-primary); + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + } + + &.active { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 52%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); + } + } + + .contact-sns-dialog-rank-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 248px; + max-height: calc((28px * 15) + 16px); + overflow-y: auto; + border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color)); + border-radius: 10px; + background: var(--bg-primary); + box-shadow: 0 14px 26px rgba(0, 0, 0, 0.18); + padding: 8px; + z-index: 12; + } + + .contact-sns-dialog-rank-empty { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.5; + text-align: center; + padding: 6px 0; + } + + .contact-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; + } + + .contact-sns-dialog-rank-row { + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-height: 28px; + padding: 0 4px; + border-radius: 7px; + + &:hover { + background: var(--bg-hover); + } + } + + .contact-sns-dialog-rank-index { + font-size: 12px; + color: var(--text-tertiary); + text-align: right; + font-variant-numeric: tabular-nums; + } + + .contact-sns-dialog-rank-name { + font-size: 12px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .contact-sns-dialog-rank-count { + font-size: 12px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .contact-sns-dialog-close-btn { + border: none; + background: transparent; + color: var(--text-secondary); + width: 28px; + height: 28px; + border-radius: 7px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .contact-sns-dialog-tip { + padding: 10px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent); + background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary)); + font-size: 12px; + line-height: 1.6; + color: var(--text-secondary); + word-break: break-word; + } + + .contact-sns-dialog-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 12px 14px 14px; + } + + .contact-sns-dialog-posts-list { + display: flex; + flex-direction: column; + gap: 14px; + } + + .contact-sns-dialog-posts-list .post-header-actions { + display: none; + } + + .contact-sns-dialog-status { + padding: 20px 12px; + text-align: center; + font-size: 13px; + color: var(--text-secondary); + + &.empty { + color: var(--text-tertiary); + } + } + + .contact-sns-dialog-load-more { + display: block; + margin: 12px auto 0; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + border-radius: 10px; + padding: 9px 18px; + font-size: 13px; + cursor: pointer; + + &:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + color: var(--primary); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.72; + } + } +} + +@media (max-width: 768px) { + .contact-sns-dialog-overlay { + padding: 12px 8px; + } + + .contact-sns-dialog { + width: min(100vw - 16px, 760px); + max-height: calc(100vh - 24px); + + .contact-sns-dialog-header { + padding: 12px; + } + + .contact-sns-dialog-header-actions { + gap: 6px; + } + + .contact-sns-dialog-rank-btn { + height: 26px; + padding: 0 8px; + font-size: 11px; + } + + .contact-sns-dialog-rank-panel { + width: min(78vw, 232px); + } + + .contact-sns-dialog-tip { + padding: 10px 12px; + line-height: 1.55; + } + + .contact-sns-dialog-body { + padding: 10px 10px 12px; + } + } +} + +@keyframes contactSnsDialogSpin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} diff --git a/src/components/Sns/ContactSnsTimelineDialog.tsx b/src/components/Sns/ContactSnsTimelineDialog.tsx new file mode 100644 index 0000000..a79d9bd --- /dev/null +++ b/src/components/Sns/ContactSnsTimelineDialog.tsx @@ -0,0 +1,577 @@ +import { createPortal } from 'react-dom' +import { Loader2, X } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { SnsPostItem } from './SnsPostItem' +import type { SnsPost } from '../../types/sns' +import { + type ContactSnsRankItem, + type ContactSnsRankMode, + type ContactSnsTimelineTarget, + getAvatarLetter +} from './contactSnsTimeline' +import './ContactSnsTimelineDialog.scss' + +const TIMELINE_PAGE_SIZE = 20 +const SNS_RANK_PAGE_SIZE = 50 +const SNS_RANK_DISPLAY_LIMIT = 15 + +interface ContactSnsRankCacheEntry { + likes: ContactSnsRankItem[] + comments: ContactSnsRankItem[] + totalPosts: number +} + +interface ContactSnsTimelineDialogProps { + target: ContactSnsTimelineTarget | null + onClose: () => void + initialTotalPosts?: number | null + initialTotalPostsLoading?: boolean +} + +const normalizeTotalPosts = (value?: number | null): number | null => { + if (!Number.isFinite(value)) return null + return Math.max(0, Math.floor(Number(value))) +} + +const formatYmdDateFromSeconds = (timestamp?: number): string => { + if (!timestamp || !Number.isFinite(timestamp)) return '—' + const date = new Date(timestamp * 1000) + const year = date.getFullYear() + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + return `${year}-${month}-${day}` +} + +const buildContactSnsRankings = (posts: SnsPost[]): { likes: ContactSnsRankItem[]; comments: ContactSnsRankItem[] } => { + 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 = (left: ContactSnsRankItem, right: ContactSnsRankItem): number => { + if (right.count !== left.count) return right.count - left.count + if (right.latestTime !== left.latestTime) return right.latestTime - left.latestTime + return left.name.localeCompare(right.name, 'zh-CN') + } + + return { + likes: [...likeMap.values()].sort(sorter), + comments: [...commentMap.values()].sort(sorter) + } +} + +export function ContactSnsTimelineDialog({ + target, + onClose, + initialTotalPosts = null, + initialTotalPostsLoading = false +}: ContactSnsTimelineDialogProps) { + const [timelinePosts, setTimelinePosts] = useState([]) + const [timelineLoading, setTimelineLoading] = useState(false) + const [timelineLoadingMore, setTimelineLoadingMore] = useState(false) + const [timelineHasMore, setTimelineHasMore] = useState(false) + const [timelineTotalPosts, setTimelineTotalPosts] = useState(null) + const [timelineStatsLoading, setTimelineStatsLoading] = useState(false) + const [rankMode, setRankMode] = useState(null) + const [likeRankings, setLikeRankings] = useState([]) + const [commentRankings, setCommentRankings] = useState([]) + const [rankLoading, setRankLoading] = useState(false) + const [rankError, setRankError] = useState(null) + const [rankLoadedPosts, setRankLoadedPosts] = useState(0) + const [rankTotalPosts, setRankTotalPosts] = useState(null) + + const timelinePostsRef = useRef([]) + const timelineLoadingRef = useRef(false) + const timelineRequestTokenRef = useRef(0) + const totalPostsRequestTokenRef = useRef(0) + const rankRequestTokenRef = useRef(0) + const rankLoadingRef = useRef(false) + const rankCacheRef = useRef>({}) + + const targetUsername = String(target?.username || '').trim() + const targetDisplayName = target?.displayName || targetUsername + const targetAvatarUrl = target?.avatarUrl + + useEffect(() => { + timelinePostsRef.current = timelinePosts + }, [timelinePosts]) + + const loadTimelinePosts = useCallback(async (nextTarget: ContactSnsTimelineTarget, options?: { reset?: boolean }) => { + const reset = Boolean(options?.reset) + if (timelineLoadingRef.current) return + + timelineLoadingRef.current = true + if (reset) { + setTimelineLoading(true) + setTimelineLoadingMore(false) + setTimelineHasMore(false) + } else { + setTimelineLoadingMore(true) + } + + const requestToken = ++timelineRequestTokenRef.current + + try { + let endTime: number | undefined + if (!reset && timelinePostsRef.current.length > 0) { + endTime = timelinePostsRef.current[timelinePostsRef.current.length - 1].createTime - 1 + } + + const result = await window.electronAPI.sns.getTimeline( + TIMELINE_PAGE_SIZE, + 0, + [nextTarget.username], + '', + undefined, + endTime + ) + if (requestToken !== timelineRequestTokenRef.current) return + + if (!result.success || !Array.isArray(result.timeline)) { + if (reset) { + setTimelinePosts([]) + setTimelineHasMore(false) + } + return + } + + const timeline = [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime) + if (reset) { + setTimelinePosts(timeline) + setTimelineHasMore(timeline.length >= TIMELINE_PAGE_SIZE) + return + } + + const existingIds = new Set(timelinePostsRef.current.map((post) => post.id)) + const uniqueOlder = timeline.filter((post) => !existingIds.has(post.id)) + if (uniqueOlder.length > 0) { + const merged = [...timelinePostsRef.current, ...uniqueOlder].sort((left, right) => right.createTime - left.createTime) + setTimelinePosts(merged) + } + if (timeline.length < TIMELINE_PAGE_SIZE) { + setTimelineHasMore(false) + } + } catch (error) { + console.error('加载联系人朋友圈失败:', error) + if (requestToken === timelineRequestTokenRef.current && reset) { + setTimelinePosts([]) + setTimelineHasMore(false) + } + } finally { + if (requestToken === timelineRequestTokenRef.current) { + timelineLoadingRef.current = false + setTimelineLoading(false) + setTimelineLoadingMore(false) + } + } + }, []) + + const loadTimelineTotalPosts = useCallback(async (nextTarget: ContactSnsTimelineTarget) => { + const requestToken = ++totalPostsRequestTokenRef.current + setTimelineStatsLoading(true) + + try { + const result = await window.electronAPI.sns.getUserPostCounts() + if (requestToken !== totalPostsRequestTokenRef.current) return + + if (!result.success || !result.counts) { + setTimelineTotalPosts(null) + setRankTotalPosts(null) + return + } + + const rawCount = Number(result.counts[nextTarget.username] || 0) + const normalized = Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0 + setTimelineTotalPosts(normalized) + setRankTotalPosts(normalized) + } catch (error) { + console.error('加载联系人朋友圈条数失败:', error) + if (requestToken !== totalPostsRequestTokenRef.current) return + setTimelineTotalPosts(null) + setRankTotalPosts(null) + } finally { + if (requestToken === totalPostsRequestTokenRef.current) { + setTimelineStatsLoading(false) + } + } + }, []) + + const loadRankings = useCallback(async (nextTarget: ContactSnsTimelineTarget) => { + const normalizedUsername = String(nextTarget?.username || '').trim() + if (!normalizedUsername || rankLoadingRef.current) return + + const normalizedKnownTotal = normalizeTotalPosts(timelineTotalPosts) + const cached = rankCacheRef.current[normalizedUsername] + + if (cached && (normalizedKnownTotal === null || cached.totalPosts === normalizedKnownTotal)) { + setLikeRankings(cached.likes) + setCommentRankings(cached.comments) + setRankLoadedPosts(cached.totalPosts) + setRankTotalPosts(cached.totalPosts) + setRankError(null) + setRankLoading(false) + return + } + + rankLoadingRef.current = true + const requestToken = ++rankRequestTokenRef.current + setRankLoading(true) + setRankError(null) + setRankLoadedPosts(0) + setRankTotalPosts(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 !== rankRequestTokenRef.current) return + + if (!result.success) { + throw new Error(result.error || '加载朋友圈排行失败') + } + + const pagePosts = Array.isArray(result.timeline) + ? [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime) + : [] + if (pagePosts.length === 0) { + hasMore = false + break + } + + allPosts.push(...pagePosts) + setRankLoadedPosts(allPosts.length) + if (normalizedKnownTotal === null) { + setRankTotalPosts(allPosts.length) + } + + endTime = pagePosts[pagePosts.length - 1].createTime - 1 + hasMore = pagePosts.length >= SNS_RANK_PAGE_SIZE + } + + if (requestToken !== rankRequestTokenRef.current) return + + const rankings = buildContactSnsRankings(allPosts) + const totalPosts = allPosts.length + rankCacheRef.current[normalizedUsername] = { + likes: rankings.likes, + comments: rankings.comments, + totalPosts + } + setLikeRankings(rankings.likes) + setCommentRankings(rankings.comments) + setRankLoadedPosts(totalPosts) + setRankTotalPosts(totalPosts) + setRankError(null) + } catch (error) { + if (requestToken !== rankRequestTokenRef.current) return + const message = error instanceof Error ? error.message : String(error) + setLikeRankings([]) + setCommentRankings([]) + setRankError(message || '加载朋友圈排行失败') + } finally { + if (requestToken === rankRequestTokenRef.current) { + rankLoadingRef.current = false + setRankLoading(false) + } + } + }, [timelineTotalPosts]) + + useEffect(() => { + if (!targetUsername) return + + totalPostsRequestTokenRef.current += 1 + rankRequestTokenRef.current += 1 + rankLoadingRef.current = false + setRankMode(null) + setLikeRankings([]) + setCommentRankings([]) + setRankLoading(false) + setRankError(null) + setRankLoadedPosts(0) + setRankTotalPosts(null) + setTimelinePosts([]) + setTimelineTotalPosts(null) + setTimelineStatsLoading(false) + setTimelineHasMore(false) + setTimelineLoadingMore(false) + setTimelineLoading(false) + + void loadTimelinePosts({ + username: targetUsername, + displayName: targetDisplayName, + avatarUrl: targetAvatarUrl + }, { reset: true }) + }, [loadTimelinePosts, targetAvatarUrl, targetDisplayName, targetUsername]) + + useEffect(() => { + if (!targetUsername) return + + const normalizedTotal = normalizeTotalPosts(initialTotalPosts) + if (normalizedTotal !== null) { + setTimelineTotalPosts(normalizedTotal) + setRankTotalPosts(normalizedTotal) + setTimelineStatsLoading(false) + return + } + + if (initialTotalPostsLoading) { + setTimelineTotalPosts(null) + setRankTotalPosts(null) + setTimelineStatsLoading(true) + return + } + + void loadTimelineTotalPosts({ + username: targetUsername, + displayName: targetDisplayName, + avatarUrl: targetAvatarUrl + }) + }, [ + initialTotalPosts, + initialTotalPostsLoading, + loadTimelineTotalPosts, + targetAvatarUrl, + targetDisplayName, + targetUsername + ]) + + useEffect(() => { + if (timelineTotalPosts === null) return + if (timelinePosts.length >= timelineTotalPosts) { + setTimelineHasMore(false) + } + }, [timelinePosts.length, timelineTotalPosts]) + + useEffect(() => { + if (!rankMode || !targetUsername) return + void loadRankings({ + username: targetUsername, + displayName: targetDisplayName, + avatarUrl: targetAvatarUrl + }) + }, [loadRankings, rankMode, targetAvatarUrl, targetDisplayName, targetUsername]) + + useEffect(() => { + if (!targetUsername) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [onClose, targetUsername]) + + const timelineStatsText = useMemo(() => { + const loadedCount = timelinePosts.length + const loadPart = timelineStatsLoading + ? `已加载 ${loadedCount} / 总数统计中...` + : timelineTotalPosts === null + ? `已加载 ${loadedCount} 条` + : `已加载 ${loadedCount} / 共 ${timelineTotalPosts} 条` + + if (timelineLoading && loadedCount === 0) return `${loadPart} | 加载中...` + if (loadedCount === 0) return loadPart + + const latest = timelinePosts[0]?.createTime + const earliest = timelinePosts[timelinePosts.length - 1]?.createTime + return `${loadPart} | ${formatYmdDateFromSeconds(earliest)} ~ ${formatYmdDateFromSeconds(latest)}` + }, [timelineLoading, timelinePosts, timelineStatsLoading, timelineTotalPosts]) + + const activeRankings = useMemo(() => { + if (rankMode === 'likes') return likeRankings + if (rankMode === 'comments') return commentRankings + return [] + }, [commentRankings, likeRankings, rankMode]) + + const loadMore = useCallback(() => { + if (!targetUsername || timelineLoading || timelineLoadingMore || !timelineHasMore) return + void loadTimelinePosts({ + username: targetUsername, + displayName: targetDisplayName, + avatarUrl: targetAvatarUrl + }, { reset: false }) + }, [ + loadTimelinePosts, + targetAvatarUrl, + targetDisplayName, + targetUsername, + timelineHasMore, + timelineLoading, + timelineLoadingMore + ]) + + const toggleRankMode = useCallback((mode: ContactSnsRankMode) => { + setRankMode((previous) => (previous === mode ? null : mode)) + }, []) + + if (!target) return null + + return createPortal( +
+
event.stopPropagation()} + > +
+
+
+ {targetAvatarUrl ? ( + + ) : ( + {getAvatarLetter(targetDisplayName)} + )} +
+
+

{targetDisplayName}

+
@{targetUsername}
+
{timelineStatsText}
+
+
+
+
+ + + {rankMode && ( +
+ {rankLoading && ( +
+ + + {rankTotalPosts !== null && rankTotalPosts > 0 + ? `统计中,已加载 ${rankLoadedPosts} / ${rankTotalPosts} 条` + : `统计中,已加载 ${rankLoadedPosts} 条`} + +
+ )} + {!rankLoading && rankError ? ( +
{rankError}
+ ) : !rankLoading && activeRankings.length === 0 ? ( +
+ {rankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'} +
+ ) : ( + activeRankings.slice(0, SNS_RANK_DISPLAY_LIMIT).map((item, index) => ( +
+ {index + 1} + {item.name} + + {item.count.toLocaleString('zh-CN')} + {rankMode === 'likes' ? '次' : '条'} + +
+ )) + )} +
+ )} +
+ +
+
+ +
+ 在微信桌面客户端中打开这个人的朋友圈浏览,可快速把其朋友圈同步到这里。若你在乎这个人,一定要试试~ +
+ +
+ {timelinePosts.length > 0 && ( +
+ {timelinePosts.map((post) => ( + { + if (isVideo) { + void window.electronAPI.window.openVideoPlayerWindow(src) + } else { + void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined) + } + }} + onDebug={() => {}} + hideAuthorMeta + /> + ))} +
+ )} + + {timelineLoading && ( +
正在加载该联系人的朋友圈...
+ )} + + {!timelineLoading && timelinePosts.length === 0 && ( +
该联系人暂无朋友圈
+ )} + + {!timelineLoading && timelineHasMore && ( + + )} +
+
+
, + document.body + ) +} diff --git a/src/components/Sns/contactSnsTimeline.ts b/src/components/Sns/contactSnsTimeline.ts new file mode 100644 index 0000000..0ec6eab --- /dev/null +++ b/src/components/Sns/contactSnsTimeline.ts @@ -0,0 +1,26 @@ +export interface ContactSnsTimelineTarget { + username: string + displayName: string + avatarUrl?: string +} + +export interface ContactSnsRankItem { + name: string + count: number + latestTime: number +} + +export type ContactSnsRankMode = 'likes' | 'comments' + +export const isSingleContactSession = (sessionId: string): boolean => { + const normalized = String(sessionId || '').trim() + if (!normalized) return false + if (normalized.includes('@chatroom')) return false + if (normalized.startsWith('gh_')) return false + return true +} + +export const getAvatarLetter = (name: string): string => { + if (!name) return '?' + return [...name][0] || '?' +} diff --git a/src/pages/ContactsPage.scss b/src/pages/ContactsPage.scss index bd6fc98..ed71ec3 100644 --- a/src/pages/ContactsPage.scss +++ b/src/pages/ContactsPage.scss @@ -535,6 +535,28 @@ word-break: break-all; user-select: text; } + + .detail-entry-btn { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + padding: 6px 10px; + font-size: 12px; + line-height: 1; + cursor: pointer; + transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease; + + &:hover { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary)); + } + } } .goto-chat-btn { diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 2d489f9..cc7e86d 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -1,20 +1,14 @@ import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react' import { useNavigate } from 'react-router-dom' -import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList } from 'lucide-react' +import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList, Aperture } from 'lucide-react' import { useChatStore } from '../stores/chatStore' import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore' import * as configService from '../services/config' +import type { ContactInfo } from '../types/models' +import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' +import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline' import './ContactsPage.scss' -interface ContactInfo { - username: string - displayName: string - remark?: string - nickname?: string - avatarUrl?: string - type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' -} - interface ContactEnrichInfo { displayName?: string avatarUrl?: string @@ -62,6 +56,9 @@ function ContactsPage() { // 导出模式与查看详情 const [exportMode, setExportMode] = useState(false) const [selectedContact, setSelectedContact] = useState(null) + const [snsUserPostCounts, setSnsUserPostCounts] = useState>({}) + const [snsUserPostCountsStatus, setSnsUserPostCountsStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle') + const [snsTimelineTarget, setSnsTimelineTarget] = useState(null) const navigate = useNavigate() const { setCurrentSession } = useChatStore() @@ -509,6 +506,41 @@ function ContactsPage() { return () => window.clearTimeout(timer) }, [searchKeyword]) + const loadSnsUserPostCounts = useCallback(async (options?: { force?: boolean }) => { + if (!options?.force && (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'ready')) { + return + } + + setSnsUserPostCountsStatus('loading') + try { + const result = await window.electronAPI.sns.getUserPostCounts() + if (!result.success || !result.counts) { + setSnsUserPostCountsStatus('error') + return + } + + const normalizedCounts: Record = {} + for (const [rawUsername, rawCount] of Object.entries(result.counts)) { + const username = String(rawUsername || '').trim() + if (!username) continue + const value = Number(rawCount) + normalizedCounts[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 + } + + setSnsUserPostCounts(normalizedCounts) + setSnsUserPostCountsStatus('ready') + } catch (error) { + console.error('加载通讯录联系人朋友圈条数失败:', error) + setSnsUserPostCountsStatus('error') + } + }, [snsUserPostCountsStatus]) + + useEffect(() => { + if (!selectedContact || !isSingleContactSession(selectedContact.username)) return + if (snsUserPostCountsStatus !== 'idle') return + void loadSnsUserPostCounts() + }, [loadSnsUserPostCounts, selectedContact, snsUserPostCountsStatus]) + const filteredContacts = useMemo(() => { let filtered = contacts.filter(contact => { if (contact.type === 'friend' && !contactTypes.friends) return false @@ -579,6 +611,38 @@ function ContactsPage() { }, [filteredContacts, selectedUsernames]) const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length + const selectedContactSupportsSns = useMemo(() => { + return Boolean(selectedContact && isSingleContactSession(selectedContact.username)) + }, [selectedContact]) + + const selectedContactSnsCount = useMemo(() => { + if (!selectedContactSupportsSns || !selectedContact) return null + if (snsUserPostCountsStatus !== 'ready') return null + const rawCount = Number(snsUserPostCounts[selectedContact.username] || 0) + return Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0 + }, [selectedContact, selectedContactSupportsSns, snsUserPostCounts, snsUserPostCountsStatus]) + + const selectedContactSnsEntryLabel = useMemo(() => { + if (!selectedContactSupportsSns) return '' + if (selectedContactSnsCount !== null) { + return `朋友圈:${selectedContactSnsCount.toLocaleString('zh-CN')}条` + } + if (snsUserPostCountsStatus === 'error') return '朋友圈:查看' + return '朋友圈:统计中...' + }, [selectedContactSupportsSns, selectedContactSnsCount, snsUserPostCountsStatus]) + + const openSelectedContactSnsTimeline = useCallback(() => { + if (!selectedContact || !selectedContactSupportsSns) return + if (snsUserPostCountsStatus === 'idle') { + void loadSnsUserPostCounts() + } + setSnsTimelineTarget({ + username: selectedContact.username, + displayName: selectedContact.displayName || selectedContact.remark || selectedContact.nickname || selectedContact.username, + avatarUrl: selectedContact.avatarUrl + }) + }, [loadSnsUserPostCounts, selectedContact, selectedContactSupportsSns, snsUserPostCountsStatus]) + const { startIndex, endIndex } = useMemo(() => { if (filteredContacts.length === 0) { return { startIndex: 0, endIndex: 0 } @@ -1069,6 +1133,19 @@ function ContactsPage() {
昵称{selectedContact.nickname || selectedContact.displayName}
{selectedContact.remark &&
备注{selectedContact.remark}
}
类型{getContactTypeName(selectedContact.type)}
+ {selectedContactSupportsSns && ( +
+ 朋友圈 + +
+ )}