fix(export): 修复导出页文件消息数量显示,并优化加载体验
This commit is contained in:
1 parent
8753038c95
commit
f77a5ab6c0
5 files changed
+251
-30
No files matched your search
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
/** 缓存版本号。增加/修改 SessionStatsCacheStats 字段后必须提升,避免旧缓存被误用。 */
|
||||
const CACHE_VERSION = 4
|
||||
const CACHE_VERSION = 3
|
||||
const MAX_SESSION_ENTRIES_PER_SCOPE = 2000
|
||||
const MAX_SCOPE_ENTRIES = 12
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ function ExportPage() {
|
||||
} = useContactsLoader()
|
||||
|
||||
// ── 3. Metrics ──
|
||||
const { metricsMap, fetchMetrics } = useSessionMetrics()
|
||||
const { metricsMap, fetchMetrics, loadingRefs } = useSessionMetrics()
|
||||
|
||||
const {
|
||||
filteredSessions,
|
||||
@@ -212,6 +212,7 @@ function ExportPage() {
|
||||
sortConfig={sortConfig}
|
||||
onSortChange={setSortConfig}
|
||||
metricsMap={metricsMap}
|
||||
loadingRefs={loadingRefs}
|
||||
onSingleExport={handleSingleExport}
|
||||
onBatchExport={handleExportSelected}
|
||||
onAutomationExport={handleAutomationExportFromSelection}
|
||||
|
||||
@@ -288,3 +288,144 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Skeleton Loading (参考「我的足迹」的 skeleton-shimmer 风格) ─────────
|
||||
.st-skeleton-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0 24px;
|
||||
|
||||
.st-skeleton-header {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(200px, 1fr) 80px 120px 60px 60px 60px 60px 60px 80px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 44px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
|
||||
|
||||
.st-skeleton-cell {
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:nth-child(3),
|
||||
&:nth-child(4),
|
||||
&:nth-child(5),
|
||||
&:nth-child(6),
|
||||
&:nth-child(7),
|
||||
&:nth-child(8),
|
||||
&:nth-child(9) {
|
||||
margin-left: auto;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin: 0 auto;
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.st-skeleton-rows {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.st-skeleton-row {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(200px, 1fr) 80px 120px 60px 60px 60px 60px 60px 80px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 64px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 20%, transparent);
|
||||
padding: 0;
|
||||
|
||||
.st-skeleton-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.st-skeleton-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.st-skeleton-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.st-skeleton-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.st-skeleton-line {
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
width: 60%;
|
||||
|
||||
&.short {
|
||||
width: 40%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.st-skeleton-stat {
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
width: 60%;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.st-skeleton-shimmer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
color-mix(in srgb, var(--bg-primary) 50%, transparent),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
animation: stShimmer 1.5s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stShimmer {
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
// 行级统计 shimmer 占位与数字淡入
|
||||
.st-stat-shimmer {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 10px;
|
||||
border-radius: 4px;
|
||||
@extend .st-skeleton-shimmer;
|
||||
}
|
||||
|
||||
.st-stat-fade-in {
|
||||
animation: stStatFadeIn 0.2s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes stStatFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@@ -23,6 +23,7 @@ interface SessionTableProps {
|
||||
onAutomationExport?: () => void
|
||||
isLoading?: boolean
|
||||
metricsMap?: Record<string, SessionContentMetric>
|
||||
loadingRefs?: Set<string>
|
||||
}
|
||||
|
||||
const SessionTable: React.FC<SessionTableProps> = ({
|
||||
@@ -39,7 +40,8 @@ const SessionTable: React.FC<SessionTableProps> = ({
|
||||
onBatchExport,
|
||||
onAutomationExport,
|
||||
isLoading,
|
||||
metricsMap
|
||||
metricsMap,
|
||||
loadingRefs
|
||||
}) => {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
|
||||
@@ -160,9 +162,35 @@ const SessionTable: React.FC<SessionTableProps> = ({
|
||||
|
||||
{/* ─── Virtualized List ─────────────────────────────────────────── */}
|
||||
<div className="table-body">
|
||||
{sessions.length === 0 ? (
|
||||
{isLoading && sessions.length === 0 ? (
|
||||
// 初始加载骨架屏,风格与「我的足迹」保持一致
|
||||
<div className="st-skeleton-body" aria-busy="true" aria-live="polite">
|
||||
<div className="st-skeleton-header">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={i} className="st-skeleton-cell st-skeleton-shimmer" />
|
||||
))}
|
||||
</div>
|
||||
<div className="st-skeleton-rows">
|
||||
{Array.from({ length: 10 }).map((_, rowIndex) => (
|
||||
<div key={rowIndex} className="st-skeleton-row">
|
||||
<div className="st-skeleton-checkbox st-skeleton-shimmer" />
|
||||
<div className="st-skeleton-info">
|
||||
<div className="st-skeleton-avatar st-skeleton-shimmer" />
|
||||
<div className="st-skeleton-text">
|
||||
<div className="st-skeleton-line st-skeleton-shimmer" />
|
||||
<div className="st-skeleton-line short st-skeleton-shimmer" />
|
||||
</div>
|
||||
</div>
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<div key={i} className="st-skeleton-stat st-skeleton-shimmer" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
{isLoading ? '加载中...' : (searchQuery ? '没有匹配的会话' : '此分类下没有会话')}
|
||||
{searchQuery ? '没有匹配的会话' : '此分类下没有会话'}
|
||||
</div>
|
||||
) : (
|
||||
<Virtuoso
|
||||
@@ -170,8 +198,9 @@ const SessionTable: React.FC<SessionTableProps> = ({
|
||||
data={sessions}
|
||||
itemContent={(index, session) => {
|
||||
if (!session) return null
|
||||
|
||||
|
||||
const metrics = metricsMap?.[session.username]
|
||||
const isMetricsLoading = !metrics && loadingRefs?.has(session.username)
|
||||
const totalCount = metrics?.totalMessages ?? session.messageCountHint ?? 0
|
||||
|
||||
return (
|
||||
@@ -198,17 +227,53 @@ const SessionTable: React.FC<SessionTableProps> = ({
|
||||
<span className="st-id" title={session.username}>{session.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-count">{totalCount.toLocaleString()}</div>
|
||||
<div className="col-count">
|
||||
{isMetricsLoading ? (
|
||||
<span className="st-stat-shimmer" />
|
||||
) : (
|
||||
<span className="st-stat-fade-in">{totalCount.toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-time">
|
||||
{session.lastTimestamp ? formatLatestMessageTimeFromSeconds(session.lastTimestamp).text : '-'}
|
||||
</div>
|
||||
<div className="col-stat">{metrics?.emojiMessages ?? '-'}</div>
|
||||
<div className="col-stat">{metrics?.voiceMessages ?? '-'}</div>
|
||||
<div className="col-stat">{metrics?.imageMessages ?? '-'}</div>
|
||||
<div className="col-stat">{metrics?.videoMessages ?? '-'}</div>
|
||||
<div className="col-stat">{metrics?.fileMessages ?? '-'}</div>
|
||||
<div className="col-stat">
|
||||
{isMetricsLoading ? (
|
||||
<span className="st-stat-shimmer" />
|
||||
) : (
|
||||
<span className="st-stat-fade-in">{metrics?.emojiMessages ?? '-'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-stat">
|
||||
{isMetricsLoading ? (
|
||||
<span className="st-stat-shimmer" />
|
||||
) : (
|
||||
<span className="st-stat-fade-in">{metrics?.voiceMessages ?? '-'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-stat">
|
||||
{isMetricsLoading ? (
|
||||
<span className="st-stat-shimmer" />
|
||||
) : (
|
||||
<span className="st-stat-fade-in">{metrics?.imageMessages ?? '-'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-stat">
|
||||
{isMetricsLoading ? (
|
||||
<span className="st-stat-shimmer" />
|
||||
) : (
|
||||
<span className="st-stat-fade-in">{metrics?.videoMessages ?? '-'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-stat">
|
||||
{isMetricsLoading ? (
|
||||
<span className="st-stat-shimmer" />
|
||||
) : (
|
||||
<span className="st-stat-fade-in">{metrics?.fileMessages ?? '-'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-action">
|
||||
<button
|
||||
<button
|
||||
className="st-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -18,6 +18,16 @@ export interface SessionContentMetric {
|
||||
appMessages?: number
|
||||
}
|
||||
|
||||
const METRICS_CHUNK_SIZE = 40
|
||||
|
||||
function chunkArray<T>(arr: T[], size: number): T[][] {
|
||||
const chunks: T[][] = []
|
||||
for (let i = 0; i < arr.length; i += size) {
|
||||
chunks.push(arr.slice(i, i + size))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
interface SessionMetricsState {
|
||||
metricsMap: Record<string, SessionContentMetric>
|
||||
isLoading: boolean
|
||||
@@ -39,31 +49,35 @@ export const useSessionMetrics = create<SessionMetricsState>((set, get) => ({
|
||||
if (missingIds.length === 0) return
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
|
||||
const newLoadingRefs = new Set(loadingRefs)
|
||||
missingIds.forEach(id => newLoadingRefs.add(id))
|
||||
set({ loadingRefs: newLoadingRefs })
|
||||
|
||||
try {
|
||||
const stats = await window.electronAPI.chat.getExportSessionStats(missingIds, { includeRelations: false })
|
||||
|
||||
const newMetrics: Record<string, SessionContentMetric> = {}
|
||||
if (stats.success && stats.data) {
|
||||
for (const [sessionId, sessionStat] of Object.entries(stats.data)) {
|
||||
newMetrics[sessionId] = {
|
||||
totalMessages: sessionStat.totalMessages,
|
||||
voiceMessages: sessionStat.voiceMessages,
|
||||
imageMessages: sessionStat.imageMessages,
|
||||
videoMessages: sessionStat.videoMessages,
|
||||
emojiMessages: sessionStat.emojiMessages,
|
||||
fileMessages: sessionStat.fileMessages ?? 0 // 文件消息数量,由后端 ExportSessionStats 返回
|
||||
// 将会话分批顺序请求,每批返回后立即更新 UI,实现「扫出一个放一个」的渐进式效果。
|
||||
const chunks = chunkArray(missingIds, METRICS_CHUNK_SIZE)
|
||||
for (const chunk of chunks) {
|
||||
const stats = await window.electronAPI.chat.getExportSessionStats(chunk, { includeRelations: false })
|
||||
|
||||
const newMetrics: Record<string, SessionContentMetric> = {}
|
||||
if (stats.success && stats.data) {
|
||||
for (const [sessionId, sessionStat] of Object.entries(stats.data)) {
|
||||
newMetrics[sessionId] = {
|
||||
totalMessages: sessionStat.totalMessages,
|
||||
voiceMessages: sessionStat.voiceMessages,
|
||||
imageMessages: sessionStat.imageMessages,
|
||||
videoMessages: sessionStat.videoMessages,
|
||||
emojiMessages: sessionStat.emojiMessages,
|
||||
fileMessages: sessionStat.fileMessages ?? 0 // 文件消息数量,由后端 ExportSessionStats 返回
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set(state => ({
|
||||
metricsMap: { ...state.metricsMap, ...newMetrics }
|
||||
}))
|
||||
set(state => ({
|
||||
metricsMap: { ...state.metricsMap, ...newMetrics }
|
||||
}))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch session metrics:', err)
|
||||
set({ error: err instanceof Error ? err : new Error(String(err)) })
|
||||
|
||||
Reference in new issue
Block a user