From ebabe1560fa65390d18c4dd51eb71cab64156493 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 16:34:29 +0800 Subject: [PATCH] feat(sns): support opening author timeline from post --- src/components/Sns/SnsPostItem.tsx | 36 ++++- src/pages/SnsPage.scss | 158 ++++++++++++++++++ src/pages/SnsPage.tsx | 248 ++++++++++++++++++++++++++++- 3 files changed, 426 insertions(+), 16 deletions(-) diff --git a/src/components/Sns/SnsPostItem.tsx b/src/components/Sns/SnsPostItem.tsx index 76972fb..980377f 100644 --- a/src/components/Sns/SnsPostItem.tsx +++ b/src/components/Sns/SnsPostItem.tsx @@ -244,9 +244,10 @@ interface SnsPostItemProps { onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void onDebug: (post: SnsPost) => void onDelete?: (postId: string) => void + onOpenAuthorPosts?: (post: SnsPost) => void } -export const SnsPostItem: React.FC = ({ post, onPreview, onDebug, onDelete }) => { +export const SnsPostItem: React.FC = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts }) => { const [mediaDeleted, setMediaDeleted] = useState(false) const [dbDeleted, setDbDeleted] = useState(false) const [deleting, setDeleting] = useState(false) @@ -306,22 +307,41 @@ export const SnsPostItem: React.FC = ({ post, onPreview, onDeb } } + const handleOpenAuthorPosts = (e: React.MouseEvent) => { + e.stopPropagation() + onOpenAuthorPosts?.(post) + } + return ( <>
- +
- {decodeHtmlEntities(post.nickname)} + {formatTime(post.createTime)}
diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index bc52b0c..013eaae 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -179,6 +179,30 @@ flex-shrink: 0; } +.author-trigger-btn { + background: transparent; + border: none; + padding: 0; + margin: 0; + color: inherit; + cursor: pointer; +} + +.avatar-trigger { + border-radius: 12px; + transition: transform 0.15s ease, box-shadow 0.15s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.1); + } + + &:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + } +} + .post-content-col { flex: 1; min-width: 0; @@ -206,6 +230,30 @@ margin-bottom: 2px; } + .author-name-trigger { + align-self: flex-start; + border-radius: 6px; + margin-bottom: 2px; + + .author-name { + transition: color 0.15s ease, text-decoration-color 0.15s ease; + text-decoration: underline; + text-decoration-color: transparent; + text-underline-offset: 2px; + margin-bottom: 0; + } + + &:hover .author-name { + color: var(--primary); + text-decoration-color: currentColor; + } + + &:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + } + } + .post-time { font-size: 12px; color: var(--text-tertiary); @@ -1317,6 +1365,116 @@ } } +.author-timeline-dialog { + background: var(--sns-card-bg); + border-radius: var(--sns-border-radius-lg); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); + width: min(860px, 94vw); + max-height: 86vh; + display: flex; + flex-direction: column; + border: 1px solid var(--border-color); + overflow: hidden; + animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +.author-timeline-header { + padding: 14px 18px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); + display: flex; + align-items: flex-start; + justify-content: space-between; + + .close-btn { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 6px; + border-radius: 6px; + display: flex; + + &:hover { + background: var(--bg-primary); + color: var(--text-primary); + } + } +} + +.author-timeline-meta { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.author-timeline-meta-text { + min-width: 0; + + h3 { + margin: 0; + font-size: 16px; + color: var(--text-primary); + } +} + +.author-timeline-username { + margin-top: 2px; + font-size: 12px; + color: var(--text-secondary); +} + +.author-timeline-stats { + margin-top: 4px; + font-size: 12px; + color: var(--text-secondary); +} + +.author-timeline-body { + padding: 16px; + overflow-y: auto; + min-height: 180px; + max-height: calc(86vh - 96px); +} + +.author-timeline-posts-list { + gap: 16px; +} + +.author-timeline-loading { + margin-top: 12px; +} + +.author-timeline-empty { + padding: 42px 10px 30px; + text-align: center; + font-size: 14px; + color: var(--text-secondary); +} + +.author-timeline-load-more { + display: block; + margin: 12px auto 2px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + border-radius: 999px; + padding: 7px 16px; + font-size: 13px; + cursor: pointer; + + &:hover:not(:disabled) { + color: var(--primary); + border-color: var(--primary); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + @keyframes slide-up-fade { from { opacity: 0; diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index e7caad5..53d34ad 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -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([]) const [loading, setLoading] = useState(false) @@ -55,6 +62,11 @@ export default function SnsPage() { // UI states const [showJumpDialog, setShowJumpDialog] = useState(false) const [debugPost, setDebugPost] = useState(null) + const [authorTimelineTarget, setAuthorTimelineTarget] = useState(null) + const [authorTimelinePosts, setAuthorTimelinePosts] = useState([]) + 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([]) + 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(//g, '$1') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/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) => { 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) => { + 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 (
@@ -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} /> ))}
@@ -657,6 +819,76 @@ export default function SnsPage() { currentDate={jumpTargetDate || new Date()} /> + {authorTimelineTarget && ( +
+
e.stopPropagation()}> +
+
+ +
+

{decodeHtmlEntities(authorTimelineTarget.nickname)}

+
@{authorTimelineTarget.username}
+
{renderAuthorTimelineStats()}
+
+
+ +
+ +
+ {authorTimelinePosts.length > 0 && ( +
+ {authorTimelinePosts.map(post => ( + { + 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} + /> + ))} +
+ )} + + {authorTimelineLoading && ( +
+ + 正在加载该用户朋友圈... +
+ )} + + {!authorTimelineLoading && authorTimelinePosts.length === 0 && ( +
该用户暂无朋友圈
+ )} + + {!authorTimelineLoading && authorTimelineHasMore && ( + + )} +
+
+
+ )} + {debugPost && (
setDebugPost(null)}>
e.stopPropagation()}>