导出统计速度优化;修复macos导出模块错误的问题

This commit is contained in:
cc committed 2026-07-02 14:58:25 +08:00
1 parent 07eb9e25a4
commit 7804985ab8
17 files changed
+239 -25

No files matched your search

+8 -9
View File
@@ -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
View File
@@ -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.
+8
View File
@@ -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>
)
+31 -11
View File
@@ -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 }
})
}
}