feat(sns): support opening author timeline from post

This commit is contained in:
aits2026
2026-03-05 16:34:29 +08:00
parent 4da697f507
commit ebabe1560f
3 changed files with 426 additions and 16 deletions

View File

@@ -5,6 +5,7 @@ import './SnsPage.scss'
import { SnsPost } from '../types/sns'
import { SnsPostItem } from '../components/Sns/SnsPostItem'
import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel'
import { Avatar } from '../components/Avatar'
import * as configService from '../services/config'
const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000
@@ -28,6 +29,12 @@ interface SnsOverviewStats {
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
interface AuthorTimelineTarget {
username: string
nickname: string
avatarUrl?: string
}
export default function SnsPage() {
const [posts, setPosts] = useState<SnsPost[]>([])
const [loading, setLoading] = useState(false)
@@ -55,6 +62,11 @@ export default function SnsPage() {
// UI states
const [showJumpDialog, setShowJumpDialog] = useState(false)
const [debugPost, setDebugPost] = useState<SnsPost | null>(null)
const [authorTimelineTarget, setAuthorTimelineTarget] = useState<AuthorTimelineTarget | null>(null)
const [authorTimelinePosts, setAuthorTimelinePosts] = useState<SnsPost[]>([])
const [authorTimelineLoading, setAuthorTimelineLoading] = useState(false)
const [authorTimelineLoadingMore, setAuthorTimelineLoadingMore] = useState(false)
const [authorTimelineHasMore, setAuthorTimelineHasMore] = useState(false)
// 导出相关状态
const [showExportDialog, setShowExportDialog] = useState(false)
@@ -89,6 +101,9 @@ export default function SnsPage() {
const cacheScopeKeyRef = useRef('')
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
const contactsLoadTokenRef = useRef(0)
const authorTimelinePostsRef = useRef<SnsPost[]>([])
const authorTimelineLoadingRef = useRef(false)
const authorTimelineRequestTokenRef = useRef(0)
// Sync posts ref
useEffect(() => {
@@ -109,6 +124,9 @@ export default function SnsPage() {
useEffect(() => {
jumpTargetDateRef.current = jumpTargetDate
}, [jumpTargetDate])
useEffect(() => {
authorTimelinePostsRef.current = authorTimelinePosts
}, [authorTimelinePosts])
// 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动
useLayoutEffect(() => {
const snapshot = scrollAdjustmentRef.current;
@@ -132,6 +150,18 @@ export default function SnsPage() {
return `${year}-${month}-${day}`
}
const decodeHtmlEntities = (text: string): string => {
if (!text) return ''
return text
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.trim()
}
const isDefaultViewNow = useCallback(() => {
return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current
}, [])
@@ -445,6 +475,117 @@ export default function SnsPage() {
}
}, [])
const closeAuthorTimeline = useCallback(() => {
authorTimelineRequestTokenRef.current += 1
authorTimelineLoadingRef.current = false
setAuthorTimelineTarget(null)
setAuthorTimelinePosts([])
setAuthorTimelineLoading(false)
setAuthorTimelineLoadingMore(false)
setAuthorTimelineHasMore(false)
}, [])
const loadAuthorTimelinePosts = useCallback(async (target: AuthorTimelineTarget, options: { reset?: boolean } = {}) => {
const { reset = false } = options
if (authorTimelineLoadingRef.current) return
authorTimelineLoadingRef.current = true
if (reset) {
setAuthorTimelineLoading(true)
setAuthorTimelineLoadingMore(false)
setAuthorTimelineHasMore(false)
} else {
setAuthorTimelineLoadingMore(true)
}
const requestToken = ++authorTimelineRequestTokenRef.current
try {
const limit = 20
let endTs: number | undefined = undefined
if (!reset && authorTimelinePostsRef.current.length > 0) {
endTs = authorTimelinePostsRef.current[authorTimelinePostsRef.current.length - 1].createTime - 1
}
const result = await window.electronAPI.sns.getTimeline(
limit,
0,
[target.username],
'',
undefined,
endTs
)
if (requestToken !== authorTimelineRequestTokenRef.current) return
if (!result.success || !result.timeline) {
if (reset) {
setAuthorTimelinePosts([])
setAuthorTimelineHasMore(false)
}
return
}
if (reset) {
const sorted = [...result.timeline].sort((a, b) => b.createTime - a.createTime)
setAuthorTimelinePosts(sorted)
setAuthorTimelineHasMore(result.timeline.length >= limit)
return
}
const existingIds = new Set(authorTimelinePostsRef.current.map((p) => p.id))
const uniqueOlder = result.timeline.filter((p) => !existingIds.has(p.id))
if (uniqueOlder.length > 0) {
const merged = [...authorTimelinePostsRef.current, ...uniqueOlder].sort((a, b) => b.createTime - a.createTime)
setAuthorTimelinePosts(merged)
}
if (result.timeline.length < limit) {
setAuthorTimelineHasMore(false)
}
} catch (error) {
console.error('Failed to load author timeline:', error)
if (requestToken === authorTimelineRequestTokenRef.current && reset) {
setAuthorTimelinePosts([])
setAuthorTimelineHasMore(false)
}
} finally {
if (requestToken === authorTimelineRequestTokenRef.current) {
authorTimelineLoadingRef.current = false
setAuthorTimelineLoading(false)
setAuthorTimelineLoadingMore(false)
}
}
}, [])
const openAuthorTimeline = useCallback((post: SnsPost) => {
authorTimelineRequestTokenRef.current += 1
authorTimelineLoadingRef.current = false
const target = {
username: post.username,
nickname: post.nickname,
avatarUrl: post.avatarUrl
}
setAuthorTimelineTarget(target)
setAuthorTimelinePosts([])
setAuthorTimelineHasMore(false)
void loadAuthorTimelinePosts(target, { reset: true })
}, [loadAuthorTimelinePosts])
const loadMoreAuthorTimeline = useCallback(() => {
if (!authorTimelineTarget || authorTimelineLoading || authorTimelineLoadingMore || !authorTimelineHasMore) return
void loadAuthorTimelinePosts(authorTimelineTarget, { reset: false })
}, [authorTimelineHasMore, authorTimelineLoading, authorTimelineLoadingMore, authorTimelineTarget, loadAuthorTimelinePosts])
const handlePostDelete = useCallback((postId: string) => {
setPosts(prev => {
const next = prev.filter(p => p.id !== postId)
void persistSnsPageCache({ posts: next })
return next
})
setAuthorTimelinePosts(prev => prev.filter(p => p.id !== postId))
void loadOverviewStats()
}, [loadOverviewStats, persistSnsPageCache])
// Initial Load & Listeners
useEffect(() => {
void hydrateSnsPageCache()
@@ -474,6 +615,17 @@ export default function SnsPage() {
return () => clearTimeout(timer)
}, [selectedUsernames, searchKeyword, jumpTargetDate, loadPosts])
useEffect(() => {
if (!authorTimelineTarget) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeAuthorTimeline()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [authorTimelineTarget, closeAuthorTimeline])
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) {
@@ -492,6 +644,22 @@ export default function SnsPage() {
}
}
const handleAuthorTimelineScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
if (scrollHeight - scrollTop - clientHeight < 260) {
loadMoreAuthorTimeline()
}
}
const renderAuthorTimelineStats = () => {
if (authorTimelineLoading) return '加载中...'
if (authorTimelinePosts.length === 0) return '暂无朋友圈'
const latest = authorTimelinePosts[0]?.createTime ?? null
const earliest = authorTimelinePosts[authorTimelinePosts.length - 1]?.createTime ?? null
const loadedLabel = authorTimelineHasMore ? `已加载 ${authorTimelinePosts.length}` : `${authorTimelinePosts.length}`
return `${loadedLabel} ${formatDateOnly(earliest)} ~ ${formatDateOnly(latest)}`
}
return (
<div className="sns-page-layout">
<div className="sns-main-viewport">
@@ -578,14 +746,8 @@ export default function SnsPage() {
}
}}
onDebug={(p) => setDebugPost(p)}
onDelete={(postId) => {
setPosts(prev => {
const next = prev.filter(p => p.id !== postId)
void persistSnsPageCache({ posts: next })
return next
})
loadOverviewStats()
}}
onDelete={handlePostDelete}
onOpenAuthorPosts={openAuthorTimeline}
/>
))}
</div>
@@ -657,6 +819,76 @@ export default function SnsPage() {
currentDate={jumpTargetDate || new Date()}
/>
{authorTimelineTarget && (
<div className="modal-overlay" onClick={closeAuthorTimeline}>
<div className="author-timeline-dialog" onClick={(e) => e.stopPropagation()}>
<div className="author-timeline-header">
<div className="author-timeline-meta">
<Avatar
src={authorTimelineTarget.avatarUrl}
name={authorTimelineTarget.nickname}
size={42}
shape="rounded"
/>
<div className="author-timeline-meta-text">
<h3>{decodeHtmlEntities(authorTimelineTarget.nickname)}</h3>
<div className="author-timeline-username">@{authorTimelineTarget.username}</div>
<div className="author-timeline-stats">{renderAuthorTimelineStats()}</div>
</div>
</div>
<button className="close-btn" onClick={closeAuthorTimeline}>
<X size={20} />
</button>
</div>
<div className="author-timeline-body" onScroll={handleAuthorTimelineScroll}>
{authorTimelinePosts.length > 0 && (
<div className="posts-list author-timeline-posts-list">
{authorTimelinePosts.map(post => (
<SnsPostItem
key={post.id}
post={{ ...post, isProtected: triggerInstalled === true }}
onPreview={(src, isVideo, liveVideoPath) => {
if (isVideo) {
void window.electronAPI.window.openVideoPlayerWindow(src)
} else {
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
}
}}
onDebug={(p) => setDebugPost(p)}
onDelete={handlePostDelete}
onOpenAuthorPosts={openAuthorTimeline}
/>
))}
</div>
)}
{authorTimelineLoading && (
<div className="status-indicator loading-more author-timeline-loading">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div>
)}
{!authorTimelineLoading && authorTimelinePosts.length === 0 && (
<div className="author-timeline-empty"></div>
)}
{!authorTimelineLoading && authorTimelineHasMore && (
<button
type="button"
className="author-timeline-load-more"
onClick={loadMoreAuthorTimeline}
disabled={authorTimelineLoadingMore}
>
{authorTimelineLoadingMore ? '正在加载...' : '加载更多'}
</button>
)}
</div>
</div>
</div>
)}
{debugPost && (
<div className="modal-overlay" onClick={() => setDebugPost(null)}>
<div className="debug-dialog" onClick={(e) => e.stopPropagation()}>