mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
fix(export): improve progress visibility and hard-stop control
This commit is contained in:
@@ -100,6 +100,7 @@ interface ExportTaskControlState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const exportTaskControlMap = new Map<string, ExportTaskControlState>()
|
const exportTaskControlMap = new Map<string, ExportTaskControlState>()
|
||||||
|
const pendingExportTaskControlMap = new Map<string, ExportTaskControlState>()
|
||||||
|
|
||||||
const getTaskControlState = (taskId?: string): ExportTaskControlState | null => {
|
const getTaskControlState = (taskId?: string): ExportTaskControlState | null => {
|
||||||
const normalized = typeof taskId === 'string' ? taskId.trim() : ''
|
const normalized = typeof taskId === 'string' ? taskId.trim() : ''
|
||||||
@@ -110,7 +111,12 @@ const getTaskControlState = (taskId?: string): ExportTaskControlState | null =>
|
|||||||
const createTaskControlState = (taskId?: string): string | null => {
|
const createTaskControlState = (taskId?: string): string | null => {
|
||||||
const normalized = typeof taskId === 'string' ? taskId.trim() : ''
|
const normalized = typeof taskId === 'string' ? taskId.trim() : ''
|
||||||
if (!normalized) return null
|
if (!normalized) return null
|
||||||
exportTaskControlMap.set(normalized, { pauseRequested: false, stopRequested: false })
|
const pending = pendingExportTaskControlMap.get(normalized)
|
||||||
|
exportTaskControlMap.set(normalized, {
|
||||||
|
pauseRequested: Boolean(pending?.pauseRequested),
|
||||||
|
stopRequested: Boolean(pending?.stopRequested)
|
||||||
|
})
|
||||||
|
pendingExportTaskControlMap.delete(normalized)
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +124,16 @@ const clearTaskControlState = (taskId?: string): void => {
|
|||||||
const normalized = typeof taskId === 'string' ? taskId.trim() : ''
|
const normalized = typeof taskId === 'string' ? taskId.trim() : ''
|
||||||
if (!normalized) return
|
if (!normalized) return
|
||||||
exportTaskControlMap.delete(normalized)
|
exportTaskControlMap.delete(normalized)
|
||||||
|
pendingExportTaskControlMap.delete(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueTaskControlRequest = (taskId: string, action: 'pause' | 'stop'): void => {
|
||||||
|
const normalized = taskId.trim()
|
||||||
|
if (!normalized) return
|
||||||
|
const existing = pendingExportTaskControlMap.get(normalized) || { pauseRequested: false, stopRequested: false }
|
||||||
|
if (action === 'pause') existing.pauseRequested = true
|
||||||
|
if (action === 'stop') existing.stopRequested = true
|
||||||
|
pendingExportTaskControlMap.set(normalized, existing)
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWindow(options: { autoShow?: boolean } = {}) {
|
function createWindow(options: { autoShow?: boolean } = {}) {
|
||||||
@@ -1297,7 +1313,8 @@ function registerIpcHandlers() {
|
|||||||
ipcMain.handle('export:pauseTask', async (_, taskId: string) => {
|
ipcMain.handle('export:pauseTask', async (_, taskId: string) => {
|
||||||
const state = getTaskControlState(taskId)
|
const state = getTaskControlState(taskId)
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return { success: false, error: '任务未在执行中或已结束' }
|
queueTaskControlRequest(taskId, 'pause')
|
||||||
|
return { success: true, queued: true }
|
||||||
}
|
}
|
||||||
state.pauseRequested = true
|
state.pauseRequested = true
|
||||||
return { success: true }
|
return { success: true }
|
||||||
@@ -1306,7 +1323,8 @@ function registerIpcHandlers() {
|
|||||||
ipcMain.handle('export:stopTask', async (_, taskId: string) => {
|
ipcMain.handle('export:stopTask', async (_, taskId: string) => {
|
||||||
const state = getTaskControlState(taskId)
|
const state = getTaskControlState(taskId)
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return { success: false, error: '任务未在执行中或已结束' }
|
queueTaskControlRequest(taskId, 'stop')
|
||||||
|
return { success: true, queued: true }
|
||||||
}
|
}
|
||||||
state.stopRequested = true
|
state.stopRequested = true
|
||||||
return { success: true }
|
return { success: true }
|
||||||
@@ -1398,8 +1416,15 @@ function registerIpcHandlers() {
|
|||||||
return groupAnalyticsService.getGroupMembers(chatroomId)
|
return groupAnalyticsService.getGroupMembers(chatroomId)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('groupAnalytics:getGroupMembersPanelData', async (_, chatroomId: string, forceRefresh?: boolean) => {
|
ipcMain.handle(
|
||||||
return groupAnalyticsService.getGroupMembersPanelData(chatroomId, forceRefresh)
|
'groupAnalytics:getGroupMembersPanelData',
|
||||||
|
async (_, chatroomId: string, options?: { forceRefresh?: boolean; includeMessageCounts?: boolean } | boolean) => {
|
||||||
|
const normalizedOptions = typeof options === 'boolean'
|
||||||
|
? { forceRefresh: options }
|
||||||
|
: options
|
||||||
|
return groupAnalyticsService.getGroupMembersPanelData(chatroomId, normalizedOptions)
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('groupAnalytics:getGroupMessageRanking', async (_, chatroomId: string, limit?: number, startTime?: number, endTime?: number) => {
|
ipcMain.handle('groupAnalytics:getGroupMessageRanking', async (_, chatroomId: string, limit?: number, startTime?: number, endTime?: number) => {
|
||||||
|
|||||||
@@ -237,8 +237,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
groupAnalytics: {
|
groupAnalytics: {
|
||||||
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
||||||
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
|
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
|
||||||
getGroupMembersPanelData: (chatroomId: string, forceRefresh?: boolean) =>
|
getGroupMembersPanelData: (
|
||||||
ipcRenderer.invoke('groupAnalytics:getGroupMembersPanelData', chatroomId, forceRefresh),
|
chatroomId: string,
|
||||||
|
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
|
||||||
|
) => ipcRenderer.invoke('groupAnalytics:getGroupMembersPanelData', chatroomId, options),
|
||||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
||||||
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||||
|
|||||||
@@ -119,6 +119,11 @@ export interface ExportProgress {
|
|||||||
phaseLabel?: string
|
phaseLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ExportTaskControl {
|
||||||
|
shouldPause?: () => boolean
|
||||||
|
shouldStop?: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
// 并发控制:限制同时执行的 Promise 数量
|
// 并发控制:限制同时执行的 Promise 数量
|
||||||
async function parallelLimit<T, R>(
|
async function parallelLimit<T, R>(
|
||||||
items: T[],
|
items: T[],
|
||||||
@@ -149,6 +154,7 @@ class ExportService {
|
|||||||
private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }>
|
private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }>
|
||||||
private inlineEmojiCache: LRUCache<string, string>
|
private inlineEmojiCache: LRUCache<string, string>
|
||||||
private htmlStyleCache: string | null = null
|
private htmlStyleCache: string | null = null
|
||||||
|
private readonly STOP_ERROR_CODE = 'WEFLOW_EXPORT_STOP_REQUESTED'
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
@@ -157,6 +163,30 @@ class ExportService {
|
|||||||
this.inlineEmojiCache = new LRUCache(100) // 最多缓存100个表情
|
this.inlineEmojiCache = new LRUCache(100) // 最多缓存100个表情
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createStopError(): Error {
|
||||||
|
const error = new Error('导出任务已停止')
|
||||||
|
;(error as Error & { code?: string }).code = this.STOP_ERROR_CODE
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
private isStopError(error: unknown): boolean {
|
||||||
|
if (!error) return false
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error.includes(this.STOP_ERROR_CODE) || error.includes('导出任务已停止')
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const code = (error as Error & { code?: string }).code
|
||||||
|
return code === this.STOP_ERROR_CODE || error.message.includes(this.STOP_ERROR_CODE) || error.message.includes('导出任务已停止')
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private throwIfStopRequested(control?: ExportTaskControl): void {
|
||||||
|
if (control?.shouldStop?.()) {
|
||||||
|
throw this.createStopError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number {
|
private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number {
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
|
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
|
||||||
const raw = Math.floor(value)
|
const raw = Math.floor(value)
|
||||||
@@ -1994,7 +2024,9 @@ class ExportService {
|
|||||||
dateRange?: { start: number; end: number } | null,
|
dateRange?: { start: number; end: number } | null,
|
||||||
senderUsernameFilter?: string,
|
senderUsernameFilter?: string,
|
||||||
collectMode: MessageCollectMode = 'full',
|
collectMode: MessageCollectMode = 'full',
|
||||||
targetMediaTypes?: Set<number>
|
targetMediaTypes?: Set<number>,
|
||||||
|
control?: ExportTaskControl,
|
||||||
|
onCollectProgress?: (payload: { fetched: number }) => void
|
||||||
): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> {
|
): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> {
|
||||||
const rows: any[] = []
|
const rows: any[] = []
|
||||||
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
|
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
|
||||||
@@ -2010,6 +2042,7 @@ class ExportService {
|
|||||||
const endTime = dateRange?.end && dateRange.end > 0 ? dateRange.end : 0
|
const endTime = dateRange?.end && dateRange.end > 0 ? dateRange.end : 0
|
||||||
|
|
||||||
const batchSize = (collectMode === 'text-fast' || collectMode === 'media-fast') ? 2000 : 500
|
const batchSize = (collectMode === 'text-fast' || collectMode === 'media-fast') ? 2000 : 500
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const cursor = collectMode === 'media-fast'
|
const cursor = collectMode === 'media-fast'
|
||||||
? await wcdbService.openMessageCursorLite(
|
? await wcdbService.openMessageCursorLite(
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -2034,6 +2067,7 @@ class ExportService {
|
|||||||
let hasMore = true
|
let hasMore = true
|
||||||
let batchCount = 0
|
let batchCount = 0
|
||||||
while (hasMore) {
|
while (hasMore) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
|
const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
|
||||||
batchCount++
|
batchCount++
|
||||||
|
|
||||||
@@ -2044,7 +2078,11 @@ class ExportService {
|
|||||||
|
|
||||||
if (!batch.rows) break
|
if (!batch.rows) break
|
||||||
|
|
||||||
|
let rowIndex = 0
|
||||||
for (const row of batch.rows) {
|
for (const row of batch.rows) {
|
||||||
|
if ((rowIndex++ & 0x7f) === 0) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
|
}
|
||||||
const createTime = parseInt(row.create_time || '0', 10)
|
const createTime = parseInt(row.create_time || '0', 10)
|
||||||
if (dateRange) {
|
if (dateRange) {
|
||||||
if (createTime < dateRange.start || createTime > dateRange.end) continue
|
if (createTime < dateRange.start || createTime > dateRange.end) continue
|
||||||
@@ -2149,10 +2187,12 @@ class ExportService {
|
|||||||
if (firstTime === null || createTime < firstTime) firstTime = createTime
|
if (firstTime === null || createTime < firstTime) firstTime = createTime
|
||||||
if (lastTime === null || createTime > lastTime) lastTime = createTime
|
if (lastTime === null || createTime > lastTime) lastTime = createTime
|
||||||
}
|
}
|
||||||
|
onCollectProgress?.({ fetched: rows.length })
|
||||||
hasMore = batch.hasMore === true
|
hasMore = batch.hasMore === true
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (this.isStopError(err)) throw err
|
||||||
console.error(`[Export] 收集消息异常:`, err)
|
console.error(`[Export] 收集消息异常:`, err)
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
@@ -2162,10 +2202,12 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
if (collectMode === 'media-fast' && mediaTypeFilter && rows.length > 0) {
|
if (collectMode === 'media-fast' && mediaTypeFilter && rows.length > 0) {
|
||||||
await this.backfillMediaFieldsFromMessageDetail(sessionId, rows, mediaTypeFilter)
|
await this.backfillMediaFieldsFromMessageDetail(sessionId, rows, mediaTypeFilter, control)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
if (senderSet.size > 0) {
|
if (senderSet.size > 0) {
|
||||||
const usernames = Array.from(senderSet)
|
const usernames = Array.from(senderSet)
|
||||||
const [nameResult, avatarResult] = await Promise.all([
|
const [nameResult, avatarResult] = await Promise.all([
|
||||||
@@ -2196,7 +2238,8 @@ class ExportService {
|
|||||||
private async backfillMediaFieldsFromMessageDetail(
|
private async backfillMediaFieldsFromMessageDetail(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
rows: any[],
|
rows: any[],
|
||||||
targetMediaTypes: Set<number>
|
targetMediaTypes: Set<number>,
|
||||||
|
control?: ExportTaskControl
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const needsBackfill = rows.filter((msg) => {
|
const needsBackfill = rows.filter((msg) => {
|
||||||
if (!targetMediaTypes.has(msg.localType)) return false
|
if (!targetMediaTypes.has(msg.localType)) return false
|
||||||
@@ -2209,6 +2252,7 @@ class ExportService {
|
|||||||
|
|
||||||
const DETAIL_CONCURRENCY = 6
|
const DETAIL_CONCURRENCY = 6
|
||||||
await parallelLimit(needsBackfill, DETAIL_CONCURRENCY, async (msg) => {
|
await parallelLimit(needsBackfill, DETAIL_CONCURRENCY, async (msg) => {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const localId = Number(msg.localId || 0)
|
const localId = Number(msg.localId || 0)
|
||||||
if (!Number.isFinite(localId) || localId <= 0) return
|
if (!Number.isFinite(localId) || localId <= 0) return
|
||||||
|
|
||||||
@@ -2788,9 +2832,11 @@ class ExportService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
outputPath: string,
|
outputPath: string,
|
||||||
options: ExportOptions,
|
options: ExportOptions,
|
||||||
onProgress?: (progress: ExportProgress) => void
|
onProgress?: (progress: ExportProgress) => void,
|
||||||
|
control?: ExportTaskControl
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||||
|
|
||||||
@@ -2813,7 +2859,8 @@ class ExportService {
|
|||||||
options.dateRange,
|
options.dateRange,
|
||||||
options.senderUsername,
|
options.senderUsername,
|
||||||
collectParams.mode,
|
collectParams.mode,
|
||||||
collectParams.targetMediaTypes
|
collectParams.targetMediaTypes,
|
||||||
|
control
|
||||||
)
|
)
|
||||||
const allMessages = collected.rows
|
const allMessages = collected.rows
|
||||||
|
|
||||||
@@ -2831,6 +2878,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isGroup) {
|
if (isGroup) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2889,6 +2937,7 @@ class ExportService {
|
|||||||
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
||||||
let mediaExported = 0
|
let mediaExported = 0
|
||||||
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
if (!mediaCache.has(mediaKey)) {
|
if (!mediaCache.has(mediaKey)) {
|
||||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||||
@@ -2933,6 +2982,7 @@ class ExportService {
|
|||||||
const VOICE_CONCURRENCY = 4
|
const VOICE_CONCURRENCY = 4
|
||||||
let voiceTranscribed = 0
|
let voiceTranscribed = 0
|
||||||
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
||||||
voiceTranscriptMap.set(msg.localId, transcript)
|
voiceTranscriptMap.set(msg.localId, transcript)
|
||||||
voiceTranscribed++
|
voiceTranscribed++
|
||||||
@@ -2957,7 +3007,11 @@ class ExportService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const chatLabMessages: ChatLabMessage[] = []
|
const chatLabMessages: ChatLabMessage[] = []
|
||||||
|
let messageIndex = 0
|
||||||
for (const msg of allMessages) {
|
for (const msg of allMessages) {
|
||||||
|
if ((messageIndex++ & 0x7f) === 0) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
|
}
|
||||||
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
|
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
|
||||||
platformId: msg.senderUsername,
|
platformId: msg.senderUsername,
|
||||||
accountName: msg.senderUsername,
|
accountName: msg.senderUsername,
|
||||||
@@ -3150,13 +3204,17 @@ class ExportService {
|
|||||||
meta: chatLabExport.meta
|
meta: chatLabExport.meta
|
||||||
}))
|
}))
|
||||||
for (const member of chatLabExport.members) {
|
for (const member of chatLabExport.members) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
lines.push(JSON.stringify({ _type: 'member', ...member }))
|
lines.push(JSON.stringify({ _type: 'member', ...member }))
|
||||||
}
|
}
|
||||||
for (const message of chatLabExport.messages) {
|
for (const message of chatLabExport.messages) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
lines.push(JSON.stringify({ _type: 'message', ...message }))
|
lines.push(JSON.stringify({ _type: 'message', ...message }))
|
||||||
}
|
}
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8')
|
fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8')
|
||||||
} else {
|
} else {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
fs.writeFileSync(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8')
|
fs.writeFileSync(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3169,6 +3227,9 @@ class ExportService {
|
|||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (this.isStopError(e)) {
|
||||||
|
return { success: false, error: '导出任务已停止' }
|
||||||
|
}
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3180,9 +3241,11 @@ class ExportService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
outputPath: string,
|
outputPath: string,
|
||||||
options: ExportOptions,
|
options: ExportOptions,
|
||||||
onProgress?: (progress: ExportProgress) => void
|
onProgress?: (progress: ExportProgress) => void,
|
||||||
|
control?: ExportTaskControl
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||||
|
|
||||||
@@ -3210,13 +3273,27 @@ class ExportService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const collectParams = this.resolveCollectParams(options)
|
const collectParams = this.resolveCollectParams(options)
|
||||||
|
let collectProgressLastReportAt = 0
|
||||||
const collected = await this.collectMessages(
|
const collected = await this.collectMessages(
|
||||||
sessionId,
|
sessionId,
|
||||||
cleanedMyWxid,
|
cleanedMyWxid,
|
||||||
options.dateRange,
|
options.dateRange,
|
||||||
options.senderUsername,
|
options.senderUsername,
|
||||||
collectParams.mode,
|
collectParams.mode,
|
||||||
collectParams.targetMediaTypes
|
collectParams.targetMediaTypes,
|
||||||
|
control,
|
||||||
|
({ fetched }) => {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - collectProgressLastReportAt < 350) return
|
||||||
|
collectProgressLastReportAt = now
|
||||||
|
onProgress?.({
|
||||||
|
current: 5,
|
||||||
|
total: 100,
|
||||||
|
currentSession: sessionInfo.displayName,
|
||||||
|
phase: 'preparing',
|
||||||
|
phaseLabel: `收集消息 ${fetched.toLocaleString()} 条`
|
||||||
|
})
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
@@ -3233,7 +3310,11 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const senderUsernames = new Set<string>()
|
const senderUsernames = new Set<string>()
|
||||||
|
let senderScanIndex = 0
|
||||||
for (const msg of collected.rows) {
|
for (const msg of collected.rows) {
|
||||||
|
if ((senderScanIndex++ & 0x7f) === 0) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
|
}
|
||||||
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
||||||
}
|
}
|
||||||
senderUsernames.add(sessionId)
|
senderUsernames.add(sessionId)
|
||||||
@@ -3272,6 +3353,7 @@ class ExportService {
|
|||||||
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
||||||
let mediaExported = 0
|
let mediaExported = 0
|
||||||
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
if (!mediaCache.has(mediaKey)) {
|
if (!mediaCache.has(mediaKey)) {
|
||||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||||
@@ -3315,6 +3397,7 @@ class ExportService {
|
|||||||
const VOICE_CONCURRENCY = 4
|
const VOICE_CONCURRENCY = 4
|
||||||
let voiceTranscribed = 0
|
let voiceTranscribed = 0
|
||||||
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
||||||
voiceTranscriptMap.set(msg.localId, transcript)
|
voiceTranscriptMap.set(msg.localId, transcript)
|
||||||
voiceTranscribed++
|
voiceTranscribed++
|
||||||
@@ -3360,7 +3443,11 @@ class ExportService {
|
|||||||
const transferCandidates: Array<{ xml: string; messageRef: any }> = []
|
const transferCandidates: Array<{ xml: string; messageRef: any }> = []
|
||||||
let needSort = false
|
let needSort = false
|
||||||
let lastCreateTime = Number.NEGATIVE_INFINITY
|
let lastCreateTime = Number.NEGATIVE_INFINITY
|
||||||
|
let messageIndex = 0
|
||||||
for (const msg of collected.rows) {
|
for (const msg of collected.rows) {
|
||||||
|
if ((messageIndex++ & 0x7f) === 0) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
|
}
|
||||||
const senderInfo = senderInfoMap.get(msg.senderUsername) || { displayName: msg.senderUsername || '' }
|
const senderInfo = senderInfoMap.get(msg.senderUsername) || { displayName: msg.senderUsername || '' }
|
||||||
const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '')
|
const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '')
|
||||||
const source = sourceMatch ? sourceMatch[0] : ''
|
const source = sourceMatch ? sourceMatch[0] : ''
|
||||||
@@ -3470,6 +3557,7 @@ class ExportService {
|
|||||||
|
|
||||||
const transferConcurrency = this.getClampedConcurrency(options.exportConcurrency, 4, 8)
|
const transferConcurrency = this.getClampedConcurrency(options.exportConcurrency, 4, 8)
|
||||||
await parallelLimit(transferCandidates, transferConcurrency, async (item) => {
|
await parallelLimit(transferCandidates, transferConcurrency, async (item) => {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const transferDesc = await this.resolveTransferDesc(
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
item.xml,
|
item.xml,
|
||||||
cleanedMyWxid,
|
cleanedMyWxid,
|
||||||
@@ -3516,6 +3604,7 @@ class ExportService {
|
|||||||
|
|
||||||
const weflow = this.getWeflowHeader()
|
const weflow = this.getWeflowHeader()
|
||||||
if (options.format === 'arkme-json' && isGroup) {
|
if (options.format === 'arkme-json' && isGroup) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3587,6 +3676,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const compactMessages = allMessages.map((message) => {
|
const compactMessages = allMessages.map((message) => {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const senderID = ensureSenderId(String(message.senderUsername || ''))
|
const senderID = ensureSenderId(String(message.senderUsername || ''))
|
||||||
const compactMessage: any = {
|
const compactMessage: any = {
|
||||||
localId: message.localId,
|
localId: message.localId,
|
||||||
@@ -3646,6 +3736,7 @@ class ExportService {
|
|||||||
|
|
||||||
groupMembers = []
|
groupMembers = []
|
||||||
for (const memberWxid of memberUsernames) {
|
for (const memberWxid of memberUsernames) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const member = collected.memberSet.get(memberWxid)?.member
|
const member = collected.memberSet.get(memberWxid)?.member
|
||||||
const contactResult = await getContactCached(memberWxid)
|
const contactResult = await getContactCached(memberWxid)
|
||||||
const contact = contactResult.success ? contactResult.contact : null
|
const contact = contactResult.success ? contactResult.contact : null
|
||||||
@@ -3712,6 +3803,7 @@ class ExportService {
|
|||||||
arkmeExport.groupMembers = groupMembers
|
arkmeExport.groupMembers = groupMembers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
fs.writeFileSync(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8')
|
fs.writeFileSync(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8')
|
||||||
} else {
|
} else {
|
||||||
const detailedExport: any = {
|
const detailedExport: any = {
|
||||||
@@ -3734,6 +3826,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
|
fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3746,6 +3839,9 @@ class ExportService {
|
|||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (this.isStopError(e)) {
|
||||||
|
return { success: false, error: '导出任务已停止' }
|
||||||
|
}
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3757,9 +3853,11 @@ class ExportService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
outputPath: string,
|
outputPath: string,
|
||||||
options: ExportOptions,
|
options: ExportOptions,
|
||||||
onProgress?: (progress: ExportProgress) => void
|
onProgress?: (progress: ExportProgress) => void,
|
||||||
|
control?: ExportTaskControl
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||||
|
|
||||||
@@ -3798,7 +3896,8 @@ class ExportService {
|
|||||||
options.dateRange,
|
options.dateRange,
|
||||||
options.senderUsername,
|
options.senderUsername,
|
||||||
collectParams.mode,
|
collectParams.mode,
|
||||||
collectParams.targetMediaTypes
|
collectParams.targetMediaTypes,
|
||||||
|
control
|
||||||
)
|
)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
@@ -3815,7 +3914,11 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const senderUsernames = new Set<string>()
|
const senderUsernames = new Set<string>()
|
||||||
|
let senderScanIndex = 0
|
||||||
for (const msg of collected.rows) {
|
for (const msg of collected.rows) {
|
||||||
|
if ((senderScanIndex++ & 0x7f) === 0) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
|
}
|
||||||
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
||||||
}
|
}
|
||||||
senderUsernames.add(sessionId)
|
senderUsernames.add(sessionId)
|
||||||
@@ -3976,6 +4079,7 @@ class ExportService {
|
|||||||
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
||||||
let mediaExported = 0
|
let mediaExported = 0
|
||||||
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
if (!mediaCache.has(mediaKey)) {
|
if (!mediaCache.has(mediaKey)) {
|
||||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||||
@@ -4019,6 +4123,7 @@ class ExportService {
|
|||||||
const VOICE_CONCURRENCY = 4
|
const VOICE_CONCURRENCY = 4
|
||||||
let voiceTranscribed = 0
|
let voiceTranscribed = 0
|
||||||
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
||||||
voiceTranscriptMap.set(msg.localId, transcript)
|
voiceTranscriptMap.set(msg.localId, transcript)
|
||||||
voiceTranscribed++
|
voiceTranscribed++
|
||||||
@@ -4043,6 +4148,9 @@ class ExportService {
|
|||||||
|
|
||||||
// ========== 写入 Excel 行 ==========
|
// ========== 写入 Excel 行 ==========
|
||||||
for (let i = 0; i < sortedMessages.length; i++) {
|
for (let i = 0; i < sortedMessages.length; i++) {
|
||||||
|
if ((i & 0x7f) === 0) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
|
}
|
||||||
const msg = sortedMessages[i]
|
const msg = sortedMessages[i]
|
||||||
|
|
||||||
// 确定发送者信息
|
// 确定发送者信息
|
||||||
@@ -4194,6 +4302,7 @@ class ExportService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 写入文件
|
// 写入文件
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
await workbook.xlsx.writeFile(outputPath)
|
await workbook.xlsx.writeFile(outputPath)
|
||||||
|
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
@@ -4205,6 +4314,9 @@ class ExportService {
|
|||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (this.isStopError(e)) {
|
||||||
|
return { success: false, error: '导出任务已停止' }
|
||||||
|
}
|
||||||
// 处理文件被占用的错误
|
// 处理文件被占用的错误
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
|
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
|
||||||
@@ -4258,9 +4370,11 @@ class ExportService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
outputPath: string,
|
outputPath: string,
|
||||||
options: ExportOptions,
|
options: ExportOptions,
|
||||||
onProgress?: (progress: ExportProgress) => void
|
onProgress?: (progress: ExportProgress) => void,
|
||||||
|
control?: ExportTaskControl
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||||
|
|
||||||
@@ -4293,7 +4407,8 @@ class ExportService {
|
|||||||
options.dateRange,
|
options.dateRange,
|
||||||
options.senderUsername,
|
options.senderUsername,
|
||||||
collectParams.mode,
|
collectParams.mode,
|
||||||
collectParams.targetMediaTypes
|
collectParams.targetMediaTypes,
|
||||||
|
control
|
||||||
)
|
)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
@@ -4310,7 +4425,11 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const senderUsernames = new Set<string>()
|
const senderUsernames = new Set<string>()
|
||||||
|
let senderScanIndex = 0
|
||||||
for (const msg of collected.rows) {
|
for (const msg of collected.rows) {
|
||||||
|
if ((senderScanIndex++ & 0x7f) === 0) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
|
}
|
||||||
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
||||||
}
|
}
|
||||||
senderUsernames.add(sessionId)
|
senderUsernames.add(sessionId)
|
||||||
@@ -4357,6 +4476,7 @@ class ExportService {
|
|||||||
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
||||||
let mediaExported = 0
|
let mediaExported = 0
|
||||||
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
if (!mediaCache.has(mediaKey)) {
|
if (!mediaCache.has(mediaKey)) {
|
||||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||||
@@ -4399,6 +4519,7 @@ class ExportService {
|
|||||||
const VOICE_CONCURRENCY = 4
|
const VOICE_CONCURRENCY = 4
|
||||||
let voiceTranscribed = 0
|
let voiceTranscribed = 0
|
||||||
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
||||||
voiceTranscriptMap.set(msg.localId, transcript)
|
voiceTranscriptMap.set(msg.localId, transcript)
|
||||||
voiceTranscribed++
|
voiceTranscribed++
|
||||||
@@ -4424,6 +4545,9 @@ class ExportService {
|
|||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
|
|
||||||
for (let i = 0; i < sortedMessages.length; i++) {
|
for (let i = 0; i < sortedMessages.length; i++) {
|
||||||
|
if ((i & 0x7f) === 0) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
|
}
|
||||||
const msg = sortedMessages[i]
|
const msg = sortedMessages[i]
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
const mediaItem = mediaCache.get(mediaKey)
|
const mediaItem = mediaCache.get(mediaKey)
|
||||||
@@ -4524,6 +4648,7 @@ class ExportService {
|
|||||||
phase: 'writing'
|
phase: 'writing'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8')
|
fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8')
|
||||||
|
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
@@ -4535,6 +4660,9 @@ class ExportService {
|
|||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (this.isStopError(e)) {
|
||||||
|
return { success: false, error: '导出任务已停止' }
|
||||||
|
}
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4546,9 +4674,11 @@ class ExportService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
outputPath: string,
|
outputPath: string,
|
||||||
options: ExportOptions,
|
options: ExportOptions,
|
||||||
onProgress?: (progress: ExportProgress) => void
|
onProgress?: (progress: ExportProgress) => void,
|
||||||
|
control?: ExportTaskControl
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||||
|
|
||||||
@@ -4581,14 +4711,19 @@ class ExportService {
|
|||||||
options.dateRange,
|
options.dateRange,
|
||||||
options.senderUsername,
|
options.senderUsername,
|
||||||
collectParams.mode,
|
collectParams.mode,
|
||||||
collectParams.targetMediaTypes
|
collectParams.targetMediaTypes,
|
||||||
|
control
|
||||||
)
|
)
|
||||||
if (collected.rows.length === 0) {
|
if (collected.rows.length === 0) {
|
||||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const senderUsernames = new Set<string>()
|
const senderUsernames = new Set<string>()
|
||||||
|
let senderScanIndex = 0
|
||||||
for (const msg of collected.rows) {
|
for (const msg of collected.rows) {
|
||||||
|
if ((senderScanIndex++ & 0x7f) === 0) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
|
}
|
||||||
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
||||||
}
|
}
|
||||||
senderUsernames.add(sessionId)
|
senderUsernames.add(sessionId)
|
||||||
@@ -4642,6 +4777,7 @@ class ExportService {
|
|||||||
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
||||||
let mediaExported = 0
|
let mediaExported = 0
|
||||||
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
if (!mediaCache.has(mediaKey)) {
|
if (!mediaCache.has(mediaKey)) {
|
||||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||||
@@ -4684,6 +4820,7 @@ class ExportService {
|
|||||||
const VOICE_CONCURRENCY = 4
|
const VOICE_CONCURRENCY = 4
|
||||||
let voiceTranscribed = 0
|
let voiceTranscribed = 0
|
||||||
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
||||||
voiceTranscriptMap.set(msg.localId, transcript)
|
voiceTranscriptMap.set(msg.localId, transcript)
|
||||||
voiceTranscribed++
|
voiceTranscribed++
|
||||||
@@ -4710,6 +4847,9 @@ class ExportService {
|
|||||||
lines.push('id,MsgSvrID,type_name,is_sender,talker,msg,src,CreateTime')
|
lines.push('id,MsgSvrID,type_name,is_sender,talker,msg,src,CreateTime')
|
||||||
|
|
||||||
for (let i = 0; i < sortedMessages.length; i++) {
|
for (let i = 0; i < sortedMessages.length; i++) {
|
||||||
|
if ((i & 0x7f) === 0) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
|
}
|
||||||
const msg = sortedMessages[i]
|
const msg = sortedMessages[i]
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
const mediaItem = mediaCache.get(mediaKey) || null
|
const mediaItem = mediaCache.get(mediaKey) || null
|
||||||
@@ -4787,6 +4927,7 @@ class ExportService {
|
|||||||
phase: 'writing'
|
phase: 'writing'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
fs.writeFileSync(outputPath, `\uFEFF${lines.join('\r\n')}`, 'utf-8')
|
fs.writeFileSync(outputPath, `\uFEFF${lines.join('\r\n')}`, 'utf-8')
|
||||||
|
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
@@ -4798,6 +4939,9 @@ class ExportService {
|
|||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (this.isStopError(e)) {
|
||||||
|
return { success: false, error: '导出任务已停止' }
|
||||||
|
}
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4893,9 +5037,11 @@ class ExportService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
outputPath: string,
|
outputPath: string,
|
||||||
options: ExportOptions,
|
options: ExportOptions,
|
||||||
onProgress?: (progress: ExportProgress) => void
|
onProgress?: (progress: ExportProgress) => void,
|
||||||
|
control?: ExportTaskControl
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||||
|
|
||||||
@@ -4931,7 +5077,8 @@ class ExportService {
|
|||||||
options.dateRange,
|
options.dateRange,
|
||||||
options.senderUsername,
|
options.senderUsername,
|
||||||
collectParams.mode,
|
collectParams.mode,
|
||||||
collectParams.targetMediaTypes
|
collectParams.targetMediaTypes,
|
||||||
|
control
|
||||||
)
|
)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
@@ -4940,7 +5087,11 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const senderUsernames = new Set<string>()
|
const senderUsernames = new Set<string>()
|
||||||
|
let senderScanIndex = 0
|
||||||
for (const msg of collected.rows) {
|
for (const msg of collected.rows) {
|
||||||
|
if ((senderScanIndex++ & 0x7f) === 0) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
|
}
|
||||||
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
||||||
}
|
}
|
||||||
senderUsernames.add(sessionId)
|
senderUsernames.add(sessionId)
|
||||||
@@ -4958,6 +5109,7 @@ class ExportService {
|
|||||||
: new Map<string, string>()
|
: new Map<string, string>()
|
||||||
|
|
||||||
if (isGroup) {
|
if (isGroup) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
||||||
}
|
}
|
||||||
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
||||||
@@ -4989,6 +5141,7 @@ class ExportService {
|
|||||||
const MEDIA_CONCURRENCY = 6
|
const MEDIA_CONCURRENCY = 6
|
||||||
let mediaExported = 0
|
let mediaExported = 0
|
||||||
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
|
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
if (!mediaCache.has(mediaKey)) {
|
if (!mediaCache.has(mediaKey)) {
|
||||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||||
@@ -5036,6 +5189,7 @@ class ExportService {
|
|||||||
const VOICE_CONCURRENCY = 4
|
const VOICE_CONCURRENCY = 4
|
||||||
let voiceTranscribed = 0
|
let voiceTranscribed = 0
|
||||||
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
||||||
voiceTranscriptMap.set(msg.localId, transcript)
|
voiceTranscriptMap.set(msg.localId, transcript)
|
||||||
voiceTranscribed++
|
voiceTranscribed++
|
||||||
@@ -5079,6 +5233,7 @@ class ExportService {
|
|||||||
|
|
||||||
const writePromise = (str: string) => {
|
const writePromise = (str: string) => {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
if (!stream.write(str)) {
|
if (!stream.write(str)) {
|
||||||
stream.once('drain', resolve)
|
stream.once('drain', resolve)
|
||||||
} else {
|
} else {
|
||||||
@@ -5145,6 +5300,9 @@ class ExportService {
|
|||||||
let writeBuf: string[] = []
|
let writeBuf: string[] = []
|
||||||
|
|
||||||
for (let i = 0; i < sortedMessages.length; i++) {
|
for (let i = 0; i < sortedMessages.length; i++) {
|
||||||
|
if ((i & 0x7f) === 0) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
|
}
|
||||||
const msg = sortedMessages[i]
|
const msg = sortedMessages[i]
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
const mediaItem = mediaCache.get(mediaKey) || null
|
const mediaItem = mediaCache.get(mediaKey) || null
|
||||||
@@ -5378,6 +5536,9 @@ class ExportService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (this.isStopError(e)) {
|
||||||
|
return { success: false, error: '导出任务已停止' }
|
||||||
|
}
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5467,10 +5628,7 @@ class ExportService {
|
|||||||
outputDir: string,
|
outputDir: string,
|
||||||
options: ExportOptions,
|
options: ExportOptions,
|
||||||
onProgress?: (progress: ExportProgress) => void,
|
onProgress?: (progress: ExportProgress) => void,
|
||||||
control?: {
|
control?: ExportTaskControl
|
||||||
shouldPause?: () => boolean
|
|
||||||
shouldStop?: () => boolean
|
|
||||||
}
|
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
successCount: number
|
successCount: number
|
||||||
@@ -5537,7 +5695,8 @@ class ExportService {
|
|||||||
let pauseRequested = false
|
let pauseRequested = false
|
||||||
let stopRequested = false
|
let stopRequested = false
|
||||||
|
|
||||||
const runOne = async (sessionId: string) => {
|
const runOne = async (sessionId: string): Promise<'done' | 'stopped'> => {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
|
|
||||||
if (emptySessionIds.has(sessionId)) {
|
if (emptySessionIds.has(sessionId)) {
|
||||||
@@ -5552,13 +5711,17 @@ class ExportService {
|
|||||||
phase: 'exporting',
|
phase: 'exporting',
|
||||||
phaseLabel: '该会话没有消息,已跳过'
|
phaseLabel: '该会话没有消息,已跳过'
|
||||||
})
|
})
|
||||||
return
|
return 'done'
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionProgress = (progress: ExportProgress) => {
|
const sessionProgress = (progress: ExportProgress) => {
|
||||||
|
const phaseTotal = Number.isFinite(progress.total) && progress.total > 0 ? progress.total : 100
|
||||||
|
const phaseCurrent = Number.isFinite(progress.current) ? progress.current : 0
|
||||||
|
const ratio = Math.max(0, Math.min(1, phaseCurrent / phaseTotal))
|
||||||
|
const aggregateCurrent = Math.min(sessionIds.length, completedCount + ratio)
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
...progress,
|
...progress,
|
||||||
current: completedCount,
|
current: aggregateCurrent,
|
||||||
total: sessionIds.length,
|
total: sessionIds.length,
|
||||||
currentSession: sessionInfo.displayName,
|
currentSession: sessionInfo.displayName,
|
||||||
currentSessionId: sessionId
|
currentSessionId: sessionId
|
||||||
@@ -5594,21 +5757,25 @@ class ExportService {
|
|||||||
|
|
||||||
let result: { success: boolean; error?: string }
|
let result: { success: boolean; error?: string }
|
||||||
if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') {
|
if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') {
|
||||||
result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress)
|
result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress, control)
|
||||||
} else if (effectiveOptions.format === 'chatlab' || effectiveOptions.format === 'chatlab-jsonl') {
|
} else if (effectiveOptions.format === 'chatlab' || effectiveOptions.format === 'chatlab-jsonl') {
|
||||||
result = await this.exportSessionToChatLab(sessionId, outputPath, effectiveOptions, sessionProgress)
|
result = await this.exportSessionToChatLab(sessionId, outputPath, effectiveOptions, sessionProgress, control)
|
||||||
} else if (effectiveOptions.format === 'excel') {
|
} else if (effectiveOptions.format === 'excel') {
|
||||||
result = await this.exportSessionToExcel(sessionId, outputPath, effectiveOptions, sessionProgress)
|
result = await this.exportSessionToExcel(sessionId, outputPath, effectiveOptions, sessionProgress, control)
|
||||||
} else if (effectiveOptions.format === 'txt') {
|
} else if (effectiveOptions.format === 'txt') {
|
||||||
result = await this.exportSessionToTxt(sessionId, outputPath, effectiveOptions, sessionProgress)
|
result = await this.exportSessionToTxt(sessionId, outputPath, effectiveOptions, sessionProgress, control)
|
||||||
} else if (effectiveOptions.format === 'weclone') {
|
} else if (effectiveOptions.format === 'weclone') {
|
||||||
result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, effectiveOptions, sessionProgress)
|
result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, effectiveOptions, sessionProgress, control)
|
||||||
} else if (effectiveOptions.format === 'html') {
|
} else if (effectiveOptions.format === 'html') {
|
||||||
result = await this.exportSessionToHtml(sessionId, outputPath, effectiveOptions, sessionProgress)
|
result = await this.exportSessionToHtml(sessionId, outputPath, effectiveOptions, sessionProgress, control)
|
||||||
} else {
|
} else {
|
||||||
result = { success: false, error: `不支持的格式: ${effectiveOptions.format}` }
|
result = { success: false, error: `不支持的格式: ${effectiveOptions.format}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!result.success && this.isStopError(result.error)) {
|
||||||
|
return 'stopped'
|
||||||
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
successCount++
|
successCount++
|
||||||
successSessionIds.push(sessionId)
|
successSessionIds.push(sessionId)
|
||||||
@@ -5625,6 +5792,7 @@ class ExportService {
|
|||||||
currentSession: sessionInfo.displayName,
|
currentSession: sessionInfo.displayName,
|
||||||
phase: 'exporting'
|
phase: 'exporting'
|
||||||
})
|
})
|
||||||
|
return 'done'
|
||||||
}
|
}
|
||||||
|
|
||||||
const workers = Array.from({ length: Math.min(sessionConcurrency, queue.length) }, async () => {
|
const workers = Array.from({ length: Math.min(sessionConcurrency, queue.length) }, async () => {
|
||||||
@@ -5640,7 +5808,12 @@ class ExportService {
|
|||||||
|
|
||||||
const sessionId = queue.shift()
|
const sessionId = queue.shift()
|
||||||
if (!sessionId) break
|
if (!sessionId) break
|
||||||
await runOne(sessionId)
|
const runState = await runOne(sessionId)
|
||||||
|
if (runState === 'stopped') {
|
||||||
|
stopRequested = true
|
||||||
|
queue.unshift(sessionId)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await Promise.all(workers)
|
await Promise.all(workers)
|
||||||
|
|||||||
@@ -468,10 +468,11 @@ class GroupAnalyticsService {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildGroupMembersPanelCacheKey(chatroomId: string): string {
|
private buildGroupMembersPanelCacheKey(chatroomId: string, includeMessageCounts: boolean): string {
|
||||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||||
const wxid = this.cleanAccountDirName(String(this.configService.get('myWxid') || '').trim())
|
const wxid = this.cleanAccountDirName(String(this.configService.get('myWxid') || '').trim())
|
||||||
return `${dbPath}::${wxid}::${chatroomId}`
|
const mode = includeMessageCounts ? 'full' : 'members'
|
||||||
|
return `${dbPath}::${wxid}::${chatroomId}::${mode}`
|
||||||
}
|
}
|
||||||
|
|
||||||
private pruneGroupMembersPanelCache(maxEntries: number = 80): void {
|
private pruneGroupMembersPanelCache(maxEntries: number = 80): void {
|
||||||
@@ -495,7 +496,11 @@ class GroupAnalyticsService {
|
|||||||
if (batch.length === 0) continue
|
if (batch.length === 0) continue
|
||||||
|
|
||||||
const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',')
|
const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',')
|
||||||
const sql = `SELECT * FROM contact WHERE username IN (${inList})`
|
const sql = `
|
||||||
|
SELECT username, user_name, encrypt_username, encrypt_user_name, remark, nick_name, alias, local_type
|
||||||
|
FROM contact
|
||||||
|
WHERE username IN (${inList})
|
||||||
|
`
|
||||||
const result = await wcdbService.execQuery('contact', null, sql)
|
const result = await wcdbService.execQuery('contact', null, sql)
|
||||||
if (!result.success || !result.rows) continue
|
if (!result.success || !result.rows) continue
|
||||||
|
|
||||||
@@ -790,7 +795,8 @@ class GroupAnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async loadGroupMembersPanelDataFresh(
|
private async loadGroupMembersPanelDataFresh(
|
||||||
chatroomId: string
|
chatroomId: string,
|
||||||
|
includeMessageCounts: boolean
|
||||||
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string }> {
|
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string }> {
|
||||||
const membersResult = await wcdbService.getGroupMembers(chatroomId)
|
const membersResult = await wcdbService.getGroupMembers(chatroomId)
|
||||||
if (!membersResult.success || !membersResult.members) {
|
if (!membersResult.success || !membersResult.members) {
|
||||||
@@ -813,7 +819,9 @@ class GroupAnalyticsService {
|
|||||||
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
|
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
|
||||||
const contactLookupPromise = this.buildGroupMemberContactLookup(usernames)
|
const contactLookupPromise = this.buildGroupMemberContactLookup(usernames)
|
||||||
const ownerPromise = this.detectGroupOwnerUsername(chatroomId, members)
|
const ownerPromise = this.detectGroupOwnerUsername(chatroomId, members)
|
||||||
const messageCountLookupPromise = this.buildGroupMessageCountLookup(chatroomId)
|
const messageCountLookupPromise = includeMessageCounts
|
||||||
|
? this.buildGroupMessageCountLookup(chatroomId)
|
||||||
|
: Promise.resolve(new Map<string, number>())
|
||||||
|
|
||||||
const [displayNames, contactLookup, ownerUsername, messageCountLookup] = await Promise.all([
|
const [displayNames, contactLookup, ownerUsername, messageCountLookup] = await Promise.all([
|
||||||
displayNamesPromise,
|
displayNamesPromise,
|
||||||
@@ -879,13 +887,15 @@ class GroupAnalyticsService {
|
|||||||
|
|
||||||
async getGroupMembersPanelData(
|
async getGroupMembersPanelData(
|
||||||
chatroomId: string,
|
chatroomId: string,
|
||||||
forceRefresh: boolean = false
|
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
|
||||||
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }> {
|
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }> {
|
||||||
try {
|
try {
|
||||||
const normalizedChatroomId = String(chatroomId || '').trim()
|
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||||
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
|
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
|
||||||
|
|
||||||
const cacheKey = this.buildGroupMembersPanelCacheKey(normalizedChatroomId)
|
const forceRefresh = Boolean(options?.forceRefresh)
|
||||||
|
const includeMessageCounts = options?.includeMessageCounts !== false
|
||||||
|
const cacheKey = this.buildGroupMembersPanelCacheKey(normalizedChatroomId, includeMessageCounts)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const cached = this.groupMembersPanelCache.get(cacheKey)
|
const cached = this.groupMembersPanelCache.get(cacheKey)
|
||||||
if (!forceRefresh && cached && now - cached.updatedAt < this.groupMembersPanelCacheTtlMs) {
|
if (!forceRefresh && cached && now - cached.updatedAt < this.groupMembersPanelCacheTtlMs) {
|
||||||
@@ -901,7 +911,7 @@ class GroupAnalyticsService {
|
|||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
if (!conn.success) return { success: false, error: conn.error }
|
if (!conn.success) return { success: false, error: conn.error }
|
||||||
|
|
||||||
const fresh = await this.loadGroupMembersPanelDataFresh(normalizedChatroomId)
|
const fresh = await this.loadGroupMembersPanelDataFresh(normalizedChatroomId, includeMessageCounts)
|
||||||
if (!fresh.success || !fresh.data) {
|
if (!fresh.success || !fresh.data) {
|
||||||
return { success: false, error: fresh.error || '获取群成员面板数据失败' }
|
return { success: false, error: fresh.error || '获取群成员面板数据失败' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ interface SessionPreviewCachePayload {
|
|||||||
interface GroupMembersPanelCacheEntry {
|
interface GroupMembersPanelCacheEntry {
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
members: GroupPanelMember[]
|
members: GroupPanelMember[]
|
||||||
|
includeMessageCounts: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
|
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
|
||||||
@@ -1033,6 +1034,88 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
}, [applySessionDetailStats, currentSessionId, isLoadingRelationStats])
|
}, [applySessionDetailStats, currentSessionId, isLoadingRelationStats])
|
||||||
|
|
||||||
|
const normalizeGroupPanelMembers = useCallback((payload: GroupPanelMember[]): GroupPanelMember[] => {
|
||||||
|
const membersPayload = Array.isArray(payload) ? payload : []
|
||||||
|
return membersPayload
|
||||||
|
.map((member: GroupPanelMember): GroupPanelMember | null => {
|
||||||
|
const username = String(member.username || '').trim()
|
||||||
|
if (!username) return null
|
||||||
|
const preferredName = String(
|
||||||
|
member.groupNickname ||
|
||||||
|
member.remark ||
|
||||||
|
member.displayName ||
|
||||||
|
member.nickname ||
|
||||||
|
username
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
username,
|
||||||
|
displayName: preferredName,
|
||||||
|
avatarUrl: member.avatarUrl,
|
||||||
|
nickname: member.nickname,
|
||||||
|
alias: member.alias,
|
||||||
|
remark: member.remark,
|
||||||
|
groupNickname: member.groupNickname,
|
||||||
|
isOwner: Boolean(member.isOwner),
|
||||||
|
isFriend: Boolean(member.isFriend),
|
||||||
|
messageCount: Number.isFinite(member.messageCount) ? Math.max(0, Math.floor(member.messageCount)) : 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((member: GroupPanelMember | null): member is GroupPanelMember => Boolean(member))
|
||||||
|
.sort((a: GroupPanelMember, b: GroupPanelMember) => {
|
||||||
|
const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner))
|
||||||
|
if (ownerDiff !== 0) return ownerDiff
|
||||||
|
|
||||||
|
const friendDiff = Number(b.isFriend) - Number(a.isFriend)
|
||||||
|
if (friendDiff !== 0) return friendDiff
|
||||||
|
|
||||||
|
if (a.messageCount !== b.messageCount) return b.messageCount - a.messageCount
|
||||||
|
return a.displayName.localeCompare(b.displayName, 'zh-Hans-CN')
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateGroupMembersPanelCache = useCallback((
|
||||||
|
chatroomId: string,
|
||||||
|
members: GroupPanelMember[],
|
||||||
|
includeMessageCounts: boolean
|
||||||
|
) => {
|
||||||
|
groupMembersPanelCacheRef.current.set(chatroomId, {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
members,
|
||||||
|
includeMessageCounts
|
||||||
|
})
|
||||||
|
if (groupMembersPanelCacheRef.current.size > 80) {
|
||||||
|
const oldestEntry = Array.from(groupMembersPanelCacheRef.current.entries())
|
||||||
|
.sort((a, b) => a[1].updatedAt - b[1].updatedAt)[0]
|
||||||
|
if (oldestEntry) {
|
||||||
|
groupMembersPanelCacheRef.current.delete(oldestEntry[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getGroupMembersPanelDataWithTimeout = useCallback(async (
|
||||||
|
chatroomId: string,
|
||||||
|
options: { forceRefresh?: boolean; includeMessageCounts?: boolean },
|
||||||
|
timeoutMs: number
|
||||||
|
) => {
|
||||||
|
let timeoutTimer: number | null = null
|
||||||
|
try {
|
||||||
|
const timeoutPromise = new Promise<{ success: false; error: string }>((resolve) => {
|
||||||
|
timeoutTimer = window.setTimeout(() => {
|
||||||
|
resolve({ success: false, error: '加载群成员超时,请稍后重试' })
|
||||||
|
}, timeoutMs)
|
||||||
|
})
|
||||||
|
return await Promise.race([
|
||||||
|
window.electronAPI.groupAnalytics.getGroupMembersPanelData(chatroomId, options),
|
||||||
|
timeoutPromise
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
if (timeoutTimer) {
|
||||||
|
window.clearTimeout(timeoutTimer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const loadGroupMembersPanel = useCallback(async (chatroomId: string) => {
|
const loadGroupMembersPanel = useCallback(async (chatroomId: string) => {
|
||||||
if (!chatroomId || !isGroupChatSession(chatroomId)) return
|
if (!chatroomId || !isGroupChatSession(chatroomId)) return
|
||||||
|
|
||||||
@@ -1041,14 +1124,52 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const cached = groupMembersPanelCacheRef.current.get(chatroomId)
|
const cached = groupMembersPanelCacheRef.current.get(chatroomId)
|
||||||
const cacheFresh = Boolean(cached && now - cached.updatedAt < GROUP_MEMBERS_PANEL_CACHE_TTL_MS)
|
const cacheFresh = Boolean(cached && now - cached.updatedAt < GROUP_MEMBERS_PANEL_CACHE_TTL_MS)
|
||||||
const hasCachedMembers = Boolean(cached && cached.members.length > 0)
|
const hasCachedMembers = Boolean(cached && cached.members.length > 0)
|
||||||
|
const hasFreshMessageCounts = Boolean(cacheFresh && cached?.includeMessageCounts)
|
||||||
|
let startedBackgroundRefresh = false
|
||||||
|
|
||||||
|
const refreshMessageCountsInBackground = (forceRefresh: boolean) => {
|
||||||
|
startedBackgroundRefresh = true
|
||||||
|
setIsRefreshingGroupMembers(true)
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const countsResult = await getGroupMembersPanelDataWithTimeout(
|
||||||
|
chatroomId,
|
||||||
|
{ forceRefresh, includeMessageCounts: true },
|
||||||
|
25000
|
||||||
|
)
|
||||||
|
if (requestSeq !== groupMembersRequestSeqRef.current) return
|
||||||
|
if (!countsResult.success || !Array.isArray(countsResult.data)) {
|
||||||
|
setGroupMembersError('成员列表已加载,发言统计稍后再试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const membersWithCounts = normalizeGroupPanelMembers(countsResult.data as GroupPanelMember[])
|
||||||
|
setGroupPanelMembers(membersWithCounts)
|
||||||
|
setGroupMembersError(null)
|
||||||
|
updateGroupMembersPanelCache(chatroomId, membersWithCounts, true)
|
||||||
|
hasInitializedGroupMembersRef.current = true
|
||||||
|
} catch {
|
||||||
|
if (requestSeq !== groupMembersRequestSeqRef.current) return
|
||||||
|
setGroupMembersError('成员列表已加载,发言统计稍后再试')
|
||||||
|
} finally {
|
||||||
|
if (requestSeq === groupMembersRequestSeqRef.current) {
|
||||||
|
setIsRefreshingGroupMembers(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
if (cacheFresh && cached) {
|
if (cacheFresh && cached) {
|
||||||
setGroupPanelMembers(cached.members)
|
setGroupPanelMembers(cached.members)
|
||||||
setGroupMembersError(null)
|
setGroupMembersError(null)
|
||||||
setGroupMembersLoadingHint('')
|
setGroupMembersLoadingHint('')
|
||||||
setIsRefreshingGroupMembers(false)
|
|
||||||
setIsLoadingGroupMembers(false)
|
setIsLoadingGroupMembers(false)
|
||||||
hasInitializedGroupMembersRef.current = true
|
hasInitializedGroupMembersRef.current = true
|
||||||
|
if (!hasFreshMessageCounts) {
|
||||||
|
refreshMessageCountsInBackground(false)
|
||||||
|
} else {
|
||||||
|
setIsRefreshingGroupMembers(false)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1070,7 +1191,11 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const membersResult = await window.electronAPI.groupAnalytics.getGroupMembersPanelData(chatroomId)
|
const membersResult = await getGroupMembersPanelDataWithTimeout(
|
||||||
|
chatroomId,
|
||||||
|
{ includeMessageCounts: false, forceRefresh: false },
|
||||||
|
12000
|
||||||
|
)
|
||||||
if (requestSeq !== groupMembersRequestSeqRef.current) return
|
if (requestSeq !== groupMembersRequestSeqRef.current) return
|
||||||
|
|
||||||
if (!membersResult.success || !Array.isArray(membersResult.data)) {
|
if (!membersResult.success || !Array.isArray(membersResult.data)) {
|
||||||
@@ -1081,58 +1206,12 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const membersPayload = membersResult.data as GroupPanelMember[]
|
const members = normalizeGroupPanelMembers(membersResult.data as GroupPanelMember[])
|
||||||
const members: GroupPanelMember[] = membersPayload
|
|
||||||
.map((member: GroupPanelMember): GroupPanelMember | null => {
|
|
||||||
const username = String(member.username || '').trim()
|
|
||||||
if (!username) return null
|
|
||||||
const preferredName = String(
|
|
||||||
member.groupNickname ||
|
|
||||||
member.remark ||
|
|
||||||
member.displayName ||
|
|
||||||
member.nickname ||
|
|
||||||
username
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
username,
|
|
||||||
displayName: preferredName,
|
|
||||||
avatarUrl: member.avatarUrl,
|
|
||||||
nickname: member.nickname,
|
|
||||||
alias: member.alias,
|
|
||||||
remark: member.remark,
|
|
||||||
groupNickname: member.groupNickname,
|
|
||||||
isOwner: Boolean(member.isOwner),
|
|
||||||
isFriend: Boolean(member.isFriend),
|
|
||||||
messageCount: Number.isFinite(member.messageCount) ? Math.max(0, Math.floor(member.messageCount)) : 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((member: GroupPanelMember | null): member is GroupPanelMember => Boolean(member))
|
|
||||||
.sort((a: GroupPanelMember, b: GroupPanelMember) => {
|
|
||||||
const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner))
|
|
||||||
if (ownerDiff !== 0) return ownerDiff
|
|
||||||
|
|
||||||
const friendDiff = Number(b.isFriend) - Number(a.isFriend)
|
|
||||||
if (friendDiff !== 0) return friendDiff
|
|
||||||
|
|
||||||
if (a.messageCount !== b.messageCount) return b.messageCount - a.messageCount
|
|
||||||
return a.displayName.localeCompare(b.displayName, 'zh-Hans-CN')
|
|
||||||
})
|
|
||||||
|
|
||||||
setGroupPanelMembers(members)
|
setGroupPanelMembers(members)
|
||||||
setGroupMembersError(null)
|
setGroupMembersError(null)
|
||||||
groupMembersPanelCacheRef.current.set(chatroomId, {
|
updateGroupMembersPanelCache(chatroomId, members, false)
|
||||||
updatedAt: Date.now(),
|
|
||||||
members
|
|
||||||
})
|
|
||||||
if (groupMembersPanelCacheRef.current.size > 80) {
|
|
||||||
const oldestEntry = Array.from(groupMembersPanelCacheRef.current.entries())
|
|
||||||
.sort((a, b) => a[1].updatedAt - b[1].updatedAt)[0]
|
|
||||||
if (oldestEntry) {
|
|
||||||
groupMembersPanelCacheRef.current.delete(oldestEntry[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hasInitializedGroupMembersRef.current = true
|
hasInitializedGroupMembersRef.current = true
|
||||||
|
refreshMessageCountsInBackground(false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (requestSeq !== groupMembersRequestSeqRef.current) return
|
if (requestSeq !== groupMembersRequestSeqRef.current) return
|
||||||
if (!hasCachedMembers) {
|
if (!hasCachedMembers) {
|
||||||
@@ -1142,11 +1221,18 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
if (requestSeq === groupMembersRequestSeqRef.current) {
|
if (requestSeq === groupMembersRequestSeqRef.current) {
|
||||||
setIsLoadingGroupMembers(false)
|
setIsLoadingGroupMembers(false)
|
||||||
setIsRefreshingGroupMembers(false)
|
|
||||||
setGroupMembersLoadingHint('')
|
setGroupMembersLoadingHint('')
|
||||||
|
if (!startedBackgroundRefresh) {
|
||||||
|
setIsRefreshingGroupMembers(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isGroupChatSession])
|
}, [
|
||||||
|
getGroupMembersPanelDataWithTimeout,
|
||||||
|
isGroupChatSession,
|
||||||
|
normalizeGroupPanelMembers,
|
||||||
|
updateGroupMembersPanelCache
|
||||||
|
])
|
||||||
|
|
||||||
const toggleGroupMembersPanel = useCallback(() => {
|
const toggleGroupMembersPanel = useCallback(() => {
|
||||||
if (!currentSessionId || !isGroupChatSession(currentSessionId)) return
|
if (!currentSessionId || !isGroupChatSession(currentSessionId)) return
|
||||||
@@ -3367,7 +3453,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
{isRefreshingGroupMembers && (
|
{isRefreshingGroupMembers && (
|
||||||
<div className="group-members-status" role="status" aria-live="polite">
|
<div className="group-members-status" role="status" aria-live="polite">
|
||||||
<Loader2 size={14} className="spin" />
|
<Loader2 size={14} className="spin" />
|
||||||
<span>正在更新群成员数据...</span>
|
<span>正在统计成员发言数...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{groupMembersError && groupPanelMembers.length > 0 && (
|
{groupMembersError && groupPanelMembers.length > 0 && (
|
||||||
|
|||||||
@@ -3250,6 +3250,13 @@ function ExportPage() {
|
|||||||
const topSessions = isPerfExpanded
|
const topSessions = isPerfExpanded
|
||||||
? getTaskPerformanceTopSessions(task.performance, nowTick, 5)
|
? getTaskPerformanceTopSessions(task.performance, nowTick, 5)
|
||||||
: []
|
: []
|
||||||
|
const normalizedProgressTotal = task.progress.total > 0 ? task.progress.total : 0
|
||||||
|
const normalizedProgressCurrent = normalizedProgressTotal > 0
|
||||||
|
? Math.max(0, Math.min(normalizedProgressTotal, task.progress.current))
|
||||||
|
: 0
|
||||||
|
const currentSessionRatio = task.progress.phaseTotal > 0
|
||||||
|
? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal))
|
||||||
|
: null
|
||||||
return (
|
return (
|
||||||
<div key={task.id} className={`task-card ${task.status} ${task.controlState ? `request-${task.controlState}` : ''}`}>
|
<div key={task.id} className={`task-card ${task.status} ${task.controlState ? `request-${task.controlState}` : ''}`}>
|
||||||
<div className="task-main">
|
<div className="task-main">
|
||||||
@@ -3263,13 +3270,16 @@ function ExportPage() {
|
|||||||
<div className="task-progress-bar">
|
<div className="task-progress-bar">
|
||||||
<div
|
<div
|
||||||
className="task-progress-fill"
|
className="task-progress-fill"
|
||||||
style={{ width: `${task.progress.total > 0 ? (task.progress.current / task.progress.total) * 100 : 0}%` }}
|
style={{ width: `${normalizedProgressTotal > 0 ? (normalizedProgressCurrent / normalizedProgressTotal) * 100 : 0}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="task-progress-text">
|
<div className="task-progress-text">
|
||||||
{task.progress.total > 0
|
{normalizedProgressTotal > 0
|
||||||
? `${task.progress.current} / ${task.progress.total}`
|
? `${Math.floor(normalizedProgressCurrent)} / ${normalizedProgressTotal}`
|
||||||
: '处理中'}
|
: '处理中'}
|
||||||
|
{task.status === 'running' && currentSessionRatio !== null
|
||||||
|
? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)`
|
||||||
|
: ''}
|
||||||
{task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''}
|
{task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
5
src/types/electron.d.ts
vendored
5
src/types/electron.d.ts
vendored
@@ -351,7 +351,10 @@ export interface ElectronAPI {
|
|||||||
}>
|
}>
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
getGroupMembersPanelData: (chatroomId: string, forceRefresh?: boolean) => Promise<{
|
getGroupMembersPanelData: (
|
||||||
|
chatroomId: string,
|
||||||
|
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
|
||||||
|
) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
data?: Array<{
|
data?: Array<{
|
||||||
username: string
|
username: string
|
||||||
|
|||||||
Reference in New Issue
Block a user