perf(export): phase-load sessions and add strong skeleton states

This commit is contained in:
tisonhuang
2026-03-01 16:11:04 +08:00
parent de7cbdf494
commit b62c18fd84
2 changed files with 211 additions and 46 deletions

View File

@@ -191,6 +191,14 @@
background: var(--primary-hover); background: var(--primary-hover);
} }
} }
&.skeleton-card {
pointer-events: none;
.card-stats {
gap: 10px;
}
}
} }
.task-center { .task-center {
@@ -332,6 +340,19 @@
overflow: hidden; 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 { .table-toolbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -589,6 +610,61 @@
color: var(--text-secondary); 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 { .export-dialog-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -867,6 +943,15 @@
} }
} }
@keyframes exportSkeletonShimmer {
0% {
background-position: 220% 0;
}
100% {
background-position: -20% 0;
}
}
@media (max-width: 1360px) { @media (max-width: 1360px) {
.export-top-panel { .export-top-panel {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -239,6 +239,8 @@ function ExportPage() {
const location = useLocation() const location = useLocation()
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isSessionEnriching, setIsSessionEnriching] = useState(false)
const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true)
const [sessions, setSessions] = useState<SessionRow[]>([]) const [sessions, setSessions] = useState<SessionRow[]>([])
const [sessionMetrics, setSessionMetrics] = useState<Record<string, SessionMetrics>>({}) const [sessionMetrics, setSessionMetrics] = useState<Record<string, SessionMetrics>>({})
const [searchKeyword, setSearchKeyword] = useState('') const [searchKeyword, setSearchKeyword] = useState('')
@@ -291,6 +293,7 @@ function ExportPage() {
const runningTaskIdRef = useRef<string | null>(null) const runningTaskIdRef = useRef<string | null>(null)
const tasksRef = useRef<ExportTask[]>([]) const tasksRef = useRef<ExportTask[]>([])
const sessionMetricsRef = useRef<Record<string, SessionMetrics>>({}) const sessionMetricsRef = useRef<Record<string, SessionMetrics>>({})
const sessionLoadTokenRef = useRef(0)
const loadingMetricsRef = useRef<Set<string>>(new Set()) const loadingMetricsRef = useRef<Set<string>>(new Set())
const preselectAppliedRef = useRef(false) const preselectAppliedRef = useRef(false)
@@ -363,6 +366,7 @@ function ExportPage() {
}, []) }, [])
const loadSnsStats = useCallback(async () => { const loadSnsStats = useCallback(async () => {
setIsSnsStatsLoading(true)
try { try {
const result = await window.electronAPI.sns.getExportStats() const result = await window.electronAPI.sns.getExportStats()
if (result.success && result.data) { if (result.success && result.data) {
@@ -373,80 +377,122 @@ function ExportPage() {
} }
} catch (error) { } catch (error) {
console.error('加载朋友圈导出统计失败:', error) console.error('加载朋友圈导出统计失败:', error)
} finally {
setIsSnsStatsLoading(false)
} }
}, []) }, [])
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
const loadToken = Date.now()
sessionLoadTokenRef.current = loadToken
setIsLoading(true) setIsLoading(true)
setIsSessionEnriching(false)
const isStale = () => sessionLoadTokenRef.current !== loadToken
try { try {
const connectResult = await window.electronAPI.chat.connect() const connectResult = await window.electronAPI.chat.connect()
if (!connectResult.success) { if (!connectResult.success) {
console.error('连接失败:', connectResult.error) console.error('连接失败:', connectResult.error)
setIsLoading(false) if (!isStale()) setIsLoading(false)
return return
} }
const [sessionsResult, contactsResult] = await Promise.all([ const sessionsResult = await window.electronAPI.chat.getSessions()
window.electronAPI.chat.getSessions(), if (isStale()) return
window.electronAPI.chat.getContacts()
])
const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : []
const nextContactMap = contacts.reduce<Record<string, ContactInfo>>((map, contact) => {
map[contact.username] = contact
return map
}, {})
if (sessionsResult.success && sessionsResult.sessions) { if (sessionsResult.success && sessionsResult.sessions) {
const baseSessions = sessionsResult.sessions const baseSessions = sessionsResult.sessions
.map((session) => { .map((session) => {
const contact = nextContactMap[session.username]
const kind = toKindByContactType(session, contact)
return { return {
...session, ...session,
kind, kind: toKindByContactType(session),
wechatId: contact?.username || session.username, wechatId: session.username,
displayName: session.displayName || contact?.displayName || session.username, displayName: session.displayName || session.username,
avatarUrl: session.avatarUrl || contact?.avatarUrl avatarUrl: session.avatarUrl
} as SessionRow } as SessionRow
}) })
.sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0))
const needsEnrichment = baseSessions if (isStale()) return
.filter(session => !session.avatarUrl || !session.displayName || session.displayName === session.username) setSessions(baseSessions)
.map(session => session.username) setIsLoading(false)
let nextSessions = baseSessions // 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。
if (needsEnrichment.length > 0) { setIsSessionEnriching(true)
void (async () => {
try { try {
const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment) const contactsResult = await window.electronAPI.chat.getContacts()
if (enrichResult.success && enrichResult.contacts) { if (isStale()) return
nextSessions = baseSessions.map((session) => {
const extra = enrichResult.contacts?.[session.username] const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : []
const nextContactMap = contacts.reduce<Record<string, ContactInfo>>((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<string, { displayName?: string; avatarUrl?: string }> = {}
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 { return {
...session, ...session,
displayName: extra?.displayName || session.displayName || session.username, kind: toKindByContactType(session, contact),
avatarUrl: extra?.avatarUrl || session.avatarUrl 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) { } catch (enrichError) {
console.error('导出页补充会话联系人信息失败:', enrichError) console.error('导出页补充会话联系人信息失败:', enrichError)
} finally {
if (!isStale()) setIsSessionEnriching(false)
} }
} })()
} else {
setSessions(nextSessions) setIsLoading(false)
} }
} catch (error) { } catch (error) {
console.error('加载会话失败:', error) console.error('加载会话失败:', error)
if (!isStale()) setIsLoading(false)
} finally { } finally {
setIsLoading(false) if (!isStale()) setIsLoading(false)
} }
}, []) }, [])
useEffect(() => { useEffect(() => {
loadBaseConfig() void loadBaseConfig()
loadSessions() void loadSessions()
loadSnsStats()
// 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。
const timer = window.setTimeout(() => {
void loadSnsStats()
}, 180)
return () => window.clearTimeout(timer)
}, [loadBaseConfig, loadSessions, loadSnsStats]) }, [loadBaseConfig, loadSessions, loadSnsStats])
useEffect(() => { useEffect(() => {
@@ -470,12 +516,12 @@ function ExportPage() {
const keyword = searchKeyword.trim().toLowerCase() const keyword = searchKeyword.trim().toLowerCase()
return sessions return sessions
.filter((session) => { .filter((session) => {
if (session.kind !== activeTab) return false if (session.kind !== activeTab) return false
if (!keyword) return true if (!keyword) return true
return ( return (
(session.displayName || '').toLowerCase().includes(keyword) || (session.displayName || '').toLowerCase().includes(keyword) ||
session.username.toLowerCase().includes(keyword) session.username.toLowerCase().includes(keyword)
) )
}) })
.sort((a, b) => { .sort((a, b) => {
const totalA = sessionMetrics[a.username]?.totalMessages ?? 0 const totalA = sessionMetrics[a.username]?.totalMessages ?? 0
@@ -1229,6 +1275,7 @@ function ExportPage() {
const formatCandidateOptions = exportDialog.scope === 'sns' const formatCandidateOptions = exportDialog.scope === 'sns'
? formatOptions.filter(option => option.value === 'html' || option.value === 'json') ? formatOptions.filter(option => option.value === 'html' || option.value === 'json')
: formatOptions : formatOptions
const showInitialSkeleton = isLoading && sessions.length === 0
return ( return (
<div className="export-board-page"> <div className="export-board-page">
@@ -1288,7 +1335,22 @@ function ExportPage() {
</div> </div>
<div className="content-card-grid"> <div className="content-card-grid">
{contentCards.map(card => { {showInitialSkeleton ? Array.from({ length: 6 }).map((_, index) => (
<div key={`skeleton-card-${index}`} className="content-card skeleton-card">
<div className="skeleton-shimmer skeleton-line w-60"></div>
<div className="card-stats">
<div className="stat-item">
<span className="skeleton-shimmer skeleton-line w-40"></span>
<strong className="skeleton-shimmer skeleton-line w-20"></strong>
</div>
<div className="stat-item">
<span className="skeleton-shimmer skeleton-line w-40"></span>
<strong className="skeleton-shimmer skeleton-line w-20"></strong>
</div>
</div>
<div className="skeleton-shimmer skeleton-line w-100 h-32"></div>
</div>
)) : contentCards.map(card => {
const Icon = card.icon const Icon = card.icon
return ( return (
<div key={card.type} className="content-card"> <div key={card.type} className="content-card">
@@ -1299,7 +1361,7 @@ function ExportPage() {
{card.stats.map((stat) => ( {card.stats.map((stat) => (
<div key={stat.label} className="stat-item"> <div key={stat.label} className="stat-item">
<span>{stat.label}</span> <span>{stat.label}</span>
<strong>{stat.value.toLocaleString()}</strong> <strong>{isSnsStatsLoading && card.type === 'sns' ? '--' : stat.value.toLocaleString()}</strong>
</div> </div>
))} ))}
</div> </div>
@@ -1411,14 +1473,32 @@ function ExportPage() {
</div> </div>
</div> </div>
{(isLoading || isSessionEnriching) && (
<div className="table-stage-hint">
<Loader2 size={14} className="spin" />
{isLoading ? '正在加载会话列表…' : '正在补充头像和统计…'}
</div>
)}
<div className="table-wrap"> <div className="table-wrap">
<table className="session-table"> <table className="session-table">
<thead>{renderTableHeader()}</thead> <thead>{renderTableHeader()}</thead>
<tbody> <tbody>
{isLoading ? ( {showInitialSkeleton ? (
<tr> <tr>
<td colSpan={tableColSpan}> <td colSpan={tableColSpan}>
<div className="table-state"><Loader2 size={16} className="spin" />...</div> <div className="table-skeleton-list">
{Array.from({ length: 8 }).map((_, rowIndex) => (
<div key={`skeleton-row-${rowIndex}`} className="table-skeleton-item">
<span className="skeleton-shimmer skeleton-dot"></span>
<span className="skeleton-shimmer skeleton-avatar"></span>
<span className="skeleton-shimmer skeleton-line w-30"></span>
<span className="skeleton-shimmer skeleton-line w-12"></span>
<span className="skeleton-shimmer skeleton-line w-12"></span>
<span className="skeleton-shimmer skeleton-line w-12"></span>
</div>
))}
</div>
</td> </td>
</tr> </tr>
) : visibleSessions.length === 0 ? ( ) : visibleSessions.length === 0 ? (