导出统计速度优化;修复macos导出模块错误的问题
This commit is contained in:
1 parent
07eb9e25a4
commit
7804985ab8
17 files changed
+239
-25
No files matched your search
@@ -4773,6 +4773,7 @@ class ChatService {
|
||||
stats.imageMessages = Math.max(0, Math.floor(Number(data.image_messages || 0)))
|
||||
stats.videoMessages = Math.max(0, Math.floor(Number(data.video_messages || 0)))
|
||||
stats.emojiMessages = Math.max(0, Math.floor(Number(data.emoji_messages || 0)))
|
||||
const hasNativeFileMessages = Object.prototype.hasOwnProperty.call(data, 'file_messages')
|
||||
stats.fileMessages = Math.max(0, Math.floor(Number(data.file_messages || 0)))
|
||||
stats.callMessages = Math.max(0, Math.floor(Number(data.call_messages || 0)))
|
||||
stats.transferMessages = Math.max(0, Math.floor(Number(data.transfer_messages || 0)))
|
||||
@@ -4794,14 +4795,11 @@ class ChatService {
|
||||
// 保留 native 聚合结果作为兜底
|
||||
}
|
||||
} else {
|
||||
// Native 聚合可能不识别 25769803825 等文件消息变体,返回 0 但 key 存在。
|
||||
// 当 Native 返回的文件消息数为 0 时,通过轻量 cursor 扫描补充,避免漏统计。
|
||||
// 仅在 0 时兜底是为了兼顾性能:若 Native 已给出非 0 结果,通常已覆盖常见文件消息。
|
||||
const nativeFileMessages = Math.max(0, Math.floor(Number(data.file_messages || 0)))
|
||||
if (nativeFileMessages === 0) {
|
||||
// 新版 native 聚合会返回 file_messages;仅兼容旧 DLL 缺少该字段时再走 cursor 兜底。
|
||||
if (!hasNativeFileMessages) {
|
||||
try {
|
||||
const cursorFileMessages = await this.collectFileMessageCountByCursorScan(sessionId, beginTimestamp, endTimestamp)
|
||||
stats.fileMessages = Math.max(nativeFileMessages, cursorFileMessages)
|
||||
stats.fileMessages = Math.max(stats.fileMessages, cursorFileMessages)
|
||||
} catch {
|
||||
// 保留 native 聚合结果作为兜底
|
||||
}
|
||||
@@ -5082,6 +5080,7 @@ class ChatService {
|
||||
}
|
||||
|
||||
const nativeBatchStats: Record<string, ExportSessionStats> = {}
|
||||
const nativeBatchHasFileMessages: Record<string, boolean> = {}
|
||||
let hasNativeBatchStats = false
|
||||
if (!preferAccurateSpecialTypes) {
|
||||
try {
|
||||
@@ -5096,6 +5095,7 @@ class ChatService {
|
||||
for (const sessionId of normalizedSessionIds) {
|
||||
const row = nativeBatch.data?.[sessionId] as Record<string, any> | undefined
|
||||
if (!row || typeof row !== 'object') continue
|
||||
nativeBatchHasFileMessages[sessionId] = Object.prototype.hasOwnProperty.call(row, 'file_messages')
|
||||
nativeBatchStats[sessionId] = this.toExportSessionStatsFromNativeTypeRow(sessionId, row, {
|
||||
updateGroupHint: beginTimestamp <= 0 && endTimestamp <= 0
|
||||
})
|
||||
@@ -5121,9 +5121,8 @@ class ChatService {
|
||||
beginTimestamp,
|
||||
endTimestamp
|
||||
)
|
||||
// Native 批量统计可能不识别文件消息变体(如 25769803825),对 fileMessages 为 0 的会话补充 cursor 扫描。
|
||||
// 导出页同时加载多个会话时走批量路径,这里需要与单会话路径保持一致的兜底策略。
|
||||
if (fromNativeBatch && stats.fileMessages === 0) {
|
||||
// 新版 native 批量统计会返回 file_messages;仅兼容旧 DLL 缺少该字段时再走 cursor 兜底。
|
||||
if (fromNativeBatch && !nativeBatchHasFileMessages[sessionId]) {
|
||||
try {
|
||||
const cursorFileMessages = await this.collectFileMessageCountByCursorScan(sessionId, beginTimestamp, endTimestamp)
|
||||
stats.fileMessages = Math.max(stats.fileMessages, cursorFileMessages)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
/** 缓存版本号。增加/修改 SessionStatsCacheStats 字段后必须提升,避免旧缓存被误用。 */
|
||||
const CACHE_VERSION = 3
|
||||
const CACHE_VERSION = 4
|
||||
const MAX_SESSION_ENTRIES_PER_SCOPE = 2000
|
||||
const MAX_SCOPE_ENTRIES = 12
|
||||
|
||||
|
||||
+4
-1
@@ -52,7 +52,7 @@
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"electron": "^41.1.1",
|
||||
"electron": "^42.5.1",
|
||||
"electron-builder": "^26.8.1",
|
||||
"esbuild": "^0.28.1",
|
||||
"sass": "^1.98.0",
|
||||
@@ -95,6 +95,9 @@
|
||||
"category": "public.app-category.utilities",
|
||||
"hardenedRuntime": false,
|
||||
"gatekeeperAssess": false,
|
||||
"signIgnore": [
|
||||
".*\\/Contents\\/Resources\\/resources\\/welive\\/macos\\/.*\\/welive$"
|
||||
],
|
||||
"entitlements": "electron/entitlements.mac.plist",
|
||||
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||
"icon": "resources/icons/macos/icon.icns",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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