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);
}
}
&.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;

View File

@@ -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<SessionRow[]>([])
const [sessionMetrics, setSessionMetrics] = useState<Record<string, SessionMetrics>>({})
const [searchKeyword, setSearchKeyword] = useState('')
@@ -291,6 +293,7 @@ function ExportPage() {
const runningTaskIdRef = useRef<string | null>(null)
const tasksRef = useRef<ExportTask[]>([])
const sessionMetricsRef = useRef<Record<string, SessionMetrics>>({})
const sessionLoadTokenRef = useRef(0)
const loadingMetricsRef = useRef<Set<string>>(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,23 +377,53 @@ 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 sessionsResult = await window.electronAPI.chat.getSessions()
if (isStale()) return
if (sessionsResult.success && sessionsResult.sessions) {
const baseSessions = sessionsResult.sessions
.map((session) => {
return {
...session,
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))
if (isStale()) return
setSessions(baseSessions)
setIsLoading(false)
// 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。
setIsSessionEnriching(true)
void (async () => {
try {
const contactsResult = await window.electronAPI.chat.getContacts()
if (isStale()) return
const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : []
const nextContactMap = contacts.reduce<Record<string, ContactInfo>>((map, contact) => {
@@ -397,56 +431,68 @@ function ExportPage() {
return map
}, {})
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
} as SessionRow
})
const needsEnrichment = baseSessions
.filter(session => !session.avatarUrl || !session.displayName || session.displayName === session.username)
.map(session => session.username)
let nextSessions = baseSessions
let extraContactMap: Record<string, { displayName?: string; avatarUrl?: string }> = {}
if (needsEnrichment.length > 0) {
try {
const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment)
if (enrichResult.success && enrichResult.contacts) {
nextSessions = baseSessions.map((session) => {
const extra = enrichResult.contacts?.[session.username]
return {
...session,
displayName: extra?.displayName || session.displayName || session.username,
avatarUrl: extra?.avatarUrl || session.avatarUrl
}
})
}
} catch (enrichError) {
console.error('导出页补充会话联系人信息失败:', enrichError)
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,
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)
}
})()
} 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(() => {
@@ -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 (
<div className="export-board-page">
@@ -1288,7 +1335,22 @@ function ExportPage() {
</div>
<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
return (
<div key={card.type} className="content-card">
@@ -1299,7 +1361,7 @@ function ExportPage() {
{card.stats.map((stat) => (
<div key={stat.label} className="stat-item">
<span>{stat.label}</span>
<strong>{stat.value.toLocaleString()}</strong>
<strong>{isSnsStatsLoading && card.type === 'sns' ? '--' : stat.value.toLocaleString()}</strong>
</div>
))}
</div>
@@ -1411,14 +1473,32 @@ function ExportPage() {
</div>
</div>
{(isLoading || isSessionEnriching) && (
<div className="table-stage-hint">
<Loader2 size={14} className="spin" />
{isLoading ? '正在加载会话列表…' : '正在补充头像和统计…'}
</div>
)}
<div className="table-wrap">
<table className="session-table">
<thead>{renderTableHeader()}</thead>
<tbody>
{isLoading ? (
{showInitialSkeleton ? (
<tr>
<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>
</tr>
) : visibleSessions.length === 0 ? (