From db0ebc6c33a8bda63ee5753ac17925f27aabdb62 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 17:24:28 +0800 Subject: [PATCH] feat(sns): show loaded vs total posts in author timeline --- electron/main.ts | 4 ++ electron/preload.ts | 1 + electron/services/snsService.ts | 52 +++++++++++++++++++++++ src/components/Sns/SnsPostItem.tsx | 65 ++++++++++++++++------------- src/pages/SnsPage.scss | 7 ++++ src/pages/SnsPage.tsx | 66 ++++++++++++++++++++++++++---- src/types/electron.d.ts | 1 + 7 files changed, 160 insertions(+), 36 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 207d3b6..6ca4580 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1517,6 +1517,10 @@ function registerIpcHandlers() { return snsService.getExportStatsFast() }) + ipcMain.handle('sns:getUserPostStats', async (_, username: string) => { + return snsService.getUserPostStats(username) + }) + ipcMain.handle('sns:debugResource', async (_, url: string) => { return snsService.debugResource(url) }) diff --git a/electron/preload.ts b/electron/preload.ts index a323f4c..feb594b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -355,6 +355,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'), getExportStats: () => ipcRenderer.invoke('sns:getExportStats'), + getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload), downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload), diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 1e0be35..9b30dfb 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -506,6 +506,10 @@ class SnsService { return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0 } + private escapeSqlString(value: string): string { + return value.replace(/'/g, "''") + } + private pickTimelineUsername(post: any): string { const raw = post?.username ?? post?.user_name ?? post?.userName ?? '' 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 }> { return wcdbService.installSnsBlockDeleteTrigger() diff --git a/src/components/Sns/SnsPostItem.tsx b/src/components/Sns/SnsPostItem.tsx index 980377f..7498b36 100644 --- a/src/components/Sns/SnsPostItem.tsx +++ b/src/components/Sns/SnsPostItem.tsx @@ -243,11 +243,12 @@ interface SnsPostItemProps { post: SnsPost onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void onDebug: (post: SnsPost) => void - onDelete?: (postId: string) => void + onDelete?: (postId: string, username: string) => void onOpenAuthorPosts?: (post: SnsPost) => void + hideAuthorMeta?: boolean } -export const SnsPostItem: React.FC = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts }) => { +export const SnsPostItem: React.FC = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts, hideAuthorMeta = false }) => { const [mediaDeleted, setMediaDeleted] = useState(false) const [dbDeleted, setDbDeleted] = useState(false) const [deleting, setDeleting] = useState(false) @@ -300,7 +301,7 @@ export const SnsPostItem: React.FC = ({ post, onPreview, onDeb const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id) if (r.success) { setDbDeleted(true) - onDelete?.(post.id) + onDelete?.(post.id, post.username) } } finally { setDeleting(false) @@ -315,35 +316,41 @@ export const SnsPostItem: React.FC = ({ post, onPreview, onDeb return ( <>
-
- -
+ {!hideAuthorMeta && ( +
+ +
+ )}
-
- - {formatTime(post.createTime)} -
+ {hideAuthorMeta ? ( + {formatTime(post.createTime)} + ) : ( +
+ + {formatTime(post.createTime)} +
+ )}
{(mediaDeleted || dbDeleted) && ( diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 013eaae..854cc28 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -267,6 +267,13 @@ flex-shrink: 0; } + .post-time-standalone { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.2; + padding-top: 2px; + } + .debug-btn { opacity: 0; transition: opacity 0.2s; diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 53d34ad..b3459f3 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -67,6 +67,8 @@ export default function SnsPage() { const [authorTimelineLoading, setAuthorTimelineLoading] = useState(false) const [authorTimelineLoadingMore, setAuthorTimelineLoadingMore] = useState(false) const [authorTimelineHasMore, setAuthorTimelineHasMore] = useState(false) + const [authorTimelineTotalPosts, setAuthorTimelineTotalPosts] = useState(null) + const [authorTimelineStatsLoading, setAuthorTimelineStatsLoading] = useState(false) // 导出相关状态 const [showExportDialog, setShowExportDialog] = useState(false) @@ -104,6 +106,7 @@ export default function SnsPage() { const authorTimelinePostsRef = useRef([]) const authorTimelineLoadingRef = useRef(false) const authorTimelineRequestTokenRef = useRef(0) + const authorTimelineStatsTokenRef = useRef(0) // Sync posts ref useEffect(() => { @@ -477,12 +480,41 @@ export default function SnsPage() { const closeAuthorTimeline = useCallback(() => { authorTimelineRequestTokenRef.current += 1 + authorTimelineStatsTokenRef.current += 1 authorTimelineLoadingRef.current = false setAuthorTimelineTarget(null) setAuthorTimelinePosts([]) setAuthorTimelineLoading(false) setAuthorTimelineLoadingMore(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 } = {}) => { @@ -568,23 +600,28 @@ export default function SnsPage() { setAuthorTimelineTarget(target) setAuthorTimelinePosts([]) setAuthorTimelineHasMore(false) + setAuthorTimelineTotalPosts(null) void loadAuthorTimelinePosts(target, { reset: true }) - }, [loadAuthorTimelinePosts]) + void loadAuthorTimelineTotalPosts(target) + }, [loadAuthorTimelinePosts, loadAuthorTimelineTotalPosts]) const loadMoreAuthorTimeline = useCallback(() => { if (!authorTimelineTarget || authorTimelineLoading || authorTimelineLoadingMore || !authorTimelineHasMore) return void loadAuthorTimelinePosts(authorTimelineTarget, { reset: false }) }, [authorTimelineHasMore, authorTimelineLoading, authorTimelineLoadingMore, authorTimelineTarget, loadAuthorTimelinePosts]) - const handlePostDelete = useCallback((postId: string) => { + const handlePostDelete = useCallback((postId: string, username: 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)) + if (authorTimelineTarget && authorTimelineTarget.username === username) { + setAuthorTimelineTotalPosts(prev => prev === null ? null : Math.max(0, prev - 1)) + } void loadOverviewStats() - }, [loadOverviewStats, persistSnsPageCache]) + }, [authorTimelineTarget, loadOverviewStats, persistSnsPageCache]) // Initial Load & Listeners useEffect(() => { @@ -626,6 +663,13 @@ export default function SnsPage() { return () => window.removeEventListener('keydown', handleKeyDown) }, [authorTimelineTarget, closeAuthorTimeline]) + useEffect(() => { + if (authorTimelineTotalPosts === null) return + if (authorTimelinePosts.length >= authorTimelineTotalPosts) { + setAuthorTimelineHasMore(false) + } + }, [authorTimelinePosts.length, authorTimelineTotalPosts]) + const handleScroll = (e: React.UIEvent) => { const { scrollTop, clientHeight, scrollHeight } = e.currentTarget if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) { @@ -652,12 +696,19 @@ export default function SnsPage() { } const renderAuthorTimelineStats = () => { - if (authorTimelineLoading) return '加载中...' - if (authorTimelinePosts.length === 0) return '暂无朋友圈' + const loadedCount = authorTimelinePosts.length + 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 earliest = authorTimelinePosts[authorTimelinePosts.length - 1]?.createTime ?? null - const loadedLabel = authorTimelineHasMore ? `已加载 ${authorTimelinePosts.length} 条` : `共 ${authorTimelinePosts.length} 条` - return `${loadedLabel} | ${formatDateOnly(earliest)} ~ ${formatDateOnly(latest)}` + return `${loadPart} | ${formatDateOnly(earliest)} ~ ${formatDateOnly(latest)}` } return ( @@ -858,6 +909,7 @@ export default function SnsPage() { onDebug={(p) => setDebugPost(p)} onDelete={handlePostDelete} onOpenAuthorPosts={openAuthorTimeline} + hideAuthorMeta /> ))}
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 00ce172..0d05be4 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -791,6 +791,7 @@ export interface ElectronAPI { getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; 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 }> + getUserPostStats: (username: string) => Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }> checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }>