导出统计速度优化;修复macos导出模块错误的问题
This commit is contained in:
1 parent
07eb9e25a4
commit
7804985ab8
17 files changed
+239
-25
No files matched your search
@@ -103,6 +103,11 @@ function ExportPage() {
|
||||
}
|
||||
}, [displayedSessions, fetchMetrics])
|
||||
|
||||
const handleRefreshStats = useCallback(() => {
|
||||
if (sessions.length === 0 || loadingRefs.size > 0) return
|
||||
void fetchMetrics(sessions.map(s => s.username), { forceRefresh: true })
|
||||
}, [fetchMetrics, loadingRefs.size, sessions])
|
||||
|
||||
// ── 4. Manage Tasks ──
|
||||
const {
|
||||
tasks: exportTasks,
|
||||
@@ -287,6 +292,9 @@ function ExportPage() {
|
||||
onSortChange={setSortConfig}
|
||||
metricsMap={metricsMap}
|
||||
loadingRefs={loadingRefs}
|
||||
metricsLoadingCount={loadingRefs.size}
|
||||
onRefreshStats={handleRefreshStats}
|
||||
isRefreshingStats={loadingRefs.size > 0}
|
||||
onSingleExport={handleSingleExport}
|
||||
onBatchExport={handleExportSelected}
|
||||
onAutomationExport={handleAutomationExportFromSelection}
|
||||
|
||||
@@ -103,6 +103,42 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.st-refresh-stats-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 17px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 58%, transparent);
|
||||
background: var(--bg-main, var(--bg-secondary));
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
opacity 0.18s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--primary) 10%, var(--bg-surface));
|
||||
border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color));
|
||||
color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
&.refreshing svg {
|
||||
animation: stRefreshStatsSpin 0.9s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grid Layout Definition ───────────────────────────────────────────
|
||||
@@ -145,6 +181,7 @@
|
||||
// ── List Body ───────────────────────────────────────────────────────────
|
||||
.table-body {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden; // virtuoso handles scrolling
|
||||
|
||||
.empty-state {
|
||||
@@ -155,6 +192,66 @@
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.st-metrics-status {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 18px;
|
||||
z-index: 4;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
max-width: min(520px, calc(100% - 48px));
|
||||
min-height: 36px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 20%, var(--border-color));
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--primary) 10%, var(--bg-surface)),
|
||||
color-mix(in srgb, var(--bg-surface) 92%, var(--bg-main))
|
||||
);
|
||||
box-shadow:
|
||||
0 12px 30px color-mix(in srgb, var(--shadow-color) 18%, transparent),
|
||||
0 1px 0 color-mix(in srgb, #fff 55%, transparent) inset;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
animation: stMetricsStatusEnter 0.24s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
|
||||
&.leaving {
|
||||
animation: stMetricsStatusExit 0.24s ease both;
|
||||
}
|
||||
|
||||
.st-metrics-status-dot {
|
||||
position: relative;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -5px;
|
||||
border-radius: inherit;
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 36%, transparent);
|
||||
animation: stMetricsStatusPulse 1.2s ease-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.st-metrics-status-count {
|
||||
flex: 0 0 auto;
|
||||
padding-left: 2px;
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data Row ────────────────────────────────────────────────────────────
|
||||
@@ -429,3 +526,40 @@
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes stMetricsStatusEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(10px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stMetricsStatusExit {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(8px) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stMetricsStatusPulse {
|
||||
from {
|
||||
opacity: 0.65;
|
||||
transform: scale(0.65);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(1.6);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stRefreshStatsSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { memo, useRef, useCallback } from 'react'
|
||||
import React, { memo, useRef, useCallback, useEffect, useState } from 'react'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
import { Search, Hash, Clock, MoreVertical, X, CheckSquare, Square } from 'lucide-react'
|
||||
import { Search, X, CheckSquare, Square, RefreshCw } from 'lucide-react'
|
||||
import type { SessionRow, ConversationTab, ContactsSortConfig } from '../../types'
|
||||
import type { SessionContentMetric } from '../../hooks/useSessionMetrics'
|
||||
import { conversationTabLabels, exportKindPriority } from '../../constants'
|
||||
@@ -24,6 +24,9 @@ interface SessionTableProps {
|
||||
isLoading?: boolean
|
||||
metricsMap?: Record<string, SessionContentMetric>
|
||||
loadingRefs?: Set<string>
|
||||
metricsLoadingCount?: number
|
||||
onRefreshStats?: () => void
|
||||
isRefreshingStats?: boolean
|
||||
}
|
||||
|
||||
const SessionTable: React.FC<SessionTableProps> = ({
|
||||
@@ -41,9 +44,32 @@ const SessionTable: React.FC<SessionTableProps> = ({
|
||||
onAutomationExport,
|
||||
isLoading,
|
||||
metricsMap,
|
||||
loadingRefs
|
||||
loadingRefs,
|
||||
metricsLoadingCount = 0,
|
||||
onRefreshStats,
|
||||
isRefreshingStats = false
|
||||
}) => {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const [isStatsNoticeMounted, setIsStatsNoticeMounted] = useState(false)
|
||||
const [isStatsNoticeLeaving, setIsStatsNoticeLeaving] = useState(false)
|
||||
const isMetricsScanning = metricsLoadingCount > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (isMetricsScanning) {
|
||||
setIsStatsNoticeMounted(true)
|
||||
setIsStatsNoticeLeaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStatsNoticeMounted) return
|
||||
setIsStatsNoticeLeaving(true)
|
||||
const timer = window.setTimeout(() => {
|
||||
setIsStatsNoticeMounted(false)
|
||||
setIsStatsNoticeLeaving(false)
|
||||
}, 260)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [isMetricsScanning, isStatsNoticeMounted])
|
||||
|
||||
const handleSearchClear = () => onSearchChange('')
|
||||
|
||||
@@ -126,6 +152,19 @@ const SessionTable: React.FC<SessionTableProps> = ({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onRefreshStats && (
|
||||
<button
|
||||
type="button"
|
||||
className={`st-refresh-stats-btn ${isRefreshingStats ? 'refreshing' : ''}`}
|
||||
onClick={onRefreshStats}
|
||||
disabled={isRefreshingStats || isLoading || sessions.length === 0}
|
||||
title="重建导出统计缓存"
|
||||
aria-label="重建导出统计缓存"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Table Columns Header ─────────────────────────────────────── */}
|
||||
@@ -288,6 +327,17 @@ const SessionTable: React.FC<SessionTableProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isStatsNoticeMounted && (
|
||||
<div
|
||||
className={`st-metrics-status ${isStatsNoticeLeaving ? 'leaving' : ''}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="st-metrics-status-dot" />
|
||||
<span>正在统计会话消息,已完成的结果会逐行更新</span>
|
||||
<span className="st-metrics-status-count">{metricsLoadingCount.toLocaleString()} 项</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface SessionContentMetric {
|
||||
appMessages?: number
|
||||
}
|
||||
|
||||
const METRICS_CHUNK_SIZE = 40
|
||||
const METRICS_CHUNK_SIZE = 160
|
||||
|
||||
function chunkArray<T>(arr: T[], size: number): T[][] {
|
||||
const chunks: T[][] = []
|
||||
@@ -33,7 +33,7 @@ interface SessionMetricsState {
|
||||
isLoading: boolean
|
||||
error: Error | null
|
||||
loadingRefs: Set<string>
|
||||
fetchMetrics: (sessionIds: string[]) => Promise<void>
|
||||
fetchMetrics: (sessionIds: string[], options?: { forceRefresh?: boolean }) => Promise<void>
|
||||
}
|
||||
|
||||
export const useSessionMetrics = create<SessionMetricsState>((set, get) => ({
|
||||
@@ -41,24 +41,44 @@ export const useSessionMetrics = create<SessionMetricsState>((set, get) => ({
|
||||
isLoading: false,
|
||||
error: null,
|
||||
loadingRefs: new Set(),
|
||||
fetchMetrics: async (sessionIds: string[]) => {
|
||||
fetchMetrics: async (sessionIds: string[], options?: { forceRefresh?: boolean }) => {
|
||||
if (sessionIds.length === 0) return
|
||||
|
||||
const forceRefresh = options?.forceRefresh === true
|
||||
const { metricsMap, loadingRefs } = get()
|
||||
const missingIds = sessionIds.filter(id => !metricsMap[id] && !loadingRefs.has(id))
|
||||
if (missingIds.length === 0) return
|
||||
const normalizedIds = Array.from(new Set(sessionIds.map(id => String(id || '').trim()).filter(Boolean)))
|
||||
const targetIds = forceRefresh
|
||||
? normalizedIds.filter(id => !loadingRefs.has(id))
|
||||
: normalizedIds.filter(id => !metricsMap[id] && !loadingRefs.has(id))
|
||||
if (targetIds.length === 0) return
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
const newLoadingRefs = new Set(loadingRefs)
|
||||
missingIds.forEach(id => newLoadingRefs.add(id))
|
||||
set({ loadingRefs: newLoadingRefs })
|
||||
targetIds.forEach(id => newLoadingRefs.add(id))
|
||||
set(state => {
|
||||
if (!forceRefresh) {
|
||||
return { loadingRefs: newLoadingRefs }
|
||||
}
|
||||
|
||||
const nextMetricsMap = { ...state.metricsMap }
|
||||
targetIds.forEach(id => {
|
||||
delete nextMetricsMap[id]
|
||||
})
|
||||
return {
|
||||
loadingRefs: newLoadingRefs,
|
||||
metricsMap: nextMetricsMap
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
// 将会话分批顺序请求,每批返回后立即更新 UI,实现「扫出一个放一个」的渐进式效果。
|
||||
const chunks = chunkArray(missingIds, METRICS_CHUNK_SIZE)
|
||||
const chunks = chunkArray(targetIds, METRICS_CHUNK_SIZE)
|
||||
for (const chunk of chunks) {
|
||||
const stats = await window.electronAPI.chat.getExportSessionStats(chunk, { includeRelations: false })
|
||||
const stats = await window.electronAPI.chat.getExportSessionStats(chunk, {
|
||||
includeRelations: false,
|
||||
forceRefresh
|
||||
})
|
||||
|
||||
const newMetrics: Record<string, SessionContentMetric> = {}
|
||||
if (stats.success && stats.data) {
|
||||
@@ -84,8 +104,8 @@ export const useSessionMetrics = create<SessionMetricsState>((set, get) => ({
|
||||
} finally {
|
||||
set(state => {
|
||||
const nextLoadingRefs = new Set(state.loadingRefs)
|
||||
missingIds.forEach(id => nextLoadingRefs.delete(id))
|
||||
return { loadingRefs: nextLoadingRefs, isLoading: false }
|
||||
targetIds.forEach(id => nextLoadingRefs.delete(id))
|
||||
return { loadingRefs: nextLoadingRefs, isLoading: nextLoadingRefs.size > 0 }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user