mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat(sns): add contact timeline dialog components
This commit is contained in:
329
src/components/Sns/ContactSnsTimelineDialog.scss
Normal file
329
src/components/Sns/ContactSnsTimelineDialog.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
577
src/components/Sns/ContactSnsTimelineDialog.tsx
Normal file
577
src/components/Sns/ContactSnsTimelineDialog.tsx
Normal 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
|
||||
)
|
||||
}
|
||||
26
src/components/Sns/contactSnsTimeline.ts
Normal file
26
src/components/Sns/contactSnsTimeline.ts
Normal 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] || '?'
|
||||
}
|
||||
Reference in New Issue
Block a user