mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat(export): add 4 media columns with visible-first staged loading
This commit is contained in:
@@ -995,6 +995,7 @@
|
|||||||
--contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows));
|
--contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows));
|
||||||
--contacts-select-col-width: 34px;
|
--contacts-select-col-width: 34px;
|
||||||
--contacts-message-col-width: 120px;
|
--contacts-message-col-width: 120px;
|
||||||
|
--contacts-media-col-width: 72px;
|
||||||
--contacts-action-col-width: 280px;
|
--contacts-action-col-width: 280px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -1167,6 +1168,16 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contacts-list-header-media {
|
||||||
|
width: var(--contacts-media-col-width);
|
||||||
|
min-width: var(--contacts-media-col-width);
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.contacts-list-header-actions {
|
.contacts-list-header-actions {
|
||||||
width: var(--contacts-action-col-width);
|
width: var(--contacts-action-col-width);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1355,6 +1366,28 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row-media-metric {
|
||||||
|
width: var(--contacts-media-col-width);
|
||||||
|
min-width: var(--contacts-media-col-width);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-media-metric-value {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.row-message-stats {
|
.row-message-stats {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2661,6 +2694,7 @@
|
|||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
--contacts-message-col-width: 104px;
|
--contacts-message-col-width: 104px;
|
||||||
|
--contacts-media-col-width: 62px;
|
||||||
--contacts-action-col-width: 236px;
|
--contacts-action-col-width: 236px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2687,6 +2721,10 @@
|
|||||||
min-width: var(--contacts-message-col-width);
|
min-width: var(--contacts-message-col-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-wrap .row-media-metric {
|
||||||
|
min-width: var(--contacts-media-col-width);
|
||||||
|
}
|
||||||
|
|
||||||
.table-wrap .row-message-stats {
|
.table-wrap .row-message-stats {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
@@ -2699,6 +2737,10 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-wrap .row-media-metric-value {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.table-wrap .row-message-stat.total .row-message-count-value {
|
.table-wrap .row-message-stat.total .row-message-count-value {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,11 @@ interface TimeRangeDialogDraft {
|
|||||||
|
|
||||||
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||||
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
|
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
|
||||||
|
const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10
|
||||||
|
const SESSION_MEDIA_METRIC_BATCH_SIZE = 12
|
||||||
|
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48
|
||||||
|
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS = 120
|
||||||
|
const SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS = 1200
|
||||||
const contentTypeLabels: Record<ContentType, string> = {
|
const contentTypeLabels: Record<ContentType, string> = {
|
||||||
text: '聊天文本',
|
text: '聊天文本',
|
||||||
voice: '语音',
|
voice: '语音',
|
||||||
@@ -870,6 +875,40 @@ const normalizeMessageCount = (value: unknown): number | undefined => {
|
|||||||
return Math.floor(parsed)
|
return Math.floor(parsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pickSessionMediaMetric = (
|
||||||
|
metricRaw: SessionExportMetric | SessionContentMetric | undefined
|
||||||
|
): SessionContentMetric | null => {
|
||||||
|
if (!metricRaw) return null
|
||||||
|
const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages)
|
||||||
|
const imageMessages = normalizeMessageCount(metricRaw.imageMessages)
|
||||||
|
const videoMessages = normalizeMessageCount(metricRaw.videoMessages)
|
||||||
|
const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages)
|
||||||
|
if (
|
||||||
|
typeof voiceMessages !== 'number' &&
|
||||||
|
typeof imageMessages !== 'number' &&
|
||||||
|
typeof videoMessages !== 'number' &&
|
||||||
|
typeof emojiMessages !== 'number'
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
voiceMessages,
|
||||||
|
imageMessages,
|
||||||
|
videoMessages,
|
||||||
|
emojiMessages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCompleteSessionMediaMetric = (metricRaw: SessionContentMetric | undefined): boolean => {
|
||||||
|
if (!metricRaw) return false
|
||||||
|
return (
|
||||||
|
typeof normalizeMessageCount(metricRaw.voiceMessages) === 'number' &&
|
||||||
|
typeof normalizeMessageCount(metricRaw.imageMessages) === 'number' &&
|
||||||
|
typeof normalizeMessageCount(metricRaw.videoMessages) === 'number' &&
|
||||||
|
typeof normalizeMessageCount(metricRaw.emojiMessages) === 'number'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const WriteLayoutSelector = memo(function WriteLayoutSelector({
|
const WriteLayoutSelector = memo(function WriteLayoutSelector({
|
||||||
writeLayout,
|
writeLayout,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -1209,6 +1248,7 @@ function ExportPage() {
|
|||||||
const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState<number | null>(null)
|
const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState<number | null>(null)
|
||||||
const [sessionMessageCounts, setSessionMessageCounts] = useState<Record<string, number>>({})
|
const [sessionMessageCounts, setSessionMessageCounts] = useState<Record<string, number>>({})
|
||||||
const [isLoadingSessionCounts, setIsLoadingSessionCounts] = useState(false)
|
const [isLoadingSessionCounts, setIsLoadingSessionCounts] = useState(false)
|
||||||
|
const [isSessionCountStageReady, setIsSessionCountStageReady] = useState(false)
|
||||||
const [sessionContentMetrics, setSessionContentMetrics] = useState<Record<string, SessionContentMetric>>({})
|
const [sessionContentMetrics, setSessionContentMetrics] = useState<Record<string, SessionContentMetric>>({})
|
||||||
const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
|
const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
|
||||||
const [contactsLoadSession, setContactsLoadSession] = useState<ContactsLoadSession | null>(null)
|
const [contactsLoadSession, setContactsLoadSession] = useState<ContactsLoadSession | null>(null)
|
||||||
@@ -1299,6 +1339,7 @@ function ExportPage() {
|
|||||||
const sessionTableSectionRef = useRef<HTMLDivElement | null>(null)
|
const sessionTableSectionRef = useRef<HTMLDivElement | null>(null)
|
||||||
const detailRequestSeqRef = useRef(0)
|
const detailRequestSeqRef = useRef(0)
|
||||||
const sessionsRef = useRef<SessionRow[]>([])
|
const sessionsRef = useRef<SessionRow[]>([])
|
||||||
|
const sessionContentMetricsRef = useRef<Record<string, SessionContentMetric>>({})
|
||||||
const contactsListSizeRef = useRef(0)
|
const contactsListSizeRef = useRef(0)
|
||||||
const contactsUpdatedAtRef = useRef<number | null>(null)
|
const contactsUpdatedAtRef = useRef<number | null>(null)
|
||||||
const sessionsHydratedAtRef = useRef(0)
|
const sessionsHydratedAtRef = useRef(0)
|
||||||
@@ -1307,9 +1348,23 @@ function ExportPage() {
|
|||||||
const activeTaskCountRef = useRef(0)
|
const activeTaskCountRef = useRef(0)
|
||||||
const hasBaseConfigReadyRef = useRef(false)
|
const hasBaseConfigReadyRef = useRef(false)
|
||||||
const sessionCountRequestIdRef = useRef(0)
|
const sessionCountRequestIdRef = useRef(0)
|
||||||
|
const isLoadingSessionCountsRef = useRef(false)
|
||||||
const activeTabRef = useRef<ConversationTab>('private')
|
const activeTabRef = useRef<ConversationTab>('private')
|
||||||
const detailStatsPriorityRef = useRef(false)
|
const detailStatsPriorityRef = useRef(false)
|
||||||
const sessionPreciseRefreshAtRef = useRef<Record<string, number>>({})
|
const sessionPreciseRefreshAtRef = useRef<Record<string, number>>({})
|
||||||
|
const sessionMediaMetricQueueRef = useRef<string[]>([])
|
||||||
|
const sessionMediaMetricQueuedSetRef = useRef<Set<string>>(new Set())
|
||||||
|
const sessionMediaMetricLoadingSetRef = useRef<Set<string>>(new Set())
|
||||||
|
const sessionMediaMetricReadySetRef = useRef<Set<string>>(new Set())
|
||||||
|
const sessionMediaMetricRunIdRef = useRef(0)
|
||||||
|
const sessionMediaMetricWorkerRunningRef = useRef(false)
|
||||||
|
const sessionMediaMetricBackgroundFeedTimerRef = useRef<number | null>(null)
|
||||||
|
const sessionMediaMetricPersistTimerRef = useRef<number | null>(null)
|
||||||
|
const sessionMediaMetricPendingPersistRef = useRef<Record<string, configService.ExportSessionContentMetricCacheEntry>>({})
|
||||||
|
const sessionMediaMetricVisibleRangeRef = useRef<{ startIndex: number; endIndex: number }>({
|
||||||
|
startIndex: 0,
|
||||||
|
endIndex: -1
|
||||||
|
})
|
||||||
|
|
||||||
const ensureExportCacheScope = useCallback(async (): Promise<string> => {
|
const ensureExportCacheScope = useCallback(async (): Promise<string> => {
|
||||||
if (exportCacheScopeReadyRef.current) {
|
if (exportCacheScopeReadyRef.current) {
|
||||||
@@ -1359,6 +1414,14 @@ function ExportPage() {
|
|||||||
contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs
|
contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs
|
||||||
}, [contactsLoadTimeoutMs])
|
}, [contactsLoadTimeoutMs])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isLoadingSessionCountsRef.current = isLoadingSessionCounts
|
||||||
|
}, [isLoadingSessionCounts])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionContentMetricsRef.current = sessionContentMetrics
|
||||||
|
}, [sessionContentMetrics])
|
||||||
|
|
||||||
const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => {
|
const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => {
|
||||||
const scopeKey = options?.scopeKey || await ensureExportCacheScope()
|
const scopeKey = options?.scopeKey || await ensureExportCacheScope()
|
||||||
const loadVersion = contactsLoadVersionRef.current + 1
|
const loadVersion = contactsLoadVersionRef.current + 1
|
||||||
@@ -1821,6 +1884,184 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const resetSessionMediaMetricLoader = useCallback(() => {
|
||||||
|
sessionMediaMetricRunIdRef.current += 1
|
||||||
|
sessionMediaMetricQueueRef.current = []
|
||||||
|
sessionMediaMetricQueuedSetRef.current.clear()
|
||||||
|
sessionMediaMetricLoadingSetRef.current.clear()
|
||||||
|
sessionMediaMetricReadySetRef.current.clear()
|
||||||
|
sessionMediaMetricWorkerRunningRef.current = false
|
||||||
|
sessionMediaMetricPendingPersistRef.current = {}
|
||||||
|
sessionMediaMetricVisibleRangeRef.current = { startIndex: 0, endIndex: -1 }
|
||||||
|
if (sessionMediaMetricBackgroundFeedTimerRef.current) {
|
||||||
|
window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current)
|
||||||
|
sessionMediaMetricBackgroundFeedTimerRef.current = null
|
||||||
|
}
|
||||||
|
if (sessionMediaMetricPersistTimerRef.current) {
|
||||||
|
window.clearTimeout(sessionMediaMetricPersistTimerRef.current)
|
||||||
|
sessionMediaMetricPersistTimerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const flushSessionMediaMetricCache = useCallback(async () => {
|
||||||
|
const pendingMetrics = sessionMediaMetricPendingPersistRef.current
|
||||||
|
sessionMediaMetricPendingPersistRef.current = {}
|
||||||
|
if (Object.keys(pendingMetrics).length === 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scopeKey = await ensureExportCacheScope()
|
||||||
|
const existing = await configService.getExportSessionContentMetricCache(scopeKey)
|
||||||
|
const nextMetrics = {
|
||||||
|
...(existing?.metrics || {}),
|
||||||
|
...pendingMetrics
|
||||||
|
}
|
||||||
|
await configService.setExportSessionContentMetricCache(scopeKey, nextMetrics)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('写入导出页会话内容统计缓存失败:', error)
|
||||||
|
}
|
||||||
|
}, [ensureExportCacheScope])
|
||||||
|
|
||||||
|
const scheduleFlushSessionMediaMetricCache = useCallback(() => {
|
||||||
|
if (sessionMediaMetricPersistTimerRef.current) return
|
||||||
|
sessionMediaMetricPersistTimerRef.current = window.setTimeout(() => {
|
||||||
|
sessionMediaMetricPersistTimerRef.current = null
|
||||||
|
void flushSessionMediaMetricCache()
|
||||||
|
}, SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS)
|
||||||
|
}, [flushSessionMediaMetricCache])
|
||||||
|
|
||||||
|
const isSessionMediaMetricReady = useCallback((sessionId: string): boolean => {
|
||||||
|
if (!sessionId) return true
|
||||||
|
if (sessionMediaMetricReadySetRef.current.has(sessionId)) return true
|
||||||
|
const existing = sessionContentMetricsRef.current[sessionId]
|
||||||
|
if (hasCompleteSessionMediaMetric(existing)) {
|
||||||
|
sessionMediaMetricReadySetRef.current.add(sessionId)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const enqueueSessionMediaMetricRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => {
|
||||||
|
const front = options?.front === true
|
||||||
|
const incoming: string[] = []
|
||||||
|
for (const sessionIdRaw of sessionIds) {
|
||||||
|
const sessionId = String(sessionIdRaw || '').trim()
|
||||||
|
if (!sessionId) continue
|
||||||
|
if (sessionMediaMetricQueuedSetRef.current.has(sessionId)) continue
|
||||||
|
if (sessionMediaMetricLoadingSetRef.current.has(sessionId)) continue
|
||||||
|
if (isSessionMediaMetricReady(sessionId)) continue
|
||||||
|
sessionMediaMetricQueuedSetRef.current.add(sessionId)
|
||||||
|
incoming.push(sessionId)
|
||||||
|
}
|
||||||
|
if (incoming.length === 0) return
|
||||||
|
if (front) {
|
||||||
|
sessionMediaMetricQueueRef.current = [...incoming, ...sessionMediaMetricQueueRef.current]
|
||||||
|
} else {
|
||||||
|
sessionMediaMetricQueueRef.current.push(...incoming)
|
||||||
|
}
|
||||||
|
}, [isSessionMediaMetricReady])
|
||||||
|
|
||||||
|
const applySessionMediaMetricsFromStats = useCallback((data?: Record<string, SessionExportMetric>) => {
|
||||||
|
if (!data) return
|
||||||
|
const nextMetrics: Record<string, SessionContentMetric> = {}
|
||||||
|
let hasPatch = false
|
||||||
|
for (const [sessionIdRaw, metricRaw] of Object.entries(data)) {
|
||||||
|
const sessionId = String(sessionIdRaw || '').trim()
|
||||||
|
if (!sessionId) continue
|
||||||
|
const metric = pickSessionMediaMetric(metricRaw)
|
||||||
|
if (!metric) continue
|
||||||
|
nextMetrics[sessionId] = metric
|
||||||
|
hasPatch = true
|
||||||
|
sessionMediaMetricPendingPersistRef.current[sessionId] = {
|
||||||
|
...sessionMediaMetricPendingPersistRef.current[sessionId],
|
||||||
|
...metric
|
||||||
|
}
|
||||||
|
if (hasCompleteSessionMediaMetric(metric)) {
|
||||||
|
sessionMediaMetricReadySetRef.current.add(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPatch) {
|
||||||
|
mergeSessionContentMetrics(nextMetrics)
|
||||||
|
scheduleFlushSessionMediaMetricCache()
|
||||||
|
}
|
||||||
|
}, [mergeSessionContentMetrics, scheduleFlushSessionMediaMetricCache])
|
||||||
|
|
||||||
|
const runSessionMediaMetricWorker = useCallback(async (runId: number) => {
|
||||||
|
if (sessionMediaMetricWorkerRunningRef.current) return
|
||||||
|
sessionMediaMetricWorkerRunningRef.current = true
|
||||||
|
try {
|
||||||
|
while (runId === sessionMediaMetricRunIdRef.current) {
|
||||||
|
if (isLoadingSessionCountsRef.current || detailStatsPriorityRef.current) {
|
||||||
|
await new Promise(resolve => window.setTimeout(resolve, 80))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionMediaMetricQueueRef.current.length === 0) break
|
||||||
|
|
||||||
|
const batchSessionIds: string[] = []
|
||||||
|
while (batchSessionIds.length < SESSION_MEDIA_METRIC_BATCH_SIZE && sessionMediaMetricQueueRef.current.length > 0) {
|
||||||
|
const nextId = sessionMediaMetricQueueRef.current.shift()
|
||||||
|
if (!nextId) continue
|
||||||
|
sessionMediaMetricQueuedSetRef.current.delete(nextId)
|
||||||
|
if (sessionMediaMetricLoadingSetRef.current.has(nextId)) continue
|
||||||
|
if (isSessionMediaMetricReady(nextId)) continue
|
||||||
|
sessionMediaMetricLoadingSetRef.current.add(nextId)
|
||||||
|
batchSessionIds.push(nextId)
|
||||||
|
}
|
||||||
|
if (batchSessionIds.length === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cacheResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
|
batchSessionIds,
|
||||||
|
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
|
||||||
|
)
|
||||||
|
if (runId !== sessionMediaMetricRunIdRef.current) return
|
||||||
|
if (cacheResult.success && cacheResult.data) {
|
||||||
|
applySessionMediaMetricsFromStats(cacheResult.data as Record<string, SessionExportMetric>)
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId))
|
||||||
|
if (missingSessionIds.length > 0) {
|
||||||
|
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
|
missingSessionIds,
|
||||||
|
{ includeRelations: false, allowStaleCache: true }
|
||||||
|
)
|
||||||
|
if (runId !== sessionMediaMetricRunIdRef.current) return
|
||||||
|
if (freshResult.success && freshResult.data) {
|
||||||
|
applySessionMediaMetricsFromStats(freshResult.data as Record<string, SessionExportMetric>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出页加载会话媒体统计失败:', error)
|
||||||
|
} finally {
|
||||||
|
for (const sessionId of batchSessionIds) {
|
||||||
|
sessionMediaMetricLoadingSetRef.current.delete(sessionId)
|
||||||
|
if (isSessionMediaMetricReady(sessionId)) {
|
||||||
|
sessionMediaMetricReadySetRef.current.add(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => window.setTimeout(resolve, 0))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
sessionMediaMetricWorkerRunningRef.current = false
|
||||||
|
if (runId === sessionMediaMetricRunIdRef.current && sessionMediaMetricQueueRef.current.length > 0) {
|
||||||
|
void runSessionMediaMetricWorker(runId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady])
|
||||||
|
|
||||||
|
const scheduleSessionMediaMetricWorker = useCallback(() => {
|
||||||
|
if (!isSessionCountStageReady) return
|
||||||
|
if (isLoadingSessionCountsRef.current) return
|
||||||
|
if (sessionMediaMetricWorkerRunningRef.current) return
|
||||||
|
const runId = sessionMediaMetricRunIdRef.current
|
||||||
|
void runSessionMediaMetricWorker(runId)
|
||||||
|
}, [isSessionCountStageReady, runSessionMediaMetricWorker])
|
||||||
|
|
||||||
const loadSessionMessageCounts = useCallback(async (
|
const loadSessionMessageCounts = useCallback(async (
|
||||||
sourceSessions: SessionRow[],
|
sourceSessions: SessionRow[],
|
||||||
priorityTab: ConversationTab,
|
priorityTab: ConversationTab,
|
||||||
@@ -1832,6 +2073,7 @@ function ExportPage() {
|
|||||||
const requestId = sessionCountRequestIdRef.current + 1
|
const requestId = sessionCountRequestIdRef.current + 1
|
||||||
sessionCountRequestIdRef.current = requestId
|
sessionCountRequestIdRef.current = requestId
|
||||||
const isStale = () => sessionCountRequestIdRef.current !== requestId
|
const isStale = () => sessionCountRequestIdRef.current !== requestId
|
||||||
|
setIsSessionCountStageReady(false)
|
||||||
|
|
||||||
const exportableSessions = sourceSessions.filter(session => session.hasSession)
|
const exportableSessions = sourceSessions.filter(session => session.hasSession)
|
||||||
const seededHintCounts = exportableSessions.reduce<Record<string, number>>((acc, session) => {
|
const seededHintCounts = exportableSessions.reduce<Record<string, number>>((acc, session) => {
|
||||||
@@ -1862,6 +2104,9 @@ function ExportPage() {
|
|||||||
|
|
||||||
if (exportableSessions.length === 0) {
|
if (exportableSessions.length === 0) {
|
||||||
setIsLoadingSessionCounts(false)
|
setIsLoadingSessionCounts(false)
|
||||||
|
if (!isStale()) {
|
||||||
|
setIsSessionCountStageReady(true)
|
||||||
|
}
|
||||||
return { ...accumulatedCounts }
|
return { ...accumulatedCounts }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1923,6 +2168,7 @@ function ExportPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
if (!isStale()) {
|
if (!isStale()) {
|
||||||
setIsLoadingSessionCounts(false)
|
setIsLoadingSessionCounts(false)
|
||||||
|
setIsSessionCountStageReady(true)
|
||||||
if (options?.scopeKey && Object.keys(accumulatedCounts).length > 0) {
|
if (options?.scopeKey && Object.keys(accumulatedCounts).length > 0) {
|
||||||
try {
|
try {
|
||||||
await configService.setExportSessionMessageCountCache(options.scopeKey, accumulatedCounts)
|
await configService.setExportSessionMessageCountCache(options.scopeKey, accumulatedCounts)
|
||||||
@@ -1940,12 +2186,14 @@ function ExportPage() {
|
|||||||
sessionLoadTokenRef.current = loadToken
|
sessionLoadTokenRef.current = loadToken
|
||||||
sessionsHydratedAtRef.current = 0
|
sessionsHydratedAtRef.current = 0
|
||||||
sessionPreciseRefreshAtRef.current = {}
|
sessionPreciseRefreshAtRef.current = {}
|
||||||
|
resetSessionMediaMetricLoader()
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setIsSessionEnriching(false)
|
setIsSessionEnriching(false)
|
||||||
sessionCountRequestIdRef.current += 1
|
sessionCountRequestIdRef.current += 1
|
||||||
setSessionMessageCounts({})
|
setSessionMessageCounts({})
|
||||||
setSessionContentMetrics({})
|
setSessionContentMetrics({})
|
||||||
setIsLoadingSessionCounts(false)
|
setIsLoadingSessionCounts(false)
|
||||||
|
setIsSessionCountStageReady(false)
|
||||||
|
|
||||||
const isStale = () => sessionLoadTokenRef.current !== loadToken
|
const isStale = () => sessionLoadTokenRef.current !== loadToken
|
||||||
|
|
||||||
@@ -1955,10 +2203,12 @@ function ExportPage() {
|
|||||||
|
|
||||||
const [
|
const [
|
||||||
cachedContactsPayload,
|
cachedContactsPayload,
|
||||||
cachedMessageCountsPayload
|
cachedMessageCountsPayload,
|
||||||
|
cachedContentMetricsPayload
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
loadContactsCaches(scopeKey),
|
loadContactsCaches(scopeKey),
|
||||||
configService.getExportSessionMessageCountCache(scopeKey)
|
configService.getExportSessionMessageCountCache(scopeKey),
|
||||||
|
configService.getExportSessionContentMetricCache(scopeKey)
|
||||||
])
|
])
|
||||||
if (isStale()) return
|
if (isStale()) return
|
||||||
|
|
||||||
@@ -2010,6 +2260,16 @@ function ExportPage() {
|
|||||||
acc[sessionId] = { totalMessages: count }
|
acc[sessionId] = { totalMessages: count }
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
|
const cachedContentMetrics = Object.entries(cachedContentMetricsPayload?.metrics || {}).reduce<Record<string, SessionContentMetric>>((acc, [sessionId, rawMetric]) => {
|
||||||
|
if (!exportableSessionIdSet.has(sessionId)) return acc
|
||||||
|
const metric = pickSessionMediaMetric(rawMetric)
|
||||||
|
if (!metric) return acc
|
||||||
|
acc[sessionId] = metric
|
||||||
|
if (hasCompleteSessionMediaMetric(metric)) {
|
||||||
|
sessionMediaMetricReadySetRef.current.add(sessionId)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
if (isStale()) return
|
if (isStale()) return
|
||||||
if (Object.keys(cachedMessageCounts).length > 0) {
|
if (Object.keys(cachedMessageCounts).length > 0) {
|
||||||
@@ -2018,6 +2278,9 @@ function ExportPage() {
|
|||||||
if (Object.keys(cachedCountAsMetrics).length > 0) {
|
if (Object.keys(cachedCountAsMetrics).length > 0) {
|
||||||
mergeSessionContentMetrics(cachedCountAsMetrics)
|
mergeSessionContentMetrics(cachedCountAsMetrics)
|
||||||
}
|
}
|
||||||
|
if (Object.keys(cachedContentMetrics).length > 0) {
|
||||||
|
mergeSessionContentMetrics(cachedContentMetrics)
|
||||||
|
}
|
||||||
setSessions(baseSessions)
|
setSessions(baseSessions)
|
||||||
sessionsHydratedAtRef.current = Date.now()
|
sessionsHydratedAtRef.current = Date.now()
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -2218,7 +2481,7 @@ function ExportPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
if (!isStale()) setIsLoading(false)
|
if (!isStale()) setIsLoading(false)
|
||||||
}
|
}
|
||||||
}, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, syncContactTypeCounts])
|
}, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, resetSessionMediaMetricLoader, syncContactTypeCounts])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExportRoute) return
|
if (!isExportRoute) return
|
||||||
@@ -3354,6 +3617,107 @@ function ExportPage() {
|
|||||||
setIsContactsListAtTop(true)
|
setIsContactsListAtTop(true)
|
||||||
}, [activeTab, searchKeyword])
|
}, [activeTab, searchKeyword])
|
||||||
|
|
||||||
|
const collectVisibleSessionMetricTargets = useCallback((sourceContacts: ContactInfo[]): string[] => {
|
||||||
|
if (sourceContacts.length === 0) return []
|
||||||
|
const startCandidate = sessionMediaMetricVisibleRangeRef.current.startIndex
|
||||||
|
const endCandidate = sessionMediaMetricVisibleRangeRef.current.endIndex
|
||||||
|
const startIndex = Math.max(0, Math.min(sourceContacts.length - 1, startCandidate >= 0 ? startCandidate : 0))
|
||||||
|
const visibleEnd = endCandidate >= startIndex
|
||||||
|
? endCandidate
|
||||||
|
: Math.min(sourceContacts.length - 1, startIndex + 9)
|
||||||
|
const endIndex = Math.max(startIndex, Math.min(sourceContacts.length - 1, visibleEnd + SESSION_MEDIA_METRIC_PREFETCH_ROWS))
|
||||||
|
const sessionIds: string[] = []
|
||||||
|
for (let index = startIndex; index <= endIndex; index += 1) {
|
||||||
|
const contact = sourceContacts[index]
|
||||||
|
if (!contact?.username) continue
|
||||||
|
const mappedSession = sessionRowByUsername.get(contact.username)
|
||||||
|
if (!mappedSession?.hasSession) continue
|
||||||
|
sessionIds.push(contact.username)
|
||||||
|
}
|
||||||
|
return sessionIds
|
||||||
|
}, [sessionRowByUsername])
|
||||||
|
|
||||||
|
const handleContactsRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => {
|
||||||
|
const startIndex = Number.isFinite(range?.startIndex) ? Math.max(0, Math.floor(range.startIndex)) : 0
|
||||||
|
const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex
|
||||||
|
sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex }
|
||||||
|
if (isLoadingSessionCountsRef.current || !isSessionCountStageReady) return
|
||||||
|
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
|
||||||
|
if (visibleTargets.length === 0) return
|
||||||
|
enqueueSessionMediaMetricRequests(visibleTargets, { front: true })
|
||||||
|
scheduleSessionMediaMetricWorker()
|
||||||
|
}, [collectVisibleSessionMetricTargets, enqueueSessionMediaMetricRequests, filteredContacts, isSessionCountStageReady, scheduleSessionMediaMetricWorker])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSessionCountStageReady || filteredContacts.length === 0) return
|
||||||
|
const runId = sessionMediaMetricRunIdRef.current
|
||||||
|
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
|
||||||
|
if (visibleTargets.length > 0) {
|
||||||
|
enqueueSessionMediaMetricRequests(visibleTargets, { front: true })
|
||||||
|
scheduleSessionMediaMetricWorker()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionMediaMetricBackgroundFeedTimerRef.current) {
|
||||||
|
window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current)
|
||||||
|
sessionMediaMetricBackgroundFeedTimerRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleTargetSet = new Set(visibleTargets)
|
||||||
|
let cursor = 0
|
||||||
|
const feedNext = () => {
|
||||||
|
if (runId !== sessionMediaMetricRunIdRef.current) return
|
||||||
|
if (isLoadingSessionCountsRef.current) return
|
||||||
|
const batchIds: string[] = []
|
||||||
|
while (cursor < filteredContacts.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) {
|
||||||
|
const contact = filteredContacts[cursor]
|
||||||
|
cursor += 1
|
||||||
|
if (!contact?.username) continue
|
||||||
|
if (visibleTargetSet.has(contact.username)) continue
|
||||||
|
const mappedSession = sessionRowByUsername.get(contact.username)
|
||||||
|
if (!mappedSession?.hasSession) continue
|
||||||
|
batchIds.push(contact.username)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batchIds.length > 0) {
|
||||||
|
enqueueSessionMediaMetricRequests(batchIds)
|
||||||
|
scheduleSessionMediaMetricWorker()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < filteredContacts.length) {
|
||||||
|
sessionMediaMetricBackgroundFeedTimerRef.current = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
feedNext()
|
||||||
|
return () => {
|
||||||
|
if (sessionMediaMetricBackgroundFeedTimerRef.current) {
|
||||||
|
window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current)
|
||||||
|
sessionMediaMetricBackgroundFeedTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
collectVisibleSessionMetricTargets,
|
||||||
|
enqueueSessionMediaMetricRequests,
|
||||||
|
filteredContacts,
|
||||||
|
isSessionCountStageReady,
|
||||||
|
scheduleSessionMediaMetricWorker,
|
||||||
|
sessionRowByUsername
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (sessionMediaMetricBackgroundFeedTimerRef.current) {
|
||||||
|
window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current)
|
||||||
|
sessionMediaMetricBackgroundFeedTimerRef.current = null
|
||||||
|
}
|
||||||
|
if (sessionMediaMetricPersistTimerRef.current) {
|
||||||
|
window.clearTimeout(sessionMediaMetricPersistTimerRef.current)
|
||||||
|
sessionMediaMetricPersistTimerRef.current = null
|
||||||
|
}
|
||||||
|
void flushSessionMediaMetricCache()
|
||||||
|
}
|
||||||
|
}, [flushSessionMediaMetricCache])
|
||||||
|
|
||||||
const contactByUsername = useMemo(() => {
|
const contactByUsername = useMemo(() => {
|
||||||
const map = new Map<string, ContactInfo>()
|
const map = new Map<string, ContactInfo>()
|
||||||
for (const contact of contactsList) {
|
for (const contact of contactsList) {
|
||||||
@@ -3820,11 +4184,23 @@ function ExportPage() {
|
|||||||
const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username])
|
const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username])
|
||||||
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
|
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
|
||||||
const displayedMessageCount = countedMessages ?? hintedMessages
|
const displayedMessageCount = countedMessages ?? hintedMessages
|
||||||
|
const mediaMetric = sessionContentMetrics[contact.username]
|
||||||
|
const metricLoadingReady = canExport && isSessionCountStageReady
|
||||||
const messageCountLabel = !canExport
|
const messageCountLabel = !canExport
|
||||||
? '--'
|
? '--'
|
||||||
: typeof displayedMessageCount === 'number'
|
: typeof displayedMessageCount === 'number'
|
||||||
? displayedMessageCount.toLocaleString('zh-CN')
|
? displayedMessageCount.toLocaleString('zh-CN')
|
||||||
: '获取中'
|
: '获取中'
|
||||||
|
const metricToLabel = (value: unknown): string => {
|
||||||
|
const normalized = normalizeMessageCount(value)
|
||||||
|
if (!canExport) return '--'
|
||||||
|
if (!metricLoadingReady) return '--'
|
||||||
|
return typeof normalized === 'number' ? normalized.toLocaleString('zh-CN') : '...'
|
||||||
|
}
|
||||||
|
const emojiLabel = metricToLabel(mediaMetric?.emojiMessages)
|
||||||
|
const voiceLabel = metricToLabel(mediaMetric?.voiceMessages)
|
||||||
|
const imageLabel = metricToLabel(mediaMetric?.imageMessages)
|
||||||
|
const videoLabel = metricToLabel(mediaMetric?.videoMessages)
|
||||||
const openChatLabel = contact.type === 'friend'
|
const openChatLabel = contact.type === 'friend'
|
||||||
? '打开私聊'
|
? '打开私聊'
|
||||||
: contact.type === 'group'
|
: contact.type === 'group'
|
||||||
@@ -3867,13 +4243,30 @@ function ExportPage() {
|
|||||||
className="row-open-chat-link"
|
className="row-open-chat-link"
|
||||||
title="在新窗口打开该会话"
|
title="在新窗口打开该会话"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void window.electronAPI.window.openSessionChatWindow(contact.username, { source: 'export' })
|
void window.electronAPI.window.openSessionChatWindow(contact.username, {
|
||||||
|
source: 'export',
|
||||||
|
initialDisplayName: contact.displayName || contact.username,
|
||||||
|
initialAvatarUrl: contact.avatarUrl,
|
||||||
|
initialContactType: contact.type
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{openChatLabel}
|
{openChatLabel}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="row-media-metric">
|
||||||
|
<strong className={`row-media-metric-value ${emojiLabel === '...' ? 'loading' : ''}`}>{emojiLabel}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="row-media-metric">
|
||||||
|
<strong className={`row-media-metric-value ${voiceLabel === '...' ? 'loading' : ''}`}>{voiceLabel}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="row-media-metric">
|
||||||
|
<strong className={`row-media-metric-value ${imageLabel === '...' ? 'loading' : ''}`}>{imageLabel}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="row-media-metric">
|
||||||
|
<strong className={`row-media-metric-value ${videoLabel === '...' ? 'loading' : ''}`}>{videoLabel}</strong>
|
||||||
|
</div>
|
||||||
<div className="row-action-cell">
|
<div className="row-action-cell">
|
||||||
<div className="row-action-main">
|
<div className="row-action-main">
|
||||||
<button
|
<button
|
||||||
@@ -3915,9 +4308,11 @@ function ExportPage() {
|
|||||||
runningSessionIds,
|
runningSessionIds,
|
||||||
selectedSessions,
|
selectedSessions,
|
||||||
sessionDetail?.wxid,
|
sessionDetail?.wxid,
|
||||||
|
sessionContentMetrics,
|
||||||
sessionMessageCounts,
|
sessionMessageCounts,
|
||||||
sessionRowByUsername,
|
sessionRowByUsername,
|
||||||
showSessionDetailPanel,
|
showSessionDetailPanel,
|
||||||
|
isSessionCountStageReady,
|
||||||
toggleSelectSession
|
toggleSelectSession
|
||||||
])
|
])
|
||||||
const handleContactsListWheelCapture = useCallback((event: WheelEvent<HTMLDivElement>) => {
|
const handleContactsListWheelCapture = useCallback((event: WheelEvent<HTMLDivElement>) => {
|
||||||
@@ -4170,6 +4565,10 @@ function ExportPage() {
|
|||||||
<span className="contacts-list-header-main-label">联系人(头像/名称/微信号)</span>
|
<span className="contacts-list-header-main-label">联系人(头像/名称/微信号)</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="contacts-list-header-count">总消息数</span>
|
<span className="contacts-list-header-count">总消息数</span>
|
||||||
|
<span className="contacts-list-header-media">表情包</span>
|
||||||
|
<span className="contacts-list-header-media">语音</span>
|
||||||
|
<span className="contacts-list-header-media">图片</span>
|
||||||
|
<span className="contacts-list-header-media">视频</span>
|
||||||
<span className="contacts-list-header-actions">
|
<span className="contacts-list-header-actions">
|
||||||
{selectedCount > 0 && (
|
{selectedCount > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -4247,6 +4646,7 @@ function ExportPage() {
|
|||||||
data={filteredContacts}
|
data={filteredContacts}
|
||||||
computeItemKey={(_, contact) => contact.username}
|
computeItemKey={(_, contact) => contact.username}
|
||||||
itemContent={renderContactRow}
|
itemContent={renderContactRow}
|
||||||
|
rangeChanged={handleContactsRangeChanged}
|
||||||
atTopStateChange={setIsContactsListAtTop}
|
atTopStateChange={setIsContactsListAtTop}
|
||||||
overscan={420}
|
overscan={420}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user