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

@@ -244,9 +244,10 @@ interface SnsPostItemProps {
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
onDebug: (post: SnsPost) => void onDebug: (post: SnsPost) => void
onDelete?: (postId: string) => void onDelete?: (postId: string) => void
onOpenAuthorPosts?: (post: SnsPost) => void
} }
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete }) => { export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts }) => {
const [mediaDeleted, setMediaDeleted] = useState(false) const [mediaDeleted, setMediaDeleted] = useState(false)
const [dbDeleted, setDbDeleted] = useState(false) const [dbDeleted, setDbDeleted] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
@@ -306,22 +307,41 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
} }
} }
const handleOpenAuthorPosts = (e: React.MouseEvent) => {
e.stopPropagation()
onOpenAuthorPosts?.(post)
}
return ( return (
<> <>
<div className={`sns-post-item ${(mediaDeleted || dbDeleted) ? 'post-deleted' : ''}`}> <div className={`sns-post-item ${(mediaDeleted || dbDeleted) ? 'post-deleted' : ''}`}>
<div className="post-avatar-col"> <div className="post-avatar-col">
<button
type="button"
className="author-trigger-btn avatar-trigger"
onClick={handleOpenAuthorPosts}
title="查看该发布者的全部朋友圈"
>
<Avatar <Avatar
src={post.avatarUrl} src={post.avatarUrl}
name={post.nickname} name={post.nickname}
size={48} size={48}
shape="rounded" shape="rounded"
/> />
</button>
</div> </div>
<div className="post-content-col"> <div className="post-content-col">
<div className="post-header-row"> <div className="post-header-row">
<div className="post-author-info"> <div className="post-author-info">
<button
type="button"
className="author-trigger-btn author-name-trigger"
onClick={handleOpenAuthorPosts}
title="查看该发布者的全部朋友圈"
>
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span> <span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
</button>
<span className="post-time">{formatTime(post.createTime)}</span> <span className="post-time">{formatTime(post.createTime)}</span>
</div> </div>
<div className="post-header-actions"> <div className="post-header-actions">

View File

@@ -179,6 +179,30 @@
flex-shrink: 0; 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 { .post-content-col {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -206,6 +230,30 @@
margin-bottom: 2px; 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 { .post-time {
font-size: 12px; font-size: 12px;
color: var(--text-tertiary); 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 { @keyframes slide-up-fade {
from { from {
opacity: 0; opacity: 0;

View File

@@ -5,6 +5,7 @@ import './SnsPage.scss'
import { SnsPost } from '../types/sns' import { SnsPost } from '../types/sns'
import { SnsPostItem } from '../components/Sns/SnsPostItem' import { SnsPostItem } from '../components/Sns/SnsPostItem'
import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel' import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel'
import { Avatar } from '../components/Avatar'
import * as configService from '../services/config' import * as configService from '../services/config'
const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000 const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000
@@ -28,6 +29,12 @@ interface SnsOverviewStats {
type OverviewStatsStatus = 'loading' | 'ready' | 'error' type OverviewStatsStatus = 'loading' | 'ready' | 'error'
interface AuthorTimelineTarget {
username: string
nickname: string
avatarUrl?: string
}
export default function SnsPage() { export default function SnsPage() {
const [posts, setPosts] = useState<SnsPost[]>([]) const [posts, setPosts] = useState<SnsPost[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -55,6 +62,11 @@ export default function SnsPage() {
// UI states // UI states
const [showJumpDialog, setShowJumpDialog] = useState(false) const [showJumpDialog, setShowJumpDialog] = useState(false)
const [debugPost, setDebugPost] = useState<SnsPost | null>(null) 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) const [showExportDialog, setShowExportDialog] = useState(false)
@@ -89,6 +101,9 @@ export default function SnsPage() {
const cacheScopeKeyRef = useRef('') const cacheScopeKeyRef = useRef('')
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null) const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
const contactsLoadTokenRef = useRef(0) const contactsLoadTokenRef = useRef(0)
const authorTimelinePostsRef = useRef<SnsPost[]>([])
const authorTimelineLoadingRef = useRef(false)
const authorTimelineRequestTokenRef = useRef(0)
// Sync posts ref // Sync posts ref
useEffect(() => { useEffect(() => {
@@ -109,6 +124,9 @@ export default function SnsPage() {
useEffect(() => { useEffect(() => {
jumpTargetDateRef.current = jumpTargetDate jumpTargetDateRef.current = jumpTargetDate
}, [jumpTargetDate]) }, [jumpTargetDate])
useEffect(() => {
authorTimelinePostsRef.current = authorTimelinePosts
}, [authorTimelinePosts])
// 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动 // 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动
useLayoutEffect(() => { useLayoutEffect(() => {
const snapshot = scrollAdjustmentRef.current; const snapshot = scrollAdjustmentRef.current;
@@ -132,6 +150,18 @@ export default function SnsPage() {
return `${year}-${month}-${day}` 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(() => { const isDefaultViewNow = useCallback(() => {
return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current 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 // Initial Load & Listeners
useEffect(() => { useEffect(() => {
void hydrateSnsPageCache() void hydrateSnsPageCache()
@@ -474,6 +615,17 @@ export default function SnsPage() {
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [selectedUsernames, searchKeyword, jumpTargetDate, loadPosts]) }, [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 handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) { 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 ( return (
<div className="sns-page-layout"> <div className="sns-page-layout">
<div className="sns-main-viewport"> <div className="sns-main-viewport">
@@ -578,14 +746,8 @@ export default function SnsPage() {
} }
}} }}
onDebug={(p) => setDebugPost(p)} onDebug={(p) => setDebugPost(p)}
onDelete={(postId) => { onDelete={handlePostDelete}
setPosts(prev => { onOpenAuthorPosts={openAuthorTimeline}
const next = prev.filter(p => p.id !== postId)
void persistSnsPageCache({ posts: next })
return next
})
loadOverviewStats()
}}
/> />
))} ))}
</div> </div>
@@ -657,6 +819,76 @@ export default function SnsPage() {
currentDate={jumpTargetDate || new Date()} 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 && ( {debugPost && (
<div className="modal-overlay" onClick={() => setDebugPost(null)}> <div className="modal-overlay" onClick={() => setDebugPost(null)}>
<div className="debug-dialog" onClick={(e) => e.stopPropagation()}> <div className="debug-dialog" onClick={(e) => e.stopPropagation()}>