From b62c18fd84ab524fa5711f5ba10853c44079cac2 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 16:11:04 +0800 Subject: [PATCH] perf(export): phase-load sessions and add strong skeleton states --- src/pages/ExportPage.scss | 85 +++++++++++++++++++ src/pages/ExportPage.tsx | 172 ++++++++++++++++++++++++++++---------- 2 files changed, 211 insertions(+), 46 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index e6bfbaf..5f31d01 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -191,6 +191,14 @@ background: var(--primary-hover); } } + + &.skeleton-card { + pointer-events: none; + + .card-stats { + gap: 10px; + } + } } .task-center { @@ -332,6 +340,19 @@ overflow: hidden; } +.table-stage-hint { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(var(--primary-rgb), 0.1); + border: 1px solid rgba(var(--primary-rgb), 0.2); + color: var(--primary); + font-size: 12px; + width: fit-content; +} + .table-toolbar { display: flex; justify-content: space-between; @@ -589,6 +610,61 @@ color: var(--text-secondary); } +.table-skeleton-list { + display: grid; + gap: 8px; + padding: 4px 0; +} + +.table-skeleton-item { + display: grid; + grid-template-columns: 20px 36px minmax(160px, 2fr) repeat(3, minmax(80px, 1fr)); + align-items: center; + gap: 12px; + padding: 10px 8px; + border-radius: 8px; + background: color-mix(in srgb, var(--bg-secondary) 80%, transparent); +} + +.skeleton-shimmer { + position: relative; + overflow: hidden; + border-radius: 8px; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.08) 0%, + rgba(255, 255, 255, 0.35) 50%, + rgba(255, 255, 255, 0.08) 100% + ); + background-size: 220% 100%; + animation: exportSkeletonShimmer 1.2s linear infinite; +} + +.skeleton-dot { + width: 16px; + height: 16px; + border-radius: 6px; +} + +.skeleton-avatar { + width: 36px; + height: 36px; + border-radius: 8px; +} + +.skeleton-line { + display: inline-block; + height: 12px; +} + +.skeleton-line.w-12 { width: 48%; min-width: 42px; } +.skeleton-line.w-20 { width: 22%; min-width: 36px; } +.skeleton-line.w-30 { width: 32%; min-width: 120px; } +.skeleton-line.w-40 { width: 45%; min-width: 80px; } +.skeleton-line.w-60 { width: 62%; min-width: 110px; } +.skeleton-line.w-100 { width: 100%; } +.skeleton-line.h-32 { height: 32px; border-radius: 10px; } + .export-dialog-overlay { position: fixed; inset: 0; @@ -867,6 +943,15 @@ } } +@keyframes exportSkeletonShimmer { + 0% { + background-position: 220% 0; + } + 100% { + background-position: -20% 0; + } +} + @media (max-width: 1360px) { .export-top-panel { grid-template-columns: 1fr; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 76cc77d..c7c2daf 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -239,6 +239,8 @@ function ExportPage() { const location = useLocation() const [isLoading, setIsLoading] = useState(true) + const [isSessionEnriching, setIsSessionEnriching] = useState(false) + const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true) const [sessions, setSessions] = useState([]) const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') @@ -291,6 +293,7 @@ function ExportPage() { const runningTaskIdRef = useRef(null) const tasksRef = useRef([]) const sessionMetricsRef = useRef>({}) + const sessionLoadTokenRef = useRef(0) const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) @@ -363,6 +366,7 @@ function ExportPage() { }, []) const loadSnsStats = useCallback(async () => { + setIsSnsStatsLoading(true) try { const result = await window.electronAPI.sns.getExportStats() if (result.success && result.data) { @@ -373,80 +377,122 @@ function ExportPage() { } } catch (error) { console.error('加载朋友圈导出统计失败:', error) + } finally { + setIsSnsStatsLoading(false) } }, []) const loadSessions = useCallback(async () => { + const loadToken = Date.now() + sessionLoadTokenRef.current = loadToken setIsLoading(true) + setIsSessionEnriching(false) + + const isStale = () => sessionLoadTokenRef.current !== loadToken + try { const connectResult = await window.electronAPI.chat.connect() if (!connectResult.success) { console.error('连接失败:', connectResult.error) - setIsLoading(false) + if (!isStale()) setIsLoading(false) return } - const [sessionsResult, contactsResult] = await Promise.all([ - window.electronAPI.chat.getSessions(), - window.electronAPI.chat.getContacts() - ]) - - const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : [] - const nextContactMap = contacts.reduce>((map, contact) => { - map[contact.username] = contact - return map - }, {}) + const sessionsResult = await window.electronAPI.chat.getSessions() + if (isStale()) return if (sessionsResult.success && sessionsResult.sessions) { const baseSessions = sessionsResult.sessions .map((session) => { - const contact = nextContactMap[session.username] - const kind = toKindByContactType(session, contact) return { ...session, - kind, - wechatId: contact?.username || session.username, - displayName: session.displayName || contact?.displayName || session.username, - avatarUrl: session.avatarUrl || contact?.avatarUrl + kind: toKindByContactType(session), + wechatId: session.username, + displayName: session.displayName || session.username, + avatarUrl: session.avatarUrl } as SessionRow }) + .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) - const needsEnrichment = baseSessions - .filter(session => !session.avatarUrl || !session.displayName || session.displayName === session.username) - .map(session => session.username) + if (isStale()) return + setSessions(baseSessions) + setIsLoading(false) - let nextSessions = baseSessions - if (needsEnrichment.length > 0) { + // 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。 + setIsSessionEnriching(true) + void (async () => { try { - const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment) - if (enrichResult.success && enrichResult.contacts) { - nextSessions = baseSessions.map((session) => { - const extra = enrichResult.contacts?.[session.username] + const contactsResult = await window.electronAPI.chat.getContacts() + if (isStale()) return + + const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : [] + const nextContactMap = contacts.reduce>((map, contact) => { + map[contact.username] = contact + return map + }, {}) + + const needsEnrichment = baseSessions + .filter(session => !session.avatarUrl || !session.displayName || session.displayName === session.username) + .map(session => session.username) + + let extraContactMap: Record = {} + if (needsEnrichment.length > 0) { + const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment) + if (enrichResult.success && enrichResult.contacts) { + extraContactMap = enrichResult.contacts + } + } + + if (isStale()) return + const nextSessions = baseSessions + .map((session) => { + const contact = nextContactMap[session.username] + const extra = extraContactMap[session.username] + const displayName = extra?.displayName || contact?.displayName || session.displayName || session.username + const avatarUrl = extra?.avatarUrl || session.avatarUrl || contact?.avatarUrl return { ...session, - displayName: extra?.displayName || session.displayName || session.username, - avatarUrl: extra?.avatarUrl || session.avatarUrl + kind: toKindByContactType(session, contact), + wechatId: contact?.username || session.wechatId || session.username, + displayName, + avatarUrl } }) - } + .sort((a, b) => { + const aMetric = sessionMetricsRef.current[a.username]?.totalMessages ?? 0 + const bMetric = sessionMetricsRef.current[b.username]?.totalMessages ?? 0 + if (bMetric !== aMetric) return bMetric - aMetric + return (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0) + }) + + setSessions(nextSessions) } catch (enrichError) { console.error('导出页补充会话联系人信息失败:', enrichError) + } finally { + if (!isStale()) setIsSessionEnriching(false) } - } - - setSessions(nextSessions) + })() + } else { + setIsLoading(false) } } catch (error) { console.error('加载会话失败:', error) + if (!isStale()) setIsLoading(false) } finally { - setIsLoading(false) + if (!isStale()) setIsLoading(false) } }, []) useEffect(() => { - loadBaseConfig() - loadSessions() - loadSnsStats() + void loadBaseConfig() + void loadSessions() + + // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 + const timer = window.setTimeout(() => { + void loadSnsStats() + }, 180) + + return () => window.clearTimeout(timer) }, [loadBaseConfig, loadSessions, loadSnsStats]) useEffect(() => { @@ -470,12 +516,12 @@ function ExportPage() { const keyword = searchKeyword.trim().toLowerCase() return sessions .filter((session) => { - if (session.kind !== activeTab) return false - if (!keyword) return true - return ( - (session.displayName || '').toLowerCase().includes(keyword) || - session.username.toLowerCase().includes(keyword) - ) + if (session.kind !== activeTab) return false + if (!keyword) return true + return ( + (session.displayName || '').toLowerCase().includes(keyword) || + session.username.toLowerCase().includes(keyword) + ) }) .sort((a, b) => { const totalA = sessionMetrics[a.username]?.totalMessages ?? 0 @@ -1229,6 +1275,7 @@ function ExportPage() { const formatCandidateOptions = exportDialog.scope === 'sns' ? formatOptions.filter(option => option.value === 'html' || option.value === 'json') : formatOptions + const showInitialSkeleton = isLoading && sessions.length === 0 return (
@@ -1288,7 +1335,22 @@ function ExportPage() {
- {contentCards.map(card => { + {showInitialSkeleton ? Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+ + +
+
+ + +
+
+
+
+ )) : contentCards.map(card => { const Icon = card.icon return (
@@ -1299,7 +1361,7 @@ function ExportPage() { {card.stats.map((stat) => (
{stat.label} - {stat.value.toLocaleString()} + {isSnsStatsLoading && card.type === 'sns' ? '--' : stat.value.toLocaleString()}
))}
@@ -1411,14 +1473,32 @@ function ExportPage() {
+ {(isLoading || isSessionEnriching) && ( +
+ + {isLoading ? '正在加载会话列表…' : '正在补充头像和统计…'} +
+ )} +
{renderTableHeader()} - {isLoading ? ( + {showInitialSkeleton ? ( ) : visibleSessions.length === 0 ? (
-
加载中...
+
+ {Array.from({ length: 8 }).map((_, rowIndex) => ( +
+ + + + + + +
+ ))} +