mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat(sns): show loaded vs total posts in author timeline
This commit is contained in:
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,6 +316,7 @@ 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' : ''}`}>
|
||||||
|
{!hideAuthorMeta && (
|
||||||
<div className="post-avatar-col">
|
<div className="post-avatar-col">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -330,9 +332,13 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="post-content-col">
|
<div className="post-content-col">
|
||||||
<div className="post-header-row">
|
<div className="post-header-row">
|
||||||
|
{hideAuthorMeta ? (
|
||||||
|
<span className="post-time post-time-standalone">{formatTime(post.createTime)}</span>
|
||||||
|
) : (
|
||||||
<div className="post-author-info">
|
<div className="post-author-info">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -344,6 +350,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
|||||||
</button>
|
</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">
|
||||||
{(mediaDeleted || dbDeleted) && (
|
{(mediaDeleted || dbDeleted) && (
|
||||||
<span className="post-deleted-badge">
|
<span className="post-deleted-badge">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
1
src/types/electron.d.ts
vendored
1
src/types/electron.d.ts
vendored
@@ -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 }>
|
||||||
|
|||||||
Reference in New Issue
Block a user