mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-26 23:16:52 +00:00
Compare commits
9 Commits
dependabot
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
338d0e2f20 | ||
|
|
a86a51c30c | ||
|
|
043332d297 | ||
|
|
608f74a3f9 | ||
|
|
551d05fe2e | ||
|
|
c9317f76a3 | ||
|
|
ffd533d865 | ||
|
|
1976edc483 | ||
|
|
bb42a7c0b2 |
@@ -3,17 +3,15 @@
|
||||
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。
|
||||
|
||||
<p align="center">
|
||||
<img src="app.png" alt="WeFlow 应用预览" width="90%">
|
||||
<img src="app.jpg" alt="WeFlow 应用预览" width="90%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<!-- 第一行修复样式 -->
|
||||
<a href="https://github.com/hicccc77/WeFlow/stargazers"><img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat&label=Stars&labelColor=1F2937&color=2563EB" alt="Stargazers"></a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/network/members"><img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat&label=Forks&labelColor=1F2937&color=7C3AED" alt="Forks"></a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/issues"><img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat&label=Issues&labelColor=1F2937&color=D97706" alt="Issues"></a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/releases"><img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat&label=Downloads&labelColor=1F2937&color=059669" alt="Downloads"></a>
|
||||
<br><br>
|
||||
<!-- 第二行:电报矮一点(22px),排名高一点(32px),使用 vertical-align: middle 居中对齐 -->
|
||||
<a href="https://t.me/weflow_cc"><img src="https://img.shields.io/badge/Telegram-频道-1D9BF0?style=flat&logo=telegram&logoColor=white&labelColor=1F2937&color=1D9BF0" alt="Telegram Channel" style="height: 22px; vertical-align: middle;"></a>
|
||||
<a href="https://www.star-history.com/hicccc77/weflow"><img src="https://api.star-history.com/badge?repo=hicccc77/WeFlow&theme=dark" alt="Star History Rank" style="height: 32px; vertical-align: middle;"></a>
|
||||
</p>
|
||||
|
||||
@@ -20,9 +20,10 @@
|
||||
1. **降级微信版本**。找一个经过大家验证、兼容性更好的老版本,目前最推荐先退回到 4.1.7.57 或者 4.1.8.100。
|
||||
2. **彻底退出微信**。请使用快捷键 Command + Q 或在活动监视器中结束进程,而不仅仅是关闭窗口。
|
||||
3. **重启你的 Mac**。这一步极其关键,必须是真正的重新启动。注销或睡眠唤醒无法清除系统底层的拦截状态。
|
||||
4. **重新打开微信**。随便点击几下保持它在最前台,并且确保它是可以正常交互的状态。
|
||||
4. **重新打开微信**。随便点击几下保持它在最前台,并且确保它是未登录的状态。
|
||||
5. **回到 WeFlow**。仅仅尝试一次“自动获取密钥”。
|
||||
6. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。
|
||||
6. **输入密码并登录**。先在弹窗中输入你的系统密码后,确认页面弹出允许登录了再登录微信
|
||||
7. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。
|
||||
|
||||
### 常见报错与应对方法
|
||||
|
||||
@@ -50,4 +51,4 @@
|
||||
|
||||
首次失败后,首要任务是排查原因,切忌盲目地连续点击自动获取。如果你在看到这篇文档前已经失败了好几次,最好的做法是直接清零重来:彻底退出微信,重启电脑,然后再进行下一次尝试。
|
||||
|
||||
最后,如果尝试了上述所有方法依然无法解决,请记得保存完整的报错文本,特别是 SCAN_FAILED 或 HOOK_FAILED 后面跟着的英文细节。把这些信息提交到[issue](https://github.com/hicccc77/WeFlow/issues/745),会大大加快定位和修复兼容性问题的速度。
|
||||
最后,如果尝试了上述所有方法依然无法解决,请记得保存完整的报错文本,特别是 SCAN_FAILED 或 HOOK_FAILED 后面跟着的英文细节。把这些信息提交到[issue](https://github.com/hicccc77/WeFlow/issues/745),会大大加快定位和修复兼容性问题的速度。
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { parentPort, workerData } from 'worker_threads'
|
||||
import type { ExportOptions } from './services/exportService'
|
||||
|
||||
interface ExportWorkerConfig {
|
||||
sessionIds: string[]
|
||||
outputDir: string
|
||||
options: ExportOptions
|
||||
mode?: 'sessions' | 'single' | 'contacts'
|
||||
sessionIds?: string[]
|
||||
sessionId?: string
|
||||
outputDir?: string
|
||||
outputPath?: string
|
||||
options?: any
|
||||
taskId?: string
|
||||
dbPath?: string
|
||||
decryptKey?: string
|
||||
myWxid?: string
|
||||
imageXorKey?: unknown
|
||||
imageAesKey?: string
|
||||
resourcesPath?: string
|
||||
userDataPath?: string
|
||||
logEnabled?: boolean
|
||||
@@ -20,6 +24,93 @@ const controlState = {
|
||||
stopRequested: false
|
||||
}
|
||||
|
||||
const CREATED_PATH_FLUSH_INTERVAL_MS = 200
|
||||
const CREATED_PATH_BATCH_LIMIT = 256
|
||||
const PROGRESS_POST_INTERVAL_MS = 180
|
||||
let queuedCreatedFiles: string[] = []
|
||||
let queuedCreatedDirs: string[] = []
|
||||
let createdPathFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pendingProgress: any = null
|
||||
let progressPostTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let lastProgressPostedAt = 0
|
||||
|
||||
function flushCreatedPaths() {
|
||||
if (createdPathFlushTimer) {
|
||||
clearTimeout(createdPathFlushTimer)
|
||||
createdPathFlushTimer = null
|
||||
}
|
||||
const filePaths = queuedCreatedFiles
|
||||
const dirPaths = queuedCreatedDirs
|
||||
queuedCreatedFiles = []
|
||||
queuedCreatedDirs = []
|
||||
if (!parentPort) return
|
||||
if (filePaths.length > 0) {
|
||||
parentPort.postMessage({ type: 'export:createdFiles', filePaths })
|
||||
}
|
||||
if (dirPaths.length > 0) {
|
||||
parentPort.postMessage({ type: 'export:createdDirs', dirPaths })
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleCreatedPathFlush() {
|
||||
if (createdPathFlushTimer) return
|
||||
createdPathFlushTimer = setTimeout(flushCreatedPaths, CREATED_PATH_FLUSH_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function queueCreatedFile(filePath: string) {
|
||||
const normalized = String(filePath || '').trim()
|
||||
if (!normalized) return
|
||||
queuedCreatedFiles.push(normalized)
|
||||
if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) {
|
||||
flushCreatedPaths()
|
||||
} else {
|
||||
scheduleCreatedPathFlush()
|
||||
}
|
||||
}
|
||||
|
||||
function queueCreatedDir(dirPath: string) {
|
||||
const normalized = String(dirPath || '').trim()
|
||||
if (!normalized) return
|
||||
queuedCreatedDirs.push(normalized)
|
||||
if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) {
|
||||
flushCreatedPaths()
|
||||
} else {
|
||||
scheduleCreatedPathFlush()
|
||||
}
|
||||
}
|
||||
|
||||
function flushProgress() {
|
||||
if (!pendingProgress) return
|
||||
if (progressPostTimer) {
|
||||
clearTimeout(progressPostTimer)
|
||||
progressPostTimer = null
|
||||
}
|
||||
parentPort?.postMessage({
|
||||
type: 'export:progress',
|
||||
data: pendingProgress
|
||||
})
|
||||
pendingProgress = null
|
||||
lastProgressPostedAt = Date.now()
|
||||
}
|
||||
|
||||
function queueProgress(progress: any) {
|
||||
pendingProgress = progress
|
||||
if (progress?.phase === 'complete') {
|
||||
flushProgress()
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const elapsed = now - lastProgressPostedAt
|
||||
if (elapsed >= PROGRESS_POST_INTERVAL_MS) {
|
||||
flushProgress()
|
||||
return
|
||||
}
|
||||
|
||||
if (progressPostTimer) return
|
||||
progressPostTimer = setTimeout(flushProgress, PROGRESS_POST_INTERVAL_MS - elapsed)
|
||||
}
|
||||
|
||||
parentPort?.on('message', (message: any) => {
|
||||
if (!message || typeof message.type !== 'string') return
|
||||
if (message.type === 'export:pause') {
|
||||
@@ -57,32 +148,49 @@ async function run() {
|
||||
exportService.setRuntimeConfig({
|
||||
dbPath: config.dbPath,
|
||||
decryptKey: config.decryptKey,
|
||||
myWxid: config.myWxid
|
||||
myWxid: config.myWxid,
|
||||
imageXorKey: config.imageXorKey,
|
||||
imageAesKey: config.imageAesKey
|
||||
})
|
||||
|
||||
const result = await exportService.exportSessions(
|
||||
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
||||
String(config.outputDir || ''),
|
||||
config.options || { format: 'json' },
|
||||
(progress) => {
|
||||
parentPort?.postMessage({
|
||||
type: 'export:progress',
|
||||
data: progress
|
||||
})
|
||||
},
|
||||
config.taskId
|
||||
? {
|
||||
shouldPause: () => controlState.pauseRequested,
|
||||
shouldStop: () => controlState.stopRequested,
|
||||
recordCreatedFile: (filePath: string) => {
|
||||
parentPort?.postMessage({ type: 'export:createdFile', filePath })
|
||||
},
|
||||
recordCreatedDir: (dirPath: string) => {
|
||||
parentPort?.postMessage({ type: 'export:createdDir', dirPath })
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
const onProgress = (progress: any) => queueProgress(progress)
|
||||
|
||||
const taskControl = config.taskId
|
||||
? {
|
||||
shouldPause: () => controlState.pauseRequested,
|
||||
shouldStop: () => controlState.stopRequested,
|
||||
recordCreatedFile: queueCreatedFile,
|
||||
recordCreatedDir: queueCreatedDir
|
||||
}
|
||||
: undefined
|
||||
|
||||
let result: any
|
||||
if (config.mode === 'contacts') {
|
||||
const { contactExportService } = await import('./services/contactExportService')
|
||||
result = await contactExportService.exportContacts(
|
||||
String(config.outputDir || ''),
|
||||
config.options || {}
|
||||
)
|
||||
} else if (config.mode === 'single') {
|
||||
result = await exportService.exportSessionToChatLab(
|
||||
String(config.sessionId || '').trim(),
|
||||
String(config.outputPath || '').trim(),
|
||||
config.options || { format: 'chatlab' },
|
||||
onProgress,
|
||||
taskControl
|
||||
)
|
||||
} else {
|
||||
result = await exportService.exportSessions(
|
||||
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
||||
String(config.outputDir || ''),
|
||||
config.options || { format: 'json' },
|
||||
onProgress,
|
||||
taskControl
|
||||
)
|
||||
}
|
||||
|
||||
flushProgress()
|
||||
flushCreatedPaths()
|
||||
|
||||
parentPort?.postMessage({
|
||||
type: 'export:result',
|
||||
@@ -91,6 +199,8 @@ async function run() {
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
flushProgress()
|
||||
flushCreatedPaths()
|
||||
parentPort?.postMessage({
|
||||
type: 'export:error',
|
||||
error: String(error)
|
||||
|
||||
171
electron/main.ts
171
electron/main.ts
@@ -23,7 +23,6 @@ import { KeyServiceMac } from './services/keyServiceMac'
|
||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||
import { videoService } from './services/videoService'
|
||||
import { snsService, isVideoUrl } from './services/snsService'
|
||||
import { contactExportService } from './services/contactExportService'
|
||||
import { windowsHelloService } from './services/windowsHelloService'
|
||||
import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService'
|
||||
import { cloudControlService } from './services/cloudControlService'
|
||||
@@ -3046,7 +3045,7 @@ function registerIpcHandlers() {
|
||||
|
||||
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions, controlOptions?: { taskId?: string }) => {
|
||||
const taskId = normalizeExportTaskId(controlOptions?.taskId)
|
||||
const taskControl = taskId ? exportTaskControlService.createControl(taskId, outputDir) : undefined
|
||||
if (taskId) exportTaskControlService.createControl(taskId, outputDir)
|
||||
if (taskId) activeExportTasks.add(taskId)
|
||||
const PROGRESS_FORWARD_INTERVAL_MS = 180
|
||||
let pendingProgress: ExportProgress | null = null
|
||||
@@ -3091,17 +3090,13 @@ function registerIpcHandlers() {
|
||||
queueProgress(progress)
|
||||
}
|
||||
|
||||
const runMainFallback = async (reason: string) => {
|
||||
console.warn(`[fallback-export-main] ${reason}`)
|
||||
return exportService.exportSessions(sessionIds, outputDir, options, onProgress, taskControl)
|
||||
}
|
||||
|
||||
const cfg = configService || new ConfigService()
|
||||
configService = cfg
|
||||
const logEnabled = cfg.get('logEnabled')
|
||||
const dbPath = String(cfg.get('dbPath') || '').trim()
|
||||
const decryptKey = String(cfg.get('decryptKey') || '').trim()
|
||||
const myWxid = String(cfg.get('myWxid') || '').trim()
|
||||
const imageKeys = cfg.getImageKeysForCurrentWxid()
|
||||
const resourcesPath = app.isPackaged
|
||||
? join(process.resourcesPath, 'resources')
|
||||
: join(app.getAppPath(), 'resources')
|
||||
@@ -3119,6 +3114,8 @@ function registerIpcHandlers() {
|
||||
dbPath,
|
||||
decryptKey,
|
||||
myWxid,
|
||||
imageXorKey: imageKeys.xorKey,
|
||||
imageAesKey: imageKeys.aesKey,
|
||||
resourcesPath,
|
||||
userDataPath,
|
||||
logEnabled
|
||||
@@ -3155,6 +3152,20 @@ function registerIpcHandlers() {
|
||||
onProgress(msg.data as ExportProgress)
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:createdFiles' && taskId) {
|
||||
const filePaths = Array.isArray(msg.filePaths) ? msg.filePaths : []
|
||||
for (const filePath of filePaths) {
|
||||
exportTaskControlService.recordCreatedFile(taskId, String(filePath || ''))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:createdDirs' && taskId) {
|
||||
const dirPaths = Array.isArray(msg.dirPaths) ? msg.dirPaths : []
|
||||
for (const dirPath of dirPaths) {
|
||||
exportTaskControlService.recordCreatedDir(taskId, String(dirPath || ''))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:createdFile' && taskId) {
|
||||
exportTaskControlService.recordCreatedFile(taskId, String(msg.filePath || ''))
|
||||
return
|
||||
@@ -3191,7 +3202,21 @@ function registerIpcHandlers() {
|
||||
const result = await runWorker()
|
||||
return await finalizeExportTaskControlResult(taskId, result)
|
||||
} catch (error) {
|
||||
const result = await runMainFallback(error instanceof Error ? error.message : String(error))
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[export-worker] ${errorMessage}`)
|
||||
const normalizedSessionIds = Array.isArray(sessionIds) ? sessionIds : []
|
||||
const failedSessionErrors: Record<string, string> = {}
|
||||
for (const sessionId of normalizedSessionIds) {
|
||||
failedSessionErrors[sessionId] = errorMessage
|
||||
}
|
||||
const result = {
|
||||
success: false,
|
||||
successCount: 0,
|
||||
failCount: normalizedSessionIds.length,
|
||||
failedSessionIds: normalizedSessionIds,
|
||||
failedSessionErrors,
|
||||
error: `导出 Worker 执行失败: ${errorMessage}`
|
||||
}
|
||||
return await finalizeExportTaskControlResult(taskId, result)
|
||||
} finally {
|
||||
if (taskId) activeExportTasks.delete(taskId)
|
||||
@@ -3203,12 +3228,136 @@ function registerIpcHandlers() {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
|
||||
return exportService.exportSessionToChatLab(sessionId, outputPath, options)
|
||||
ipcMain.handle('export:exportSession', async (event, sessionId: string, outputPath: string, options: ExportOptions) => {
|
||||
const cfg = configService || new ConfigService()
|
||||
configService = cfg
|
||||
const imageKeys = cfg.getImageKeysForCurrentWxid()
|
||||
const workerPath = join(__dirname, 'exportWorker.js')
|
||||
|
||||
try {
|
||||
return await new Promise<any>((resolve) => {
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: {
|
||||
mode: 'single',
|
||||
sessionId,
|
||||
outputPath,
|
||||
options,
|
||||
dbPath: String(cfg.get('dbPath') || '').trim(),
|
||||
decryptKey: String(cfg.get('decryptKey') || '').trim(),
|
||||
myWxid: String(cfg.get('myWxid') || '').trim(),
|
||||
imageXorKey: imageKeys.xorKey,
|
||||
imageAesKey: imageKeys.aesKey,
|
||||
resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'),
|
||||
userDataPath: app.getPath('userData'),
|
||||
logEnabled: cfg.get('logEnabled')
|
||||
}
|
||||
})
|
||||
|
||||
let settled = false
|
||||
const finalize = (value: any) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
worker.removeAllListeners()
|
||||
void worker.terminate()
|
||||
resolve(value)
|
||||
}
|
||||
const fail = (error: unknown) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[export-worker-single] ${errorMessage}`)
|
||||
finalize({ success: false, error: `导出 Worker 执行失败: ${errorMessage}` })
|
||||
}
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg && msg.type === 'export:progress') {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('export:progress', msg.data)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:result') {
|
||||
finalize(msg.data)
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:error') {
|
||||
fail(String(msg.error || '导出 Worker 执行失败'))
|
||||
}
|
||||
})
|
||||
worker.on('error', fail)
|
||||
worker.on('exit', (code) => {
|
||||
if (settled) return
|
||||
if (code === 0) {
|
||||
finalize({ success: false, error: '导出 Worker 未返回结果' })
|
||||
} else {
|
||||
fail(`导出 Worker 异常退出: ${code}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[export-worker-single] ${errorMessage}`)
|
||||
return { success: false, error: `导出 Worker 启动失败: ${errorMessage}` }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('export:exportContacts', async (_, outputDir: string, options: any) => {
|
||||
return contactExportService.exportContacts(outputDir, options)
|
||||
const cfg = configService || new ConfigService()
|
||||
configService = cfg
|
||||
const workerPath = join(__dirname, 'exportWorker.js')
|
||||
|
||||
try {
|
||||
return await new Promise<any>((resolve) => {
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: {
|
||||
mode: 'contacts',
|
||||
outputDir,
|
||||
options,
|
||||
dbPath: String(cfg.get('dbPath') || '').trim(),
|
||||
decryptKey: String(cfg.get('decryptKey') || '').trim(),
|
||||
myWxid: String(cfg.get('myWxid') || '').trim(),
|
||||
resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'),
|
||||
userDataPath: app.getPath('userData'),
|
||||
logEnabled: cfg.get('logEnabled')
|
||||
}
|
||||
})
|
||||
|
||||
let settled = false
|
||||
const finalize = (value: any) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
worker.removeAllListeners()
|
||||
void worker.terminate()
|
||||
resolve(value)
|
||||
}
|
||||
const fail = (error: unknown) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[export-worker-contacts] ${errorMessage}`)
|
||||
finalize({ success: false, error: `导出 Worker 执行失败: ${errorMessage}` })
|
||||
}
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg && msg.type === 'export:result') {
|
||||
finalize(msg.data)
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:error') {
|
||||
fail(String(msg.error || '导出 Worker 执行失败'))
|
||||
}
|
||||
})
|
||||
worker.on('error', fail)
|
||||
worker.on('exit', (code) => {
|
||||
if (settled) return
|
||||
if (code === 0) {
|
||||
finalize({ success: false, error: '导出 Worker 未返回结果' })
|
||||
} else {
|
||||
fail(`导出 Worker 异常退出: ${code}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[export-worker-contacts] ${errorMessage}`)
|
||||
return { success: false, error: `导出 Worker 启动失败: ${errorMessage}` }
|
||||
}
|
||||
})
|
||||
|
||||
// 数据分析相关
|
||||
|
||||
@@ -112,6 +112,7 @@ export interface ExportOptions {
|
||||
excelCompactColumns?: boolean
|
||||
txtColumns?: string[]
|
||||
sessionLayout?: 'shared' | 'per-session'
|
||||
exportWriteLayout?: 'A' | 'B' | 'C'
|
||||
sessionNameWithTypePrefix?: boolean
|
||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||
exportConcurrency?: number
|
||||
@@ -271,7 +272,7 @@ async function parallelLimit<T, R>(
|
||||
|
||||
class ExportService {
|
||||
private configService: ConfigService
|
||||
private runtimeConfig: { dbPath?: string; decryptKey?: string; myWxid?: string } | null = null
|
||||
private runtimeConfig: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null = null
|
||||
private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }>
|
||||
private inlineEmojiCache: LRUCache<string, string>
|
||||
private htmlStyleCache: string | null = null
|
||||
@@ -287,6 +288,8 @@ class ExportService {
|
||||
private mediaExportTelemetry: MediaExportTelemetry | null = null
|
||||
private mediaRunSourceDedupMap = new Map<string, string>()
|
||||
private mediaRunMissingImageKeys = new Set<string>()
|
||||
private activeChatImagePipelineCount = 0
|
||||
private chatImagePipelineWaiters: Array<() => void> = []
|
||||
private mediaFileCacheCleanupPending: Promise<void> | null = null
|
||||
private mediaFileCacheLastCleanupAt = 0
|
||||
private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000
|
||||
@@ -320,8 +323,22 @@ class ExportService {
|
||||
return error
|
||||
}
|
||||
|
||||
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string } | null): void {
|
||||
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void {
|
||||
this.runtimeConfig = config
|
||||
imageDecryptService.setRuntimeConfig({
|
||||
dbPath: config?.dbPath,
|
||||
myWxid: config?.myWxid,
|
||||
imageXorKey: config?.imageXorKey,
|
||||
imageAesKey: config?.imageAesKey
|
||||
})
|
||||
}
|
||||
|
||||
private getConfiguredDbPath(): string {
|
||||
return String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
|
||||
}
|
||||
|
||||
private getConfiguredMyWxid(): string {
|
||||
return String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
|
||||
}
|
||||
|
||||
private normalizeSessionIds(sessionIds: string[]): string[] {
|
||||
@@ -354,6 +371,33 @@ class ExportService {
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
private normalizeMaxFileSizeMb(value: unknown): number | undefined {
|
||||
const raw = Number(value)
|
||||
if (!Number.isFinite(raw) || raw <= 0) return undefined
|
||||
return Math.floor(raw)
|
||||
}
|
||||
|
||||
private normalizeExportOptionsForRun(options: ExportOptions): ExportOptions {
|
||||
const normalizedDateRange = this.normalizeExportDateRange(options.dateRange)
|
||||
const normalizedMaxFileSizeMb = this.normalizeMaxFileSizeMb(options.maxFileSizeMb)
|
||||
const normalizedWriteLayout = this.resolveExportWriteLayout(options)
|
||||
return {
|
||||
...options,
|
||||
dateRange: normalizedDateRange,
|
||||
maxFileSizeMb: normalizedMaxFileSizeMb,
|
||||
exportWriteLayout: normalizedWriteLayout
|
||||
}
|
||||
}
|
||||
|
||||
private resolveExportWriteLayout(options?: Pick<ExportOptions, 'exportWriteLayout'> | null): 'A' | 'B' | 'C' {
|
||||
const optionLayout = options?.exportWriteLayout
|
||||
if (optionLayout === 'A' || optionLayout === 'B' || optionLayout === 'C') return optionLayout
|
||||
const rawWriteLayout = this.configService.get('exportWriteLayout')
|
||||
return rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
|
||||
? rawWriteLayout
|
||||
: 'B'
|
||||
}
|
||||
|
||||
private getExportStatsDateRangeToken(dateRange?: { start: number; end: number } | null): string {
|
||||
const normalized = this.normalizeExportDateRange(dateRange)
|
||||
if (!normalized) return 'all'
|
||||
@@ -370,8 +414,8 @@ class ExportService {
|
||||
const normalizedIds = this.normalizeSessionIds(sessionIds).sort()
|
||||
const senderToken = String(options.senderUsername || '').trim()
|
||||
const dateToken = this.getExportStatsDateRangeToken(options.dateRange)
|
||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||
const wxidToken = String(cleanedWxid || this.cleanAccountDirName(String(this.configService.get('myWxid') || '')) || '').trim()
|
||||
const dbPath = this.getConfiguredDbPath()
|
||||
const wxidToken = String(cleanedWxid || this.cleanAccountDirName(this.getConfiguredMyWxid()) || '').trim()
|
||||
return `${dbPath}::${wxidToken}::${dateToken}::${senderToken}::${normalizedIds.join('\u001f')}`
|
||||
}
|
||||
|
||||
@@ -712,6 +756,20 @@ class ExportService {
|
||||
this.mediaRunMissingImageKeys.clear()
|
||||
}
|
||||
|
||||
private async runWithChatImagePipelineLimit<T>(fn: () => Promise<T>): Promise<T> {
|
||||
while (this.activeChatImagePipelineCount >= 2) {
|
||||
await new Promise<void>((resolve) => this.chatImagePipelineWaiters.push(resolve))
|
||||
}
|
||||
this.activeChatImagePipelineCount += 1
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
this.activeChatImagePipelineCount = Math.max(0, this.activeChatImagePipelineCount - 1)
|
||||
const next = this.chatImagePipelineWaiters.shift()
|
||||
if (next) next()
|
||||
}
|
||||
}
|
||||
|
||||
private getMediaTelemetrySnapshot(): Partial<ExportProgress> {
|
||||
const stats = this.mediaExportTelemetry
|
||||
if (!stats) return {}
|
||||
@@ -1577,8 +1635,8 @@ class ExportService {
|
||||
}
|
||||
|
||||
private resolveStrictEmoticonDbPath(): string | null {
|
||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||
const rawWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
const dbPath = this.getConfiguredDbPath()
|
||||
const rawWxid = this.getConfiguredMyWxid()
|
||||
const cleanedWxid = this.cleanAccountDirName(rawWxid)
|
||||
const token = `${dbPath}::${rawWxid}::${cleanedWxid}`
|
||||
if (token === this.emoticonDbPathCacheToken) {
|
||||
@@ -1823,8 +1881,8 @@ class ExportService {
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
|
||||
const wxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
|
||||
const dbPath = String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
|
||||
const wxid = this.getConfiguredMyWxid()
|
||||
const dbPath = this.getConfiguredDbPath()
|
||||
const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim()
|
||||
if (!wxid) return { success: false, error: '请先在设置页面配置微信ID' }
|
||||
if (!dbPath) return { success: false, error: '请先在设置页面配置数据库路径' }
|
||||
@@ -4092,44 +4150,79 @@ class ExportService {
|
||||
|
||||
const tryResolveImagePath = async (imageMd5?: string, imageDatName?: string): Promise<string | null> => {
|
||||
if (!imageMd5 && !imageDatName) return null
|
||||
return this.runWithChatImagePipelineLimit(async () => {
|
||||
const pickResolvedImagePath = (result: any): string | null => {
|
||||
if (!result?.success) return null
|
||||
const resolved = String(result.localPath || '').trim()
|
||||
return resolved || null
|
||||
}
|
||||
|
||||
const decryptResult = await imageDecryptService.decryptImage({
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
createTime: msg.createTime,
|
||||
force: true, // 导出优先高清,失败再回退缩略图
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
allowCacheIndex: !imageMd5,
|
||||
suppressEvents: true
|
||||
const resolveCachedPath = async (candidateMd5?: string, candidateDatName?: string): Promise<string | null> => {
|
||||
const cachedResult = await imageDecryptService.resolveCachedImage({
|
||||
sessionId,
|
||||
imageMd5: candidateMd5,
|
||||
imageDatName: candidateDatName,
|
||||
createTime: msg.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
allowCacheIndex: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
return pickResolvedImagePath(cachedResult)
|
||||
}
|
||||
|
||||
const cachedPath = await resolveCachedPath(imageMd5, imageDatName)
|
||||
if (cachedPath) {
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
const decryptResult = await imageDecryptService.decryptImage({
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
createTime: msg.createTime,
|
||||
force: false,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
allowCacheIndex: true
|
||||
})
|
||||
const decryptedPath = pickResolvedImagePath(decryptResult)
|
||||
if (decryptedPath) return decryptedPath
|
||||
|
||||
const localId = Number(msg?.localId || 0)
|
||||
if (Number.isFinite(localId) && localId > 0) {
|
||||
const fallback = await chatService.getImageData(sessionId, String(localId))
|
||||
if (fallback.success && fallback.data) {
|
||||
const buffer = Buffer.from(fallback.data, 'base64')
|
||||
const mime = this.detectMimeType(buffer) || 'image/jpeg'
|
||||
return `data:${mime};base64,${fallback.data}`
|
||||
}
|
||||
}
|
||||
|
||||
if (decryptResult.failureKind === 'decrypt_failed') {
|
||||
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`)
|
||||
} else {
|
||||
console.log(`[Export] 图片本地无数据 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`)
|
||||
}
|
||||
|
||||
const thumbResult = await imageDecryptService.resolveCachedImage({
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
createTime: msg.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
allowCacheIndex: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (thumbResult.success && thumbResult.localPath) {
|
||||
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
|
||||
return thumbResult.localPath
|
||||
}
|
||||
return null
|
||||
})
|
||||
if (decryptResult.success && decryptResult.localPath) {
|
||||
return decryptResult.localPath
|
||||
}
|
||||
|
||||
if (decryptResult.failureKind === 'decrypt_failed') {
|
||||
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`)
|
||||
} else {
|
||||
console.log(`[Export] 图片本地无数据 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`)
|
||||
}
|
||||
|
||||
const thumbResult = await imageDecryptService.resolveCachedImage({
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
createTime: msg.createTime,
|
||||
preferFilePath: true,
|
||||
disableUpdateCheck: true,
|
||||
allowCacheIndex: !imageMd5,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (thumbResult.success && thumbResult.localPath) {
|
||||
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
|
||||
return thumbResult.localPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 使用消息对象中已提取的字段,先尝试快速导出。
|
||||
@@ -4235,11 +4328,10 @@ class ExportService {
|
||||
const imageMd5 = String(msg?.imageMd5 || '').trim().toLowerCase()
|
||||
if (imageMd5) {
|
||||
imageMd5Set.add(imageMd5)
|
||||
} else {
|
||||
const imageDatName = String(msg?.imageDatName || '').trim().toLowerCase()
|
||||
if (md5Pattern.test(imageDatName)) {
|
||||
imageMd5Set.add(imageDatName)
|
||||
}
|
||||
}
|
||||
const imageDatName = String(msg?.imageDatName || '').trim().toLowerCase()
|
||||
if (md5Pattern.test(imageDatName)) {
|
||||
imageMd5Set.add(imageDatName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4487,16 +4579,89 @@ class ExportService {
|
||||
*/
|
||||
private extractImageDatName(content: string): string | undefined {
|
||||
if (!content) return undefined
|
||||
// 尝试从 cdnthumburl 或其他字段提取
|
||||
const urlMatch = /cdnthumburl[^>]*>([^<]+)/i.exec(content)
|
||||
if (urlMatch) {
|
||||
const urlParts = urlMatch[1].split('/')
|
||||
const last = urlParts[urlParts.length - 1]
|
||||
if (last && last.includes('_')) {
|
||||
return last.split('_')[0]
|
||||
}
|
||||
const candidate =
|
||||
this.extractXmlValue(content, 'imgname') ||
|
||||
this.extractXmlValue(content, 'cdnmidimgurl') ||
|
||||
this.extractXmlValue(content, 'cdnthumburl') ||
|
||||
this.extractXmlAttribute(content, 'img', 'imgname') ||
|
||||
this.extractXmlAttribute(content, 'img', 'cdnmidimgurl') ||
|
||||
this.extractXmlAttribute(content, 'img', 'cdnthumburl')
|
||||
return this.normalizeImageDatNameToken(candidate)
|
||||
}
|
||||
|
||||
private normalizeImageDatNameToken(value: unknown): string | undefined {
|
||||
let text = String(value ?? '').trim()
|
||||
if (!text) return undefined
|
||||
text = text.replace(/&/g, '&')
|
||||
try {
|
||||
if (text.includes('%')) text = decodeURIComponent(text)
|
||||
} catch { }
|
||||
|
||||
const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(text)
|
||||
if (datLike?.[1]) return datLike[1].toLowerCase()
|
||||
|
||||
const base = text
|
||||
.split(/[?#]/, 1)[0]
|
||||
.replace(/^.*[\\/]/, '')
|
||||
.replace(/\.(?:t\.)?dat$/i, '')
|
||||
.trim()
|
||||
if (!base) return undefined
|
||||
|
||||
const cdnToken = base.includes('_') ? base.split('_')[0] : base
|
||||
const exact = /^([a-fA-F0-9]{16,64})$/.exec(cdnToken)
|
||||
if (exact?.[1]) return exact[1].toLowerCase()
|
||||
|
||||
const preferred32 = /([a-fA-F0-9]{32})(?![a-fA-F0-9])/i.exec(cdnToken)
|
||||
if (preferred32?.[1]) return preferred32[1].toLowerCase()
|
||||
const fallback = /([a-fA-F0-9]{16,64})(?![a-fA-F0-9])/i.exec(cdnToken)
|
||||
return fallback?.[1]?.toLowerCase()
|
||||
}
|
||||
|
||||
private extractImageDatNameFromPackedRaw(raw: unknown): string | undefined {
|
||||
const buffer = this.decodePackedInfoBuffer(raw)
|
||||
if (!buffer || buffer.length === 0) return undefined
|
||||
const printable: number[] = []
|
||||
for (const byte of buffer) {
|
||||
printable.push(byte >= 0x20 && byte <= 0x7e ? byte : 0x20)
|
||||
}
|
||||
return undefined
|
||||
const text = Buffer.from(printable).toString('utf-8')
|
||||
const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(text)
|
||||
if (datLike?.[1]) return datLike[1].toLowerCase()
|
||||
const fallback = /([0-9a-fA-F]{16,})/.exec(text)
|
||||
return fallback?.[1]?.toLowerCase()
|
||||
}
|
||||
|
||||
private extractImageDatNameFromRow(row: Record<string, any>, content?: string): string | undefined {
|
||||
const byColumn = this.normalizeImageDatNameToken(this.getRowField(row, [
|
||||
'image_path',
|
||||
'imagePath',
|
||||
'image_dat_name',
|
||||
'imageDatName',
|
||||
'img_path',
|
||||
'imgPath',
|
||||
'img_name',
|
||||
'imgName'
|
||||
]))
|
||||
if (byColumn) return byColumn
|
||||
|
||||
const packedRaw = this.getRowField(row, [
|
||||
'packed_info_data',
|
||||
'packedInfoData',
|
||||
'packed_info_blob',
|
||||
'packedInfoBlob',
|
||||
'packed_info',
|
||||
'packedInfo',
|
||||
'BytesExtra',
|
||||
'bytes_extra',
|
||||
'WCDB_CT_packed_info',
|
||||
'reserved0',
|
||||
'Reserved0',
|
||||
'WCDB_CT_Reserved0'
|
||||
])
|
||||
const byPacked = this.extractImageDatNameFromPackedRaw(packedRaw)
|
||||
if (byPacked) return byPacked
|
||||
|
||||
return this.extractImageDatName(content || '')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4699,8 +4864,8 @@ class ExportService {
|
||||
}
|
||||
|
||||
private resolveFileAttachmentSearchRoots(): FileAttachmentSearchRoot[] {
|
||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||
const rawWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
const dbPath = this.getConfiguredDbPath()
|
||||
const rawWxid = this.getConfiguredMyWxid()
|
||||
const cleanedWxid = this.cleanAccountDirName(rawWxid)
|
||||
if (!dbPath) return []
|
||||
|
||||
@@ -5050,10 +5215,7 @@ class ExportService {
|
||||
const exportMediaEnabled = options.exportMedia === true &&
|
||||
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles)
|
||||
const outputDir = path.dirname(outputPath)
|
||||
const rawWriteLayout = this.configService.get('exportWriteLayout')
|
||||
const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
|
||||
? rawWriteLayout
|
||||
: 'A'
|
||||
const writeLayout = this.resolveExportWriteLayout(options)
|
||||
// A: type-first layout, text exports are placed under `texts/`, media is placed at sibling type directories.
|
||||
if (writeLayout === 'A' && path.basename(outputDir) === 'texts') {
|
||||
return {
|
||||
@@ -5229,7 +5391,7 @@ class ExportService {
|
||||
: await wcdbService.openMessageCursor(
|
||||
sessionId,
|
||||
batchSize,
|
||||
true,
|
||||
false,
|
||||
beginTime,
|
||||
endTime
|
||||
)
|
||||
@@ -5417,7 +5579,7 @@ class ExportService {
|
||||
if (collectMode === 'full' || collectMode === 'media-fast') {
|
||||
// 优先复用游标返回的字段,缺失时再回退到 XML 解析。
|
||||
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
|
||||
imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined
|
||||
imageDatName = localType === 3 ? this.extractImageDatNameFromRow(row, content) : undefined
|
||||
videoMd5 = this.extractVideoFileNameFromRow(row, content)
|
||||
xmlType = rowFileHints.xmlType
|
||||
fileName = rowFileHints.fileName
|
||||
@@ -5439,7 +5601,7 @@ class ExportService {
|
||||
if (localType === 3 && content) {
|
||||
// 图片消息
|
||||
imageMd5 = imageMd5 || this.extractImageMd5(content)
|
||||
imageDatName = imageDatName || this.extractImageDatName(content)
|
||||
imageDatName = imageDatName || this.extractImageDatNameFromRow(row, content)
|
||||
} else if (localType === 43 && content) {
|
||||
// 视频消息
|
||||
videoMd5 = videoMd5 || this.extractVideoFileNameFromRow(row, content)
|
||||
@@ -5587,9 +5749,51 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length > 1) {
|
||||
rows.sort((a, b) => {
|
||||
const timeDelta = (a.createTime || 0) - (b.createTime || 0)
|
||||
if (timeDelta !== 0) return timeDelta
|
||||
return (a.localId || 0) - (b.localId || 0)
|
||||
})
|
||||
}
|
||||
|
||||
return { rows, memberSet, firstTime, lastTime }
|
||||
}
|
||||
|
||||
private async getRecentWcdbCursorLogSummary(sessionId: string): Promise<string | undefined> {
|
||||
try {
|
||||
const logResult = await wcdbService.getLogs()
|
||||
if (!logResult.success || !Array.isArray(logResult.logs)) return undefined
|
||||
const sid = String(sessionId || '').trim()
|
||||
const interesting = logResult.logs
|
||||
.filter((line) => {
|
||||
const text = String(line || '')
|
||||
if (sid && text.includes(sid)) return true
|
||||
return text.includes('QueryMessageBatch') ||
|
||||
text.includes('InitExportCursorHeap') ||
|
||||
text.includes('cursor_init') ||
|
||||
text.includes('fetch_message_batch') ||
|
||||
text.includes('open_message_cursor')
|
||||
})
|
||||
.slice(-8)
|
||||
if (interesting.length === 0) return undefined
|
||||
return interesting.join(' | ')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async buildNoMessagesError(
|
||||
sessionId: string,
|
||||
collected: { error?: string },
|
||||
fallback = '该会话在指定时间范围内没有消息'
|
||||
): Promise<string> {
|
||||
if (collected.error) return collected.error
|
||||
const nativeLogSummary = await this.getRecentWcdbCursorLogSummary(sessionId)
|
||||
if (!nativeLogSummary) return fallback
|
||||
return `${fallback};WCDB日志:${nativeLogSummary}`
|
||||
}
|
||||
|
||||
private async backfillMediaFieldsFromMessageDetail(
|
||||
sessionId: string,
|
||||
rows: any[],
|
||||
@@ -5608,7 +5812,7 @@ class ExportService {
|
||||
return !msg.xmlType || !msg.fileName || !msg.fileMd5 || !msg.fileSize || !msg.fileExt
|
||||
}
|
||||
if (!targetMediaTypes.has(msg.localType)) return false
|
||||
if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName
|
||||
if (msg.localType === 3) return !msg.imageMd5 || !msg.imageDatName
|
||||
if (msg.localType === 47) return !msg.emojiMd5
|
||||
if (msg.localType === 43) return !msg.videoMd5
|
||||
return false
|
||||
@@ -5639,7 +5843,7 @@ class ExportService {
|
||||
|
||||
if (msg.localType === 3) {
|
||||
const imageMd5 = (String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content) || '').toLowerCase()
|
||||
const imageDatName = (String(row.image_dat_name || row.imageDatName || '').trim() || this.extractImageDatName(content) || '').toLowerCase()
|
||||
const imageDatName = this.extractImageDatNameFromRow(row, content) || ''
|
||||
if (imageMd5) msg.imageMd5 = imageMd5
|
||||
if (imageDatName) msg.imageDatName = imageDatName
|
||||
return
|
||||
@@ -6111,7 +6315,7 @@ class ExportService {
|
||||
|
||||
const cleanedMyWxid = conn.cleanedWxid
|
||||
const isGroup = sessionId.includes('@chatroom')
|
||||
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
const rawMyWxid = this.getConfiguredMyWxid()
|
||||
|
||||
const sessionInfo = await this.getContactInfo(sessionId)
|
||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||
@@ -6149,7 +6353,7 @@ class ExportService {
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
if (totalMessages === 0) {
|
||||
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' }
|
||||
return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
|
||||
}
|
||||
|
||||
await this.hydrateEmojiCaptionsForMessages(sessionId, allMessages, control)
|
||||
@@ -6649,7 +6853,7 @@ class ExportService {
|
||||
|
||||
const cleanedMyWxid = conn.cleanedWxid
|
||||
const isGroup = sessionId.includes('@chatroom')
|
||||
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
const rawMyWxid = this.getConfiguredMyWxid()
|
||||
|
||||
const sessionInfo = await this.getContactInfo(sessionId)
|
||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||
@@ -6687,7 +6891,7 @@ class ExportService {
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
if (totalMessages === 0) {
|
||||
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' }
|
||||
return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
|
||||
}
|
||||
|
||||
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
||||
@@ -7380,7 +7584,7 @@ class ExportService {
|
||||
|
||||
const cleanedMyWxid = conn.cleanedWxid
|
||||
const isGroup = sessionId.includes('@chatroom')
|
||||
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
const rawMyWxid = this.getConfiguredMyWxid()
|
||||
|
||||
const sessionInfo = await this.getContactInfo(sessionId)
|
||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||
@@ -7423,7 +7627,7 @@ class ExportService {
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
if (totalMessages === 0) {
|
||||
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' }
|
||||
return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
|
||||
}
|
||||
|
||||
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
||||
@@ -8264,7 +8468,7 @@ class ExportService {
|
||||
|
||||
const cleanedMyWxid = conn.cleanedWxid
|
||||
const isGroup = sessionId.includes('@chatroom')
|
||||
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
const rawMyWxid = this.getConfiguredMyWxid()
|
||||
const sessionInfo = await this.getContactInfo(sessionId)
|
||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||
|
||||
@@ -8301,7 +8505,7 @@ class ExportService {
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
if (totalMessages === 0) {
|
||||
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' }
|
||||
return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
|
||||
}
|
||||
|
||||
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
||||
@@ -8662,7 +8866,7 @@ class ExportService {
|
||||
|
||||
const cleanedMyWxid = conn.cleanedWxid
|
||||
const isGroup = sessionId.includes('@chatroom')
|
||||
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
const rawMyWxid = this.getConfiguredMyWxid()
|
||||
const sessionInfo = await this.getContactInfo(sessionId)
|
||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||
|
||||
@@ -8697,7 +8901,7 @@ class ExportService {
|
||||
)
|
||||
let totalMessages = collected.rows.length
|
||||
if (totalMessages === 0) {
|
||||
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' }
|
||||
return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
|
||||
}
|
||||
|
||||
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
||||
@@ -9113,7 +9317,7 @@ class ExportService {
|
||||
|
||||
const cleanedMyWxid = conn.cleanedWxid
|
||||
const isGroup = sessionId.includes('@chatroom')
|
||||
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
const rawMyWxid = this.getConfiguredMyWxid()
|
||||
const sessionInfo = await this.getContactInfo(sessionId)
|
||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
|
||||
@@ -9152,7 +9356,7 @@ class ExportService {
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
if (collected.rows.length === 0) {
|
||||
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' }
|
||||
return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
|
||||
}
|
||||
const totalMessages = collected.rows.length
|
||||
|
||||
@@ -9948,6 +10152,7 @@ class ExportService {
|
||||
pendingSessionIds?: string[]
|
||||
successSessionIds?: string[]
|
||||
failedSessionIds?: string[]
|
||||
failedSessionErrors?: Record<string, string>
|
||||
sessionOutputPaths?: Record<string, string>
|
||||
error?: string
|
||||
}> {
|
||||
@@ -9955,6 +10160,7 @@ class ExportService {
|
||||
let failCount = 0
|
||||
const successSessionIds: string[] = []
|
||||
const failedSessionIds: string[] = []
|
||||
const failedSessionErrors: Record<string, string> = {}
|
||||
const sessionOutputPaths: Record<string, string> = {}
|
||||
const progressEmitter = this.createProgressEmitter(onProgress)
|
||||
let attachMediaTelemetry = false
|
||||
@@ -9972,9 +10178,10 @@ class ExportService {
|
||||
}
|
||||
|
||||
this.resetMediaRuntimeState()
|
||||
const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(options)
|
||||
? { ...options, exportVoiceAsText: false }
|
||||
: options
|
||||
const normalizedOptions = this.normalizeExportOptionsForRun(options)
|
||||
const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(normalizedOptions)
|
||||
? { ...normalizedOptions, exportVoiceAsText: false }
|
||||
: normalizedOptions
|
||||
|
||||
const exportMediaEnabled = effectiveOptions.exportMedia === true &&
|
||||
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles)
|
||||
@@ -9982,10 +10189,7 @@ class ExportService {
|
||||
if (exportMediaEnabled) {
|
||||
this.triggerMediaFileCacheCleanup()
|
||||
}
|
||||
const rawWriteLayout = this.configService.get('exportWriteLayout')
|
||||
const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
|
||||
? rawWriteLayout
|
||||
: 'A'
|
||||
const writeLayout = this.resolveExportWriteLayout(effectiveOptions)
|
||||
const exportBaseDir = writeLayout === 'A'
|
||||
? path.join(outputDir, 'texts')
|
||||
: outputDir
|
||||
@@ -10020,7 +10224,6 @@ class ExportService {
|
||||
const queue = [...sessionIds]
|
||||
let pauseRequested = false
|
||||
let stopRequested = false
|
||||
const emptySessionIds = new Set<string>()
|
||||
const sessionMessageCountHints = new Map<string, number>()
|
||||
const sessionLatestTimestampHints = new Map<string, number>()
|
||||
const exportStatsCacheKey = this.buildExportStatsCacheKey(sessionIds, effectiveOptions, conn.cleanedWxid)
|
||||
@@ -10033,17 +10236,12 @@ class ExportService {
|
||||
if (Number.isFinite(snapshot.lastTimestamp) && Number(snapshot.lastTimestamp) > 0) {
|
||||
sessionLatestTimestampHints.set(sessionId, Math.floor(Number(snapshot.lastTimestamp)))
|
||||
}
|
||||
if (snapshot.totalCount <= 0) {
|
||||
emptySessionIds.add(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
const canUseSessionSnapshotHints = isTextContentBatchExport &&
|
||||
this.isUnboundedDateRange(effectiveOptions.dateRange) &&
|
||||
!String(effectiveOptions.senderUsername || '').trim()
|
||||
const canFastSkipEmptySessions = !isTextContentBatchExport &&
|
||||
this.isUnboundedDateRange(effectiveOptions.dateRange) &&
|
||||
!String(effectiveOptions.senderUsername || '').trim()
|
||||
const canFastSkipEmptySessions = false
|
||||
const canTrySkipUnchangedTextSessions = canUseSessionSnapshotHints
|
||||
const precheckSessionIds = canFastSkipEmptySessions
|
||||
? sessionIds.filter((sessionId) => !sessionMessageCountHints.has(sessionId))
|
||||
@@ -10082,9 +10280,6 @@ class ExportService {
|
||||
if (typeof count === 'number' && Number.isFinite(count) && count >= 0) {
|
||||
sessionMessageCountHints.set(batchSessionId, Math.max(0, Math.floor(count)))
|
||||
}
|
||||
if (typeof count === 'number' && Number.isFinite(count) && count <= 0) {
|
||||
emptySessionIds.add(batchSessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10154,6 +10349,7 @@ class ExportService {
|
||||
pendingSessionIds: [...queue],
|
||||
successSessionIds,
|
||||
failedSessionIds,
|
||||
failedSessionErrors,
|
||||
sessionOutputPaths
|
||||
}
|
||||
}
|
||||
@@ -10166,6 +10362,7 @@ class ExportService {
|
||||
pendingSessionIds: [...queue],
|
||||
successSessionIds,
|
||||
failedSessionIds,
|
||||
failedSessionErrors,
|
||||
sessionOutputPaths
|
||||
}
|
||||
}
|
||||
@@ -10177,46 +10374,6 @@ class ExportService {
|
||||
const messageCountHint = sessionMessageCountHints.get(sessionId)
|
||||
const latestTimestampHint = sessionLatestTimestampHints.get(sessionId)
|
||||
|
||||
if (
|
||||
isTextContentBatchExport &&
|
||||
typeof messageCountHint === 'number' &&
|
||||
messageCountHint <= 0
|
||||
) {
|
||||
successCount++
|
||||
successSessionIds.push(sessionId)
|
||||
activeSessionRatios.delete(sessionId)
|
||||
completedCount++
|
||||
emitProgress({
|
||||
current: computeAggregateCurrent(),
|
||||
total: sessionIds.length,
|
||||
currentSession: sessionInfo.displayName,
|
||||
currentSessionId: sessionId,
|
||||
phase: 'complete',
|
||||
phaseLabel: '该会话没有消息,已跳过',
|
||||
estimatedTotalMessages: 0,
|
||||
exportedMessages: 0
|
||||
}, { force: true })
|
||||
return 'done'
|
||||
}
|
||||
|
||||
if (emptySessionIds.has(sessionId)) {
|
||||
successCount++
|
||||
successSessionIds.push(sessionId)
|
||||
activeSessionRatios.delete(sessionId)
|
||||
completedCount++
|
||||
emitProgress({
|
||||
current: computeAggregateCurrent(),
|
||||
total: sessionIds.length,
|
||||
currentSession: sessionInfo.displayName,
|
||||
currentSessionId: sessionId,
|
||||
phase: 'complete',
|
||||
phaseLabel: '该会话没有消息,已跳过',
|
||||
estimatedTotalMessages: 0,
|
||||
exportedMessages: 0
|
||||
}, { force: true })
|
||||
return 'done'
|
||||
}
|
||||
|
||||
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
|
||||
@@ -10339,6 +10496,7 @@ class ExportService {
|
||||
} else {
|
||||
failCount++
|
||||
failedSessionIds.push(sessionId)
|
||||
failedSessionErrors[sessionId] = result.error || '导出失败'
|
||||
console.error(`导出 ${sessionId} 失败:`, result.error)
|
||||
}
|
||||
|
||||
@@ -10433,6 +10591,7 @@ class ExportService {
|
||||
pendingSessionIds,
|
||||
successSessionIds,
|
||||
failedSessionIds,
|
||||
failedSessionErrors,
|
||||
sessionOutputPaths
|
||||
}
|
||||
}
|
||||
@@ -10445,6 +10604,7 @@ class ExportService {
|
||||
pendingSessionIds,
|
||||
successSessionIds,
|
||||
failedSessionIds,
|
||||
failedSessionErrors,
|
||||
sessionOutputPaths
|
||||
}
|
||||
}
|
||||
@@ -10458,7 +10618,20 @@ class ExportService {
|
||||
}, { force: true })
|
||||
progressEmitter.flush()
|
||||
|
||||
return { success: true, successCount, failCount, successSessionIds, failedSessionIds, sessionOutputPaths }
|
||||
const allFailed = successCount === 0 && failCount > 0
|
||||
const failureSummary = allFailed
|
||||
? Object.values(failedSessionErrors).slice(0, 3).join(';') || '所有会话导出失败'
|
||||
: undefined
|
||||
return {
|
||||
success: !allFailed,
|
||||
successCount,
|
||||
failCount,
|
||||
successSessionIds,
|
||||
failedSessionIds,
|
||||
failedSessionErrors,
|
||||
sessionOutputPaths,
|
||||
error: failureSummary
|
||||
}
|
||||
} catch (e) {
|
||||
progressEmitter.flush()
|
||||
return { success: false, successCount, failCount, error: String(e) }
|
||||
|
||||
@@ -81,6 +81,7 @@ export class ImageDecryptService {
|
||||
private pending = new Map<string, Promise<DecryptResult>>()
|
||||
private updateFlags = new Map<string, boolean>()
|
||||
private nativeLogged = false
|
||||
private runtimeConfig: { dbPath?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null = null
|
||||
private datNameScanMissAt = new Map<string, number>()
|
||||
private readonly datNameScanMissTtlMs = 1200
|
||||
private readonly accountDirCache = new Map<string, string>()
|
||||
@@ -99,6 +100,32 @@ export class ImageDecryptService {
|
||||
return this.shouldEmitImageEvents(payload)
|
||||
}
|
||||
|
||||
setRuntimeConfig(config: { dbPath?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void {
|
||||
this.runtimeConfig = config
|
||||
}
|
||||
|
||||
private getConfiguredDbPath(): string {
|
||||
return String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
|
||||
}
|
||||
|
||||
private getConfiguredMyWxid(): string {
|
||||
return String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
|
||||
}
|
||||
|
||||
private getConfiguredImageKeys(): { xorKey: unknown; aesKey: string } {
|
||||
const runtimeImageXorKey = this.runtimeConfig?.imageXorKey
|
||||
const hasRuntimeXorKey = runtimeImageXorKey !== undefined && runtimeImageXorKey !== null && String(runtimeImageXorKey).trim() !== ''
|
||||
const runtimeAesKey = String(this.runtimeConfig?.imageAesKey || '').trim()
|
||||
if (hasRuntimeXorKey || runtimeAesKey) {
|
||||
const fallback = this.configService.getImageKeysForCurrentWxid()
|
||||
return {
|
||||
xorKey: hasRuntimeXorKey ? runtimeImageXorKey : fallback.xorKey,
|
||||
aesKey: runtimeAesKey || fallback.aesKey
|
||||
}
|
||||
}
|
||||
return this.configService.getImageKeysForCurrentWxid()
|
||||
}
|
||||
|
||||
private logInfo(message: string, meta?: Record<string, unknown>): void {
|
||||
if (!this.configService.get('logEnabled')) return
|
||||
const timestamp = new Date().toISOString()
|
||||
@@ -266,8 +293,8 @@ export class ImageDecryptService {
|
||||
)
|
||||
if (normalizedList.length === 0) return
|
||||
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
const wxid = this.getConfiguredMyWxid()
|
||||
const dbPath = this.getConfiguredDbPath()
|
||||
if (!wxid || !dbPath) return
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
@@ -294,8 +321,8 @@ export class ImageDecryptService {
|
||||
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'locating', 14, 'running')
|
||||
try {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
const wxid = this.getConfiguredMyWxid()
|
||||
const dbPath = this.getConfiguredDbPath()
|
||||
if (!wxid || !dbPath) {
|
||||
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失')
|
||||
@@ -404,7 +431,7 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
// 优先使用当前 wxid 对应的密钥,找不到则回退到全局配置
|
||||
const imageKeys = this.configService.getImageKeysForCurrentWxid()
|
||||
const imageKeys = this.getConfiguredImageKeys()
|
||||
const xorKeyRaw = imageKeys.xorKey
|
||||
// 支持十六进制格式(如 0x53)和十进制格式
|
||||
let xorKey: number
|
||||
@@ -427,7 +454,7 @@ export class ImageDecryptService {
|
||||
const aesKeyText = typeof aesKeyRaw === 'string' ? aesKeyRaw.trim() : ''
|
||||
const aesKeyForNative = aesKeyText || undefined
|
||||
|
||||
this.logInfo('开始解密DAT文件(仅Rust原生)', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) })
|
||||
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running')
|
||||
const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative)
|
||||
if (!nativeResult) {
|
||||
@@ -527,8 +554,8 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
private resolveCurrentAccountDir(): string | null {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
const wxid = this.getConfiguredMyWxid()
|
||||
const dbPath = this.getConfiguredDbPath()
|
||||
if (!wxid || !dbPath) return null
|
||||
return this.resolveAccountDir(dbPath, wxid)
|
||||
}
|
||||
@@ -1551,7 +1578,117 @@ export class ImageDecryptService {
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
if (result) return result
|
||||
const fallback = this.tryDecryptDatWithJs(datPath, xorKey, aesKey)
|
||||
if (fallback) {
|
||||
this.logInfo('JS DAT 解密 fallback 已启用', { datPath, ext: fallback.ext })
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private tryDecryptDatWithJs(
|
||||
datPath: string,
|
||||
xorKey: number,
|
||||
aesKey?: string
|
||||
): { data: Buffer; ext: string; isWxgf: boolean } | null {
|
||||
try {
|
||||
const encrypted = readFileSync(datPath)
|
||||
const directExt = this.detectImageExtension(encrypted)
|
||||
if (directExt) return { data: encrypted, ext: directExt, isWxgf: false }
|
||||
|
||||
const candidates: Buffer[] = []
|
||||
const aesKeyText = String(aesKey || '').trim()
|
||||
const datVersion = this.getDatVersion(encrypted)
|
||||
if (datVersion === 2 && aesKeyText.length >= 16) {
|
||||
try {
|
||||
candidates.push(this.decryptDatV4WithJs(encrypted, xorKey, Buffer.from(aesKeyText, 'ascii').subarray(0, 16)))
|
||||
} catch { }
|
||||
}
|
||||
if (datVersion !== 2) {
|
||||
candidates.push(this.decryptDatV3WithJs(encrypted, xorKey))
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const ext = this.detectImageExtension(candidate)
|
||||
if (ext) return { data: candidate, ext, isWxgf: false }
|
||||
}
|
||||
} catch (error) {
|
||||
this.logError('JS DAT 解密 fallback 失败', error, { datPath })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private decryptDatV3WithJs(data: Buffer, xorKey: number): Buffer {
|
||||
const output = Buffer.allocUnsafe(data.length)
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
output[i] = data[i] ^ xorKey
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private decryptDatV4WithJs(data: Buffer, xorKey: number, aesKey: Buffer): Buffer {
|
||||
if (data.length < 0x0f) {
|
||||
throw new Error('dat file too small')
|
||||
}
|
||||
const header = data.subarray(0, 0x0f)
|
||||
const payload = data.subarray(0x0f)
|
||||
const aesSize = this.readInt32LeSafe(header, 6)
|
||||
const xorSize = this.readInt32LeSafe(header, 10)
|
||||
const remainder = ((aesSize % 16) + 16) % 16
|
||||
const alignedAesSize = aesSize + (16 - remainder)
|
||||
if (alignedAesSize > payload.length) throw new Error('invalid aes size')
|
||||
|
||||
const aesData = payload.subarray(0, alignedAesSize)
|
||||
|
||||
let plainAes = Buffer.alloc(0)
|
||||
if (aesData.length > 0) {
|
||||
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, Buffer.alloc(0))
|
||||
decipher.setAutoPadding(false)
|
||||
plainAes = this.strictRemovePkcs7Padding(Buffer.concat([decipher.update(aesData), decipher.final()]))
|
||||
}
|
||||
|
||||
const remaining = payload.subarray(alignedAesSize)
|
||||
if (xorSize < 0 || xorSize > remaining.length) throw new Error('invalid xor size')
|
||||
|
||||
let rawData = Buffer.alloc(0)
|
||||
let decodedXor = Buffer.alloc(0)
|
||||
if (xorSize > 0) {
|
||||
const rawLength = remaining.length - xorSize
|
||||
if (rawLength < 0) throw new Error('invalid raw size')
|
||||
rawData = remaining.subarray(0, rawLength)
|
||||
const xorData = remaining.subarray(rawLength)
|
||||
decodedXor = Buffer.allocUnsafe(xorData.length)
|
||||
for (let i = 0; i < xorData.length; i += 1) {
|
||||
decodedXor[i] = xorData[i] ^ xorKey
|
||||
}
|
||||
} else {
|
||||
rawData = remaining
|
||||
}
|
||||
return Buffer.concat([plainAes, rawData, decodedXor])
|
||||
}
|
||||
|
||||
private getDatVersion(data: Buffer): number {
|
||||
if (data.length < 6) return 0
|
||||
const sigV1 = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07])
|
||||
const sigV2 = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||
if (data.subarray(0, 6).equals(sigV1)) return 1
|
||||
if (data.subarray(0, 6).equals(sigV2)) return 2
|
||||
return 0
|
||||
}
|
||||
|
||||
private readInt32LeSafe(buffer: Buffer, offset: number): number {
|
||||
if (offset < 0 || offset + 4 > buffer.length) throw new Error('invalid int32 offset')
|
||||
return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24)
|
||||
}
|
||||
|
||||
private strictRemovePkcs7Padding(data: Buffer): Buffer {
|
||||
if (data.length === 0) throw new Error('empty decrypted data')
|
||||
const pad = data[data.length - 1]
|
||||
if (pad <= 0 || pad > 16 || pad > data.length) throw new Error('invalid pkcs7 padding')
|
||||
for (let i = data.length - pad; i < data.length; i += 1) {
|
||||
if (data[i] !== pad) throw new Error('invalid pkcs7 padding')
|
||||
}
|
||||
return data.subarray(0, data.length - pad)
|
||||
}
|
||||
|
||||
private detectImageExtension(buffer: Buffer): string | null {
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -3057,9 +3057,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.13",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
||||
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
|
||||
"version": "0.8.12",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
|
||||
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5192,6 +5192,7 @@ function ExportPage() {
|
||||
exportConcurrency: sourceOptions.exportConcurrency,
|
||||
fileNamingMode: exportDefaultFileNamingMode,
|
||||
sessionLayout,
|
||||
exportWriteLayout: writeLayout,
|
||||
sessionNameWithTypePrefix,
|
||||
dateRange: sourceOptions.useAllTime
|
||||
? null
|
||||
@@ -6008,9 +6009,10 @@ function ExportPage() {
|
||||
}
|
||||
return {
|
||||
...task.template.optionTemplate,
|
||||
exportWriteLayout: task.template.optionTemplate.exportWriteLayout || writeLayout,
|
||||
dateRange
|
||||
}
|
||||
}, [])
|
||||
}, [writeLayout])
|
||||
|
||||
const enqueueAutomationTask = useCallback((
|
||||
task: ExportAutomationTask,
|
||||
|
||||
@@ -2729,6 +2729,54 @@
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.export-progress-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.export-progress-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 82px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
border-color: color-mix(in srgb, #ff4d4f 36%, var(--border-color));
|
||||
background: color-mix(in srgb, #ff4d4f 10%, var(--bg-secondary));
|
||||
color: #d9363e;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.export-result {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useLayoutEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, Shield, ShieldOff, Loader2 } from 'lucide-react'
|
||||
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, Shield, ShieldOff, Loader2, Pause, Play, Square } from 'lucide-react'
|
||||
import './SnsPage.scss'
|
||||
import { SnsPost } from '../types/sns'
|
||||
import { SnsPostItem } from '../components/Sns/SnsPostItem'
|
||||
@@ -64,10 +64,42 @@ interface SnsOverviewStats {
|
||||
|
||||
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
|
||||
type SnsExportScope = { kind: 'all' } | { kind: 'selected'; usernames: string[] }
|
||||
type SnsExportTaskStatus = 'idle' | 'running' | 'pause_requested' | 'paused' | 'cancel_requested'
|
||||
|
||||
interface SnsExportProgress {
|
||||
current: number
|
||||
total: number
|
||||
status: string
|
||||
}
|
||||
|
||||
interface SnsExportResult {
|
||||
success: boolean
|
||||
filePath?: string
|
||||
postCount?: number
|
||||
mediaCount?: number
|
||||
paused?: boolean
|
||||
stopped?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface SnsExportRequest {
|
||||
taskId: string
|
||||
outputDir: string
|
||||
format: 'json' | 'html' | 'arkmejson'
|
||||
usernames?: string[]
|
||||
keyword?: string
|
||||
exportImages: boolean
|
||||
exportLivePhotos: boolean
|
||||
exportVideos: boolean
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
}
|
||||
|
||||
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||
const SNS_CACHE_MIGRATION_PROMPT_SESSION_KEY = 'sns_cache_migration_prompted_v1'
|
||||
|
||||
const createSnsExportTaskId = (): string => `sns-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
interface SnsCacheMigrationItem {
|
||||
label: string
|
||||
sourceDir: string
|
||||
@@ -179,8 +211,9 @@ export default function SnsPage() {
|
||||
() => createExportDateRangeSelectionFromPreset('all')
|
||||
)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [exportProgress, setExportProgress] = useState<{ current: number; total: number; status: string } | null>(null)
|
||||
const [exportResult, setExportResult] = useState<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string } | null>(null)
|
||||
const [exportTaskStatus, setExportTaskStatus] = useState<SnsExportTaskStatus>('idle')
|
||||
const [exportProgress, setExportProgress] = useState<SnsExportProgress | null>(null)
|
||||
const [exportResult, setExportResult] = useState<SnsExportResult | null>(null)
|
||||
const [refreshSpin, setRefreshSpin] = useState(false)
|
||||
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
|
||||
|
||||
@@ -211,6 +244,8 @@ export default function SnsPage() {
|
||||
const snsUserPostCountsCacheScopeKeyRef = useRef('')
|
||||
const activeContactsLoadTaskIdRef = useRef<string | null>(null)
|
||||
const activeContactsCountTaskIdRef = useRef<string | null>(null)
|
||||
const activeExportTaskIdRef = useRef<string | null>(null)
|
||||
const activeExportRequestRef = useRef<SnsExportRequest | null>(null)
|
||||
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
|
||||
const pendingResetFeedRef = useRef(false)
|
||||
const contactsLoadTokenRef = useRef(0)
|
||||
@@ -465,7 +500,11 @@ export default function SnsPage() {
|
||||
: overviewStatsStatus === 'loading' || contactsLoading
|
||||
)
|
||||
|
||||
const canStartExport = Boolean(exportFolder) && !isExporting && (
|
||||
const isExportLocked = isExporting || exportTaskStatus !== 'idle'
|
||||
const canPauseExport = exportTaskStatus === 'running'
|
||||
const canResumeExport = exportTaskStatus === 'paused' || exportTaskStatus === 'pause_requested'
|
||||
const canCancelExport = exportTaskStatus !== 'idle'
|
||||
const canStartExport = Boolean(exportFolder) && !isExportLocked && (
|
||||
exportScope.kind === 'all' || exportScope.usernames.length > 0
|
||||
)
|
||||
|
||||
@@ -772,14 +811,205 @@ export default function SnsPage() {
|
||||
|
||||
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDateRangeSelection), [exportDateRangeSelection])
|
||||
|
||||
const clearActiveExportTask = useCallback(() => {
|
||||
activeExportTaskIdRef.current = null
|
||||
activeExportRequestRef.current = null
|
||||
setExportTaskStatus('idle')
|
||||
setIsExporting(false)
|
||||
}, [])
|
||||
|
||||
const buildSnsExportRequest = useCallback((taskId: string): SnsExportRequest => ({
|
||||
taskId,
|
||||
outputDir: exportFolder,
|
||||
format: exportFormat,
|
||||
usernames: exportScope.kind === 'selected' ? [...exportScope.usernames] : undefined,
|
||||
keyword: searchKeyword || undefined,
|
||||
exportImages,
|
||||
exportLivePhotos,
|
||||
exportVideos,
|
||||
startTime: exportDateRangeSelection.useAllTime
|
||||
? undefined
|
||||
: Math.floor(exportDateRangeSelection.dateRange.start.getTime() / 1000),
|
||||
endTime: exportDateRangeSelection.useAllTime
|
||||
? undefined
|
||||
: Math.floor(exportDateRangeSelection.dateRange.end.getTime() / 1000)
|
||||
}), [
|
||||
exportDateRangeSelection,
|
||||
exportFolder,
|
||||
exportFormat,
|
||||
exportImages,
|
||||
exportLivePhotos,
|
||||
exportScope,
|
||||
exportVideos,
|
||||
searchKeyword
|
||||
])
|
||||
|
||||
const runSnsExport = useCallback(async (request: SnsExportRequest, statusText = '准备导出...') => {
|
||||
activeExportTaskIdRef.current = request.taskId
|
||||
activeExportRequestRef.current = request
|
||||
setIsExporting(true)
|
||||
setExportTaskStatus('running')
|
||||
setExportResult(null)
|
||||
setExportProgress(prev => prev || { current: 0, total: 0, status: statusText })
|
||||
|
||||
let keepTaskActive = false
|
||||
const removeProgress = window.electronAPI.sns.onExportProgress((progress: SnsExportProgress) => {
|
||||
setExportProgress(progress)
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sns.exportTimeline(request)
|
||||
if (!result.success) {
|
||||
setExportResult(result)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.paused) {
|
||||
keepTaskActive = true
|
||||
setExportTaskStatus('paused')
|
||||
setExportProgress(prev => ({
|
||||
current: Math.max(prev?.current || 0, result.postCount || 0),
|
||||
total: Math.max(prev?.total || 0, result.postCount || 0),
|
||||
status: '已暂停,可继续或取消'
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (result.stopped) {
|
||||
setExportResult(null)
|
||||
setExportProgress(null)
|
||||
setShowExportDialog(false)
|
||||
return
|
||||
}
|
||||
|
||||
setExportResult(result)
|
||||
} catch (e: any) {
|
||||
setExportResult({ success: false, error: e.message || String(e) })
|
||||
} finally {
|
||||
removeProgress()
|
||||
setIsExporting(false)
|
||||
if (!keepTaskActive) {
|
||||
activeExportTaskIdRef.current = null
|
||||
activeExportRequestRef.current = null
|
||||
setExportTaskStatus('idle')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleStartSnsExport = useCallback(() => {
|
||||
if (!canStartExport) return
|
||||
const request = buildSnsExportRequest(createSnsExportTaskId())
|
||||
setExportProgress({ current: 0, total: 0, status: '准备导出...' })
|
||||
void runSnsExport(request)
|
||||
}, [buildSnsExportRequest, canStartExport, runSnsExport])
|
||||
|
||||
const handlePauseSnsExport = useCallback(() => {
|
||||
const taskId = activeExportTaskIdRef.current
|
||||
if (!taskId || exportTaskStatus !== 'running') return
|
||||
setExportTaskStatus('pause_requested')
|
||||
setExportProgress(prev => ({
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
status: '暂停请求已发送,正在等待安全检查点'
|
||||
}))
|
||||
window.electronAPI.export.pauseTask(taskId).then(result => {
|
||||
if (result.success) return
|
||||
setExportTaskStatus(current => current === 'pause_requested' ? 'running' : current)
|
||||
setExportProgress(prev => ({
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
status: result.error || '暂停请求失败'
|
||||
}))
|
||||
}).catch(error => {
|
||||
setExportTaskStatus(current => current === 'pause_requested' ? 'running' : current)
|
||||
setExportProgress(prev => ({
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
status: String(error)
|
||||
}))
|
||||
})
|
||||
}, [exportTaskStatus])
|
||||
|
||||
const handleResumeSnsExport = useCallback(() => {
|
||||
const taskId = activeExportTaskIdRef.current
|
||||
const request = activeExportRequestRef.current
|
||||
if (!taskId || !request || (exportTaskStatus !== 'paused' && exportTaskStatus !== 'pause_requested')) return
|
||||
setExportTaskStatus('running')
|
||||
setExportProgress(prev => ({
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
status: '正在继续导出...'
|
||||
}))
|
||||
window.electronAPI.export.resumeTask(taskId).then(result => {
|
||||
if (!result.success) {
|
||||
setExportTaskStatus('paused')
|
||||
setExportProgress(prev => ({
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
status: result.error || '继续任务失败'
|
||||
}))
|
||||
return
|
||||
}
|
||||
void runSnsExport(request, '正在继续导出...')
|
||||
}).catch(error => {
|
||||
setExportTaskStatus('paused')
|
||||
setExportProgress(prev => ({
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
status: String(error)
|
||||
}))
|
||||
})
|
||||
}, [exportTaskStatus, runSnsExport])
|
||||
|
||||
const handleCancelSnsExport = useCallback(() => {
|
||||
const taskId = activeExportTaskIdRef.current
|
||||
if (!taskId || exportTaskStatus === 'idle' || exportTaskStatus === 'cancel_requested') return
|
||||
const shouldCloseAfterAck = exportTaskStatus === 'paused' || !isExporting
|
||||
setExportTaskStatus('cancel_requested')
|
||||
setExportProgress(prev => ({
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
status: '取消请求已发送,正在安全停止并清理'
|
||||
}))
|
||||
window.electronAPI.export.cancelTask(taskId).then(result => {
|
||||
if (!result.success) {
|
||||
setExportTaskStatus(shouldCloseAfterAck ? 'paused' : 'running')
|
||||
setExportProgress(prev => ({
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
status: result.error || '取消任务失败'
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (shouldCloseAfterAck) {
|
||||
clearActiveExportTask()
|
||||
setExportResult(null)
|
||||
setExportProgress(null)
|
||||
setShowExportDialog(false)
|
||||
}
|
||||
}).catch(error => {
|
||||
setExportTaskStatus(shouldCloseAfterAck ? 'paused' : 'running')
|
||||
setExportProgress(prev => ({
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
status: String(error)
|
||||
}))
|
||||
})
|
||||
}, [clearActiveExportTask, exportTaskStatus, isExporting])
|
||||
|
||||
const openExportDialog = useCallback((scope: SnsExportScope) => {
|
||||
if (isExportLocked) {
|
||||
setShowExportDialog(true)
|
||||
return
|
||||
}
|
||||
setExportScope(scope)
|
||||
setExportResult(null)
|
||||
setExportProgress(null)
|
||||
clearActiveExportTask()
|
||||
setExportDateRangeSelection(createExportDateRangeSelectionFromPreset('all'))
|
||||
setIsExportDateRangeDialogOpen(false)
|
||||
setShowExportDialog(true)
|
||||
}, [])
|
||||
}, [clearActiveExportTask, isExportLocked])
|
||||
|
||||
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
|
||||
const { reset = false, direction = 'older' } = options
|
||||
@@ -2048,11 +2278,11 @@ export default function SnsPage() {
|
||||
|
||||
{/* 导出对话框 */}
|
||||
{showExportDialog && (
|
||||
<div className="modal-overlay" onClick={() => !isExporting && setShowExportDialog(false)}>
|
||||
<div className="modal-overlay" onClick={() => !isExportLocked && setShowExportDialog(false)}>
|
||||
<div className="export-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="export-dialog-header">
|
||||
<h3>导出朋友圈</h3>
|
||||
<button className="close-btn" onClick={() => !isExporting && setShowExportDialog(false)} disabled={isExporting}>
|
||||
<button className="close-btn" onClick={() => !isExportLocked && setShowExportDialog(false)} disabled={isExportLocked}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -2078,7 +2308,7 @@ export default function SnsPage() {
|
||||
<button
|
||||
className={`format-option ${exportFormat === 'html' ? 'active' : ''}`}
|
||||
onClick={() => setExportFormat('html')}
|
||||
disabled={isExporting}
|
||||
disabled={isExportLocked}
|
||||
>
|
||||
<FileText size={20} />
|
||||
<span>HTML</span>
|
||||
@@ -2087,7 +2317,7 @@ export default function SnsPage() {
|
||||
<button
|
||||
className={`format-option ${exportFormat === 'json' ? 'active' : ''}`}
|
||||
onClick={() => setExportFormat('json')}
|
||||
disabled={isExporting}
|
||||
disabled={isExportLocked}
|
||||
>
|
||||
<FileJson size={20} />
|
||||
<span>JSON</span>
|
||||
@@ -2096,7 +2326,7 @@ export default function SnsPage() {
|
||||
<button
|
||||
className={`format-option ${exportFormat === 'arkmejson' ? 'active' : ''}`}
|
||||
onClick={() => setExportFormat('arkmejson')}
|
||||
disabled={isExporting}
|
||||
disabled={isExportLocked}
|
||||
>
|
||||
<FileJson size={20} />
|
||||
<span>ArkmeJSON</span>
|
||||
@@ -2124,7 +2354,7 @@ export default function SnsPage() {
|
||||
setExportFolder(result.filePath)
|
||||
}
|
||||
}}
|
||||
disabled={isExporting}
|
||||
disabled={isExportLocked}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</button>
|
||||
@@ -2139,9 +2369,9 @@ export default function SnsPage() {
|
||||
type="button"
|
||||
className="time-range-trigger sns-export-time-range-trigger"
|
||||
onClick={() => {
|
||||
if (!isExporting) setIsExportDateRangeDialogOpen(true)
|
||||
if (!isExportLocked) setIsExportDateRangeDialogOpen(true)
|
||||
}}
|
||||
disabled={isExporting}
|
||||
disabled={isExportLocked}
|
||||
>
|
||||
<span>{exportDateRangeLabel}</span>
|
||||
<span className="time-range-arrow">></span>
|
||||
@@ -2161,7 +2391,7 @@ export default function SnsPage() {
|
||||
type="checkbox"
|
||||
checked={exportImages}
|
||||
onChange={(e) => setExportImages(e.target.checked)}
|
||||
disabled={isExporting}
|
||||
disabled={isExportLocked}
|
||||
/>
|
||||
图片
|
||||
</label>
|
||||
@@ -2170,7 +2400,7 @@ export default function SnsPage() {
|
||||
type="checkbox"
|
||||
checked={exportLivePhotos}
|
||||
onChange={(e) => setExportLivePhotos(e.target.checked)}
|
||||
disabled={isExporting}
|
||||
disabled={isExportLocked}
|
||||
/>
|
||||
实况图
|
||||
</label>
|
||||
@@ -2179,7 +2409,7 @@ export default function SnsPage() {
|
||||
type="checkbox"
|
||||
checked={exportVideos}
|
||||
onChange={(e) => setExportVideos(e.target.checked)}
|
||||
disabled={isExporting}
|
||||
disabled={isExportLocked}
|
||||
/>
|
||||
视频
|
||||
</label>
|
||||
@@ -2194,7 +2424,7 @@ export default function SnsPage() {
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{isExporting && exportProgress && (
|
||||
{isExportLocked && exportProgress && (
|
||||
<div className="export-progress">
|
||||
<div className="export-progress-bar">
|
||||
<div
|
||||
@@ -2203,6 +2433,39 @@ export default function SnsPage() {
|
||||
/>
|
||||
</div>
|
||||
<span className="export-progress-text">{exportProgress.status}</span>
|
||||
<div className="export-progress-actions">
|
||||
{canPauseExport && (
|
||||
<button
|
||||
type="button"
|
||||
className="export-progress-btn"
|
||||
onClick={handlePauseSnsExport}
|
||||
>
|
||||
<Pause size={14} />
|
||||
暂停
|
||||
</button>
|
||||
)}
|
||||
{canResumeExport && (
|
||||
<button
|
||||
type="button"
|
||||
className="export-progress-btn primary"
|
||||
onClick={handleResumeSnsExport}
|
||||
>
|
||||
<Play size={14} />
|
||||
继续
|
||||
</button>
|
||||
)}
|
||||
{canCancelExport && (
|
||||
<button
|
||||
type="button"
|
||||
className="export-progress-btn danger"
|
||||
onClick={handleCancelSnsExport}
|
||||
disabled={exportTaskStatus === 'cancel_requested'}
|
||||
>
|
||||
<Square size={14} />
|
||||
{exportTaskStatus === 'cancel_requested' ? '取消中' : '取消'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2211,47 +2474,14 @@ export default function SnsPage() {
|
||||
<button
|
||||
className="export-cancel-btn"
|
||||
onClick={() => setShowExportDialog(false)}
|
||||
disabled={isExporting}
|
||||
disabled={isExportLocked}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="export-start-btn"
|
||||
disabled={!canStartExport}
|
||||
onClick={async () => {
|
||||
setIsExporting(true)
|
||||
setExportProgress({ current: 0, total: 0, status: '准备导出...' })
|
||||
setExportResult(null)
|
||||
|
||||
// 监听进度
|
||||
const removeProgress = window.electronAPI.sns.onExportProgress((progress: any) => {
|
||||
setExportProgress(progress)
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sns.exportTimeline({
|
||||
outputDir: exportFolder,
|
||||
format: exportFormat,
|
||||
usernames: exportScope.kind === 'selected' ? exportScope.usernames : undefined,
|
||||
keyword: searchKeyword || undefined,
|
||||
exportImages,
|
||||
exportLivePhotos,
|
||||
exportVideos,
|
||||
startTime: exportDateRangeSelection.useAllTime
|
||||
? undefined
|
||||
: Math.floor(exportDateRangeSelection.dateRange.start.getTime() / 1000),
|
||||
endTime: exportDateRangeSelection.useAllTime
|
||||
? undefined
|
||||
: Math.floor(exportDateRangeSelection.dateRange.end.getTime() / 1000)
|
||||
})
|
||||
setExportResult(result)
|
||||
} catch (e: any) {
|
||||
setExportResult({ success: false, error: e.message || String(e) })
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
removeProgress()
|
||||
}
|
||||
}}
|
||||
onClick={handleStartSnsExport}
|
||||
>
|
||||
{isExporting ? '导出中...' : '开始导出'}
|
||||
</button>
|
||||
|
||||
2
src/types/electron.d.ts
vendored
2
src/types/electron.d.ts
vendored
@@ -1101,6 +1101,7 @@ export interface ElectronAPI {
|
||||
pendingSessionIds?: string[]
|
||||
successSessionIds?: string[]
|
||||
failedSessionIds?: string[]
|
||||
failedSessionErrors?: Record<string, string>
|
||||
sessionOutputPaths?: Record<string, string>
|
||||
error?: string
|
||||
}>
|
||||
@@ -1269,6 +1270,7 @@ export interface ExportOptions {
|
||||
txtColumns?: string[]
|
||||
fileNamingMode?: 'classic' | 'date-range'
|
||||
sessionLayout?: 'shared' | 'per-session'
|
||||
exportWriteLayout?: 'A' | 'B' | 'C'
|
||||
sessionNameWithTypePrefix?: boolean
|
||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||
exportConcurrency?: number
|
||||
|
||||
Reference in New Issue
Block a user