mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
perf(export): phase-load sessions and add strong skeleton states
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user