feat(sns): add contact timeline dialog components

This commit is contained in:
aits2026
2026-03-06 10:22:24 +08:00
parent ad217d4a3b
commit bc2ab60c59
5 changed files with 1049 additions and 10 deletions

View File

@@ -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);
}
}

View File

@@ -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<string, ContactSnsRankItem>()
const commentMap = new Map<string, ContactSnsRankItem>()
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<SnsPost[]>([])
const [timelineLoading, setTimelineLoading] = useState(false)
const [timelineLoadingMore, setTimelineLoadingMore] = useState(false)
const [timelineHasMore, setTimelineHasMore] = useState(false)
const [timelineTotalPosts, setTimelineTotalPosts] = useState<number | null>(null)
const [timelineStatsLoading, setTimelineStatsLoading] = useState(false)
const [rankMode, setRankMode] = useState<ContactSnsRankMode | null>(null)
const [likeRankings, setLikeRankings] = useState<ContactSnsRankItem[]>([])
const [commentRankings, setCommentRankings] = useState<ContactSnsRankItem[]>([])
const [rankLoading, setRankLoading] = useState(false)
const [rankError, setRankError] = useState<string | null>(null)
const [rankLoadedPosts, setRankLoadedPosts] = useState(0)
const [rankTotalPosts, setRankTotalPosts] = useState<number | null>(null)
const timelinePostsRef = useRef<SnsPost[]>([])
const timelineLoadingRef = useRef(false)
const timelineRequestTokenRef = useRef(0)
const totalPostsRequestTokenRef = useRef(0)
const rankRequestTokenRef = useRef(0)
const rankLoadingRef = useRef(false)
const rankCacheRef = useRef<Record<string, ContactSnsRankCacheEntry>>({})
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(
<div className="contact-sns-dialog-overlay" onClick={onClose}>
<div
className="contact-sns-dialog"
role="dialog"
aria-modal="true"
aria-label="联系人朋友圈"
onClick={(event) => event.stopPropagation()}
>
<div className="contact-sns-dialog-header">
<div className="contact-sns-dialog-header-main">
<div className="contact-sns-dialog-avatar">
{targetAvatarUrl ? (
<img src={targetAvatarUrl} alt="" />
) : (
<span>{getAvatarLetter(targetDisplayName)}</span>
)}
</div>
<div className="contact-sns-dialog-meta">
<h4>{targetDisplayName}</h4>
<div className="contact-sns-dialog-username">@{targetUsername}</div>
<div className="contact-sns-dialog-stats">{timelineStatsText}</div>
</div>
</div>
<div className="contact-sns-dialog-header-actions">
<div className="contact-sns-dialog-rank-switch">
<button
type="button"
className={`contact-sns-dialog-rank-btn ${rankMode === 'likes' ? 'active' : ''}`}
onClick={() => toggleRankMode('likes')}
>
</button>
<button
type="button"
className={`contact-sns-dialog-rank-btn ${rankMode === 'comments' ? 'active' : ''}`}
onClick={() => toggleRankMode('comments')}
>
</button>
{rankMode && (
<div
className="contact-sns-dialog-rank-panel"
role="region"
aria-label={rankMode === 'likes' ? '点赞排行' : '评论排行'}
>
{rankLoading && (
<div className="contact-sns-dialog-rank-loading">
<Loader2 size={12} className="spin" />
<span>
{rankTotalPosts !== null && rankTotalPosts > 0
? `统计中,已加载 ${rankLoadedPosts} / ${rankTotalPosts}`
: `统计中,已加载 ${rankLoadedPosts}`}
</span>
</div>
)}
{!rankLoading && rankError ? (
<div className="contact-sns-dialog-rank-empty">{rankError}</div>
) : !rankLoading && activeRankings.length === 0 ? (
<div className="contact-sns-dialog-rank-empty">
{rankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'}
</div>
) : (
activeRankings.slice(0, SNS_RANK_DISPLAY_LIMIT).map((item, index) => (
<div className="contact-sns-dialog-rank-row" key={`${rankMode}-${item.name}`}>
<span className="contact-sns-dialog-rank-index">{index + 1}</span>
<span className="contact-sns-dialog-rank-name" title={item.name}>{item.name}</span>
<span className="contact-sns-dialog-rank-count">
{item.count.toLocaleString('zh-CN')}
{rankMode === 'likes' ? '次' : '条'}
</span>
</div>
))
)}
</div>
)}
</div>
<button className="contact-sns-dialog-close-btn" type="button" onClick={onClose}>
<X size={16} />
</button>
</div>
</div>
<div className="contact-sns-dialog-tip">
</div>
<div className="contact-sns-dialog-body">
{timelinePosts.length > 0 && (
<div className="contact-sns-dialog-posts-list">
{timelinePosts.map((post) => (
<SnsPostItem
key={post.id}
post={post}
onPreview={(src, isVideo, liveVideoPath) => {
if (isVideo) {
void window.electronAPI.window.openVideoPlayerWindow(src)
} else {
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
}
}}
onDebug={() => {}}
hideAuthorMeta
/>
))}
</div>
)}
{timelineLoading && (
<div className="contact-sns-dialog-status">...</div>
)}
{!timelineLoading && timelinePosts.length === 0 && (
<div className="contact-sns-dialog-status empty"></div>
)}
{!timelineLoading && timelineHasMore && (
<button
className="contact-sns-dialog-load-more"
type="button"
onClick={loadMore}
disabled={timelineLoadingMore}
>
{timelineLoadingMore ? '正在加载...' : '加载更多'}
</button>
)}
</div>
</div>
</div>,
document.body
)
}

View File

@@ -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] || '?'
}

View File

@@ -535,6 +535,28 @@
word-break: break-all; word-break: break-all;
user-select: text; 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 { .goto-chat-btn {

View File

@@ -1,20 +1,14 @@
import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react' import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react'
import { useNavigate } from 'react-router-dom' 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 { useChatStore } from '../stores/chatStore'
import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore' import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import * as configService from '../services/config' 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' import './ContactsPage.scss'
interface ContactInfo {
username: string
displayName: string
remark?: string
nickname?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
interface ContactEnrichInfo { interface ContactEnrichInfo {
displayName?: string displayName?: string
avatarUrl?: string avatarUrl?: string
@@ -62,6 +56,9 @@ function ContactsPage() {
// 导出模式与查看详情 // 导出模式与查看详情
const [exportMode, setExportMode] = useState(false) const [exportMode, setExportMode] = useState(false)
const [selectedContact, setSelectedContact] = useState<ContactInfo | null>(null) const [selectedContact, setSelectedContact] = useState<ContactInfo | null>(null)
const [snsUserPostCounts, setSnsUserPostCounts] = useState<Record<string, number>>({})
const [snsUserPostCountsStatus, setSnsUserPostCountsStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
const [snsTimelineTarget, setSnsTimelineTarget] = useState<ContactSnsTimelineTarget | null>(null)
const navigate = useNavigate() const navigate = useNavigate()
const { setCurrentSession } = useChatStore() const { setCurrentSession } = useChatStore()
@@ -509,6 +506,41 @@ function ContactsPage() {
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [searchKeyword]) }, [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<string, number> = {}
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(() => { const filteredContacts = useMemo(() => {
let filtered = contacts.filter(contact => { let filtered = contacts.filter(contact => {
if (contact.type === 'friend' && !contactTypes.friends) return false if (contact.type === 'friend' && !contactTypes.friends) return false
@@ -579,6 +611,38 @@ function ContactsPage() {
}, [filteredContacts, selectedUsernames]) }, [filteredContacts, selectedUsernames])
const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length 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(() => { const { startIndex, endIndex } = useMemo(() => {
if (filteredContacts.length === 0) { if (filteredContacts.length === 0) {
return { startIndex: 0, endIndex: 0 } return { startIndex: 0, endIndex: 0 }
@@ -1069,6 +1133,19 @@ function ContactsPage() {
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div> <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
{selectedContact.remark && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.remark}</span></div>} {selectedContact.remark && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.remark}</span></div>}
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div> <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
{selectedContactSupportsSns && (
<div className="detail-row">
<span className="detail-label"></span>
<button
type="button"
className="detail-entry-btn"
onClick={openSelectedContactSnsTimeline}
>
<Aperture size={14} />
<span>{selectedContactSnsEntryLabel}</span>
</button>
</div>
)}
</div> </div>
<button <button
@@ -1091,6 +1168,14 @@ function ContactsPage() {
</div> </div>
</div> </div>
)} )}
<ContactSnsTimelineDialog
target={snsTimelineTarget}
onClose={() => setSnsTimelineTarget(null)}
initialTotalPosts={selectedContact && snsTimelineTarget?.username === selectedContact.username ? selectedContactSnsCount : null}
initialTotalPostsLoading={selectedContact && snsTimelineTarget?.username === selectedContact.username
? snsUserPostCountsStatus === 'idle' || snsUserPostCountsStatus === 'loading'
: false}
/>
</div> </div>
) )
} }