Merge pull request #1095 from RickHunterS/Fixed_the_number_of_messages_in_the_exported_page_file
fix(export): 修复导出页文件消息数量始终显示为 0
This commit is contained in:
8 files changed
+378
-40
No files matched your search
@@ -13,6 +13,7 @@ import { wcdbService } from './wcdbService'
|
|||||||
import { MessageCacheService } from './messageCacheService'
|
import { MessageCacheService } from './messageCacheService'
|
||||||
import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
|
import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
|
||||||
import { SessionStatsCacheService, SessionStatsCacheEntry, SessionStatsCacheStats } from './sessionStatsCacheService'
|
import { SessionStatsCacheService, SessionStatsCacheEntry, SessionStatsCacheStats } from './sessionStatsCacheService'
|
||||||
|
import { FILE_APP_LOCAL_TYPE_SET } from './export/constants'
|
||||||
import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from './groupMyMessageCountCacheService'
|
import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from './groupMyMessageCountCacheService'
|
||||||
import { exportCardDiagnosticsService } from './exportCardDiagnosticsService'
|
import { exportCardDiagnosticsService } from './exportCardDiagnosticsService'
|
||||||
import { voiceTranscribeService } from './voiceTranscribeService'
|
import { voiceTranscribeService } from './voiceTranscribeService'
|
||||||
@@ -181,6 +182,8 @@ interface ExportSessionStats {
|
|||||||
imageMessages: number
|
imageMessages: number
|
||||||
videoMessages: number
|
videoMessages: number
|
||||||
emojiMessages: number
|
emojiMessages: number
|
||||||
|
/** 文件类消息数量(包含 49 / 34359738417 / 103079215153 / 25769803825 等 localType 变体) */
|
||||||
|
fileMessages: number
|
||||||
transferMessages: number
|
transferMessages: number
|
||||||
redPacketMessages: number
|
redPacketMessages: number
|
||||||
callMessages: number
|
callMessages: number
|
||||||
@@ -4233,6 +4236,7 @@ class ChatService {
|
|||||||
imageMessages: Number.isFinite(stats.imageMessages) ? Math.max(0, Math.floor(stats.imageMessages)) : 0,
|
imageMessages: Number.isFinite(stats.imageMessages) ? Math.max(0, Math.floor(stats.imageMessages)) : 0,
|
||||||
videoMessages: Number.isFinite(stats.videoMessages) ? Math.max(0, Math.floor(stats.videoMessages)) : 0,
|
videoMessages: Number.isFinite(stats.videoMessages) ? Math.max(0, Math.floor(stats.videoMessages)) : 0,
|
||||||
emojiMessages: Number.isFinite(stats.emojiMessages) ? Math.max(0, Math.floor(stats.emojiMessages)) : 0,
|
emojiMessages: Number.isFinite(stats.emojiMessages) ? Math.max(0, Math.floor(stats.emojiMessages)) : 0,
|
||||||
|
fileMessages: Number.isFinite(stats.fileMessages) ? Math.max(0, Math.floor(stats.fileMessages)) : 0,
|
||||||
transferMessages: Number.isFinite(stats.transferMessages) ? Math.max(0, Math.floor(stats.transferMessages)) : 0,
|
transferMessages: Number.isFinite(stats.transferMessages) ? Math.max(0, Math.floor(stats.transferMessages)) : 0,
|
||||||
redPacketMessages: Number.isFinite(stats.redPacketMessages) ? Math.max(0, Math.floor(stats.redPacketMessages)) : 0,
|
redPacketMessages: Number.isFinite(stats.redPacketMessages) ? Math.max(0, Math.floor(stats.redPacketMessages)) : 0,
|
||||||
callMessages: Number.isFinite(stats.callMessages) ? Math.max(0, Math.floor(stats.callMessages)) : 0
|
callMessages: Number.isFinite(stats.callMessages) ? Math.max(0, Math.floor(stats.callMessages)) : 0
|
||||||
@@ -4256,6 +4260,7 @@ class ChatService {
|
|||||||
imageMessages: stats.imageMessages,
|
imageMessages: stats.imageMessages,
|
||||||
videoMessages: stats.videoMessages,
|
videoMessages: stats.videoMessages,
|
||||||
emojiMessages: stats.emojiMessages,
|
emojiMessages: stats.emojiMessages,
|
||||||
|
fileMessages: stats.fileMessages,
|
||||||
transferMessages: stats.transferMessages,
|
transferMessages: stats.transferMessages,
|
||||||
redPacketMessages: stats.redPacketMessages,
|
redPacketMessages: stats.redPacketMessages,
|
||||||
callMessages: stats.callMessages,
|
callMessages: stats.callMessages,
|
||||||
@@ -4511,11 +4516,13 @@ class ChatService {
|
|||||||
transferMessages: number
|
transferMessages: number
|
||||||
redPacketMessages: number
|
redPacketMessages: number
|
||||||
callMessages: number
|
callMessages: number
|
||||||
|
fileMessages: number
|
||||||
}> {
|
}> {
|
||||||
const counters = {
|
const counters = {
|
||||||
transferMessages: 0,
|
transferMessages: 0,
|
||||||
redPacketMessages: 0,
|
redPacketMessages: 0,
|
||||||
callMessages: 0
|
callMessages: 0,
|
||||||
|
fileMessages: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const cursorResult = await wcdbService.openMessageCursor(sessionId, 500, false, beginTimestamp, endTimestamp)
|
const cursorResult = await wcdbService.openMessageCursor(sessionId, 500, false, beginTimestamp, endTimestamp)
|
||||||
@@ -4551,6 +4558,7 @@ class ChatService {
|
|||||||
const xmlType = this.extractType49XmlTypeForStats(content)
|
const xmlType = this.extractType49XmlTypeForStats(content)
|
||||||
if (xmlType === '2000') counters.transferMessages += 1
|
if (xmlType === '2000') counters.transferMessages += 1
|
||||||
if (xmlType === '2001') counters.redPacketMessages += 1
|
if (xmlType === '2001') counters.redPacketMessages += 1
|
||||||
|
if (xmlType === '6') counters.fileMessages += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!batch.hasMore || rows.length === 0) break
|
if (!batch.hasMore || rows.length === 0) break
|
||||||
@@ -4562,6 +4570,60 @@ class ChatService {
|
|||||||
return counters
|
return counters
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 cursor 扫描精确统计指定会话中的文件消息数量。
|
||||||
|
*
|
||||||
|
* 背景:Native 聚合接口 `getSessionMessageTypeStats` 通常只把 localType === 49 且
|
||||||
|
* appmsg type === 6 的消息计为文件;但微信 4.x 中文件消息还存在
|
||||||
|
* 34359738417 / 103079215153 / 25769803825 等 localType 变体,Native 不会计入。
|
||||||
|
* 该函数覆盖 FILE_APP_LOCAL_TYPE_SET 中所有文件变体,作为 Native 统计的兜底/修正。
|
||||||
|
*/
|
||||||
|
private async collectFileMessageCountByCursorScan(
|
||||||
|
sessionId: string,
|
||||||
|
beginTimestamp: number = 0,
|
||||||
|
endTimestamp: number = 0
|
||||||
|
): Promise<number> {
|
||||||
|
let count = 0
|
||||||
|
const fileLocalTypeSet = FILE_APP_LOCAL_TYPE_SET
|
||||||
|
const cursorResult = await wcdbService.openMessageCursor(sessionId, 500, false, beginTimestamp, endTimestamp)
|
||||||
|
if (!cursorResult.success || !cursorResult.cursor) {
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = cursorResult.cursor
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const batch = await wcdbService.fetchMessageBatch(cursor)
|
||||||
|
if (!batch.success) break
|
||||||
|
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||||
|
for (const row of rows) {
|
||||||
|
const localType = this.getRowInt(row, ['local_type'], 1)
|
||||||
|
if (!fileLocalTypeSet.has(localType)) continue
|
||||||
|
|
||||||
|
// 文件消息变体中,49 是通用 appmsg,需要解析 XML 判断 type === 6;
|
||||||
|
// 34359738417 / 103079215153 / 25769803825 等变体几乎专用于文件,直接计数即可,
|
||||||
|
// 避免大量 CPU 密集型 XML 解码/正则解析。
|
||||||
|
if (localType !== 49) {
|
||||||
|
count += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawMessageContent = row.message_content
|
||||||
|
const rawCompressContent = row.compress_content
|
||||||
|
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
|
||||||
|
const xmlType = this.extractType49XmlTypeForStats(content)
|
||||||
|
if (xmlType === '6') count += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!batch.hasMore || rows.length === 0) break
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await wcdbService.closeMessageCursor(cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
private async collectSessionExportStatsByCursorScan(
|
private async collectSessionExportStatsByCursorScan(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
selfIdentitySet: Set<string>,
|
selfIdentitySet: Set<string>,
|
||||||
@@ -4574,6 +4636,7 @@ class ChatService {
|
|||||||
imageMessages: 0,
|
imageMessages: 0,
|
||||||
videoMessages: 0,
|
videoMessages: 0,
|
||||||
emojiMessages: 0,
|
emojiMessages: 0,
|
||||||
|
fileMessages: 0,
|
||||||
transferMessages: 0,
|
transferMessages: 0,
|
||||||
redPacketMessages: 0,
|
redPacketMessages: 0,
|
||||||
callMessages: 0
|
callMessages: 0
|
||||||
@@ -4609,13 +4672,22 @@ class ChatService {
|
|||||||
if (localType === 50) stats.callMessages += 1
|
if (localType === 50) stats.callMessages += 1
|
||||||
if (localType === 8589934592049) stats.transferMessages += 1
|
if (localType === 8589934592049) stats.transferMessages += 1
|
||||||
if (localType === 8594229559345) stats.redPacketMessages += 1
|
if (localType === 8594229559345) stats.redPacketMessages += 1
|
||||||
if (localType === 49) {
|
// 文件、转账、红包等共享 appmsg 类型的 localType 变体,需要解析 XML 进一步区分。
|
||||||
const rawMessageContent = row.message_content
|
// 非 49 的文件消息变体(34359738417 / 103079215153 / 25769803825)几乎专用于文件,
|
||||||
const rawCompressContent = row.compress_content
|
// 跳过 XML 解析直接计为文件消息,以降低 cursor 扫描开销。
|
||||||
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
|
if (FILE_APP_LOCAL_TYPE_SET.has(localType)) {
|
||||||
const xmlType = this.extractType49XmlTypeForStats(content)
|
if (localType !== 49) {
|
||||||
if (xmlType === '2000') stats.transferMessages += 1
|
stats.fileMessages += 1
|
||||||
if (xmlType === '2001') stats.redPacketMessages += 1
|
} else {
|
||||||
|
const rawMessageContent = row.message_content
|
||||||
|
const rawCompressContent = row.compress_content
|
||||||
|
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
|
||||||
|
const xmlType = this.extractType49XmlTypeForStats(content)
|
||||||
|
if (xmlType === '2000') stats.transferMessages += 1
|
||||||
|
if (xmlType === '2001') stats.redPacketMessages += 1
|
||||||
|
// appmsg type === 6 表示普通文件附件
|
||||||
|
if (xmlType === '6') stats.fileMessages += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createTime = this.getRowInt(
|
const createTime = this.getRowInt(
|
||||||
@@ -4679,6 +4751,7 @@ class ChatService {
|
|||||||
imageMessages: 0,
|
imageMessages: 0,
|
||||||
videoMessages: 0,
|
videoMessages: 0,
|
||||||
emojiMessages: 0,
|
emojiMessages: 0,
|
||||||
|
fileMessages: 0,
|
||||||
transferMessages: 0,
|
transferMessages: 0,
|
||||||
redPacketMessages: 0,
|
redPacketMessages: 0,
|
||||||
callMessages: 0
|
callMessages: 0
|
||||||
@@ -4700,6 +4773,7 @@ class ChatService {
|
|||||||
stats.imageMessages = Math.max(0, Math.floor(Number(data.image_messages || 0)))
|
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.videoMessages = Math.max(0, Math.floor(Number(data.video_messages || 0)))
|
||||||
stats.emojiMessages = Math.max(0, Math.floor(Number(data.emoji_messages || 0)))
|
stats.emojiMessages = Math.max(0, Math.floor(Number(data.emoji_messages || 0)))
|
||||||
|
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.callMessages = Math.max(0, Math.floor(Number(data.call_messages || 0)))
|
||||||
stats.transferMessages = Math.max(0, Math.floor(Number(data.transfer_messages || 0)))
|
stats.transferMessages = Math.max(0, Math.floor(Number(data.transfer_messages || 0)))
|
||||||
stats.redPacketMessages = Math.max(0, Math.floor(Number(data.red_packet_messages || 0)))
|
stats.redPacketMessages = Math.max(0, Math.floor(Number(data.red_packet_messages || 0)))
|
||||||
@@ -4715,9 +4789,23 @@ class ChatService {
|
|||||||
stats.transferMessages = preciseCounters.transferMessages
|
stats.transferMessages = preciseCounters.transferMessages
|
||||||
stats.redPacketMessages = preciseCounters.redPacketMessages
|
stats.redPacketMessages = preciseCounters.redPacketMessages
|
||||||
stats.callMessages = preciseCounters.callMessages
|
stats.callMessages = preciseCounters.callMessages
|
||||||
|
stats.fileMessages = preciseCounters.fileMessages
|
||||||
} catch {
|
} catch {
|
||||||
// 保留 native 聚合结果作为兜底
|
// 保留 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) {
|
||||||
|
try {
|
||||||
|
const cursorFileMessages = await this.collectFileMessageCountByCursorScan(sessionId, beginTimestamp, endTimestamp)
|
||||||
|
stats.fileMessages = Math.max(nativeFileMessages, cursorFileMessages)
|
||||||
|
} catch {
|
||||||
|
// 保留 native 聚合结果作为兜底
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGroup) {
|
if (isGroup) {
|
||||||
@@ -4742,6 +4830,7 @@ class ChatService {
|
|||||||
imageMessages: Math.max(0, Math.floor(Number(row?.image_messages || 0))),
|
imageMessages: Math.max(0, Math.floor(Number(row?.image_messages || 0))),
|
||||||
videoMessages: Math.max(0, Math.floor(Number(row?.video_messages || 0))),
|
videoMessages: Math.max(0, Math.floor(Number(row?.video_messages || 0))),
|
||||||
emojiMessages: Math.max(0, Math.floor(Number(row?.emoji_messages || 0))),
|
emojiMessages: Math.max(0, Math.floor(Number(row?.emoji_messages || 0))),
|
||||||
|
fileMessages: Math.max(0, Math.floor(Number(row?.file_messages || 0))),
|
||||||
callMessages: Math.max(0, Math.floor(Number(row?.call_messages || 0))),
|
callMessages: Math.max(0, Math.floor(Number(row?.call_messages || 0))),
|
||||||
transferMessages: Math.max(0, Math.floor(Number(row?.transfer_messages || 0))),
|
transferMessages: Math.max(0, Math.floor(Number(row?.transfer_messages || 0))),
|
||||||
redPacketMessages: Math.max(0, Math.floor(Number(row?.red_packet_messages || 0)))
|
redPacketMessages: Math.max(0, Math.floor(Number(row?.red_packet_messages || 0)))
|
||||||
@@ -4864,6 +4953,7 @@ class ChatService {
|
|||||||
imageMessages: 0,
|
imageMessages: 0,
|
||||||
videoMessages: 0,
|
videoMessages: 0,
|
||||||
emojiMessages: 0,
|
emojiMessages: 0,
|
||||||
|
fileMessages: 0,
|
||||||
transferMessages: 0,
|
transferMessages: 0,
|
||||||
redPacketMessages: 0,
|
redPacketMessages: 0,
|
||||||
callMessages: 0
|
callMessages: 0
|
||||||
@@ -5019,9 +5109,10 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.forEachWithConcurrency(normalizedSessionIds, 3, async (sessionId) => {
|
await this.forEachWithConcurrency(normalizedSessionIds, 6, async (sessionId) => {
|
||||||
try {
|
try {
|
||||||
const stats = hasNativeBatchStats && nativeBatchStats[sessionId]
|
const fromNativeBatch = hasNativeBatchStats && nativeBatchStats[sessionId]
|
||||||
|
const stats = fromNativeBatch
|
||||||
? { ...nativeBatchStats[sessionId] }
|
? { ...nativeBatchStats[sessionId] }
|
||||||
: await this.collectSessionExportStats(
|
: await this.collectSessionExportStats(
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -5030,6 +5121,16 @@ class ChatService {
|
|||||||
beginTimestamp,
|
beginTimestamp,
|
||||||
endTimestamp
|
endTimestamp
|
||||||
)
|
)
|
||||||
|
// Native 批量统计可能不识别文件消息变体(如 25769803825),对 fileMessages 为 0 的会话补充 cursor 扫描。
|
||||||
|
// 导出页同时加载多个会话时走批量路径,这里需要与单会话路径保持一致的兜底策略。
|
||||||
|
if (fromNativeBatch && stats.fileMessages === 0) {
|
||||||
|
try {
|
||||||
|
const cursorFileMessages = await this.collectFileMessageCountByCursorScan(sessionId, beginTimestamp, endTimestamp)
|
||||||
|
stats.fileMessages = Math.max(stats.fileMessages, cursorFileMessages)
|
||||||
|
} catch {
|
||||||
|
// 保留 native 批量结果作为兜底
|
||||||
|
}
|
||||||
|
}
|
||||||
if (sessionId.endsWith('@chatroom')) {
|
if (sessionId.endsWith('@chatroom')) {
|
||||||
if (shouldLoadGroupMemberCount) {
|
if (shouldLoadGroupMemberCount) {
|
||||||
stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number'
|
stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number'
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { join, dirname } from 'path'
|
|||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
const CACHE_VERSION = 2
|
/** 缓存版本号。增加/修改 SessionStatsCacheStats 字段后必须提升,避免旧缓存被误用。 */
|
||||||
|
const CACHE_VERSION = 3
|
||||||
const MAX_SESSION_ENTRIES_PER_SCOPE = 2000
|
const MAX_SESSION_ENTRIES_PER_SCOPE = 2000
|
||||||
const MAX_SCOPE_ENTRIES = 12
|
const MAX_SCOPE_ENTRIES = 12
|
||||||
|
|
||||||
@@ -12,6 +13,8 @@ export interface SessionStatsCacheStats {
|
|||||||
imageMessages: number
|
imageMessages: number
|
||||||
videoMessages: number
|
videoMessages: number
|
||||||
emojiMessages: number
|
emojiMessages: number
|
||||||
|
/** 文件类消息数量(对应 ExportSessionStats.fileMessages) */
|
||||||
|
fileMessages: number
|
||||||
transferMessages: number
|
transferMessages: number
|
||||||
redPacketMessages: number
|
redPacketMessages: number
|
||||||
callMessages: number
|
callMessages: number
|
||||||
@@ -53,6 +56,7 @@ function normalizeStats(raw: unknown): SessionStatsCacheStats | null {
|
|||||||
const imageMessages = toNonNegativeInt(source.imageMessages)
|
const imageMessages = toNonNegativeInt(source.imageMessages)
|
||||||
const videoMessages = toNonNegativeInt(source.videoMessages)
|
const videoMessages = toNonNegativeInt(source.videoMessages)
|
||||||
const emojiMessages = toNonNegativeInt(source.emojiMessages)
|
const emojiMessages = toNonNegativeInt(source.emojiMessages)
|
||||||
|
const fileMessages = toNonNegativeInt(source.fileMessages)
|
||||||
const transferMessages = toNonNegativeInt(source.transferMessages)
|
const transferMessages = toNonNegativeInt(source.transferMessages)
|
||||||
const redPacketMessages = toNonNegativeInt(source.redPacketMessages)
|
const redPacketMessages = toNonNegativeInt(source.redPacketMessages)
|
||||||
const callMessages = toNonNegativeInt(source.callMessages)
|
const callMessages = toNonNegativeInt(source.callMessages)
|
||||||
@@ -63,6 +67,7 @@ function normalizeStats(raw: unknown): SessionStatsCacheStats | null {
|
|||||||
imageMessages === undefined ||
|
imageMessages === undefined ||
|
||||||
videoMessages === undefined ||
|
videoMessages === undefined ||
|
||||||
emojiMessages === undefined ||
|
emojiMessages === undefined ||
|
||||||
|
fileMessages === undefined ||
|
||||||
transferMessages === undefined ||
|
transferMessages === undefined ||
|
||||||
redPacketMessages === undefined ||
|
redPacketMessages === undefined ||
|
||||||
callMessages === undefined
|
callMessages === undefined
|
||||||
@@ -76,6 +81,7 @@ function normalizeStats(raw: unknown): SessionStatsCacheStats | null {
|
|||||||
imageMessages,
|
imageMessages,
|
||||||
videoMessages,
|
videoMessages,
|
||||||
emojiMessages,
|
emojiMessages,
|
||||||
|
fileMessages,
|
||||||
transferMessages,
|
transferMessages,
|
||||||
redPacketMessages,
|
redPacketMessages,
|
||||||
callMessages
|
callMessages
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function ExportPage() {
|
|||||||
} = useContactsLoader()
|
} = useContactsLoader()
|
||||||
|
|
||||||
// ── 3. Metrics ──
|
// ── 3. Metrics ──
|
||||||
const { metricsMap, fetchMetrics } = useSessionMetrics()
|
const { metricsMap, fetchMetrics, loadingRefs } = useSessionMetrics()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
sessions,
|
sessions,
|
||||||
@@ -286,6 +286,7 @@ function ExportPage() {
|
|||||||
sortConfig={sortConfig}
|
sortConfig={sortConfig}
|
||||||
onSortChange={setSortConfig}
|
onSortChange={setSortConfig}
|
||||||
metricsMap={metricsMap}
|
metricsMap={metricsMap}
|
||||||
|
loadingRefs={loadingRefs}
|
||||||
onSingleExport={handleSingleExport}
|
onSingleExport={handleSingleExport}
|
||||||
onBatchExport={handleExportSelected}
|
onBatchExport={handleExportSelected}
|
||||||
onAutomationExport={handleAutomationExportFromSelection}
|
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
|
onAutomationExport?: () => void
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
metricsMap?: Record<string, SessionContentMetric>
|
metricsMap?: Record<string, SessionContentMetric>
|
||||||
|
loadingRefs?: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
const SessionTable: React.FC<SessionTableProps> = ({
|
const SessionTable: React.FC<SessionTableProps> = ({
|
||||||
@@ -39,7 +40,8 @@ const SessionTable: React.FC<SessionTableProps> = ({
|
|||||||
onBatchExport,
|
onBatchExport,
|
||||||
onAutomationExport,
|
onAutomationExport,
|
||||||
isLoading,
|
isLoading,
|
||||||
metricsMap
|
metricsMap,
|
||||||
|
loadingRefs
|
||||||
}) => {
|
}) => {
|
||||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||||
|
|
||||||
@@ -160,9 +162,35 @@ const SessionTable: React.FC<SessionTableProps> = ({
|
|||||||
|
|
||||||
{/* ─── Virtualized List ─────────────────────────────────────────── */}
|
{/* ─── Virtualized List ─────────────────────────────────────────── */}
|
||||||
<div className="table-body">
|
<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">
|
<div className="empty-state">
|
||||||
{isLoading ? '加载中...' : (searchQuery ? '没有匹配的会话' : '此分类下没有会话')}
|
{searchQuery ? '没有匹配的会话' : '此分类下没有会话'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
@@ -170,8 +198,9 @@ const SessionTable: React.FC<SessionTableProps> = ({
|
|||||||
data={sessions}
|
data={sessions}
|
||||||
itemContent={(index, session) => {
|
itemContent={(index, session) => {
|
||||||
if (!session) return null
|
if (!session) return null
|
||||||
|
|
||||||
const metrics = metricsMap?.[session.username]
|
const metrics = metricsMap?.[session.username]
|
||||||
|
const isMetricsLoading = !metrics && loadingRefs?.has(session.username)
|
||||||
const totalCount = metrics?.totalMessages ?? session.messageCountHint ?? 0
|
const totalCount = metrics?.totalMessages ?? session.messageCountHint ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -198,17 +227,53 @@ const SessionTable: React.FC<SessionTableProps> = ({
|
|||||||
<span className="st-id" title={session.username}>{session.username}</span>
|
<span className="st-id" title={session.username}>{session.username}</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="col-time">
|
||||||
{session.lastTimestamp ? formatLatestMessageTimeFromSeconds(session.lastTimestamp).text : '-'}
|
{session.lastTimestamp ? formatLatestMessageTimeFromSeconds(session.lastTimestamp).text : '-'}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-stat">{metrics?.emojiMessages ?? '-'}</div>
|
<div className="col-stat">
|
||||||
<div className="col-stat">{metrics?.voiceMessages ?? '-'}</div>
|
{isMetricsLoading ? (
|
||||||
<div className="col-stat">{metrics?.imageMessages ?? '-'}</div>
|
<span className="st-stat-shimmer" />
|
||||||
<div className="col-stat">{metrics?.videoMessages ?? '-'}</div>
|
) : (
|
||||||
<div className="col-stat">{metrics?.fileMessages ?? '-'}</div>
|
<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">
|
<div className="col-action">
|
||||||
<button
|
<button
|
||||||
className="st-action-btn"
|
className="st-action-btn"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ export interface SessionContentMetric {
|
|||||||
appMessages?: number
|
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 {
|
interface SessionMetricsState {
|
||||||
metricsMap: Record<string, SessionContentMetric>
|
metricsMap: Record<string, SessionContentMetric>
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
@@ -39,31 +49,35 @@ export const useSessionMetrics = create<SessionMetricsState>((set, get) => ({
|
|||||||
if (missingIds.length === 0) return
|
if (missingIds.length === 0) return
|
||||||
|
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
|
|
||||||
const newLoadingRefs = new Set(loadingRefs)
|
const newLoadingRefs = new Set(loadingRefs)
|
||||||
missingIds.forEach(id => newLoadingRefs.add(id))
|
missingIds.forEach(id => newLoadingRefs.add(id))
|
||||||
set({ loadingRefs: newLoadingRefs })
|
set({ loadingRefs: newLoadingRefs })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stats = await window.electronAPI.chat.getExportSessionStats(missingIds, { includeRelations: false })
|
// 将会话分批顺序请求,每批返回后立即更新 UI,实现「扫出一个放一个」的渐进式效果。
|
||||||
|
const chunks = chunkArray(missingIds, METRICS_CHUNK_SIZE)
|
||||||
const newMetrics: Record<string, SessionContentMetric> = {}
|
for (const chunk of chunks) {
|
||||||
if (stats.success && stats.data) {
|
const stats = await window.electronAPI.chat.getExportSessionStats(chunk, { includeRelations: false })
|
||||||
for (const [sessionId, sessionStat] of Object.entries(stats.data)) {
|
|
||||||
newMetrics[sessionId] = {
|
const newMetrics: Record<string, SessionContentMetric> = {}
|
||||||
totalMessages: sessionStat.totalMessages,
|
if (stats.success && stats.data) {
|
||||||
voiceMessages: sessionStat.voiceMessages,
|
for (const [sessionId, sessionStat] of Object.entries(stats.data)) {
|
||||||
imageMessages: sessionStat.imageMessages,
|
newMetrics[sessionId] = {
|
||||||
videoMessages: sessionStat.videoMessages,
|
totalMessages: sessionStat.totalMessages,
|
||||||
emojiMessages: sessionStat.emojiMessages,
|
voiceMessages: sessionStat.voiceMessages,
|
||||||
fileMessages: 0 // Will need specific metric if available
|
imageMessages: sessionStat.imageMessages,
|
||||||
|
videoMessages: sessionStat.videoMessages,
|
||||||
|
emojiMessages: sessionStat.emojiMessages,
|
||||||
|
fileMessages: sessionStat.fileMessages ?? 0 // 文件消息数量,由后端 ExportSessionStats 返回
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
set(state => ({
|
set(state => ({
|
||||||
metricsMap: { ...state.metricsMap, ...newMetrics }
|
metricsMap: { ...state.metricsMap, ...newMetrics }
|
||||||
}))
|
}))
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch session metrics:', err)
|
console.error('Failed to fetch session metrics:', err)
|
||||||
set({ error: err instanceof Error ? err : new Error(String(err)) })
|
set({ error: err instanceof Error ? err : new Error(String(err)) })
|
||||||
|
|||||||
@@ -931,6 +931,8 @@ export interface ExportSessionContentMetricCacheEntry {
|
|||||||
imageMessages?: number
|
imageMessages?: number
|
||||||
videoMessages?: number
|
videoMessages?: number
|
||||||
emojiMessages?: number
|
emojiMessages?: number
|
||||||
|
/** 文件类消息数量,与后端 ExportSessionStats.fileMessages 对齐 */
|
||||||
|
fileMessages?: number
|
||||||
firstTimestamp?: number
|
firstTimestamp?: number
|
||||||
lastTimestamp?: number
|
lastTimestamp?: number
|
||||||
}
|
}
|
||||||
@@ -1099,6 +1101,9 @@ export async function getExportSessionContentMetricCache(scopeKey: string): Prom
|
|||||||
if (typeof source.emojiMessages === 'number' && Number.isFinite(source.emojiMessages) && source.emojiMessages >= 0) {
|
if (typeof source.emojiMessages === 'number' && Number.isFinite(source.emojiMessages) && source.emojiMessages >= 0) {
|
||||||
metric.emojiMessages = Math.floor(source.emojiMessages)
|
metric.emojiMessages = Math.floor(source.emojiMessages)
|
||||||
}
|
}
|
||||||
|
if (typeof source.fileMessages === 'number' && Number.isFinite(source.fileMessages) && source.fileMessages >= 0) {
|
||||||
|
metric.fileMessages = Math.floor(source.fileMessages)
|
||||||
|
}
|
||||||
if (typeof source.firstTimestamp === 'number' && Number.isFinite(source.firstTimestamp) && source.firstTimestamp > 0) {
|
if (typeof source.firstTimestamp === 'number' && Number.isFinite(source.firstTimestamp) && source.firstTimestamp > 0) {
|
||||||
metric.firstTimestamp = Math.floor(source.firstTimestamp)
|
metric.firstTimestamp = Math.floor(source.firstTimestamp)
|
||||||
}
|
}
|
||||||
@@ -1144,6 +1149,9 @@ export async function setExportSessionContentMetricCache(
|
|||||||
if (typeof rawMetric.emojiMessages === 'number' && Number.isFinite(rawMetric.emojiMessages) && rawMetric.emojiMessages >= 0) {
|
if (typeof rawMetric.emojiMessages === 'number' && Number.isFinite(rawMetric.emojiMessages) && rawMetric.emojiMessages >= 0) {
|
||||||
metric.emojiMessages = Math.floor(rawMetric.emojiMessages)
|
metric.emojiMessages = Math.floor(rawMetric.emojiMessages)
|
||||||
}
|
}
|
||||||
|
if (typeof rawMetric.fileMessages === 'number' && Number.isFinite(rawMetric.fileMessages) && rawMetric.fileMessages >= 0) {
|
||||||
|
metric.fileMessages = Math.floor(rawMetric.fileMessages)
|
||||||
|
}
|
||||||
if (typeof rawMetric.firstTimestamp === 'number' && Number.isFinite(rawMetric.firstTimestamp) && rawMetric.firstTimestamp > 0) {
|
if (typeof rawMetric.firstTimestamp === 'number' && Number.isFinite(rawMetric.firstTimestamp) && rawMetric.firstTimestamp > 0) {
|
||||||
metric.firstTimestamp = Math.floor(rawMetric.firstTimestamp)
|
metric.firstTimestamp = Math.floor(rawMetric.firstTimestamp)
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -711,6 +711,8 @@ export interface ElectronAPI {
|
|||||||
imageMessages: number
|
imageMessages: number
|
||||||
videoMessages: number
|
videoMessages: number
|
||||||
emojiMessages: number
|
emojiMessages: number
|
||||||
|
/** 文件类消息数量 */
|
||||||
|
fileMessages: number
|
||||||
transferMessages: number
|
transferMessages: number
|
||||||
redPacketMessages: number
|
redPacketMessages: number
|
||||||
callMessages: number
|
callMessages: number
|
||||||
|
|||||||
Reference in new issue
Block a user