mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat(sns): support opening author timeline from post
This commit is contained in:
@@ -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">
|
||||||
<Avatar
|
<button
|
||||||
src={post.avatarUrl}
|
type="button"
|
||||||
name={post.nickname}
|
className="author-trigger-btn avatar-trigger"
|
||||||
size={48}
|
onClick={handleOpenAuthorPosts}
|
||||||
shape="rounded"
|
title="查看该发布者的全部朋友圈"
|
||||||
/>
|
>
|
||||||
|
<Avatar
|
||||||
|
src={post.avatarUrl}
|
||||||
|
name={post.nickname}
|
||||||
|
size={48}
|
||||||
|
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">
|
||||||
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="author-trigger-btn author-name-trigger"
|
||||||
|
onClick={handleOpenAuthorPosts}
|
||||||
|
title="查看该发布者的全部朋友圈"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(/&/gi, '&')
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'/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()}>
|
||||||
|
|||||||
Reference in New Issue
Block a user