feat(sns): show loaded vs total posts in author timeline

This commit is contained in:
aits2026
2026-03-05 17:24:28 +08:00
parent 7cc2961538
commit db0ebc6c33
7 changed files with 160 additions and 36 deletions

View File

@@ -1517,6 +1517,10 @@ function registerIpcHandlers() {
return snsService.getExportStatsFast() return snsService.getExportStatsFast()
}) })
ipcMain.handle('sns:getUserPostStats', async (_, username: string) => {
return snsService.getUserPostStats(username)
})
ipcMain.handle('sns:debugResource', async (_, url: string) => { ipcMain.handle('sns:debugResource', async (_, url: string) => {
return snsService.debugResource(url) return snsService.debugResource(url)
}) })

View File

@@ -355,6 +355,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'), getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'), getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username),
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload), proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload), downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),

View File

@@ -506,6 +506,10 @@ class SnsService {
return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0 return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0
} }
private escapeSqlString(value: string): string {
return value.replace(/'/g, "''")
}
private pickTimelineUsername(post: any): string { private pickTimelineUsername(post: any): string {
const raw = post?.username ?? post?.user_name ?? post?.userName ?? '' const raw = post?.username ?? post?.user_name ?? post?.userName ?? ''
if (typeof raw !== 'string') return '' if (typeof raw !== 'string') return ''
@@ -864,6 +868,54 @@ class SnsService {
}) })
} }
async getUserPostStats(username: string): Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> {
const normalizedUsername = this.toOptionalString(username)
if (!normalizedUsername) {
return { success: false, error: '用户名不能为空' }
}
const escapedUsername = this.escapeSqlString(normalizedUsername)
const primaryResult = await wcdbService.execQuery(
'sns',
null,
`SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = '${escapedUsername}'`
)
if (primaryResult.success) {
const totalPosts = primaryResult.rows && primaryResult.rows.length > 0
? this.parseCountValue(primaryResult.rows[0])
: 0
return {
success: true,
data: {
username: normalizedUsername,
totalPosts
}
}
}
const fallbackResult = await wcdbService.execQuery(
'sns',
null,
`SELECT COUNT(1) AS total FROM SnsTimeLine WHERE userName = '${escapedUsername}'`
)
if (fallbackResult.success) {
const totalPosts = fallbackResult.rows && fallbackResult.rows.length > 0
? this.parseCountValue(fallbackResult.rows[0])
: 0
return {
success: true,
data: {
username: normalizedUsername,
totalPosts
}
}
}
return { success: false, error: primaryResult.error || fallbackResult.error || '统计单个好友朋友圈失败' }
}
// 安装朋友圈删除拦截 // 安装朋友圈删除拦截
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
return wcdbService.installSnsBlockDeleteTrigger() return wcdbService.installSnsBlockDeleteTrigger()

View File

@@ -243,11 +243,12 @@ interface SnsPostItemProps {
post: SnsPost post: SnsPost
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, username: string) => void
onOpenAuthorPosts?: (post: SnsPost) => void onOpenAuthorPosts?: (post: SnsPost) => void
hideAuthorMeta?: boolean
} }
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts }) => { export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts, hideAuthorMeta = false }) => {
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)
@@ -300,7 +301,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id) const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id)
if (r.success) { if (r.success) {
setDbDeleted(true) setDbDeleted(true)
onDelete?.(post.id) onDelete?.(post.id, post.username)
} }
} finally { } finally {
setDeleting(false) setDeleting(false)
@@ -315,35 +316,41 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
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"> {!hideAuthorMeta && (
<button <div className="post-avatar-col">
type="button" <button
className="author-trigger-btn avatar-trigger" type="button"
onClick={handleOpenAuthorPosts} className="author-trigger-btn avatar-trigger"
title="查看该发布者的全部朋友圈" onClick={handleOpenAuthorPosts}
> title="查看该发布者的全部朋友圈"
<Avatar >
src={post.avatarUrl} <Avatar
name={post.nickname} src={post.avatarUrl}
size={48} name={post.nickname}
shape="rounded" size={48}
/> shape="rounded"
</button> />
</div> </button>
</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"> {hideAuthorMeta ? (
<button <span className="post-time post-time-standalone">{formatTime(post.createTime)}</span>
type="button" ) : (
className="author-trigger-btn author-name-trigger" <div className="post-author-info">
onClick={handleOpenAuthorPosts} <button
title="查看该发布者的全部朋友圈" type="button"
> className="author-trigger-btn author-name-trigger"
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span> onClick={handleOpenAuthorPosts}
</button> title="查看该发布者的全部朋友圈"
<span className="post-time">{formatTime(post.createTime)}</span> >
</div> <span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
</button>
<span className="post-time">{formatTime(post.createTime)}</span>
</div>
)}
<div className="post-header-actions"> <div className="post-header-actions">
{(mediaDeleted || dbDeleted) && ( {(mediaDeleted || dbDeleted) && (
<span className="post-deleted-badge"> <span className="post-deleted-badge">

View File

@@ -267,6 +267,13 @@
flex-shrink: 0; flex-shrink: 0;
} }
.post-time-standalone {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.2;
padding-top: 2px;
}
.debug-btn { .debug-btn {
opacity: 0; opacity: 0;
transition: opacity 0.2s; transition: opacity 0.2s;

View File

@@ -67,6 +67,8 @@ export default function SnsPage() {
const [authorTimelineLoading, setAuthorTimelineLoading] = useState(false) const [authorTimelineLoading, setAuthorTimelineLoading] = useState(false)
const [authorTimelineLoadingMore, setAuthorTimelineLoadingMore] = useState(false) const [authorTimelineLoadingMore, setAuthorTimelineLoadingMore] = useState(false)
const [authorTimelineHasMore, setAuthorTimelineHasMore] = useState(false) const [authorTimelineHasMore, setAuthorTimelineHasMore] = useState(false)
const [authorTimelineTotalPosts, setAuthorTimelineTotalPosts] = useState<number | null>(null)
const [authorTimelineStatsLoading, setAuthorTimelineStatsLoading] = useState(false)
// 导出相关状态 // 导出相关状态
const [showExportDialog, setShowExportDialog] = useState(false) const [showExportDialog, setShowExportDialog] = useState(false)
@@ -104,6 +106,7 @@ export default function SnsPage() {
const authorTimelinePostsRef = useRef<SnsPost[]>([]) const authorTimelinePostsRef = useRef<SnsPost[]>([])
const authorTimelineLoadingRef = useRef(false) const authorTimelineLoadingRef = useRef(false)
const authorTimelineRequestTokenRef = useRef(0) const authorTimelineRequestTokenRef = useRef(0)
const authorTimelineStatsTokenRef = useRef(0)
// Sync posts ref // Sync posts ref
useEffect(() => { useEffect(() => {
@@ -477,12 +480,41 @@ export default function SnsPage() {
const closeAuthorTimeline = useCallback(() => { const closeAuthorTimeline = useCallback(() => {
authorTimelineRequestTokenRef.current += 1 authorTimelineRequestTokenRef.current += 1
authorTimelineStatsTokenRef.current += 1
authorTimelineLoadingRef.current = false authorTimelineLoadingRef.current = false
setAuthorTimelineTarget(null) setAuthorTimelineTarget(null)
setAuthorTimelinePosts([]) setAuthorTimelinePosts([])
setAuthorTimelineLoading(false) setAuthorTimelineLoading(false)
setAuthorTimelineLoadingMore(false) setAuthorTimelineLoadingMore(false)
setAuthorTimelineHasMore(false) setAuthorTimelineHasMore(false)
setAuthorTimelineTotalPosts(null)
setAuthorTimelineStatsLoading(false)
}, [])
const loadAuthorTimelineTotalPosts = useCallback(async (target: AuthorTimelineTarget) => {
const requestToken = ++authorTimelineStatsTokenRef.current
setAuthorTimelineStatsLoading(true)
setAuthorTimelineTotalPosts(null)
try {
const result = await window.electronAPI.sns.getUserPostStats(target.username)
if (requestToken !== authorTimelineStatsTokenRef.current) return
if (result.success && result.data) {
setAuthorTimelineTotalPosts(Math.max(0, Number(result.data.totalPosts || 0)))
} else {
setAuthorTimelineTotalPosts(null)
}
} catch (error) {
console.error('Failed to load author timeline total posts:', error)
if (requestToken === authorTimelineStatsTokenRef.current) {
setAuthorTimelineTotalPosts(null)
}
} finally {
if (requestToken === authorTimelineStatsTokenRef.current) {
setAuthorTimelineStatsLoading(false)
}
}
}, []) }, [])
const loadAuthorTimelinePosts = useCallback(async (target: AuthorTimelineTarget, options: { reset?: boolean } = {}) => { const loadAuthorTimelinePosts = useCallback(async (target: AuthorTimelineTarget, options: { reset?: boolean } = {}) => {
@@ -568,23 +600,28 @@ export default function SnsPage() {
setAuthorTimelineTarget(target) setAuthorTimelineTarget(target)
setAuthorTimelinePosts([]) setAuthorTimelinePosts([])
setAuthorTimelineHasMore(false) setAuthorTimelineHasMore(false)
setAuthorTimelineTotalPosts(null)
void loadAuthorTimelinePosts(target, { reset: true }) void loadAuthorTimelinePosts(target, { reset: true })
}, [loadAuthorTimelinePosts]) void loadAuthorTimelineTotalPosts(target)
}, [loadAuthorTimelinePosts, loadAuthorTimelineTotalPosts])
const loadMoreAuthorTimeline = useCallback(() => { const loadMoreAuthorTimeline = useCallback(() => {
if (!authorTimelineTarget || authorTimelineLoading || authorTimelineLoadingMore || !authorTimelineHasMore) return if (!authorTimelineTarget || authorTimelineLoading || authorTimelineLoadingMore || !authorTimelineHasMore) return
void loadAuthorTimelinePosts(authorTimelineTarget, { reset: false }) void loadAuthorTimelinePosts(authorTimelineTarget, { reset: false })
}, [authorTimelineHasMore, authorTimelineLoading, authorTimelineLoadingMore, authorTimelineTarget, loadAuthorTimelinePosts]) }, [authorTimelineHasMore, authorTimelineLoading, authorTimelineLoadingMore, authorTimelineTarget, loadAuthorTimelinePosts])
const handlePostDelete = useCallback((postId: string) => { const handlePostDelete = useCallback((postId: string, username: string) => {
setPosts(prev => { setPosts(prev => {
const next = prev.filter(p => p.id !== postId) const next = prev.filter(p => p.id !== postId)
void persistSnsPageCache({ posts: next }) void persistSnsPageCache({ posts: next })
return next return next
}) })
setAuthorTimelinePosts(prev => prev.filter(p => p.id !== postId)) setAuthorTimelinePosts(prev => prev.filter(p => p.id !== postId))
if (authorTimelineTarget && authorTimelineTarget.username === username) {
setAuthorTimelineTotalPosts(prev => prev === null ? null : Math.max(0, prev - 1))
}
void loadOverviewStats() void loadOverviewStats()
}, [loadOverviewStats, persistSnsPageCache]) }, [authorTimelineTarget, loadOverviewStats, persistSnsPageCache])
// Initial Load & Listeners // Initial Load & Listeners
useEffect(() => { useEffect(() => {
@@ -626,6 +663,13 @@ export default function SnsPage() {
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, [authorTimelineTarget, closeAuthorTimeline]) }, [authorTimelineTarget, closeAuthorTimeline])
useEffect(() => {
if (authorTimelineTotalPosts === null) return
if (authorTimelinePosts.length >= authorTimelineTotalPosts) {
setAuthorTimelineHasMore(false)
}
}, [authorTimelinePosts.length, authorTimelineTotalPosts])
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) {
@@ -652,12 +696,19 @@ export default function SnsPage() {
} }
const renderAuthorTimelineStats = () => { const renderAuthorTimelineStats = () => {
if (authorTimelineLoading) return '加载中...' const loadedCount = authorTimelinePosts.length
if (authorTimelinePosts.length === 0) return '暂无朋友圈' const loadPart = authorTimelineStatsLoading
? `已加载 ${loadedCount} / 总数统计中...`
: authorTimelineTotalPosts === null
? `已加载 ${loadedCount}`
: `已加载 ${loadedCount} / 共 ${authorTimelineTotalPosts}`
if (authorTimelineLoading && loadedCount === 0) return `${loadPart} 加载中...`
if (loadedCount === 0) return loadPart
const latest = authorTimelinePosts[0]?.createTime ?? null const latest = authorTimelinePosts[0]?.createTime ?? null
const earliest = authorTimelinePosts[authorTimelinePosts.length - 1]?.createTime ?? null const earliest = authorTimelinePosts[authorTimelinePosts.length - 1]?.createTime ?? null
const loadedLabel = authorTimelineHasMore ? `已加载 ${authorTimelinePosts.length}` : `${authorTimelinePosts.length}` return `${loadPart} ${formatDateOnly(earliest)} ~ ${formatDateOnly(latest)}`
return `${loadedLabel} ${formatDateOnly(earliest)} ~ ${formatDateOnly(latest)}`
} }
return ( return (
@@ -858,6 +909,7 @@ export default function SnsPage() {
onDebug={(p) => setDebugPost(p)} onDebug={(p) => setDebugPost(p)}
onDelete={handlePostDelete} onDelete={handlePostDelete}
onOpenAuthorPosts={openAuthorTimeline} onOpenAuthorPosts={openAuthorTimeline}
hideAuthorMeta
/> />
))} ))}
</div> </div>

View File

@@ -791,6 +791,7 @@ export interface ElectronAPI {
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }> getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }>
getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }>
getUserPostStats: (username: string) => Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }>
installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }>
uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }> uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }>
checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }> checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }>