fix(sns): add overview stats status and fallback resilience

This commit is contained in:
tisonhuang
2026-03-02 14:54:19 +08:00
parent 086ac8fdc9
commit d65d6d2396
3 changed files with 95 additions and 19 deletions

View File

@@ -497,6 +497,8 @@ class SnsService {
} }
let { totalPosts, totalFriends } = await this.getExportStatsFromTableCount() let { totalPosts, totalFriends } = await this.getExportStatsFromTableCount()
let fallbackAttempted = false
let fallbackError = ''
// 某些环境下 SnsTimeLine 统计查询会返回 0这里在允许时回退到与导出同源的 timeline 接口统计。 // 某些环境下 SnsTimeLine 统计查询会返回 0这里在允许时回退到与导出同源的 timeline 接口统计。
if ( if (
@@ -504,23 +506,52 @@ class SnsService {
(totalPosts <= 0 || totalFriends <= 0) && (totalPosts <= 0 || totalFriends <= 0) &&
now - this.lastTimelineFallbackAt >= this.timelineFallbackCooldownMs now - this.lastTimelineFallbackAt >= this.timelineFallbackCooldownMs
) { ) {
this.lastTimelineFallbackAt = now fallbackAttempted = true
try {
const timelineStats = await this.getExportStatsFromTimeline() const timelineStats = await this.getExportStatsFromTimeline()
this.lastTimelineFallbackAt = Date.now()
if (timelineStats.totalPosts > 0) { if (timelineStats.totalPosts > 0) {
totalPosts = timelineStats.totalPosts totalPosts = timelineStats.totalPosts
} }
if (timelineStats.totalFriends > 0) { if (timelineStats.totalFriends > 0) {
totalFriends = timelineStats.totalFriends totalFriends = timelineStats.totalFriends
} }
} catch (error) {
fallbackError = String(error)
console.error('[SnsService] getExportStats timeline fallback failed:', error)
}
}
const normalizedStats = {
totalPosts: Math.max(0, Number(totalPosts || 0)),
totalFriends: Math.max(0, Number(totalFriends || 0))
}
const computedHasData = normalizedStats.totalPosts > 0 || normalizedStats.totalFriends > 0
const cacheHasData = !!this.exportStatsCache && (this.exportStatsCache.totalPosts > 0 || this.exportStatsCache.totalFriends > 0)
// 计算结果全 0 时,优先使用已有非零缓存,避免瞬时异常覆盖有效统计。
if (!computedHasData && cacheHasData && this.exportStatsCache) {
return {
success: true,
data: {
totalPosts: this.exportStatsCache.totalPosts,
totalFriends: this.exportStatsCache.totalFriends
}
}
}
// 当主查询结果全 0 且回退统计执行失败时,返回失败给前端显示明确状态(而非错误地展示 0
if (!computedHasData && fallbackAttempted && fallbackError) {
return { success: false, error: fallbackError }
} }
this.exportStatsCache = { this.exportStatsCache = {
totalPosts, totalPosts: normalizedStats.totalPosts,
totalFriends, totalFriends: normalizedStats.totalFriends,
updatedAt: Date.now() updatedAt: Date.now()
} }
return { success: true, data: { totalPosts, totalFriends } } return { success: true, data: normalizedStats }
} catch (e) { } catch (e) {
if (this.exportStatsCache) { if (this.exportStatsCache) {
return { return {

View File

@@ -74,6 +74,22 @@
&.loading { &.loading {
opacity: 0.7; opacity: 0.7;
} }
&.error {
color: #d94f45;
}
}
.feed-stats-retry {
border: none;
background: transparent;
color: inherit;
font-size: 13px;
padding: 0;
line-height: 1.4;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
} }
.header-actions { .header-actions {

View File

@@ -27,6 +27,8 @@ interface SnsOverviewStats {
latestTime: number | null latestTime: number | null
} }
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
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)
@@ -38,7 +40,7 @@ export default function SnsPage() {
earliestTime: null, earliestTime: null,
latestTime: null latestTime: null
}) })
const [overviewStatsLoading, setOverviewStatsLoading] = useState(false) const [overviewStatsStatus, setOverviewStatsStatus] = useState<OverviewStatsStatus>('loading')
// Filter states // Filter states
const [searchKeyword, setSearchKeyword] = useState('') const [searchKeyword, setSearchKeyword] = useState('')
@@ -78,6 +80,7 @@ export default function SnsPage() {
const [loadingNewer, setLoadingNewer] = useState(false) const [loadingNewer, setLoadingNewer] = useState(false)
const postsRef = useRef<SnsPost[]>([]) const postsRef = useRef<SnsPost[]>([])
const overviewStatsRef = useRef<SnsOverviewStats>(overviewStats) const overviewStatsRef = useRef<SnsOverviewStats>(overviewStats)
const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus)
const selectedUsernamesRef = useRef<string[]>(selectedUsernames) const selectedUsernamesRef = useRef<string[]>(selectedUsernames)
const searchKeywordRef = useRef(searchKeyword) const searchKeywordRef = useRef(searchKeyword)
const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate) const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate)
@@ -92,6 +95,9 @@ export default function SnsPage() {
useEffect(() => { useEffect(() => {
overviewStatsRef.current = overviewStats overviewStatsRef.current = overviewStats
}, [overviewStats]) }, [overviewStats])
useEffect(() => {
overviewStatsStatusRef.current = overviewStatsStatus
}, [overviewStatsStatus])
useEffect(() => { useEffect(() => {
selectedUsernamesRef.current = selectedUsernames selectedUsernamesRef.current = selectedUsernames
}, [selectedUsernames]) }, [selectedUsernames])
@@ -141,14 +147,17 @@ export default function SnsPage() {
try { try {
const scopeKey = await ensureSnsCacheScopeKey() const scopeKey = await ensureSnsCacheScopeKey()
if (!scopeKey) return if (!scopeKey) return
const existingCache = await configService.getSnsPageCache(scopeKey)
let postsToStore = patch?.posts ?? postsRef.current let postsToStore = patch?.posts ?? postsRef.current
if (!patch?.posts && postsToStore.length === 0) { if (!patch?.posts && postsToStore.length === 0) {
const existingCache = await configService.getSnsPageCache(scopeKey)
if (existingCache && Array.isArray(existingCache.posts) && existingCache.posts.length > 0) { if (existingCache && Array.isArray(existingCache.posts) && existingCache.posts.length > 0) {
postsToStore = existingCache.posts as SnsPost[] postsToStore = existingCache.posts as SnsPost[]
} }
} }
const overviewToStore = patch?.overviewStats ?? overviewStatsRef.current const overviewToStore = patch?.overviewStats
?? (overviewStatsStatusRef.current === 'ready'
? overviewStatsRef.current
: existingCache?.overviewStats ?? overviewStatsRef.current)
await configService.setSnsPageCache(scopeKey, { await configService.setSnsPageCache(scopeKey, {
overviewStats: overviewToStore, overviewStats: overviewToStore,
posts: postsToStore.slice(0, SNS_PAGE_CACHE_POST_LIMIT) posts: postsToStore.slice(0, SNS_PAGE_CACHE_POST_LIMIT)
@@ -167,12 +176,18 @@ export default function SnsPage() {
const cachedOverview = cached.overviewStats const cachedOverview = cached.overviewStats
if (cachedOverview) { if (cachedOverview) {
const cachedTotalPosts = Math.max(0, Number(cachedOverview.totalPosts || 0))
const cachedTotalFriends = Math.max(0, Number(cachedOverview.totalFriends || 0))
const hasCachedPosts = Array.isArray(cached.posts) && cached.posts.length > 0
const hasOverviewData = cachedTotalPosts > 0 || cachedTotalFriends > 0
setOverviewStats({ setOverviewStats({
totalPosts: Math.max(0, Number(cachedOverview.totalPosts || 0)), totalPosts: cachedTotalPosts,
totalFriends: Math.max(0, Number(cachedOverview.totalFriends || 0)), totalFriends: cachedTotalFriends,
earliestTime: cachedOverview.earliestTime ?? null, earliestTime: cachedOverview.earliestTime ?? null,
latestTime: cachedOverview.latestTime ?? null latestTime: cachedOverview.latestTime ?? null
}) })
// 只有明确有统计值(或确实无帖子)时才把缓存视为 ready避免历史异常 0 卡住显示。
setOverviewStatsStatus(hasOverviewData || !hasCachedPosts ? 'ready' : 'loading')
} }
if (Array.isArray(cached.posts) && cached.posts.length > 0) { if (Array.isArray(cached.posts) && cached.posts.length > 0) {
@@ -197,7 +212,7 @@ export default function SnsPage() {
}, [ensureSnsCacheScopeKey]) }, [ensureSnsCacheScopeKey])
const loadOverviewStats = useCallback(async () => { const loadOverviewStats = useCallback(async () => {
setOverviewStatsLoading(true) setOverviewStatsStatus('loading')
try { try {
const statsResult = await window.electronAPI.sns.getExportStats() const statsResult = await window.electronAPI.sns.getExportStats()
if (!statsResult.success || !statsResult.data) { if (!statsResult.success || !statsResult.data) {
@@ -232,14 +247,28 @@ export default function SnsPage() {
latestTime latestTime
} }
setOverviewStats(nextOverviewStats) setOverviewStats(nextOverviewStats)
setOverviewStatsStatus('ready')
void persistSnsPageCache({ overviewStats: nextOverviewStats }) void persistSnsPageCache({ overviewStats: nextOverviewStats })
} catch (error) { } catch (error) {
console.error('Failed to load SNS overview stats:', error) console.error('Failed to load SNS overview stats:', error)
} finally { setOverviewStatsStatus('error')
setOverviewStatsLoading(false)
} }
}, [persistSnsPageCache]) }, [persistSnsPageCache])
const renderOverviewStats = () => {
if (overviewStatsStatus === 'error') {
return (
<button type="button" className="feed-stats-retry" onClick={() => { void loadOverviewStats() }}>
</button>
)
}
if (overviewStatsStatus === 'loading') {
return '统计中...'
}
return `${overviewStats.totalPosts} ${formatDateOnly(overviewStats.earliestTime)} ~ ${formatDateOnly(overviewStats.latestTime)} ${overviewStats.totalFriends} 位好友`
}
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => { const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
const { reset = false, direction = 'older' } = options const { reset = false, direction = 'older' } = options
if (loadingRef.current) return if (loadingRef.current) return
@@ -513,8 +542,8 @@ export default function SnsPage() {
<div className="feed-header"> <div className="feed-header">
<div className="feed-header-main"> <div className="feed-header-main">
<h2></h2> <h2></h2>
<div className={`feed-stats-line ${overviewStatsLoading ? 'loading' : ''}`}> <div className={`feed-stats-line ${overviewStatsStatus}`}>
{overviewStats.totalPosts} {formatDateOnly(overviewStats.earliestTime)} ~ {formatDateOnly(overviewStats.latestTime)} {overviewStats.totalFriends} {renderOverviewStats()}
</div> </div>
</div> </div>
<div className="header-actions"> <div className="header-actions">